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; +}