Ein Plan für das, was an keinem Aufruf hängt
Sichern, Datenbank aufräumen, verwaiste Bilder wegwerfen — alle drei Tage, alle sieben Tage, von selbst. **Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.** An drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne Gegenwert, und für das Wegräumen abgelaufener Gerätecodes stimmt das: solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand etwas anklickt, und dafür gibt es eine Uhr. Alle fünfzehn Minuten eine Frage an eine Tabelle mit einer Handvoll Zeilen; ist nichts fällig — der Normalfall —, kostet es nichts. Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. Ein Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — oder, bei einem Zähler, der bei null anfängt, nie. Auch ein Fehlschlag wird vermerkt, sonst versuchte der Plan es alle fünfzehn Minuten wieder und eine volle Platte erzeugte ein volles Protokoll. **Die Sicherung ist `VACUUM INTO`, nicht Datei kopieren.** Die Datenbank läuft im WAL-Modus: neben `flaro.db` stehen `-wal` und `-shm`, und wer nur die erste Datei kopiert, bekommt einen Stand von vor Stunden oder einen halben. `VACUUM INTO` schreibt eine vollständige, aufgeräumte Kopie im laufenden Betrieb. Der Zielordner ist einstellbar und geht als gebundener Wert in die Anweisung — eine Einstellung soll keine SQL-Anweisung werden. Die Prüfung verlässt sich nicht darauf, dass eine Datei entstanden ist: sie öffnet die Kopie als Datenbank und zählt die Zeilen. Eine Sicherung, die sich nicht öffnen lässt, ist schlimmer als keine — man verlässt sich darauf und merkt es erst, wenn es zu spät ist. **Bilder nur, wenn verwaist.** Den ganzen Bildspeicher zu leeren gibt es als Knopf und ist gefahrlos, kostet danach aber tausende Abrufe bei TMDB. Hier fliegt nur, worauf kein Titel mehr zeigt — mit einer Stunde Schonfrist, weil ein Bild abgelegt wird, bevor der Verweis darauf gespeichert ist. Ohne diese Frist räumte ein Lauf mitten im Scan genau das weg, was gerade geholt wurde. **Eine eigene Seite „Wartung".** Die drei Kratzplatzzahlen standen unter „Wiedergabe" — dort ist ihr Anlass, aber nicht ihr Thema. Sie ziehen mit um: alles auf dieser Seite ist etwas, das der Server ohne Zutun erledigt, und man sieht es an einem Ort nach. Je Aufgabe steht da, wann sie zuletzt lief und was herauskam, dazu ein Knopf „Jetzt ausführen" — wer gleich etwas Grosses vorhat, will die Kopie jetzt und nicht in drei Tagen. 725 Tests grün, davon elf neue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c4af182d31
commit
532473b1f9
+21
-1
@@ -1035,7 +1035,27 @@ export type Aufraeumbericht = {
|
||||
export const kratzplatzAufraeumen = () =>
|
||||
anfrage<Aufraeumbericht>('/system/storage/transcode/cleanup', { method: 'POST' });
|
||||
|
||||
export type Wartungseinstellung = { capGb: number; unusedDays: number; graceMinutes: number };
|
||||
export type Wartungseinstellung = {
|
||||
capGb: number; unusedDays: number; graceMinutes: number;
|
||||
backupDays: number; backupKeep: number; backupFolder: string;
|
||||
optimizeDays: number; imageDays: number;
|
||||
};
|
||||
|
||||
/** Was eine Wartungsaufgabe zuletzt getan hat. */
|
||||
export type Wartungsstand = {
|
||||
aufgabe: 'Sicherung' | 'Optimieren' | 'Bilder';
|
||||
tage: number;
|
||||
zuletzt: string | null;
|
||||
faellig: string | null;
|
||||
ergebnis: string | null;
|
||||
fehler: string | null;
|
||||
};
|
||||
|
||||
export const wartungsstand = () => anfrage<Wartungsstand[]>('/system/maintenance');
|
||||
|
||||
/** Führt eine Wartungsaufgabe sofort aus, ohne auf den Plan zu warten. */
|
||||
export const wartungAusfuehren = (aufgabe: string) =>
|
||||
anfrage<{ ergebnis: string }>(`/system/maintenance/${aufgabe}`, { method: 'POST' });
|
||||
|
||||
export const wartungseinstellung = () =>
|
||||
anfrage<Wartungseinstellung>('/settings/maintenance');
|
||||
|
||||
@@ -10,12 +10,13 @@ import { wiedergabebereich } from './wiedergabe';
|
||||
import { sprachbereich } from './metadaten';
|
||||
import { benutzerbereich } from './benutzer';
|
||||
import { protokollbereich } from './protokolle';
|
||||
import { wartungsbereich } from './wartung';
|
||||
|
||||
/** Welcher Teil der Einstellungen offen ist. */
|
||||
export type Bereich =
|
||||
| 'konto' | 'geschaut'
|
||||
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
|
||||
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
|
||||
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'wartung' | 'protokolle';
|
||||
|
||||
type Eintrag = { wert: Bereich; text: string; bild: IconName };
|
||||
|
||||
@@ -34,6 +35,7 @@ const SERVER: Eintrag[] = [
|
||||
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
|
||||
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
|
||||
{ wert: 'benutzer', text: 'Benutzer', bild: 'serien' },
|
||||
{ wert: 'wartung', text: 'Wartung', bild: 'stufen' },
|
||||
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
|
||||
];
|
||||
|
||||
@@ -162,6 +164,8 @@ export function einstellungenseite(
|
||||
return [sprachbereich()];
|
||||
case 'benutzer':
|
||||
return [benutzerbereich()];
|
||||
case 'wartung':
|
||||
return [wartungsbereich()];
|
||||
case 'protokolle':
|
||||
return [protokollbereich()];
|
||||
case 'uebersicht':
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
wartungsstand, wartungAusfuehren, wartungseinstellung, wartungSetzen,
|
||||
type Wartungsstand, type Wartungseinstellung,
|
||||
} from '../../api';
|
||||
import { fehlerzeile, knopf, meldung, hinweisZeigen } from '../../util';
|
||||
import { dialog, type Dialogfeld } from '../../dialog';
|
||||
import { abschnitt, zeile } from './teile';
|
||||
|
||||
/** Was der Server in Abständen von selbst tut.
|
||||
*
|
||||
* **Warum das eine eigene Seite ist.** Die drei Zahlen zum Kratzplatz
|
||||
* standen zuerst unter „Wiedergabe" — dort ist ihr Anlass, aber nicht ihr
|
||||
* Thema. Sobald es eine Sicherung und ein Aufräumen der Datenbank gibt,
|
||||
* gehören sie zusammen: alles hier ist etwas, das der Server ohne Zutun
|
||||
* erledigt, und man sieht es an einem Ort nach.
|
||||
*/
|
||||
|
||||
/** Wie die Aufgaben heissen, wenn man sie liest statt sie aufzurufen. */
|
||||
const NAMEN: Record<Wartungsstand['aufgabe'], { titel: string; was: string }> = {
|
||||
Sicherung: {
|
||||
titel: 'Sicherung der Datenbank',
|
||||
was: 'Eine vollständige Kopie, im laufenden Betrieb geschrieben. '
|
||||
+ 'Das ist das Stück, das im Ernstfall zählt.',
|
||||
},
|
||||
Optimieren: {
|
||||
titel: 'Datenbank aufräumen',
|
||||
was: 'Packt die Datei zusammen und frischt die Statistiken auf, nach '
|
||||
+ 'denen SQLite seine Abfragen plant. Sperrt dabei für ein paar Sekunden.',
|
||||
},
|
||||
Bilder: {
|
||||
titel: 'Verwaiste Bilder entfernen',
|
||||
was: 'Nur Bilder, auf die kein Titel mehr zeigt. Den ganzen Bildspeicher '
|
||||
+ 'zu leeren gibt es als Knopf in der Übersicht — das kostet danach '
|
||||
+ 'aber tausende Abrufe bei TMDB.',
|
||||
},
|
||||
};
|
||||
|
||||
export function wartungsbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Wartung'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void laden();
|
||||
return bereich;
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let stand: Wartungsstand[];
|
||||
let werte: Wartungseinstellung;
|
||||
try {
|
||||
[stand, werte] = await Promise.all([wartungsstand(), wartungseinstellung()]);
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
liste.innerHTML = '';
|
||||
stand.forEach(s => liste.appendChild(aufgabe(s, werte)));
|
||||
|
||||
/* ---------- Der Kratzplatz ---------- */
|
||||
|
||||
liste.appendChild(abschnitt('Kratzplatz'));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Obergrenze',
|
||||
werte.capGb > 0 ? `${werte.capGb} GB` : 'Unbegrenzt',
|
||||
'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. '
|
||||
+ 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.',
|
||||
() => zahlFragen('Obergrenze', 'Gigabyte', werte.capGb, v => wartungSetzen({ capGb: v }))));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Unbenutzt löschen nach',
|
||||
werte.unusedDays > 0 ? `${werte.unusedDays} Tagen` : 'Nie',
|
||||
'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen '
|
||||
+ 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.',
|
||||
() => zahlFragen('Unbenutzt löschen nach', 'Tagen', werte.unusedDays,
|
||||
v => wartungSetzen({ unusedDays: v }))));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Schonfrist',
|
||||
`${werte.graceMinutes} Minuten`,
|
||||
'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. '
|
||||
+ 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte '
|
||||
+ 'eine Datei mitten im Herunterladen weg.',
|
||||
() => zahlFragen('Schonfrist', 'Minuten', werte.graceMinutes,
|
||||
v => wartungSetzen({ graceMinutes: v }))));
|
||||
}
|
||||
|
||||
/** Eine Aufgabe mit Abstand, letztem Lauf und einem Knopf zum Sofortstart. */
|
||||
function aufgabe(s: Wartungsstand, werte: Wartungseinstellung): HTMLElement {
|
||||
const { titel, was } = NAMEN[s.aufgabe];
|
||||
|
||||
const takt = s.tage > 0
|
||||
? (s.tage === 1 ? 'Täglich' : `Alle ${s.tage} Tage`)
|
||||
: 'Abgeschaltet';
|
||||
|
||||
// **Der letzte Lauf gehört in dieselbe Zeile wie der Takt.** „Alle 3
|
||||
// Tage" allein sagt nicht, ob es je geschehen ist — und genau das will
|
||||
// man bei einer Sicherung wissen.
|
||||
const wann = s.fehler
|
||||
? `zuletzt gescheitert: ${s.fehler}`
|
||||
: s.zuletzt
|
||||
? `zuletzt ${datum(s.zuletzt)}${s.ergebnis ? ` — ${s.ergebnis}` : ''}`
|
||||
: 'noch nie gelaufen';
|
||||
|
||||
const d = zeile(titel, `${takt} · ${wann}`, was,
|
||||
() => takteFragen(s, werte));
|
||||
|
||||
const jetzt = knopf('Jetzt ausführen', 'btn');
|
||||
jetzt.onclick = async () => {
|
||||
jetzt.disabled = true;
|
||||
const vorher = jetzt.textContent;
|
||||
jetzt.textContent = 'Läuft …';
|
||||
try {
|
||||
const { ergebnis } = await wartungAusfuehren(s.aufgabe.toLowerCase());
|
||||
hinweisZeigen(`${titel}: ${ergebnis}`);
|
||||
await laden();
|
||||
} catch (fehler) {
|
||||
hinweisZeigen(meldung(fehler), 'fehler');
|
||||
jetzt.disabled = false;
|
||||
jetzt.textContent = vorher;
|
||||
}
|
||||
};
|
||||
|
||||
d.querySelector('.knopfreihe')?.appendChild(jetzt);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Der Dialog je Aufgabe — Takt, und bei der Sicherung zwei Felder mehr. */
|
||||
function takteFragen(s: Wartungsstand, werte: Wartungseinstellung): void {
|
||||
const felder: Dialogfeld[] = [{
|
||||
name: 'tage', beschriftung: 'Alle wie viele Tage (0 = aus)',
|
||||
wert: String(s.tage), art: 'number',
|
||||
}];
|
||||
|
||||
if (s.aufgabe === 'Sicherung') {
|
||||
felder.push(
|
||||
{
|
||||
name: 'behalten', beschriftung: 'Wie viele Kopien behalten',
|
||||
wert: String(werte.backupKeep), art: 'number',
|
||||
},
|
||||
{
|
||||
name: 'ordner', beschriftung: 'Ordner (leer = neben der Datenbank)',
|
||||
wert: werte.backupFolder, art: 'ordner',
|
||||
hinweis: 'Am besten ein anderes Volume als die Datenbank — eine '
|
||||
+ 'Kopie neben dem Original hilft gegen Versehen, nicht gegen '
|
||||
+ 'eine kaputte Platte.',
|
||||
});
|
||||
}
|
||||
|
||||
dialog({
|
||||
titel: NAMEN[s.aufgabe].titel,
|
||||
felder,
|
||||
bestaetigen: 'Übernehmen',
|
||||
tun: async lesen => {
|
||||
const tage = Number(lesen('tage'));
|
||||
await wartungSetzen(
|
||||
s.aufgabe === 'Sicherung'
|
||||
? {
|
||||
backupDays: tage,
|
||||
backupKeep: Number(lesen('behalten')),
|
||||
backupFolder: lesen('ordner'),
|
||||
}
|
||||
: s.aufgabe === 'Optimieren'
|
||||
? { optimizeDays: tage }
|
||||
: { imageDays: tage });
|
||||
hinweisZeigen('Übernommen.');
|
||||
await laden();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function zahlFragen(
|
||||
titel: string, beschriftung: string, wert: number,
|
||||
setzen: (wert: number) => Promise<unknown>,
|
||||
): void {
|
||||
dialog({
|
||||
titel,
|
||||
felder: [{ name: 'wert', beschriftung, wert: String(wert), art: 'number' }],
|
||||
bestaetigen: 'Übernehmen',
|
||||
tun: async lesen => {
|
||||
await setzen(Number(lesen('wert')));
|
||||
hinweisZeigen('Übernommen.');
|
||||
await laden();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function datum(wert: string): string {
|
||||
return new Date(wert).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
wiedergabeEinstellung, wiedergabeSetzen, wartungseinstellung, wartungSetzen,
|
||||
} from '../../api';
|
||||
import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api';
|
||||
import { fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
|
||||
@@ -49,9 +47,8 @@ export function wiedergabebereich(): HTMLElement {
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let stand;
|
||||
let wartung;
|
||||
try {
|
||||
[stand, wartung] = await Promise.all([wiedergabeEinstellung(), wartungseinstellung()]);
|
||||
stand = await wiedergabeEinstellung();
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
@@ -109,55 +106,6 @@ export function wiedergabebereich(): HTMLElement {
|
||||
wert: stand.encoder,
|
||||
setzen: v => wiedergabeSetzen({ encoder: v }),
|
||||
})));
|
||||
|
||||
/* ---------- Was die Wiedergabe auf der Platte hinterlässt ---------- */
|
||||
|
||||
// **Hier und nicht unter einem eigenen Punkt.** Der Kratzplatz ist, was
|
||||
// die Wiedergabe hinterlässt: umgepackte Dateien, Segmente, Untertitel.
|
||||
// Drei Felder rechtfertigen keine eigene Seite in der Leiste.
|
||||
liste.appendChild(abschnitt('Kratzplatz'));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Obergrenze',
|
||||
wartung.capGb > 0 ? `${wartung.capGb} GB` : 'Unbegrenzt',
|
||||
'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. '
|
||||
+ 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.',
|
||||
() => zahlFragen('Obergrenze', 'Gigabyte', String(wartung!.capGb),
|
||||
v => wartungSetzen({ capGb: Number(v) }))));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Unbenutzt löschen nach',
|
||||
wartung.unusedDays > 0 ? `${wartung.unusedDays} Tagen` : 'Nie',
|
||||
'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen '
|
||||
+ 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht. '
|
||||
+ '0 heisst: nur die Obergrenze zählt.',
|
||||
() => zahlFragen('Unbenutzt löschen nach', 'Tagen', String(wartung!.unusedDays),
|
||||
v => wartungSetzen({ unusedDays: Number(v) }))));
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Schonfrist',
|
||||
`${wartung.graceMinutes} Minuten`,
|
||||
'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. '
|
||||
+ 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte '
|
||||
+ 'eine Datei mitten im Herunterladen weg.',
|
||||
() => zahlFragen('Schonfrist', 'Minuten', String(wartung!.graceMinutes),
|
||||
v => wartungSetzen({ graceMinutes: Number(v) }))));
|
||||
}
|
||||
|
||||
/** Ein Dialog für eine einzelne Zahl. */
|
||||
function zahlFragen(
|
||||
titel: string, beschriftung: string, wert: string, setzen: (wert: string) => Promise<unknown>,
|
||||
): void {
|
||||
dialog({
|
||||
titel,
|
||||
felder: [{ name: 'wert', beschriftung, wert, art: 'number' }],
|
||||
bestaetigen: 'Übernehmen',
|
||||
tun: async lesen => {
|
||||
await setzen(lesen('wert'));
|
||||
hinweisZeigen('Übernommen.');
|
||||
await laden();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */
|
||||
|
||||
Reference in New Issue
Block a user