Der Fortschritt sagt, woran er gerade ist — und schreibt es mit
Vorher stand in der Verwaltung minutenlang "Wird durchsucht…", ohne Hinweis, ob überhaupt etwas passiert. Bei vierzigtausend Dateien ist das nicht zu unterscheiden von einem hängenden Lauf. Scanschritt trägt Phase, Gegenstand und Zählstand und bildet daraus den Satz selbst — im Kern und nicht in der Oberfläche, weil die Apps denselben brauchen und zweimal formuliert er auseinanderdriftet. Sechs Phasen von "Suche Medien in /media/Filme" über "Suche ‚Ich einfach unverbesserlich' bei der Metadatenquelle — 44 von 1204" bis "Räume auf und lege Doppeltes zusammen". Pfade werden auf die letzten zwei Bestandteile gekürzt. ScanAsync und EnrichAsync bekommen je einen optionalen IProgress; optional heisst, dass kein vorhandener Aufrufer bricht. Der Koordinator hält nur den letzten Schritt — eine Liste über vierzigtausend Titel wäre ein Leck im Arbeitsspeicher. Dazu das Tagebuch: eine Datei je Tag unter config/logs, nach lokaler Zeit und nicht nach UTC, weil ein Scan um 23 Uhr am heutigen Datum gesucht wird. Dreissig Tage Aufbewahrung, einstellbar über FLARO_LOG_TAGE — ohne Grenze läuft die config-Partition eines NAS lautlos voll. Es wirft nie: ein volles Dateisystem darf keinen Scan abbrechen. In der Verwaltung lesbar, nach Fehlern filterbar und herunterladbar. Das Containerprotokoll ist nach einem Neustart weg und nur über SSH erreichbar — die Frage "warum wurde der Film gestern Nacht nicht erkannt?" liess sich damit nicht beantworten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
637e95fbb1
commit
f359807561
@@ -85,6 +85,8 @@ export type Scanstand = {
|
||||
removed: number | null; unrecognised: number | null; probed: number | null;
|
||||
/** Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. */
|
||||
missing: number | null;
|
||||
/** Woran der Lauf gerade ist, in einem Satz. */
|
||||
step: string | null;
|
||||
problems: { path: string; reason: string }[] | null; error: string | null;
|
||||
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
|
||||
skipped: boolean; error: string | null } | null;
|
||||
@@ -430,6 +432,14 @@ export type Ordnerstand = {
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export type Protokolltag = { date: string; bytes: number };
|
||||
|
||||
/** Welche Tage protokolliert sind, neueste zuerst. */
|
||||
export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
|
||||
|
||||
/** Die Adresse, unter der ein Protokolltag liegt — zum Lesen und Herunterladen. */
|
||||
export const protokollAdresse = (tag: string) => `/api/v1/system/logs/${tag}`;
|
||||
|
||||
export type VermissteDatei = {
|
||||
fileId: string; path: string; versionLabel: string | null;
|
||||
missingSince: string; libraryName: string;
|
||||
|
||||
@@ -634,3 +634,18 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Protokollanzeige — Festbreitenschrift, begrenzte Höhe, eigener Bildlauf. */
|
||||
.protokoll {
|
||||
margin: var(--s-regular) 0 0;
|
||||
padding: var(--s-regular);
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-control);
|
||||
font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--text-dim);
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
|
||||
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
||||
vermisste, vermisstesEntfernen, type VermissteDatei,
|
||||
protokolltage, protokollAdresse,
|
||||
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||
servernameSetzen, systemauskunft,
|
||||
laufendeWiedergaben, serverzustand, bildAdresse,
|
||||
@@ -26,7 +27,8 @@ export function verwaltung(
|
||||
): HTMLElement {
|
||||
const wurzel = document.createElement('div');
|
||||
wurzel.append(dashboard(signal), vermisstenbereich(geaendert),
|
||||
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert));
|
||||
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
|
||||
protokollbereich());
|
||||
return wurzel;
|
||||
}
|
||||
|
||||
@@ -158,6 +160,92 @@ function laufzeit(sekunden: number): string {
|
||||
return `${minuten} Min.`;
|
||||
}
|
||||
|
||||
/* ---------- Protokolle ---------- */
|
||||
|
||||
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
|
||||
*
|
||||
* Das Containerprotokoll ist nach einem Neustart weg und nur über SSH
|
||||
* erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
|
||||
* lässt sich damit nicht beantworten. */
|
||||
function protokollbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Protokolle'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
const anzeige = document.createElement('pre');
|
||||
anzeige.className = 'protokoll hidden';
|
||||
bereich.appendChild(anzeige);
|
||||
|
||||
void (async () => {
|
||||
let tage;
|
||||
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
|
||||
|
||||
liste.innerHTML = '';
|
||||
if (tage.length === 0) {
|
||||
liste.appendChild(leer('Noch nichts protokolliert.'));
|
||||
return;
|
||||
}
|
||||
|
||||
tage.forEach(t => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
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>`
|
||||
+ `<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);
|
||||
});
|
||||
})();
|
||||
|
||||
async function zeigen(tag: string, nurFehler: boolean): Promise<void> {
|
||||
anzeige.classList.remove('hidden');
|
||||
anzeige.textContent = 'Wird geladen…';
|
||||
try {
|
||||
const antwort = await fetch(protokollAdresse(tag));
|
||||
if (!antwort.ok) throw new Error(`${antwort.status}`);
|
||||
const text = await antwort.text();
|
||||
const zeilen = text.split('\n');
|
||||
// **Das Ende ist interessanter als der Anfang.** Ein Protokolltag kann
|
||||
// zehntausend Zeilen haben; wer etwas sucht, sucht meist das Letzte.
|
||||
const gefiltert = nurFehler
|
||||
? zeilen.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
|
||||
: zeilen;
|
||||
anzeige.textContent = gefiltert.length === 0
|
||||
? (nurFehler ? 'Keine Fehler an diesem Tag.' : 'Leer.')
|
||||
: gefiltert.slice(-800).join('\n');
|
||||
anzeige.scrollTop = anzeige.scrollHeight;
|
||||
} catch (fehler) {
|
||||
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
/* ---------- Nicht mehr gefunden ---------- */
|
||||
|
||||
/** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt.
|
||||
@@ -362,8 +450,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
for (let i = 0; i < 900; i++) {
|
||||
if (signal.aborted) return;
|
||||
const s = await scanstand(b.id);
|
||||
if (s.state === 'running') stand.textContent = 'Wird durchsucht…';
|
||||
else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…';
|
||||
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
|
||||
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
|
||||
if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…';
|
||||
else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…';
|
||||
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
|
||||
else if (s.state === 'finished') {
|
||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||
|
||||
Reference in New Issue
Block a user