per-step screenshot + html snapshots, matches-only list, full German UI, CSV export

* apply: Recorder.step_snap(page, name) captures both a JPEG screenshot and
  the page HTML for every major moment; every provider now calls step_snap at
  each logical step so failure reports contain the exact DOM and rendered
  state at every stage of the flow
* ZIP report: each snapshot becomes snapshots/NN_<label>.jpg +
  snapshots/NN_<label>.html for AI-assisted debugging
* web: Wohnungsliste zeigt nur noch Flats, die die eigenen Filter treffen;
  Match-Chip entfernt (Liste ist jetzt implizit matchend)
* UI komplett auf Deutsch: Protokoll statt Logs, Administrator statt admin,
  Trockenmodus statt dry-run, Automatik pausiert statt circuit open,
  Alarm statt Alert, Abmelden statt Logout
* Wohnungen-Header: Zeile 1 Info (Alarm + Filter), Zeile 2 Schalter mit
  echten Radio-Paaren (An/Aus) für Automatisch bewerben und Trockenmodus;
  hx-confirm auf den kritischen Radios; per-form CSS für sichtbaren Check-State
* Protokoll: von/bis-Datumsfilter (Berliner Zeit) + CSV-Download
  (/logs/export.csv) mit UTC + lokaler Zeit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 11:40:12 +02:00
parent 04b591fa9e
commit 7444f90d6a
16 changed files with 360 additions and 202 deletions

View file

@ -11,9 +11,9 @@
<h1 class="text-xl font-semibold">lazyflat</h1>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">admin</span>{% endif %}</span>
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">Administrator</span>{% endif %}</span>
<form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
<button class="btn btn-ghost text-sm" type="submit">Abmelden</button>
</form>
</div>
</div>
@ -21,7 +21,7 @@
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
{% if is_admin %}
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Protokoll</a>
{% endif %}
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
</nav>

View file

@ -102,18 +102,10 @@
</form>
<hr class="my-6 border-soft">
<h3 class="font-semibold mb-2">Formulare wirklich absenden?</h3>
<h3 class="font-semibold mb-2">Trockenmodus</h3>
<p class="text-sm text-slate-600 mb-3">
<span class="chip chip-warn">experimentell</span>
Im Dry-Run-Modus füllt apply das Formular aus und stoppt vor „Senden". Nur einschalten, wenn du jeden Anbieter einmal im Dry-Run verifiziert hast.
Im Trockenmodus wird das Formular ausgefüllt, aber nicht abgesendet. Erst deaktivieren,
wenn du jeden Anbieter einmal im Trockenmodus erfolgreich getestet hast — den Schalter
findest du auch oben auf der Wohnungen-Seite.
</p>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="on">
<button class="btn btn-ghost text-sm" type="submit">Echt senden einschalten</button>
</form>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center ml-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="off">
<button class="btn btn-ghost text-sm" type="submit">Dry-Run</button>
</form>

View file

@ -18,7 +18,7 @@
</div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_admin">
<span>Admin-Rechte</span>
<span>Administrator-Rechte</span>
</label>
<button class="btn btn-primary" type="submit">Anlegen</button>
</form>
@ -30,7 +30,7 @@
{% for u in users %}
<div class="px-3 py-2 flex items-center gap-2 text-sm">
<span class="flex-1">{{ u.username }}</span>
{% if u.is_admin %}<span class="chip chip-info">admin</span>{% endif %}
{% if u.is_admin %}<span class="chip chip-info">Administrator</span>{% endif %}
{% if u.disabled %}<span class="chip chip-bad">deaktiviert</span>
{% else %}<span class="chip chip-ok">aktiv</span>{% endif %}
{% if u.id != user.id %}

View file

