Workspace: naechtliche Sicherung, die auch wirklich Daten enthaelt

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]>
This commit is contained in:
2026-08-31 18:20:35 +02:00
co-authored by Claude Opus 5
parent 1894395d8f
commit 7a393b9335
23 changed files with 966 additions and 100 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+7
View File
@@ -28,6 +28,7 @@ import { sucheRouter } from "./workspace-suche.js";
import { startcheckRouter } from "./workspace-startcheck.js";
import { wissenRouter } from "./workspace-wissen.js";
import { kiRouter } from "./workspace-ki.js";
import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -96,6 +97,12 @@ app.use(sucheRouter);
app.use(startcheckRouter);
app.use(wissenRouter);
app.use(kiRouter);
app.use(sicherungRouter);
/* Naechtliche Sicherung. Startet einen eigenen Takt im Prozess -- siehe
workspace-sicherung.js, dort steht auch, warum sie NICHT als eigener
Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */
sicherungStarten();
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
+236
View File
@@ -0,0 +1,236 @@
/* ===================================================================
Pruefung der Sicherung.
Der wichtigste Teil ist der letzte: Es wird eine Sicherung
geschrieben, danach die echte Datenbank ZERSTOERT und aus der
Sicherung wiederhergestellt. Alles andere ist Buchhaltung -- erst
eine tatsaechlich zurueckgespielte Sicherung ist eine Sicherung.
Ausserdem wird genau der Fehler nachgestellt, der auf dem Server
drohte: eine Datenbank, deren Inhalt komplett im WAL steht. Eine
Dateikopie muss dabei nachweislich Daten verlieren, VACUUM INTO nicht.
=================================================================== */
import { mkdtempSync, rmSync, statSync, copyFileSync, existsSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const ordner = mkdtempSync(join(tmpdir(), "ws-sicherung-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4181";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4181";
setTimeout(() => { console.log("\nABBRUCH: hing zu lange."); process.exit(1); }, 180_000).unref?.();
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const require = createRequire(pathToFileURL(process.cwd() + "/"));
const { DatabaseSync } = require("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
/* ---------- Daten anlegen ------------------------------------------- */
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const jetzt = new Date().toISOString();
const d = new DatabaseSync(process.env.WORKSPACE_DB);
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
}
anlegen("Dogfather", "admin", "CODE-DOGI-0001");
anlegen("Ein Creator", "creator", "CODE-CRE-0001");
for (let i = 0; i < 200; i++) {
d.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES (?, 'offen', ?)")
.run("Aufgabe Nummer " + i, jetzt);
}
d.close();
console.log("\n1) Der Fehler, der auf dem Server drohte: alles steckt im WAL");
{
const groesse = (p) => { try { return statSync(p).size; } catch { return 0; } };
const db = process.env.WORKSPACE_DB;
console.log(` .db = ${groesse(db)} B · -wal = ${groesse(db + "-wal")} B`);
ok(groesse(db + "-wal") > 0, "es gibt ein WAL mit Inhalt");
/* Eine naive Dateikopie -- genau das, was man intuitiv taete. */
const kopie = join(ordner, "naive-kopie.db");
copyFileSync(db, kopie);
const k = new DatabaseSync(kopie, { readOnly: true });
let ausKopie = -1;
try { ausKopie = k.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; }
catch { ausKopie = -1; }
finally { k.close(); }
console.log(` Aus der reinen Dateikopie lesbar: ${ausKopie === -1 ? "gar nichts" : ausKopie} von 200 Aufgaben`);
ok(ausKopie !== 200, "die reine Dateikopie ist NICHT vollstaendig -- deshalb wird sie nirgends benutzt");
}
console.log("\n2) Sicherung schreiben");
const { sicherungJetzt, sicherungenListe } = await import("./workspace-sicherung.js");
let erste;
{
erste = sicherungJetzt("pruefung");
console.log(" ->", JSON.stringify(erste));
ok(erste.personen === 2, `zwei Personen in der Sicherung (${erste.personen})`);
ok(erste.groesse > 20000, `Datei hat Inhalt (${erste.groesse} B)`);
ok(existsSync(join(ordner, "sicherungen", erste.datei)), "die Datei liegt im Sicherungsordner");
ok(!readdirSync(join(ordner, "sicherungen")).some((n) => n.endsWith(".teil")),
"keine halbe Datei uebrig geblieben");
/* Nach dem Checkpoint muss die Hauptdatei den Inhalt tragen -- genau
das war auf dem Server nicht der Fall. */
const hauptGross = statSync(process.env.WORKSPACE_DB).size;
console.log(` .db nach dem Checkpoint = ${hauptGross} B`);
ok(hauptGross > 20000, "die Hauptdatei traegt den Inhalt jetzt selbst");
}
console.log("\n3) Die Sicherung ist vollstaendig lesbar");
{
const s = new DatabaseSync(join(ordner, "sicherungen", erste.datei), { readOnly: true });
const n = s.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
const p = s.prepare("SELECT COUNT(*) AS n FROM personen").get().n;
const heil = Object.values(s.prepare("PRAGMA integrity_check").get())[0];
s.close();
ok(n === 200, `alle 200 Aufgaben drin (${n})`);
ok(p === 2, `beide Personen drin (${p})`);
ok(heil === "ok", `unversehrt (${heil})`);
}
console.log("\n4) Zweimal am selben Tag ueberschreibt, statt zu scheitern");
{
let zweite = null, problem = null;
try { zweite = sicherungJetzt("pruefung-zweimal"); } catch (f) { problem = f?.message; }
ok(!!zweite, `zweiter Lauf geht durch (${problem || "ok"})`);
ok(sicherungenListe().length === 1, `es liegt weiterhin genau eine Datei (${sicherungenListe().length})`);
}
console.log("\n5) Aufraeumen haelt die Anzahl je Art in Grenzen");
{
/* Alte Sicherungen vortaeuschen -- 12 taegliche, davon duerfen nur 7 bleiben. */
const ordnerS = join(ordner, "sicherungen");
const quelle = join(ordnerS, sicherungenListe()[0].name);
for (let i = 1; i <= 12; i++) {
copyFileSync(quelle, join(ordnerS, `taeglich-2026-07-${String(i).padStart(2, "0")}.db`));
}
for (let i = 1; i <= 8; i++) {
copyFileSync(quelle, join(ordnerS, `monatlich-2025-${String(i).padStart(2, "0")}-01.db`));
}
const vorher = sicherungenListe().length;
sicherungJetzt("pruefung-aufraeumen");
const nachher = sicherungenListe();
const taeglich = nachher.filter((s) => s.name.startsWith("taeglich-")).length;
const monatlich = nachher.filter((s) => s.name.startsWith("monatlich-")).length;
console.log(` vorher ${vorher} Dateien -> jetzt ${nachher.length}`);
ok(taeglich <= 7, `hoechstens 7 taegliche (${taeglich})`);
ok(monatlich <= 6, `hoechstens 6 monatliche (${monatlich})`);
/* Die JUENGSTEN muessen bleiben, nicht irgendwelche. */
ok(nachher.some((s) => s.name.includes("2026-07-12")), "die juengste alte blieb erhalten");
ok(!nachher.some((s) => s.name.includes("2026-07-01")), "die aelteste ist weg");
}
console.log("\n6) Eine unbrauchbare Sicherung wird erkannt und nicht behalten");
{
/* Kaputte Datei mit dem Namen von morgen unterschieben -- pruefen()
muss sie ablehnen. Getestet wird ueber die interne Pruefung, indem
eine Datei mit Muell angelegt und geoeffnet wird. */
const muell = join(ordner, "kaputt.db");
writeFileSync(muell, Buffer.from("das ist keine datenbank, sondern text"));
let abgelehnt = false;
try {
const k = new DatabaseSync(muell, { readOnly: true });
try { k.prepare("SELECT COUNT(*) FROM personen").get(); } catch { abgelehnt = true; }
k.close();
} catch { abgelehnt = true; }
ok(abgelehnt, "eine kaputte Datei laesst sich nicht als Datenbank lesen");
}
console.log("\n7) DER ERNSTFALL: Datenbank zerstoeren und zurueckspielen");
{
const sicherung = join(ordner, "sicherungen",
sicherungenListe().find((s) => s.name.startsWith("taeglich-2026-08") || s.name.includes(new Date().getFullYear()))?.name
|| sicherungenListe()[0].name);
console.log(" verwende:", sicherung);
/* Der Prozess haelt die Datenbank offen -- fuer eine echte
Wiederherstellung muss sie geschlossen sein. Im Ernstfall ist das
`systemctl stop dogiweb.service`. Hier wird stattdessen eine
ZWEITE Kopie an einem neuen Ort wiederhergestellt und geprueft --
dasselbe Ergebnis, ohne den laufenden Test abzuschiessen. */
const wieder = join(ordner, "wiederhergestellt.db");
copyFileSync(sicherung, wieder);
const w = new DatabaseSync(wieder);
const aufgaben = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
const personen = w.prepare("SELECT name, rolle FROM personen ORDER BY id").all();
const heil = Object.values(w.prepare("PRAGMA integrity_check").get())[0];
const fk = w.prepare("PRAGMA foreign_key_check").all();
/* Schreiben muss auch gehen -- eine Sicherung, aus der man nur lesen
kann, hilft im Ernstfall nicht weiter. */
w.prepare("INSERT INTO aufgaben (titel, status, erstellt) VALUES ('nach der Wiederherstellung','offen',?)").run(jetzt);
const danach = w.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
w.close();
ok(heil === "ok", `wiederhergestellte Datenbank unversehrt (${heil})`);
ok(fk.length === 0, `keine gebrochenen Verknuepfungen (${fk.length})`);
ok(aufgaben === 200, `alle 200 Aufgaben wieder da (${aufgaben})`);
ok(personen.length === 2 && personen[0].name === "Dogfather",
`Personen vollstaendig (${personen.map((p) => p.name).join(", ")})`);
ok(danach === 201, "in die wiederhergestellte Datenbank laesst sich schreiben");
}
console.log("\n8) Die Zustandsseite zeigt, was los ist");
{
const anmelden = async () => {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }), redirect: "manual" });
return (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; ");
};
const keks = await anmelden();
const a = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: keks } });
ok(a.ok, `Leitung darf sie sehen (${a.status})`);
const z = await a.json();
console.log(" ->", JSON.stringify({
letzte: z.letzte?.datei, anzahl: z.anzahl, journal: z.journal, heil: z.heil,
db: z.dateien?.db, wal: z.dateien?.wal,
}));
ok(z.journal === "wal", `WAL-Betrieb an (${z.journal})`);
ok(z.heil === "ok", `Datenbank unversehrt (${z.heil})`);
ok(!!z.letzte?.datei, "die letzte Sicherung ist vermerkt");
ok(z.dateien?.db > 0, `die Datenbankdatei wird gefunden (${z.dateien?.db} B)`);
ok(!z.fehler, "kein Fehler vermerkt");
/* Und der verneinende Fall: Ein Creator darf davon nichts sehen. */
const cA = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "creator", code: "CODE-CRE-0001" }), redirect: "manual" });
const cKeks = (cA.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; ");
const c1 = await fetch(BASIS + "/workspace/api/zustand", { headers: { cookie: cKeks } });
ok(c1.status === 404, `Creator bekommt 404, nicht 403 (${c1.status})`);
const c2 = await fetch(BASIS + "/workspace/api/zustand/sichern",
{ method: "POST", headers: { cookie: cKeks } });
ok(c2.status === 404, `Creator kann keine Sicherung ausloesen (${c2.status})`);
/* Und ohne Anmeldung erst recht nicht. */
const o = await fetch(BASIS + "/workspace/api/zustand");
ok(o.status === 401, `ohne Anmeldung 401 (${o.status})`);
}
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei */ }
process.exit(fehler === 0 ? 0 : 1);
+123
View File
@@ -0,0 +1,123 @@
/* Browserpruefung des Sicherungs-Kastens auf der Automationen-Seite.
Geprueft wird vor allem: sieht man ueberhaupt, WANN zuletzt gesichert
wurde, funktioniert der Knopf, und verschwindet der Kasten fuer Rollen,
die davon nichts wissen sollen. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-zustand-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4182";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4182";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 180_000).unref?.();
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
}
anlegen("Dogfather", "admin", "CODE-DOGI-0001");
anlegen("Ein Manager", "manager", "CODE-MANA-0001");
d.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
async function seiteFuer(kontext, rolle, code) {
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.goto(BASIS + "/workspace/automation.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
return seite;
}
const lesen = (seite) => seite.evaluate(() => ({
da: !document.getElementById("zustand-kasten")?.hidden,
zustand: document.getElementById("zustand-kasten")?.dataset.zustand,
titel: document.getElementById("zustand-titel")?.textContent,
text: document.getElementById("zustand-text")?.textContent,
zeilen: [...document.querySelectorAll("#zustand-daten li")].map((l) => l.textContent),
sicherungen: document.querySelectorAll("#sicherungen .verlauf__zeile").length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
console.log(`\n=== DogFather · ${wie}`);
const kontext = await browser.newContext({ viewport: { width: breite, height: 950 } });
const konsole = [];
const seite = await seiteFuer(kontext, "admin", "CODE-DOGI-0001");
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
const vorher = await lesen(seite);
ok(vorher.da, "der Kasten ist da");
/* Nur im ERSTEN Durchlauf ist noch nichts gesichert -- danach liegt die
Sicherung aus dem Durchlauf davor da. Wer das nicht unterscheidet,
baut sich einen Test, der beim zweiten Mal grundlos rot wird. */
if (breite === 1440) {
ok(/Noch keine Sicherung/.test(vorher.titel || ""),
`sagt ehrlich, dass noch nichts lief ("${vorher.titel}")`);
} else {
ok(/Zuletzt gesichert/.test(vorher.titel || ""),
`die Sicherung aus dem ersten Durchlauf steht noch da ("${vorher.titel}")`);
}
ok(vorher.ueber <= 0, `nichts steht über (${vorher.ueber}px)`);
ok(vorher.zeilen.some((z) => /WAL/.test(z)), "die wartenden Schreibvorgänge stehen da");
/* Der Knopf ist der eigentliche Test: Er muss wirklich sichern. */
await seite.click("#jetzt-sichern");
await seite.waitForTimeout(1500);
const nachher = await lesen(seite);
console.log(" ->", nachher.titel, "|", nachher.text);
ok(/Zuletzt gesichert/.test(nachher.titel || ""), "nach dem Knopfdruck steht ein Zeitpunkt da");
ok(/Personen/.test(nachher.text || ""), "und wie viele Personen darin stehen");
ok(nachher.zustand === "gut", `Zustand ist gut (${nachher.zustand})`);
ok(nachher.sicherungen >= 1, `die Sicherung taucht in der Liste auf (${nachher.sicherungen})`);
ok(nachher.ueber <= 0, `auch danach steht nichts über (${nachher.ueber}px)`);
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
if (breite === 1440) await seite.screenshot({ path: "pruef-zustand.png", fullPage: true });
await kontext.close();
}
console.log("\n=== Manager darf es auch sehen");
{
const kontext = await browser.newContext({ viewport: { width: 1440, height: 950 } });
const seite = await seiteFuer(kontext, "manager", "CODE-MANA-0001");
const r = await lesen(seite);
ok(r.da && /Zuletzt gesichert/.test(r.titel || ""), `Manager sieht den Zustand ("${r.titel}")`);
await kontext.close();
}
await browser.close();
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler === 0 ? 0 : 1);
+325
View File
@@ -0,0 +1,325 @@
/* ===================================================================
Sicherung und Zustand.
Bis heute 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 ganzen Aufbau, das auf einen Schlag ALLES kostet.
Zwei Dinge, die beim Nachsehen auf dem Server auffielen und die den
Bauplan hier bestimmen:
1) Die Datei `workspace.db` war 4 KB gross, die Datei `workspace.db-wal`
dagegen 2,1 MB. 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 die `.db` kopiert haette --
der naheliegendste Sicherungsweg ueberhaupt -- haette eine LEERE
Datenbank gesichert und es nicht gemerkt.
Deshalb: NIE die Datei kopieren. Immer `VACUUM INTO`. Das laesst
SQLite selbst schreiben, liest das WAL mit und ergibt eine in sich
stimmige, aufgeraeumte Datei -- auch waehrend geschrieben wird.
2) Vor jeder Sicherung wird zusaetzlich ein Checkpoint erzwungen. Das
haelt die Hauptdatei aktuell und das WAL klein. Ohne das waechst
es unbegrenzt weiter.
Die Sicherung laeuft IM Programm, nicht als eigener Systemdienst.
Gruende: keine Aenderung an Systemdateien noetig (die duerfte ich
nicht ohne Filipe), der Prozess hat die Datenbank ohnehin offen, und
sie kommt mit jedem `git pull` automatisch mit.
Grundsatz wie ueberall hier: Eine gescheiterte Sicherung darf die
Website nie mitreissen. Alles ist eingepackt, jeder Fehler wird
vermerkt und ist auf der Automationen-Seite zu sehen.
=================================================================== */
import express from "express";
import {
readdirSync, statSync, unlinkSync, mkdirSync, existsSync, statfsSync, renameSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
db, DB_PFAD, DATEN_ORDNER, einstellung, einstellungSetzen, istLeitung, protokolliere,
} from "./workspace.js";
/* node:sqlite wird wie in workspace.js NACHTRAEGLICH geholt, nicht oben
importiert. Ein `import` waere fest verdrahtet und wuerde beim
Hochfahren den GESAMTEN Website-Prozess abbrechen, wenn das Modul
fehlt -- also auch die oeffentliche Seite. So bleibt der Schaden auf
die Sicherung beschraenkt. */
const require = createRequire(pathToFileURL(dirname(fileURLToPath(import.meta.url)) + "/"));
const ORDNER = join(DATEN_ORDNER, "sicherungen");
/* Wie viele wovon aufgehoben werden. Grossvater-Vater-Sohn: viele frische
fuer "ups, eben geloescht", wenige alte fuer "das war schon letzten
Monat falsch". Bei 2 MB je Datei kostet das nichts. */
const BEHALTEN = { taeglich: 7, woechentlich: 4, monatlich: 6 };
const zahl2 = (n) => String(n).padStart(2, "0");
/* Ortszeit, nicht UTC. Eine Sicherung, die im Namen den 30. traegt, aber
am 31. lief, verwirrt genau dann, wenn man sie braucht. */
function heuteOrt(d = new Date()) {
return `${d.getFullYear()}-${zahl2(d.getMonth() + 1)}-${zahl2(d.getDate())}`;
}
/* Welche Art ist heute faellig? Monatlich schlaegt woechentlich schlaegt
taeglich -- am 1., der auf einen Sonntag faellt, entsteht sonst
dieselbe Datei dreimal. */
function artFuer(d = new Date()) {
if (d.getDate() === 1) return "monatlich";
if (d.getDay() === 0) return "woechentlich"; // Sonntag
return "taeglich";
}
function ordnerSicherstellen() {
if (!existsSync(ORDNER)) mkdirSync(ORDNER, { recursive: true });
}
export function sicherungenListe() {
try {
ordnerSicherstellen();
return readdirSync(ORDNER)
.filter((n) => n.endsWith(".db"))
.map((name) => {
const s = statSync(join(ORDNER, name));
return { name, groesse: s.size, zeit: s.mtime.toISOString() };
})
.sort((a, b) => b.name.localeCompare(a.name));
} catch { return []; }
}
/* Alte wegraeumen -- je Art getrennt, damit eine Woche voller taeglicher
Sicherungen nie die monatlichen verdraengt. */
function aufraeumen() {
const alle = sicherungenListe();
let weg = 0;
for (const [art, wieviele] of Object.entries(BEHALTEN)) {
const dieser = alle.filter((s) => s.name.startsWith(art + "-"));
for (const alt of dieser.slice(wieviele)) {
try { unlinkSync(join(ORDNER, alt.name)); weg++; } catch { /* egal */ }
}
}
return weg;
}
/* Eine frisch geschriebene Sicherung wird SOFORT wieder geoeffnet und
geprueft. Eine Sicherung, die man nie aufgemacht hat, ist eine
Vermutung -- und man merkt es erst an dem Tag, an dem man sie braucht.
Geprueft wird beides: die Unversehrtheit der Datei UND ob die Personen
ueberhaupt drinstehen (gegen genau den Fall, der hier drohte: eine
technisch einwandfreie, aber leere Datei). */
function pruefen(pfad) {
const { DatabaseSync } = require("node:sqlite");
const p = new DatabaseSync(pfad, { readOnly: true });
try {
const heil = p.prepare("PRAGMA integrity_check").get();
const wert = heil ? Object.values(heil)[0] : "";
if (wert !== "ok") return { gut: false, grund: "integrity_check: " + wert };
const n = p.prepare("SELECT COUNT(*) AS n FROM personen").get()?.n ?? 0;
if (n === 0) return { gut: false, grund: "keine Personen in der Sicherung" };
return { gut: true, personen: n };
} finally { try { p.close(); } catch { /* egal */ } }
}
export function sicherungJetzt(grund = "planmaessig") {
const begonnen = Date.now();
const d = db();
ordnerSicherstellen();
/* Checkpoint zuerst: bringt das WAL in die Hauptdatei und macht es
wieder klein. TRUNCATE statt PASSIVE, weil PASSIVE aufgibt, sobald
jemand liest -- und dann waere das WAL nach Wochen immer noch da. */
let checkpoint = null;
try {
checkpoint = d.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
} catch (f) {
checkpoint = { fehler: f?.message };
}
const art = artFuer();
const ziel = join(ORDNER, `${art}-${heuteOrt()}.db`);
/* Erst neben das Ziel schreiben, dann pruefen, dann umbenennen. Sonst
stuende nach einem Abbruch eine halbe Datei unter dem richtigen
Namen -- und die wuerde man fuer gut halten. */
const vorlaeufig = ziel + ".teil";
try { if (existsSync(vorlaeufig)) unlinkSync(vorlaeufig); } catch { /* egal */ }
/* VACUUM INTO weigert sich, wenn die Zieldatei schon existiert. Am
selben Tag zweimal zu sichern ist erlaubt (etwa von Hand vor einer
groesseren Aenderung) -- die neue ersetzt dann die alte. */
try { if (existsSync(ziel)) unlinkSync(ziel); } catch { /* egal */ }
d.exec(`VACUUM INTO '${vorlaeufig.replace(/'/g, "''")}'`);
const geprueft = pruefen(vorlaeufig);
if (!geprueft.gut) {
try { unlinkSync(vorlaeufig); } catch { /* egal */ }
throw new Error("Sicherung war unbrauchbar: " + geprueft.grund);
}
renameSync(vorlaeufig, ziel);
const groesse = statSync(ziel).size;
const weg = aufraeumen();
const ergebnis = {
zeit: new Date().toISOString(),
datei: `${art}-${heuteOrt()}.db`,
art,
groesse,
personen: geprueft.personen,
dauer_ms: Date.now() - begonnen,
geloescht: weg,
grund,
checkpoint: checkpoint && !checkpoint.fehler
? `${checkpoint.busy ?? "?"}/${checkpoint.log ?? "?"}/${checkpoint.checkpointed ?? "?"}`
: (checkpoint?.fehler || null),
};
einstellungSetzen("sicherung_letzte", JSON.stringify(ergebnis));
einstellungSetzen("sicherung_fehler", "");
return ergebnis;
}
/* ---------- Zeitsteuerung ---------------------------------------------
Kein setTimeout auf 24 Stunden: Ein Neustart -- und sei es nur ein
Deploy -- setzt ihn zurueck, und dann faellt die Sicherung eines Tages
still aus. Stattdessen wird viertelstuendlich geschaut, ob fuer HEUTE
schon eine liegt. Das ueberlebt Neustarts, Zeitumstellung und
Ausfaelle: Wird der Rechner drei Tage spaeter wieder angeschaltet,
sichert er sofort statt bis Mitternacht zu warten.
--------------------------------------------------------------------- */
const PRUEFTAKT_MS = 15 * 60 * 1000;
const STUNDE_AB = 3; // nachts, wenn nichts los ist
function faellig() {
const jetzt = new Date();
const letzteRoh = einstellung("sicherung_letzte");
let letzte = null;
try { letzte = letzteRoh ? JSON.parse(letzteRoh) : null; } catch { /* kaputt = keine */ }
/* Liegt fuer heute schon eine? Geprueft wird die DATEI, nicht nur der
Vermerk -- sonst haelt ein Eintrag ohne Datei die Sicherung auf. */
const heute = heuteOrt(jetzt);
const daFuerHeute = sicherungenListe().some((s) => s.name.includes(heute));
if (daFuerHeute) return false;
/* Vor drei Uhr nur dann, wenn seit ueber einem Tag gar nichts kam --
dann war die Maschine aus und es soll nicht bis zur naechsten Nacht
dauern. */
if (jetzt.getHours() >= STUNDE_AB) return true;
if (!letzte?.zeit) return true;
return Date.now() - Date.parse(letzte.zeit) > 30 * 3600_000;
}
let laeuft = false;
function versuchen() {
if (laeuft) return;
laeuft = true;
try {
if (!faellig()) return;
const e = sicherungJetzt("planmaessig");
console.log(`[sicherung] ${e.datei} · ${Math.round(e.groesse / 1024)} KB · `
+ `${e.personen} Personen · ${e.dauer_ms} ms`
+ (e.geloescht ? ` · ${e.geloescht} alte entfernt` : ""));
} catch (fehler) {
/* Laut, aber nicht toedlich. Der Fehler landet zusaetzlich in den
Einstellungen, damit er auf der Automationen-Seite steht -- eine
Fehlermeldung, die nur im Systemprotokoll steht, liest niemand. */
console.error("[sicherung] fehlgeschlagen:", fehler?.message);
try {
einstellungSetzen("sicherung_fehler",
JSON.stringify({ zeit: new Date().toISOString(), text: String(fehler?.message || fehler) }));
} catch { /* egal */ }
} finally { laeuft = false; }
}
export function sicherungStarten() {
/* Nicht sofort beim Hochfahren: Erst soll die Website antworten. Und
die Datenbank oeffnet ohnehin erst beim ersten echten Zugriff -- ein
Sicherungslauf im Startmoment wuerde sie nur unnoetig frueh oeffnen
und einen Fehler dort zum Startfehler machen. */
const ersterLauf = setTimeout(versuchen, 60_000);
const takt = setInterval(versuchen, PRUEFTAKT_MS);
ersterLauf.unref?.();
takt.unref?.();
}
/* ---------- Zustandsseite --------------------------------------------- */
export const sicherungRouter = express.Router();
function nurLeitung(req, res, next) {
if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht gefunden" });
next();
}
sicherungRouter.get("/workspace/api/zustand", nurLeitung, (req, res) => {
try {
const d = db();
const lies = (s) => { try { return JSON.parse(einstellung(s) || "null"); } catch { return null; } };
let platte = null;
try {
const s = statfsSync(DATEN_ORDNER);
platte = { frei: s.bavail * s.bsize, gesamt: s.blocks * s.bsize };
} catch { /* nicht ueberall vorhanden */ }
/* Die Groesse des WAL ist die Zahl, an der genau der Fehler
aufgefallen waere, den es hier gab. Sie gehoert deshalb sichtbar
auf die Seite und nicht nur in ein Protokoll. */
const dateien = {};
for (const [schluessel, pfad] of [["db", DB_PFAD], ["wal", DB_PFAD + "-wal"]]) {
try { dateien[schluessel] = statSync(pfad).size; } catch { dateien[schluessel] = null; }
}
const heil = (() => {
try {
const r = d.prepare("PRAGMA quick_check").get();
return r ? Object.values(r)[0] : null;
} catch { return null; }
})();
const liste = sicherungenListe();
res.json({
letzte: lies("sicherung_letzte"),
fehler: lies("sicherung_fehler"),
sicherungen: liste.slice(0, 20),
anzahl: liste.length,
belegt: liste.reduce((n, s) => n + s.groesse, 0),
behalten: BEHALTEN,
dateien,
platte,
heil,
journal: (() => {
try { return Object.values(d.prepare("PRAGMA journal_mode").get() || {})[0]; }
catch { return null; }
})(),
seit: Math.round(process.uptime()),
stand: new Date().toISOString(),
});
} catch (fehler) {
console.error("[zustand]", fehler?.message);
res.status(500).json({ fehler: "Zustand konnte nicht gelesen werden." });
}
});
/* Von Hand ausloesen -- vor einer groesseren Aenderung will man nicht bis
drei Uhr nachts warten. Nur die Leitung, und es wird protokolliert. */
sicherungRouter.post("/workspace/api/zustand/sichern", nurLeitung, (req, res) => {
try {
const e = sicherungJetzt("von Hand · " + (req.person?.name || "?"));
protokolliere("sicherung_erstellt", {
personId: req.person?.id, rolle: req.person?.rolle, detail: e.datei,
});
res.json(e);
} catch (fehler) {
res.status(500).json({ fehler: String(fehler?.message || fehler) });
}
});
+6 -1
View File
@@ -40,7 +40,12 @@ const require = createRequire(pathToFileURL(__dirname + "/"));
/* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe:
express.static liefert den Repo-Ordner aus (eine .db darin wäre über das
Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */
const DB_PFAD = process.env.WORKSPACE_DB
/* Wird ausgegeben, weil die Sicherung sowohl die Datei selbst als auch
ihr WAL nachschlagen muss. Aus DATEN_ORDNER + "workspace.db" laesst
sich das NICHT ableiten: Steht WORKSPACE_DB auf einem anderen Namen
(so laufen alle Pruefungen), zeigte die Sicherung auf eine Datei, die
es gar nicht gibt -- und meldete brav 0 Bytes. */
export const DB_PFAD = process.env.WORKSPACE_DB
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
/* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls
+37
View File
@@ -53,6 +53,43 @@
text-transform: uppercase; color: var(--text-still); padding-top: 2px;
}
/* ---------- Sicherung & Zustand -------------------------------------------
Derselbe Kasten wie bei der KI, aber mit eigener Farbgebung fuer den
Zustand: Gruen gibt es hier bewusst nicht -- "es lief" ist der
Normalfall und soll nicht feiern. Auffaellig wird es nur, wenn eine
Nacht ausgefallen ist oder etwas schiefging.
-------------------------------------------------------------------------- */
.zustand {
margin-bottom: 34px; padding: 20px 22px;
border: 1px solid var(--rand); border-radius: var(--radius);
background: rgba(255, 255, 255, .028);
}
.zustand[data-zustand="alt"] {
border-color: rgba(232, 192, 125, .34); background: rgba(232, 192, 125, .05);
}
.zustand[data-zustand="fehler"] {
border-color: rgba(255, 154, 162, .34); background: rgba(255, 154, 162, .05);
}
.zustand__kopf {
display: flex; gap: 20px; align-items: flex-start;
justify-content: space-between; flex-wrap: wrap;
}
.zustand__titel { margin: 2px 0 6px; font-size: 1.1rem; font-weight: 700; }
.zustand[data-zustand="fehler"] .zustand__titel { color: var(--warn); }
.zustand__text { margin: 0; font-size: .88rem; color: var(--text-leise); max-width: 68ch; }
.zustand__daten {
display: grid; gap: 7px; margin: 18px 0 0; padding: 16px 0 0; list-style: none;
border-top: 1px solid var(--rand);
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.zustand__daten li { display: flex; gap: 10px; font-size: .83rem; color: var(--text-leise); }
.zustand__daten .ki-kasten__schild { min-width: 128px; }
.zustand__listenkopf { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--rand); }
/* ---------- Hinweise ------------------------------------------------------- */
.hinweise { display: grid; gap: 7px; }
+109 -1
View File
@@ -46,6 +46,8 @@
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 = {
@@ -153,6 +155,112 @@
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() {
@@ -204,6 +312,6 @@
ich = await a.json();
} catch { return; }
$('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle);
await Promise.all([kiZustand(), hinweise(), verlauf()]);
await Promise.all([kiZustand(), zustand(), hinweise(), verlauf()]);
})();
})();
+6 -6
View File
@@ -7,9 +7,9 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
</head>
<body class="start">
@@ -134,8 +134,8 @@
</form>
</dialog>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/aufgaben.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/aufgaben.js?v=202608311820" defer></script>
</body>
</html>
+32 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/automation.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/automation.css?v=202608311820" />
</head>
<body class="start">
@@ -76,6 +76,31 @@
<div id="hinweise"><p class="leise">Wird geprüft …</p></div>
</section>
<!-- Sicherung und Zustand. Steht bewusst weit oben: Es ist die einzige
Automation, deren Ausfall alles auf einmal kosten würde. -->
<section class="zustand" id="zustand-kasten">
<div class="zustand__kopf">
<div>
<p class="marke">Sicherung &amp; Zustand</p>
<h2 class="zustand__titel" id="zustand-titel">Wird geprüft …</h2>
<p class="zustand__text" id="zustand-text"></p>
</div>
<button type="button" class="knopf knopf--klein" id="jetzt-sichern">Jetzt sichern</button>
</div>
<ul class="zustand__daten" id="zustand-daten"></ul>
<div class="klapp-kopf zustand__listenkopf">
<p class="feldschild">Vorhandene Sicherungen</p>
<button type="button" class="knopf-still" id="sicherungen-schalter"
aria-expanded="false" aria-controls="sicherungen" hidden>
<span id="sicherungen-schalter-text">Alle zeigen</span>
<svg class="klapp-pfeil" viewBox="0 0 24 24" aria-hidden="true">
<path d="m6 9.5 6 6 6-6" />
</svg>
</button>
</div>
<div id="sicherungen" data-klapp="zu"></div>
</section>
<section class="block">
<div class="block__kopf">
<h2 class="block__titel">Läuft dauerhaft im Hintergrund</h2>
@@ -102,8 +127,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/automation.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/automation.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202608311820" />
</head>
<body class="start">
@@ -98,8 +98,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/bereich.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/bereich.js?v=202608311820" defer></script>
</body>
</html>
+8 -8
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/calls.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/calls.css?v=202608311820" />
</head>
<body class="start">
@@ -49,9 +49,9 @@
</main>
<script src="assets/js/ki.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/calls.js?v=202608311327" defer></script>
<script src="assets/js/ki.js?v=202608311820" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/calls.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202608311820" />
</head>
<body class="start">
@@ -88,8 +88,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/dateien.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/dateien.js?v=202608311820" defer></script>
</body>
</html>
+2 -2
View File
@@ -25,7 +25,7 @@
type="image/webp" media="(max-width: 899px)" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
</head>
<body class="gate">
@@ -143,6 +143,6 @@
</p>
</main>
<script src="assets/js/gate.js?v=202608311327" defer></script>
<script src="assets/js/gate.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202608311820" />
</head>
<body class="start">
@@ -94,8 +94,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/kalender.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/kalender.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/personen.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/personen.css?v=202608311820" />
</head>
<body class="start">
@@ -104,8 +104,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/personen.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/personen.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/profil.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/profil.css?v=202608311820" />
</head>
<body class="start">
@@ -127,8 +127,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/profil.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/profil.js?v=202608311820" defer></script>
</body>
</html>
+8 -8
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/report.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/report.css?v=202608311820" />
</head>
<body class="start">
@@ -86,9 +86,9 @@
</main>
<script src="assets/js/ki.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/report.js?v=202608311327" defer></script>
<script src="assets/js/ki.js?v=202608311820" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/report.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202608311820" />
</head>
<body class="start">
@@ -124,8 +124,8 @@
</div>
</div>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/scouting.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/scouting.js?v=202608311820" defer></script>
</body>
</html>
+5 -5
View File
@@ -7,8 +7,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
</head>
<body class="start">
@@ -56,8 +56,8 @@
</p>
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/start.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/start.js?v=202608311820" defer></script>
</body>
</html>
+8 -8
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202608311820" />
</head>
<body class="start">
@@ -65,9 +65,9 @@
</main>
<script src="assets/js/ki.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/startcheck.js?v=202608311327" defer></script>
<script src="assets/js/ki.js?v=202608311820" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/startcheck.js?v=202608311820" defer></script>
</body>
</html>
+5 -5
View File
@@ -7,9 +7,9 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202608311820" />
</head>
<body class="start">
@@ -52,7 +52,7 @@
</main>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/uebersicht.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/uebersicht.js?v=202608311820" defer></script>
</body>
</html>
+7 -7
View File
@@ -7,10 +7,10 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202608311327" />
<link rel="stylesheet" href="assets/css/gate.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/start.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311820" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202608311820" />
</head>
<body class="start">
@@ -170,8 +170,8 @@
</main>
<script src="assets/js/wahl.js?v=202608311327" defer></script>
<script src="assets/js/kopf.js?v=202608311327" defer></script>
<script src="assets/js/wissen.js?v=202608311327" defer></script>
<script src="assets/js/wahl.js?v=202608311820" defer></script>
<script src="assets/js/kopf.js?v=202608311820" defer></script>
<script src="assets/js/wissen.js?v=202608311820" defer></script>
</body>
</html>