diff --git a/web/app/src/style.css b/web/app/src/style.css
index d6f7731..d1829aa 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -1312,6 +1312,46 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
word-break: break-word;
}
+/* **Das Protokoll als Liste, nicht als Textblock.**
+ *
+ * Eine Zeile je Eintrag, neueste oben, Zeit links. So liest es sich wie
+ * eine Liste von Ereignissen — und nicht wie eine Datei, in der man die
+ * Stelle erst suchen muss. */
+.protokollliste {
+ margin-top: var(--s-regular);
+ border: 1px solid var(--line);
+ border-radius: var(--r-control);
+ background: var(--canvas);
+ max-height: 60vh;
+ overflow: auto;
+}
+.protokollzeile {
+ display: flex; gap: var(--s-regular);
+ padding: 7px var(--s-regular);
+ border-bottom: 1px solid var(--line);
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ color: var(--text-dim);
+}
+.protokollzeile:last-child { border-bottom: 0; }
+.protokollzeit { flex: 0 0 auto; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.protokolltext { flex: 1; white-space: pre-wrap; word-break: break-word; }
+
+/* Fehler und Warnungen tragen die Farbe am Rand, nicht im Text: eine ganze
+ Zeile in Rot liest sich schlechter als eine in Grau mit rotem Strich. */
+.protokollzeile.fehler { border-left: 3px solid var(--danger, #e5484d); color: var(--text); }
+.protokollzeile.warnung { border-left: 3px solid #d9a441; }
+.protokollzeile.fortsetzung { padding-left: calc(var(--s-regular) * 2 + 52px); }
+
+.protokolltage { margin-top: var(--s-regular); }
+.protokollkacheln { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
+.protokollkachel {
+ display: flex; flex-direction: column; gap: 2px;
+ padding: 8px 12px;
+ border: 1px solid var(--line); border-radius: var(--r-control);
+ color: var(--text); text-decoration: none;
+}
+.protokollkachel:hover { background: var(--hover, rgba(255, 255, 255, .06)); }
+
/* Der Originaltitel unter dem übersetzten — leiser, aber lesbar. */
.originaltitel {
margin-top: 2px;
diff --git a/web/app/src/views/einstellungen/protokolle.ts b/web/app/src/views/einstellungen/protokolle.ts
index 41fd525..bbc840a 100644
--- a/web/app/src/views/einstellungen/protokolle.ts
+++ b/web/app/src/views/einstellungen/protokolle.ts
@@ -1,110 +1,169 @@
-import { protokolltage, protokollAdresse, ApiFehler } from '../../api';
-import { esc, knopf, leer, meldung } from '../../util';
+import { protokolltage, protokollAdresse, type Protokolltag } from '../../api';
+import { esc, leer, meldung } from '../../util';
import { abschnitt } from './teile';
-/** Die Tagesprotokolle des Servers. */
+/** Das Protokoll des Servers — offen, nicht weggeklappt.
+ *
+ * **Vorher musste man es sich holen.** Eine Liste von Tagen, je Tag drei
+ * Knöpfe, und erst nach „Ansehen" stand irgendwo Text. Wer wissen wollte,
+ * was gerade schiefgeht, brauchte drei Handgriffe für eine Antwort, die auf
+ * der ersten Seite hätte stehen können.
+ *
+ * **Jetzt steht es sofort da und läuft mit.** Neueste Zeile oben, damit man
+ * zum Mitlesen nicht scrollen muss; die älteren Tage stehen darunter als
+ * Kacheln zum Herunterladen.
+ */
+
+/** Wie viele Zeilen anfangs geholt werden. */
+const ANFANG = 300;
+
+/** Wie oft nachgesehen wird, solange die Seite offen ist. */
+const TAKT = 4000;
export function protokollbereich(): HTMLElement {
const bereich = document.createElement('section');
- bereich.appendChild(abschnitt('Protokolle'));
+ bereich.appendChild(abschnitt('Protokoll'));
const liste = document.createElement('div');
- liste.className = 'einstellungen';
+ liste.className = 'protokollliste';
liste.innerHTML = '
';
bereich.appendChild(liste);
- const anzeige = document.createElement('pre');
- anzeige.className = 'protokoll hidden';
- bereich.appendChild(anzeige);
-
- // **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
- // fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
- const mehr = knopf('Mehr laden', 'btn hidden');
+ const mehr = document.createElement('button');
+ mehr.className = 'btn hidden';
+ mehr.textContent = 'Weiter zurück';
bereich.appendChild(mehr);
- void (async () => {
- let tage;
- try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
+ const archiv = document.createElement('div');
+ archiv.className = 'protokolltage';
+ bereich.appendChild(archiv);
+
+ /** Wie viele Zeilen gerade geholt werden. */
+ let zeilen = ANFANG;
+ /** Was zuletzt ankam — damit nicht bei jedem Takt neu gezeichnet wird. */
+ let zuletzt = '';
+ let timer: number | undefined;
+
+ const heute = new Date().toLocaleDateString('sv-SE');
+
+ void tageLaden();
+ void laden();
+
+ // Aufhören, sobald die Seite weg ist — sonst fragt ein unsichtbares
+ // Protokoll bis zum Neuladen weiter.
+ const waechter = new MutationObserver(() => {
+ if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); }
+ });
+ waechter.observe(document.body, { childList: true, subtree: true });
+
+ mehr.onclick = () => { zeilen = Math.min(zeilen * 3, 5000); zuletzt = ''; void laden(); };
+
+ async function laden(): Promise {
+ try {
+ const antwort = await fetch(protokollAdresse(heute, zeilen));
+
+ // **404 heisst: heute noch nichts.** Das ist kein Fehler, sondern ein
+ // ruhiger Server.
+ if (antwort.status === 404) {
+ liste.innerHTML = '';
+ liste.appendChild(leer('Heute noch nichts protokolliert.'));
+ return;
+ }
+ if (!antwort.ok) throw new Error(String(antwort.status));
+
+ const roh = await antwort.text();
+ if (roh !== zuletzt) {
+ zuletzt = roh;
+ zeichnen(roh);
+ }
+
+ // „Weiter zurück" nur, solange es weiter zurück geht.
+ const anzahl = roh.split('\n').filter(z => z.trim().length > 0).length;
+ mehr.classList.toggle('hidden', anzahl < zeilen || zeilen >= 5000);
+ } catch (fehler) {
+ liste.innerHTML = '';
+ liste.appendChild(leer(`Konnte nicht gelesen werden: ${meldung(fehler)}`));
+ } finally {
+ timer = window.setTimeout(() => void laden(), TAKT);
+ }
+ }
+
+ function zeichnen(roh: string): void {
+ const zeilenListe = roh.split('\n').filter(z => z.trim().length > 0).reverse();
liste.innerHTML = '';
- if (tage.length === 0) {
- liste.appendChild(leer('Noch nichts protokolliert.'));
+ if (zeilenListe.length === 0) {
+ liste.appendChild(leer('Heute noch nichts protokolliert.'));
return;
}
- tage.forEach(t => {
- const d = document.createElement('div');
- d.className = 'einstellung';
+ zeilenListe.forEach(z => liste.appendChild(protokollzeile(z)));
+ }
- const oben = document.createElement('div');
- oben.className = 'knopfreihe';
- const text = document.createElement('div');
- text.className = 'zeile';
- text.style.flex = '1';
- const datum = new Date(t.date + 'T12:00:00').toLocaleDateString('de-CH',
- { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
- text.innerHTML = `${esc(datum)}`
+ async function tageLaden(): Promise {
+ let tage: Protokolltag[];
+ try { tage = await protokolltage(); } catch { return; }
+
+ // Der heutige Tag steht schon oben in voller Länge; ihn hier noch
+ // einmal als Kachel anzubieten wäre eine zweite Tür zum selben Raum.
+ const frueher = tage.filter(t => t.date !== heute);
+ if (frueher.length === 0) return;
+
+ const titel = document.createElement('div');
+ titel.className = 'winzig';
+ titel.textContent = 'Frühere Tage';
+ archiv.appendChild(titel);
+
+ const kacheln = document.createElement('div');
+ kacheln.className = 'protokollkacheln';
+
+ frueher.forEach(t => {
+ const a = document.createElement('a');
+ a.className = 'protokollkachel';
+ a.href = protokollAdresse(t.date);
+ a.download = `flaro-${t.date}.log`;
+ const datum = new Date(`${t.date}T12:00:00`).toLocaleDateString('de-CH',
+ { weekday: 'short', day: '2-digit', month: '2-digit' });
+ a.innerHTML = `${esc(datum)}`
+ `${Math.max(1, Math.round(t.bytes / 1024))} KB`;
- oben.appendChild(text);
-
- const lesen = knopf('Ansehen', 'btn');
- lesen.onclick = () => void zeigen(t.date, false);
- const nurFehler = knopf('Nur Fehler', 'btn');
- nurFehler.onclick = () => void zeigen(t.date, true);
- const holen = document.createElement('a');
- holen.className = 'btn';
- holen.href = protokollAdresse(t.date);
- holen.download = `flaro-${t.date}.log`;
- holen.textContent = 'Herunterladen';
- oben.append(lesen, nurFehler, holen);
-
- d.appendChild(oben);
- liste.appendChild(d);
+ kacheln.appendChild(a);
});
- })();
- /** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
- let gezeigt = 0;
-
- async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise {
- // **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
- // Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
- // wackelige Verbindung endete das in „Failed to fetch". Bei der
- // Fehlersuche mehr, weil davon die meisten weggefiltert werden.
- gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
-
- anzeige.classList.remove('hidden');
- anzeige.textContent = 'Wird geladen…';
- mehr.classList.add('hidden');
-
- try {
- const antwort = await fetch(protokollAdresse(tag, gezeigt));
- if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
-
- const roh = (await antwort.text()).split('\n');
- const gefiltert = nurFehler
- ? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
- : roh;
-
- anzeige.textContent = gefiltert.join('\n').trim().length === 0
- ? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
- : gefiltert.join('\n');
- anzeige.scrollTop = anzeige.scrollHeight;
-
- // **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
- // zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
- // nichts tut, ist schlechter als keiner.
- if (roh.length >= gezeigt && gezeigt < 20000) {
- mehr.classList.remove('hidden');
- mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
- mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
- }
- } catch (fehler) {
- anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
- }
+ archiv.appendChild(kacheln);
}
return bereich;
}
+/** Eine Zeile „14:37:51 INFO Server gestartet" in ihre drei Teile.
+ *
+ * **Was nicht passt, bleibt eine Zeile.** Eine mehrzeilige Fehlermeldung —
+ * ffmpeg schreibt gern vier Zeilen — hat keinen eigenen Zeitstempel; sie
+ * gehört zur vorigen und wird als Fortsetzung gezeigt statt verworfen.
+ */
+function protokollzeile(roh: string): HTMLElement {
+ const d = document.createElement('div');
+ const teile = /^(\d\d:\d\d:\d\d)\s+(\S+)\s+([\s\S]*)$/.exec(roh);
+
+ if (!teile) {
+ d.className = 'protokollzeile fortsetzung';
+ d.textContent = roh;
+ return d;
+ }
+
+ const [, zeit, art, text] = teile;
+ d.className = `protokollzeile ${art?.toLowerCase() === 'fehler' ? 'fehler'
+ : art?.toLowerCase() === 'warnung' ? 'warnung' : 'info'}`;
+
+ const z = document.createElement('span');
+ z.className = 'protokollzeit';
+ z.textContent = zeit ?? '';
+
+ const t = document.createElement('span');
+ t.className = 'protokolltext';
+ t.textContent = text ?? '';
+
+ d.append(z, t);
+ return d;
+}