@ -5,62 +5,73 @@
hx-trigger="every {{ poll_interval }}s"
hx-swap="outerHTML">
<!-- Slim status strip: Alert · Filter · Auto-Bewerben · Trockenmodus -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Alert -->
<!-- Reihe 1: Info-Kacheln Alarm + Filter -->
<section class="grid grid-cols-2 gap-3">
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alert</div>
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
<div class="flex items-center gap-2">
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
</div>
</a>
<!-- Filter summary -->
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Filter</div>
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
</a>
</section>
<!-- Auto-Bewerben toggle -->
<form class="card px-4 py-2.5 flex items-center justify-between gap-2"
method="post" action="/actions/auto-apply"
hx-post="/actions/auto-apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
<!-- Reihe 2: Schalter Automatisch bewerben + Trockenmodus (Radio-Gruppen) -->
<section class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Automatisch bewerben -->
<form class="card p-4"
hx-post="/actions/auto-apply"
hx-trigger="change"
hx-target="#wohnungen-body"
hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
<div class="flex flex-col gap-0.5">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
<div>{% if auto_apply_enabled %}<span class="chip chip-warn">aktiv</span>
{% else %}<span class="chip chip-info">aus</span>{% endif %}</div>
<div class="text-[11px] uppercase tracking-wide text-slate-500 mb-2">Automatisch bewerben</div>
<div class="flex gap-4">
<label class="radio-opt">
<input type="radio" name="value" value="off"
{% if not auto_apply_enabled %}checked{% endif %}>
<span>Aus</span>
</label>
<label class="radio-opt"
{% if not auto_apply_enabled %}data-hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}>
<input type="radio" name="value" value="on"
hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."
{% if auto_apply_enabled %}checked{% endif %}>
<span>An</span>
</label>
</div>
<button class="btn {% if auto_apply_enabled %}btn-ghost{% else %}btn-hot{% endif %} text-xs"
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Bei jedem passenden Flat wird automatisch beworben.{% endif %}');"
type="submit">
{% if auto_apply_enabled %}AUS{% else %}AN{% endif %}
</button>
</form>
<!-- Trockenmodus toggle -->
<form class="card px-4 py-2.5 flex items-center justify-between gap-2"
method="post" action="/actions/submit-forms"
hx-post="/actions/submit-forms" hx-target="#wohnungen-body" hx-swap="outerHTML">
<!-- Trockenmodus -->
<form class="card p-4"
hx-post="/actions/submit-forms"
hx-trigger="change"
hx-target="#wohnungen-body"
hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if submit_forms %}off{% else %}on{% endif %}">
<div class="flex flex-col gap-0.5">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Trockenmodus</div>
<div>{% if submit_forms %}<span class="chip chip-warn">aus (echt!)</span>
{% else %}<span class="chip chip-ok">an</span>{% endif %}</div>
<div class="text-[11px] uppercase tracking-wide text-slate-500 mb-2">Trockenmodus</div>
<div class="flex gap-4">
<label class="radio-opt">
<input type="radio" name="value" value="on"
{% if not submit_forms %}checked{% endif %}>
<span>An <span class="text-xs text-slate-500">(Formular ausfüllen, nicht absenden)</span></span>
</label>
<label class="radio-opt">
<input type="radio" name="value" value="off"
hx-confirm="Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!"
{% if submit_forms %}checked{% endif %}>
<span>Aus <span class="text-xs text-[#b8404e]">(echt senden)</span></span>
</label>
</div>
<button class="btn btn-ghost text-xs"
onclick="return confirm('{% if submit_forms %}Trockenmodus wieder einschalten?{% else %}Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!{% endif %}');"
type="submit">
{% if submit_forms %}AN{% else %}AUS{% endif %}
</button>
</form>
</section>
{% if not apply_allowed %}
<div class="card p-3 text-sm">
<span class="chip chip-bad">apply blockiert</span>
<span class="chip chip-bad">Bewerbungs-Dienst nicht erreichbar</span>
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
</div>
{% endif %}
@ -68,8 +79,8 @@
{% if circuit_open %}
<div class="card p-3 text-sm flex items-center justify-between">
<div>
<span class="chip chip-bad">circuit open</span>
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Serie — Auto-Bewerben pausiert</span>
<span class="chip chip-bad">Automatik pausiert</span>
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Folge</span>
</div>
<form method="post" action="/actions/reset-circuit"
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
@ -79,12 +90,12 @@
</div>
{% endif %}
<!-- Liste aller Wohnungen -->
<!-- Liste passender Wohnungen -->
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
<div class="text-xs text-slate-500 flex gap-3 items-center">
<span>{{ flats|length }} gesehen</span>
<span>{{ flats|length }} gefunden</span>
{% if next_scrape_utc %}
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}"></span></span>
{% endif %}
@ -93,17 +104,16 @@
<div class="divide-y divide-soft">
{% for item in flats %}
{% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
<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" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }}
</a>
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
{% if item.last and item.last.finished_at is none %}
<span class="chip chip-warn">läuft…</span>
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% endif %}
</div>
<div class="text-xs text-slate-500 mt-0.5">
@ -128,7 +138,7 @@
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit"
{% if is_running %}disabled{% endif %}
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
</button>
</form>
@ -136,7 +146,13 @@
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
<div class="px-4 py-8 text-center text-slate-500">
{% if alert_label == 'nicht eingerichtet' %}
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
{% else %}
Aktuell keine Wohnung, die alle Filter erfüllt.
{% endif %}
</div>
{% endfor %}
</div>
</section>

View file

@ -47,6 +47,12 @@
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
.radio-opt { display: inline-flex; align-items: center; gap: 0.45rem; cursor: pointer; padding: 0.35rem 0.65rem;
border: 1px solid var(--border); border-radius: 10px; background: var(--surface);
transition: border-color .15s, background .15s; user-select: none; }
.radio-opt:has(input:checked) { border-color: var(--primary); background: #ecf4fc; box-shadow: 0 0 0 1px var(--primary) inset; }
.radio-opt:hover { background: var(--ghost); }
.radio-opt input[type="radio"] { accent-color: var(--primary); }
.brand-dot {
width: 2rem; height: 2rem; border-radius: 10px;
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);

View file

@ -8,10 +8,10 @@
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ application.triggered_by }}</span>
<span class="chip chip-info">{% if application.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
{% else %}<span class="chip chip-info">Trockenmodus</span>{% endif %}
{% if application.success == 0 %}
<a class="btn btn-danger text-sm ml-auto"
@ -53,12 +53,20 @@
{% if forensics.screenshots %}
<details>
<summary class="font-medium">Screenshots ({{ forensics.screenshots|length }})</summary>
<summary class="font-medium">Momentaufnahmen ({{ forensics.screenshots|length }})</summary>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
{% for s in forensics.screenshots %}
<div class="border border-soft rounded-lg p-2">
<div class="text-xs text-slate-500 mb-1">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
<div class="border border-soft rounded-lg p-2 space-y-2">
<div class="text-xs text-slate-500">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
{% if s.b64_jpeg %}
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
{% endif %}
{% if s.html %}
<details>
<summary class="text-xs text-slate-500">Quellcode anzeigen ({{ s.html_size }} B)</summary>
<pre class="mono whitespace-pre-wrap break-all mt-2 p-2 bg-[#f6fafd] rounded border border-soft max-h-72 overflow-auto">{{ s.html }}</pre>
</details>
{% endif %}
</div>
{% endfor %}
</div>

View file

@ -12,10 +12,10 @@
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span>
<span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
{% else %}<span class="chip chip-info">Trockenmodus</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto"
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
</div>

View file

@ -1,9 +1,36 @@
{% extends "_layout.html" %}
{% block content %}
<section class="card p-4">
<form method="get" action="/logs" class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">von</label>
<input class="input" type="date" name="from" value="{{ from_str }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">bis</label>
<input class="input" type="date" name="to" value="{{ to_str }}">
</div>
<button class="btn btn-primary text-sm" type="submit">Anwenden</button>
<a class="btn btn-ghost text-sm" href="/logs">zurücksetzen</a>
<a class="btn btn-ghost text-sm"
href="/logs/export.csv?from={{ from_str }}&to={{ to_str }}">
CSV herunterladen
</a>
</form>
</section>
<section class="card">
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
<h2 class="font-semibold">System-Log</h2>
<span class="text-xs text-slate-500">{{ events|length }} Einträge (max 300, letzte 14 Tage)</span>
<h2 class="font-semibold">System-Protokoll</h2>
<span class="text-xs text-slate-500">
{{ events|length }} Einträge
{% if from_str or to_str %}
· Zeitraum:
{{ from_str or "alles davor" }} — {{ to_str or "heute" }}
{% else %}
· ohne Filter (Anzeige bis 500; Aufbewahrung 14 Tage)
{% endif %}
</span>
</div>
<div class="divide-y divide-soft">
{% for e in events %}
@ -15,12 +42,12 @@
<span class="chip chip-info">{{ e.source }}</span>
{% endif %}
<span class="text-slate-700">{{ e.action }}</span>
{% if e.user %}<span class="text-slate-500">user={{ e.user }}</span>{% endif %}
{% if e.user %}<span class="text-slate-500">Benutzer: {{ e.user }}</span>{% endif %}
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
{% if e.ip %}<span class="text-slate-400">[{{ e.ip }}]</span>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge.</div>
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge im gewählten Zeitraum.</div>
{% endfor %}
</div>
</section>