lazyflat: combined alert + apply behind authenticated web UI
Three isolated services (alert scraper, apply HTTP worker, web UI+DB) with argon2 auth, signed cookies, CSRF, rate-limited login, kill switch, apply circuit breaker, audit log, and strict CSP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
69f2f1f635
46 changed files with 4183 additions and 0 deletions
176
web/templates/_dashboard_body.html
Normal file
176
web/templates/_dashboard_body.html
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<section class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="card p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">alert</div>
|
||||
<div class="mt-2 text-lg">
|
||||
{% if last_alert_heartbeat %}
|
||||
<span class="chip chip-ok">live</span>
|
||||
{% else %}
|
||||
<span class="chip chip-warn">kein Heartbeat</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-1">letzter Heartbeat: {{ last_alert_heartbeat or "—" }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">apply</div>
|
||||
<div class="mt-2 text-lg">
|
||||
{% if apply_reachable %}
|
||||
<span class="chip chip-ok">reachable</span>
|
||||
{% else %}
|
||||
<span class="chip chip-bad">down</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-1">
|
||||
{% if circuit_open %}
|
||||
<span class="chip chip-bad">circuit open</span>
|
||||
{% elif apply_failures > 0 %}
|
||||
{{ apply_failures }} recent failure(s)
|
||||
{% else %}
|
||||
healthy
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">Modus</div>
|
||||
<div class="mt-2 text-lg">
|
||||
{% if mode == "auto" %}
|
||||
<span class="chip chip-warn">full-auto</span>
|
||||
{% else %}
|
||||
<span class="chip chip-info">manuell</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/actions/mode" class="mt-2 flex gap-2">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="mode" value="{% if mode == 'auto' %}manual{% else %}auto{% endif %}">
|
||||
<button class="btn btn-ghost text-sm" type="submit">
|
||||
→ zu {% if mode == 'auto' %}manuell{% else %}full-auto{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-400">Kill‑Switch</div>
|
||||
<div class="mt-2 text-lg">
|
||||
{% if kill_switch %}
|
||||
<span class="chip chip-bad">apply gestoppt</span>
|
||||
{% else %}
|
||||
<span class="chip chip-ok">aktiv</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/actions/kill-switch" class="mt-2 flex gap-2">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
|
||||
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
|
||||
{% if kill_switch %}Freigeben{% else %}Alles stoppen{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% if circuit_open %}
|
||||
<form method="post" action="/actions/reset-circuit" class="mt-2">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not apply_allowed %}
|
||||
<div class="card p-4 border-red-900/50">
|
||||
<span class="chip chip-bad">apply blockiert</span>
|
||||
<span class="ml-2 text-sm text-slate-300">{{ apply_block_reason }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[#1e2335]">
|
||||
<h2 class="font-semibold">Wohnungen</h2>
|
||||
<span class="text-xs text-slate-400">{{ flats|length }} zuletzt gesehen</span>
|
||||
</div>
|
||||
<div class="divide-y divide-[#1e2335]">
|
||||
{% for flat in flats %}
|
||||
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<a class="font-medium truncate hover:underline" href="{{ flat.link }}" target="_blank" rel="noopener noreferrer">
|
||||
{{ flat.address or flat.link }}
|
||||
</a>
|
||||
{% if flat.matched_criteria %}
|
||||
<span class="chip chip-ok">match</span>
|
||||
{% else %}
|
||||
<span class="chip chip-info">info</span>
|
||||
{% endif %}
|
||||
{% if flat.last_application_success == 1 %}
|
||||
<span class="chip chip-ok">beworben</span>
|
||||
{% elif flat.last_application_success == 0 %}
|
||||
<span class="chip chip-bad">apply fehlgeschlagen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">
|
||||
{% if flat.rooms %}{{ "%.1f"|format(flat.rooms) }} Z{% endif %}
|
||||
{% if flat.size %} · {{ "%.0f"|format(flat.size) }} m²{% endif %}
|
||||
{% if flat.total_rent %} · {{ "%.0f"|format(flat.total_rent) }} €{% endif %}
|
||||
{% if flat.sqm_price %} ({{ "%.2f"|format(flat.sqm_price) }} €/m²){% endif %}
|
||||
{% if flat.connectivity_morning_time %} · {{ "%.0f"|format(flat.connectivity_morning_time) }} min morgens{% endif %}
|
||||
· entdeckt {{ flat.discovered_at }}
|
||||
</div>
|
||||
{% if flat.last_application_message %}
|
||||
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ flat.last_application_message }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if apply_allowed and not flat.last_application_success %}
|
||||
<form method="post" action="/actions/apply">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="flat_id" value="{{ flat.id }}">
|
||||
<button class="btn btn-primary text-sm" type="submit"
|
||||
onclick="return confirm('Bewerbung für {{ (flat.address or flat.link)|e }} ausführen?');">
|
||||
Bewerben
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen gesehen.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<div class="px-4 py-3 border-b border-[#1e2335]"><h2 class="font-semibold">Letzte Bewerbungen</h2></div>
|
||||
<div class="divide-y divide-[#1e2335]">
|
||||
{% for a in applications %}
|
||||
<div class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
||||
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span>
|
||||
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||
<span class="chip chip-info">{{ a.triggered_by }}</span>
|
||||
<span class="text-slate-400 text-xs">{{ a.started_at }}</span>
|
||||
</div>
|
||||
<div class="mt-1 truncate">{{ a.address or a.url }}</div>
|
||||
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5">{{ a.message }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center text-slate-500">Keine Bewerbungen bisher.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-4 py-3 border-b border-[#1e2335]"><h2 class="font-semibold">Audit-Log</h2></div>
|
||||
<div class="divide-y divide-[#1e2335]">
|
||||
{% for e in audit %}
|
||||
<div class="px-4 py-2 text-xs font-mono">
|
||||
<span class="text-slate-500">{{ e.timestamp }}</span>
|
||||
<span class="text-slate-400">{{ e.actor }}</span>
|
||||
<span class="text-slate-200">{{ e.action }}</span>
|
||||
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center text-slate-500">leer</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
31
web/templates/base.html
Normal file
31
web/templates/base.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{% block title %}lazyflat{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<style>
|
||||
html { color-scheme: dark; }
|
||||
body { background: #0b0d12; color: #e6e8ee; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif; }
|
||||
.card { background: #121521; border: 1px solid #1e2335; border-radius: 12px; }
|
||||
.btn { border-radius: 8px; padding: 0.4rem 0.9rem; font-weight: 500; transition: background 0.15s; }
|
||||
.btn-primary { background: #5b8def; color: white; }
|
||||
.btn-primary:hover { background: #4a7ce0; }
|
||||
.btn-danger { background: #e14a56; color: white; }
|
||||
.btn-danger:hover { background: #c63c48; }
|
||||
.btn-ghost { background: #1e2335; color: #e6e8ee; }
|
||||
.btn-ghost:hover { background: #2a3048; }
|
||||
.chip { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
|
||||
.chip-ok { background: #1a3a2a; color: #7bd88f; border: 1px solid #2d5a3f; }
|
||||
.chip-warn { background: #3a2a1a; color: #f5b26b; border: 1px solid #5a432d; }
|
||||
.chip-bad { background: #3a1a1f; color: #e14a56; border: 1px solid #5a2d33; }
|
||||
.chip-info { background: #1a253a; color: #7ca7ea; border: 1px solid #2d3f5a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
25
web/templates/dashboard.html
Normal file
25
web/templates/dashboard.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}lazyflat dashboard{% endblock %}
|
||||
{% block body %}
|
||||
<header class="border-b border-[#1e2335]">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b8def] to-[#7bd88f]"></div>
|
||||
<h1 class="text-xl font-semibold">lazyflat</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-slate-400">{{ user }}</span>
|
||||
<form method="post" action="/logout">
|
||||
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6"
|
||||
hx-get="/partials/dashboard" hx-trigger="every 15s" hx-target="#dashboard-body" hx-swap="innerHTML">
|
||||
<div id="dashboard-body">
|
||||
{% include "_dashboard_body.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
28
web/templates/login.html
Normal file
28
web/templates/login.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Login — lazyflat{% endblock %}
|
||||
{% block body %}
|
||||
<main class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="card w-full max-w-sm p-8">
|
||||
<h1 class="text-2xl font-semibold mb-1">lazyflat</h1>
|
||||
<p class="text-sm text-slate-400 mb-6">Anmeldung erforderlich</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="chip chip-bad inline-block mb-4">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs uppercase tracking-wide text-slate-400 mb-1">Benutzer</label>
|
||||
<input type="text" name="username" autocomplete="username" required
|
||||
class="w-full bg-[#0b0d12] border border-[#1e2335] rounded-lg px-3 py-2 focus:outline-none focus:border-[#5b8def]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs uppercase tracking-wide text-slate-400 mb-1">Passwort</label>
|
||||
<input type="password" name="password" autocomplete="current-password" required
|
||||
class="w-full bg-[#0b0d12] border border-[#1e2335] rounded-lg px-3 py-2 focus:outline-none focus:border-[#5b8def]">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue