Das Protokoll steht jetzt da, statt sich holen zu lassen

Vorher: 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 ist „Protokolle" eine Liste von Ereignissen, wie man sie aus Home
Assistant kennt: der heutige Tag sofort und offen, neueste Zeile oben —
damit man zum Mitlesen nicht scrollen muss —, und alle vier Sekunden
kommt dazu, was dazugekommen ist. Fehler und Warnungen tragen die Farbe
am Rand statt im Text; eine ganze Zeile in Rot liest sich schlechter als
eine in Grau mit rotem Strich.

Die früheren Tage stehen darunter als Kacheln zum Herunterladen. Der
heutige nicht: er steht schon oben, und eine zweite Tür zum selben Raum
ist keine Hilfe.

Mehrzeiliges — ffmpeg schreibt gern vier Zeilen — hat keinen eigenen
Zeitstempel und gehört zur Zeile darüber. Es wird eingerückt gezeigt
statt verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-10 20:15:50 +02:00
co-authored by Claude Opus 5
parent 4db2a17e64
commit a6b3217d6e
2 changed files with 182 additions and 83 deletions
+40
View File
@@ -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;
+142 -83
View File
@@ -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 = '<div class="spinner"></div>';
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<void> {
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 = `<strong>${esc(datum)}</strong>`
async function tageLaden(): Promise<void> {
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 = `<strong>${esc(datum)}</strong>`
+ `<span class="winzig">${Math.max(1, Math.round(t.bytes / 1024))} KB</span>`;
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<void> {
// **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;
}