Bisher wurde nur EINMAL gesichert: direkt vor einer Umstellung der
Datenbank. Faellt die Platte aus, ist alles weg -- die komplette
Betreuung, jede Aufgabe, jedes Protokoll. Das war das einzige Risiko im
System, das auf einen Schlag ALLES kostet.
Beim Nachsehen auf dem Server kam ein zweiter, schwerwiegenderer Befund
dazu:
workspace.db 4.096 B
workspace.db-wal 2.101.232 B
Im WAL-Betrieb landen Schreibvorgaenge zuerst im -wal und wandern erst
beim Checkpoint in die Hauptdatei. Der lief seit dem ersten Tag nicht.
Wer also workspace.db kopiert haette -- der naheliegendste Sicherungsweg
ueberhaupt -- haette eine LEERE Datenbank gesichert und es nicht gemerkt.
Der Test stellt genau das nach: aus der reinen Dateikopie waren 0 von 200
Aufgaben lesbar.
Deshalb:
- Nie eine Dateikopie. Immer VACUUM INTO -- SQLite schreibt selbst, liest
das WAL mit, Ergebnis ist in sich stimmig auch waehrend Schreibzugriffen.
- Vor jeder Sicherung ein erzwungener Checkpoint (TRUNCATE). Haelt die
Hauptdatei aktuell und das WAL klein.
- Jede frische Sicherung wird SOFORT wieder geoeffnet und geprueft: auf
Unversehrtheit UND darauf, dass Personen darin stehen -- gegen genau den
Fall einer technisch einwandfreien, aber leeren Datei. Faellt sie durch,
wird sie geloescht statt behalten.
- Erst unter Zwischennamen schreiben, dann pruefen, dann umbenennen. Sonst
stuende nach einem Abbruch eine halbe Datei unter dem richtigen Namen.
- Aufbewahrung 7 taeglich / 4 woechentlich / 6 monatlich, je Art getrennt
aufgeraeumt, damit taegliche nie die monatlichen verdraengen.
Laeuft IM Prozess, nicht als Systemdienst: keine Aenderung an Systemdateien
noetig, die Datenbank ist ohnehin offen, und es kommt mit jedem git pull
mit. Statt setTimeout auf 24 h wird viertelstuendlich geprueft, ob fuer
HEUTE schon eine liegt -- das ueberlebt Neustarts und Ausfaelle.
Dazu eine Zustandsansicht auf der Automationen-Seite: wann zuletzt, wie
gross, wie viele Personen darin, wie viel wartet noch im WAL, wie viel
Platz ist frei. Plus "Jetzt sichern" von Hand. Nur Leitung, 404 statt 403
fuer alle anderen.
Geprueft (server/pruef-sicherung.mjs, 27 Pruefungen) einschliesslich des
Ernstfalls: Sicherung schreiben, Datenbank zerstoeren, zurueckspielen,
Vollstaendigkeit und Schreibbarkeit nachweisen. Eine ungetestete Sicherung
ist eine Vermutung.
Co-Authored-By: Claude Opus 5 <[email protected]>
318 lines
14 KiB
JavaScript
318 lines
14 KiB
JavaScript
/* ===================================================================
|
||
Automationen.
|
||
|
||
Die Kachel trug lange "Phase 4", obwohl die Automationen laengst
|
||
liefen -- sie waren nur verteilt und hatten keinen Ort. Diese Seite
|
||
ist dieser Ort.
|
||
|
||
Sie fuehrt bewusst NICHTS Eigenes: Was hier steht, kommt aus den
|
||
Bereichen selbst. Eine Seite, die Automationen doppelt abbildet,
|
||
waere die naechste Stelle, die irgendwann etwas anderes behauptet als
|
||
die Wirklichkeit.
|
||
=================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
return a;
|
||
}
|
||
|
||
/* Was dauerhaft laeuft. Steht hier als Text, weil es Verhalten ist und
|
||
keine Daten -- aber jede Zeile beschreibt etwas, das tatsaechlich im
|
||
Code steht, nicht etwas Gewuenschtes. */
|
||
const REGELN = [
|
||
{ name: 'Hinweise auf der Übersicht',
|
||
text: 'Überfälliges, heute Fälliges, Gespräche ohne Protokoll, fällige Follow-ups und dringende Punkte werden bei jedem Aufruf neu ermittelt. Sie führen keine eigene Liste – ein Hinweis verschwindet von selbst, sobald die Sache erledigt ist.' },
|
||
{ name: '„Neu" in der Bibliothek',
|
||
text: 'Eine frisch eingestellte Anleitung steht 48 Stunden unter „Neu & wichtig“ und ist danach nur noch in ihrer Kategorie zu finden. Angepinntes bleibt, bis es jemand abschaltet.' },
|
||
{ name: 'Zuständigkeit bei der Übergabe',
|
||
text: 'Wird aus einem übergebenen Lead ein Creator angelegt, betreut ihn automatisch der Scout, der ihn gefunden hat. Die Angaben aus der Pipeline wandern dabei ins Creator-Profil.' },
|
||
{ name: 'Aufgaben aus Gesprächen',
|
||
text: 'To-dos aus einem Protokoll, Entscheidungen aus einem Review und Befunde aus dem Start-Check werden zu echten Aufgaben im Brett – in einem Zug, nicht als Text, den man später abtippt.' },
|
||
{ name: 'Sitzungen laufen ab',
|
||
text: 'Eine Anmeldung gilt zwölf Stunden. Nach einem neuen Zugangscode oder einer Sperre enden alle offenen Sitzungen der Person sofort.' },
|
||
{ name: 'Schutz vor Rateversuchen',
|
||
text: 'Acht Fehlversuche je Anschluss in zehn Minuten, danach ist zu. Gezählt wird die echte Adresse, nicht die von Cloudflare.' },
|
||
{ name: 'Sicherung vor Umbauten',
|
||
text: 'Bevor die Datenbank umgestellt wird, schreibt der Server eine vollständige Sicherung. Ohne Sicherung wird nicht umgestellt.' },
|
||
{ name: 'Nächtliche Sicherung',
|
||
text: 'Jede Nacht eine vollständige Sicherung, die sofort wieder geöffnet und geprüft wird – auf Unversehrtheit und darauf, dass wirklich Daten drinstehen. Aufgehoben werden 7 tägliche, 4 wöchentliche und 6 monatliche. Geschrieben wird nie eine Dateikopie, sondern immer von SQLite selbst.' },
|
||
];
|
||
|
||
const ART = {
|
||
ki_gefragt: 'KI um einen Vorschlag gebeten',
|
||
einstellung_geaendert: 'Einstellung geändert',
|
||
betreuung_gesetzt: 'Zuständigkeit gesetzt',
|
||
onboarding_gestartet: 'Creator aus einem Lead angelegt',
|
||
protokoll_geschrieben: 'Gesprächsprotokoll festgehalten',
|
||
startcheck_aufgabe: 'Aufgabe aus dem Start-Check',
|
||
review_entscheidung: 'Aufgabe aus dem Review',
|
||
anmeldung_gesperrt: 'Anmeldung wegen zu vieler Versuche gesperrt',
|
||
};
|
||
|
||
const zeitpunkt = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${p(d.getDate())}.${p(d.getMonth() + 1)}. · ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
};
|
||
|
||
/* ---------- KI ---------------------------------------------------------- */
|
||
|
||
async function kiZustand() {
|
||
let d;
|
||
try { d = await (await hole('/workspace/api/ki/status')).json(); } catch { return; }
|
||
|
||
$('ki-schalter-block').hidden = false;
|
||
$('ki-schalter').checked = d.angeschaltet !== false;
|
||
$('ki-schalter-text').textContent = d.angeschaltet !== false ? 'Angeschaltet' : 'Abgeschaltet';
|
||
$('ki-kasten').dataset.zustand = d.da ? 'laeuft' : (d.angeschaltet === false ? 'aus' : 'weg');
|
||
|
||
$('ki-text').textContent = d.angeschaltet === false
|
||
? 'Abgeschaltet. Die Vorschlags-Knöpfe erscheinen nirgends, und der Dienst wird nicht gefragt.'
|
||
: d.da
|
||
? 'Läuft. Die Vorschlags-Knöpfe erscheinen in Calls, Start-Check und Report.'
|
||
: 'Angeschaltet, aber gerade nicht erreichbar. Alles andere funktioniert normal weiter.';
|
||
|
||
const daten = $('ki-daten');
|
||
daten.textContent = '';
|
||
for (const [name, wert] of [
|
||
['Modell', 'Qwen3.5-4B, Apache 2.0'],
|
||
['Ort', 'auf diesem Server, nur über 127.0.0.1'],
|
||
['Kosten', '0 € – nichts verlässt die Maschine'],
|
||
['Grenzen', 'zwei von vier Kernen, eine Anfrage gleichzeitig, fünf je Person in zehn Minuten'],
|
||
['Zustand', d.beschaeftigt ? 'denkt gerade' : (d.da ? 'bereit' : '–')],
|
||
]) {
|
||
const li = el('li');
|
||
li.append(el('span', 'ki-kasten__schild', name), el('span', null, wert));
|
||
daten.append(li);
|
||
}
|
||
}
|
||
|
||
$('ki-schalter').addEventListener('change', async () => {
|
||
const an = $('ki-schalter').checked;
|
||
$('ki-schalter').disabled = true;
|
||
try {
|
||
const a = await hole('/workspace/api/ki/schalter', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ an }),
|
||
});
|
||
if (!a.ok) {
|
||
melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
|
||
$('ki-schalter').checked = !an;
|
||
return;
|
||
}
|
||
melde('');
|
||
await kiZustand();
|
||
} catch { /* umgeleitet */ } finally { $('ki-schalter').disabled = false; }
|
||
});
|
||
|
||
/* ---------- Hinweise ----------------------------------------------------- */
|
||
|
||
const HINWEISTEXT = {
|
||
aufgaben_ueberfaellig: 'überfällige Aufgaben', aufgaben_heute: 'heute fällige Aufgaben',
|
||
aufgaben_review: 'Aufgaben warten auf Freigabe', termine_heute: 'Termine heute',
|
||
protokoll_fehlt: 'Gespräche ohne Protokoll', dateien_review: 'Dateien im Review',
|
||
bereiche_dringend: 'dringende Punkte in den Bereichen', startcheck_handlung: 'Punkte im Start-Check',
|
||
followup_faellig: 'fällige Follow-ups', uebergabe_offen: 'übergebene Leads ohne Creator',
|
||
review_faellig: 'überfällige Review-Termine', review_bald: 'Reviews in den nächsten 7 Tagen',
|
||
ohne_betreuung: 'Creator ohne zuständige Person', ohne_profil: 'leere Creator-Profile',
|
||
ohne_startcheck: 'Creator ohne Start-Check',
|
||
};
|
||
|
||
async function hinweise() {
|
||
let d;
|
||
try { d = await (await hole('/workspace/api/hinweise')).json(); } catch { return; }
|
||
const ziel = $('hinweise');
|
||
ziel.textContent = '';
|
||
$('hinweis-zahl').textContent = String(d.hinweise.length);
|
||
if (!d.hinweise.length) {
|
||
ziel.append(el('p', 'leer-hinweis', 'Nichts offen. Alles läuft.'));
|
||
return;
|
||
}
|
||
const liste = el('div', 'hinweise');
|
||
for (const h of d.hinweise) {
|
||
const a = el('a', 'hinweis');
|
||
a.href = h.ziel;
|
||
a.dataset.stufe = h.stufe;
|
||
a.append(el('span', 'hinweis__zahl', String(h.anzahl)));
|
||
a.append(el('span', 'hinweis__text', HINWEISTEXT[h.art] || h.text));
|
||
a.append(el('span', 'hinweis__pfeil', '→'));
|
||
liste.append(a);
|
||
}
|
||
ziel.append(liste);
|
||
}
|
||
|
||
/* ---------- Sicherung & Zustand ------------------------------------------
|
||
|
||
Die einzige Automation, deren Ausfall ALLES auf einmal kostet -- und
|
||
die einzige, die man nicht bemerkt, wenn sie ausbleibt. Deshalb steht
|
||
hier nicht nur "läuft", sondern wann zuletzt, wie viel, wie viele
|
||
Personen darin standen und wie alt das ist.
|
||
------------------------------------------------------------------------ */
|
||
|
||
const bytes = (n) => {
|
||
if (n === null || n === undefined) return '–';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||
return (n / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||
};
|
||
|
||
function herZeit(iso) {
|
||
if (!iso) return null;
|
||
const min = Math.floor((Date.now() - Date.parse(iso)) / 60000);
|
||
if (!Number.isFinite(min)) return null;
|
||
if (min < 60) return `vor ${Math.max(min, 0)} Minuten`;
|
||
const std = Math.floor(min / 60);
|
||
if (std < 48) return `vor ${std} Stunden`;
|
||
return `vor ${Math.floor(std / 24)} Tagen`;
|
||
}
|
||
|
||
async function zustand() {
|
||
const kasten = $('zustand-kasten');
|
||
let z;
|
||
try {
|
||
const a = await hole('/workspace/api/zustand');
|
||
/* 404 heisst hier: diese Rolle soll davon gar nichts wissen. Dann
|
||
verschwindet der ganze Kasten, statt leer dazustehen. */
|
||
if (!a.ok) { kasten.hidden = true; return; }
|
||
z = await a.json();
|
||
} catch { kasten.hidden = true; return; }
|
||
|
||
const her = herZeit(z.letzte?.zeit);
|
||
/* Älter als 36 Stunden heisst: mindestens eine Nacht ist ausgefallen. */
|
||
const alt = z.letzte?.zeit
|
||
? (Date.now() - Date.parse(z.letzte.zeit)) > 36 * 3600_000 : true;
|
||
|
||
kasten.dataset.zustand = z.fehler ? 'fehler' : (alt ? 'alt' : 'gut');
|
||
$('zustand-titel').textContent = z.fehler
|
||
? 'Die letzte Sicherung ist fehlgeschlagen'
|
||
: z.letzte
|
||
? `Zuletzt gesichert ${her}`
|
||
: 'Noch keine Sicherung gelaufen';
|
||
$('zustand-text').textContent = z.fehler
|
||
? z.fehler.text
|
||
: z.letzte
|
||
? `${z.letzte.datei} · ${bytes(z.letzte.groesse)} · ${z.letzte.personen} Personen · `
|
||
+ `geschrieben und sofort wieder geprüft in ${z.letzte.dauer_ms} ms`
|
||
: 'Die erste läuft in der kommenden Nacht – oder jetzt gleich auf Knopfdruck.';
|
||
|
||
const daten = $('zustand-daten');
|
||
daten.textContent = '';
|
||
const zeilen = [
|
||
['Aufgehoben', `${z.anzahl} Sicherungen · ${bytes(z.belegt)} · `
|
||
+ `${z.behalten.taeglich} täglich, ${z.behalten.woechentlich} wöchentlich, ${z.behalten.monatlich} monatlich`],
|
||
['Datenbank', `${bytes(z.dateien?.db)} · Prüfung: ${z.heil || '–'} · Betrieb: ${z.journal || '–'}`],
|
||
/* Die WAL-Grösse steht hier, weil genau daran der Fehler vom
|
||
31.08.2026 aufgefallen wäre: Datenbank 4 KB, WAL 2,1 MB. */
|
||
['Wartende Schreibvorgänge', `${bytes(z.dateien?.wal)} (WAL – wird bei jeder Sicherung zusammengeführt)`],
|
||
['Platz', z.platte ? `${bytes(z.platte.frei)} frei von ${bytes(z.platte.gesamt)}` : '–'],
|
||
['Dienst läuft', `${Math.floor(z.seit / 3600)} h ${Math.floor((z.seit % 3600) / 60)} min`],
|
||
];
|
||
for (const [name, wert] of zeilen) {
|
||
const li = el('li');
|
||
li.append(el('span', 'ki-kasten__schild', name), el('span', null, wert));
|
||
daten.append(li);
|
||
}
|
||
|
||
const liste = $('sicherungen');
|
||
liste.textContent = '';
|
||
if (!z.sicherungen.length) {
|
||
liste.append(el('p', 'leise', 'Noch keine.'));
|
||
$('sicherungen-schalter').hidden = true;
|
||
return;
|
||
}
|
||
for (const s of z.sicherungen) {
|
||
const zeile = el('div', 'verlauf__zeile');
|
||
zeile.append(el('span', 'verlauf__zeit', s.name.replace(/\.db$/, '').split('-').slice(1).reverse().join('.')));
|
||
zeile.append(el('span', 'verlauf__was', s.name.split('-')[0]));
|
||
zeile.append(el('span', 'verlauf__wer', bytes(s.groesse)));
|
||
liste.append(zeile);
|
||
}
|
||
window.klappe?.(liste, $('sicherungen-schalter'), $('sicherungen-schalter-text'));
|
||
}
|
||
|
||
$('jetzt-sichern').addEventListener('click', async () => {
|
||
const knopf = $('jetzt-sichern');
|
||
knopf.disabled = true;
|
||
const vorher = knopf.textContent;
|
||
knopf.textContent = 'Sichert …';
|
||
try {
|
||
const a = await hole('/workspace/api/zustand/sichern', { method: 'POST' });
|
||
const d = await a.json().catch(() => ({}));
|
||
if (!a.ok) { melde(d.fehler || 'Die Sicherung ist fehlgeschlagen.'); return; }
|
||
melde('');
|
||
await zustand();
|
||
} catch { /* umgeleitet */ } finally {
|
||
knopf.disabled = false; knopf.textContent = vorher;
|
||
}
|
||
});
|
||
|
||
/* ---------- Verlauf ------------------------------------------------------ */
|
||
|
||
async function verlauf() {
|
||
const ziel = $('verlauf');
|
||
let d;
|
||
try {
|
||
const a = await hole('/workspace/api/verwaltung/protokoll?n=40');
|
||
if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Kein Zugriff auf das Protokoll.')); return; }
|
||
d = await a.json();
|
||
} catch { return; }
|
||
|
||
/* Nur die Eintraege, die tatsaechlich aus einer Automation stammen --
|
||
Anmeldungen und Handarbeit gehoeren hier nicht hin. */
|
||
const eintraege = (d.eintraege || d.protokoll || []).filter((e) => ART[e.aktion]).slice(0, 12);
|
||
ziel.textContent = '';
|
||
if (!eintraege.length) {
|
||
ziel.append(el('p', 'leer-hinweis', 'Noch nichts passiert, was von allein lief.'));
|
||
return;
|
||
}
|
||
const liste = el('div', 'verlauf');
|
||
for (const e of eintraege) {
|
||
const z = el('div', 'verlauf__zeile');
|
||
z.append(el('span', 'verlauf__zeit', zeitpunkt(e.zeitpunkt)));
|
||
z.append(el('span', 'verlauf__was', ART[e.aktion]));
|
||
z.append(el('span', 'verlauf__wer', [e.wer, e.detail].filter(Boolean).join(' · ')));
|
||
liste.append(z);
|
||
}
|
||
ziel.append(liste);
|
||
/* Geklappt wird die INNERE Liste, nicht #verlauf -- dessen einziges
|
||
Kind ist diese Liste, da gaebe es nichts zu verbergen. */
|
||
window.klappe?.(liste, $('verlauf-schalter'), $('verlauf-schalter-text'));
|
||
}
|
||
|
||
/* ---------- Start --------------------------------------------------------- */
|
||
|
||
const regeln = $('regeln');
|
||
for (const r of REGELN) {
|
||
const k = el('div', 'regel');
|
||
k.append(el('h3', 'regel__name', r.name));
|
||
k.append(el('p', 'regel__text', r.text));
|
||
regeln.append(k);
|
||
}
|
||
|
||
(async () => {
|
||
let ich;
|
||
try {
|
||
const a = await hole('/workspace/api/ich');
|
||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||
ich = await a.json();
|
||
} catch { return; }
|
||
$('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle);
|
||
await Promise.all([kiZustand(), zustand(), hinweise(), verlauf()]);
|
||
})();
|
||
})();
|