Stufe 1 (#764): Werkzeugeinstellungen, Sicherungszeitplan und Import als neue Instanz

- Werkzeugformulare aus den typisierten Einstellungsklassen (L8): Geheimnisse
  nur schreibend, nur geaenderte Schluessel werden ersetzt, Rechte bleiben
- Database.allowedTables las ein JSON-Array als rohen Text und haette es beim
  naechsten Speichern zerlegt
- Automatische Sicherung der Instanz in der Sicherungsansicht (L10)
- Sicherung hochladen und als neue Instanz einspielen (L11): Pfade der alten
  Maschine werden zurueckgesetzt, eine Kopie neben dem Original bekommt eine
  eigene ID, Autostart aus; Upload bis 4 GB
- api.js schickte eine Datei als "{}"; Dialoge schliessen beim Seitenwechsel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 14:13:37 +02:00
co-authored by Claude Opus 5.5
parent 8552755bb6
commit d2559c763a
17 changed files with 1027 additions and 28 deletions
@@ -97,11 +97,17 @@ export async function rendereSicherung() {
);
}
layout.append(infoBereich, inhaltBereich, ftpBereich);
// #764: the schedule of the active instance, and importing a backup as a new instance.
const scheduleArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" });
const importArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" });
layout.append(infoBereich, inhaltBereich, scheduleArea, ftpBereich, importArea);
container.append(layout);
// Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da.
rendereFtp(ftpBereich);
renderImport(importArea);
if (instanzId) renderSchedule(scheduleArea);
if (!instanzId) return;
@@ -505,3 +511,104 @@ async function rendereFtp(bereich) {
bereich.replaceChildren(erstelleKarte(t("backup.ftpTitle"), [form]));
}
// ─── Automatic backup of the active instance (#764) ───
//
// The manager makes the daily backup and reads switch, time and retention from the instance
// file. That file belongs to the instance process, so it is written through the instance
// (/api/settings) — which is why the instance has to be running to change it.
async function renderSchedule(area) {
let settings;
try {
settings = await api("/settings");
} catch {
area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [
erstelleElement("p", { klasse: "hinweis", text: t("backup.scheduleNeedsRunning") })
]));
return;
}
const form = erstelleElement("form", { klasse: "formular" });
const enabled = checkbox("backup-auto", t("backup.scheduleEnabled"), settings.autoBackupEnabled);
const time = erstelleElement("input", { type: "time", value: settings.autoBackupTime || "03:00" });
const keep = erstelleElement("input", { type: "number", min: "0", max: "365", value: String(settings.backupKeepCount ?? 14) });
const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") });
form.append(
enabled.gruppe,
feld(t("backup.scheduleTime"), time),
feld(t("backup.scheduleKeep"), keep, t("backup.scheduleKeepHint")),
erstelleElement("div", { klasse: "gruppe-horizontal" }, save));
form.addEventListener("submit", async (e) => {
e.preventDefault();
save.disabled = true;
try {
const result = await api("/settings", {
method: "PUT",
body: { autoBackupEnabled: enabled.box.checked, autoBackupTime: time.value, backupKeepCount: Number(keep.value) }
});
zeigeMeldung(result?.message ?? t("common.saved"), "erfolg");
} catch (err) {
zeigeMeldung(err.message, "gefahr");
} finally {
save.disabled = false;
}
});
area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [form]));
}
// ─── Import a backup as a new instance (#764) ───
//
// The move to a fresh machine: there is no instance yet, so no backup list to pick from.
// A backup from another installation needs that installation's license key; the server says
// so (code "fremde_lizenz") and the field appears.
function renderImport(area) {
const form = erstelleElement("form", { klasse: "formular" });
const file = erstelleElement("input", { type: "file", accept: ".clawdbak,.zip", required: true });
const name = erstelleElement("input", { type: "text", required: true, placeholder: t("backup.importNamePlaceholder") });
const key = erstelleElement("input", { type: "password", autocomplete: "off", placeholder: t("backup.sourceLicensePlaceholder") });
const keyField = feld(t("backup.sourceLicenseLabel"), key, t("backup.sourceLicenseHint"));
keyField.hidden = true;
const submit = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("backup.importButton") });
form.append(
erstelleElement("p", { klasse: "gedaempft", text: t("backup.importHint") }),
feld(t("backup.importFile"), file),
feld(t("backup.importName"), name),
keyField,
erstelleElement("div", { klasse: "gruppe-horizontal" }, submit));
form.addEventListener("submit", async (e) => {
e.preventDefault();
const chosen = file.files?.[0];
if (!chosen) return;
const headers = { "Content-Type": "application/octet-stream" };
if (key.value.trim()) headers["X-Source-License-Key"] = key.value.trim();
submit.disabled = true;
submit.textContent = t("backup.importing");
try {
const result = await api(`/verwaltung/backups/import?name=${encodeURIComponent(name.value.trim())}`, {
method: "POST",
headers,
body: chosen
});
zeigeMeldung(result?.meldung ?? result?.message ?? t("backup.imported"), "erfolg", 8000);
form.reset();
keyField.hidden = true;
} catch (err) {
if (err.code === "fremde_lizenz") keyField.hidden = false;
zeigeMeldung(t("backup.importFailed", { error: err.message }), "gefahr", 8000);
} finally {
submit.disabled = false;
submit.textContent = t("backup.importButton");
}
});
area.replaceChildren(erstelleKarte(t("backup.importTitle"), [form]));
}