Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js
T
RichardandClaude Opus 5.5 d2559c763a 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>
2026-10-02 14:13:37 +02:00

615 lines
23 KiB
JavaScript

// ClawdDotNet — Datensicherung & Wiederherstellung (#530)
//
// Jede Sicherung, die der Instanzmanager anlegt, ist als Ganzes mit seinem
// Lizenzschlüssel verschlüsselt (.clawdbak). Stammt eine Sicherung von einer anderen
// Installation, fragt die Wiederherstellung nach deren Lizenzschlüssel.
// Dazu der optionale FTP-Upload.
// Strikt DOM API, kein innerHTML.
import { api, holeAktiveInstanz } from "../modules/api.js";
import { t } from "../modules/i18n.js";
import {
erstelleElement,
erstelleKarte,
erstellePlakette,
erstelleIcon,
erstelleModal,
PFADE,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
function formatiereBytes(bytes) {
if (bytes === 0) return "0 B";
if (!bytes) return "—";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
/** Was eine Sicherung schützt — als Plakette. */
function schutzPlakette(b) {
if (b.fremdeLizenz) return erstellePlakette(t("backup.foreignLicense"), "warnung");
if (b.secrets === "Unlesbar") return erstellePlakette(t("backup.unreadable"), "gefahr");
if (b.verschluesselt) return erstellePlakette(t("backup.licenseProtection", { count: b.secretCount }), "erfolg");
if (b.secrets === "Passphrase") return erstellePlakette(t("backup.passphraseProtection", { count: b.secretCount }), "neutral");
return erstellePlakette(t("backup.unencryptedProtection"), "neutral");
}
function checkbox(id, text, an) {
const gruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
const box = erstelleElement("input", { type: "checkbox", id, checked: an });
gruppe.append(box, erstelleElement("label", { for: id, text }));
return { gruppe, box };
}
function feld(label, eingabe, hilfe) {
const gruppe = erstelleElement("div", { klasse: "form-gruppe" });
gruppe.append(erstelleElement("label", { text: label }), eingabe);
if (hilfe) gruppe.append(erstelleElement("p", { klasse: "form-hilfe", text: hilfe }));
return gruppe;
}
export async function rendereSicherung() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const instanzId = holeAktiveInstanz();
const pfadInstanz = encodeURIComponent(instanzId || "");
const layout = erstelleElement("div", { klasse: "sicherung-ansicht" });
// ─── Kopf ───
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
kopf.append(
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("h1", { text: t("backup.title") })),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", {
klasse: "schaltflaeche flach",
title: t("backup.refreshTooltip"),
disabled: !instanzId,
onclick: () => ladeSicherungen()
}, erstelleIcon(PFADE.aktualisieren, 16), erstelleElement("span", { text: ` ${t("backup.refresh")}` })),
erstelleElement("button", {
klasse: "schaltflaeche primaer",
title: t("backup.newBackupTooltip"),
disabled: !instanzId,
onclick: () => oeffneSicherungsDialog()
}, erstelleIcon(PFADE.plus, 16), erstelleElement("span", { text: ` ${t("backup.newBackup")}` }))
)
);
layout.append(kopf);
const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" });
const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" });
const ftpBereich = erstelleElement("div", { klasse: "sicherung-ftp-bereich" });
if (!instanzId) {
inhaltBereich.append(
erstelleKarte(t("backup.noInstance"), [
erstelleElement("p", {
klasse: "hinweis",
text: t("backup.selectInstancePrompt")
})
])
);
}
// #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;
// ─── Liste ───
async function ladeSicherungen() {
inhaltBereich.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("backup.loading") })
);
try {
const daten = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`);
const backups = daten?.backups ?? [];
infoBereich.replaceChildren(
erstelleKarte(t("backup.backupDir"), [
erstelleElement("p", {
klasse: "gedaempft",
text: t("backup.backupDirHint")
}),
erstelleElement("div", { klasse: "ordner-pfad-anzeige" },
erstelleIcon(PFADE.server, 14),
erstelleElement("code", { text: daten?.directory ?? "—" }))
])
);
if (backups.length === 0) {
inhaltBereich.replaceChildren(
erstelleKarte(t("backup.existingBackups", { count: 0 }), [
erstelleElement("p", {
klasse: "gedaempft",
text: t("backup.noBackups")
})
])
);
return;
}
const tbody = erstelleElement("tbody");
for (const b of backups) {
const aktionen = erstelleElement("td", { klasse: "spalte-aktionen" },
erstelleElement("button", {
klasse: "schaltflaeche klein sekundär",
title: t("backup.restoreTooltip"),
disabled: b.secrets === "Unlesbar",
onclick: () => oeffneRestoreDialog(b)
}, erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: ` ${t("backup.restore")}` })),
erstelleElement("button", {
klasse: "schaltflaeche klein gefahr flach",
title: t("backup.deleteTooltip"),
onclick: () => oeffneLoeschenDialog(b.fileName)
}, erstelleIcon(PFADE.muelleimer, 14))
);
tbody.append(erstelleElement("tr", {},
erstelleElement("td", { klasse: "dateiname-zelle" },
erstelleIcon(PFADE.sicherung, 16), erstelleElement("strong", { text: b.fileName })),
erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) }),
erstelleElement("td", { text: formatiereBytes(b.sizeBytes) }),
erstelleElement("td", {}, schutzPlakette(b)),
erstelleElement("td", { text: b.fileCount > 0 ? t("backup.fileCount", { count: b.fileCount }) : "—" }),
aktionen
));
}
const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" },
erstelleElement("thead", {},
erstelleElement("tr", {},
erstelleElement("th", { text: t("backup.archiveFile") }),
erstelleElement("th", { text: t("backup.createdAt") }),
erstelleElement("th", { text: t("backup.size") }),
erstelleElement("th", { text: t("backup.protection") }),
erstelleElement("th", { text: t("backup.files") }),
erstelleElement("th", { klasse: "spalte-aktionen", text: t("backup.actions") }))),
tbody);
inhaltBereich.replaceChildren(erstelleKarte(t("backup.existingBackups", { count: backups.length }), [tabelle]));
} catch (err) {
inhaltBereich.replaceChildren(
erstelleKarte("Fehler", [
erstelleElement("p", { klasse: "fehler-meldung", text: `Sicherungen konnten nicht geladen werden: ${err.message}` })
])
);
}
}
// ─── Neue Sicherung ───
function oeffneSicherungsDialog() {
const form = erstelleElement("form", { klasse: "formular" });
const chats = checkbox("backup-chk-chats", t("backup.includeChats"), true);
const logs = checkbox("backup-chk-logs", t("backup.includeLogs"), false);
form.append(
erstelleElement("p", {
klasse: "form-hilfe",
text: t("backup.dialogHint")
}),
chats.gruppe,
logs.gruppe
);
let modal;
const speichernBtn = erstelleElement("button", {
type: "submit", klasse: "schaltflaeche primaer", text: t("backup.createButton")
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
speichernBtn.textContent = t("backup.creating");
try {
const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups`, {
method: "POST",
body: { includeLogs: logs.box.checked, includeChatHistory: chats.box.checked }
});
// Ein gescheiterter Upload steht in der Meldung; die Sicherung liegt trotzdem lokal.
const warnt = (res.meldung || "").includes("gescheitert");
zeigeMeldung(res.meldung || t("backup.created"), warnt ? "warnung" : "erfolg", warnt ? 8000 : undefined);
modal.schliessen();
ladeSicherungen();
} catch (err) {
zeigeMeldung(t("backup.createFailed", { error: err.message }), "gefahr");
speichernBtn.disabled = false;
speichernBtn.textContent = t("backup.createButton");
}
});
modal = erstelleModal(t("backup.dialogTitle"), form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: t("common.cancel"), onclick: () => modal.schliessen()
}),
speichernBtn
]);
modal.oeffnen();
}
// ─── Wiederherstellen ───
async function stelleWiederHer(backup, { passphrase = null, lizenzschluessel = null } = {}) {
return api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(backup.fileName)}/restore`, {
method: "POST",
body: { passphrase, lizenzschluessel }
});
}
function oeffneRestoreDialog(backup) {
const form = erstelleElement("form", { klasse: "formular" });
form.append(erstelleElement("div", { klasse: "hinweis-box warnung" },
erstelleIcon(PFADE.ausrufezeichen, 18),
erstelleElement("div", {},
erstelleElement("strong", { text: t("backup.overwriteWarningTitle") }),
erstelleElement("p", {
text: t("backup.overwriteWarningText", { date: formatiereDatumZeit(backup.createdAt) })
}),
erstelleElement("p", {
text: t("backup.instanceStopNotice")
}))));
// Nur ältere, unverschlüsselte Sicherungen mit Passphrase brauchen sie noch.
let passInput = null;
if (backup.secrets === "Passphrase" && !backup.verschluesselt) {
passInput = erstelleElement("input", {
type: "password", klasse: "eingabe", placeholder: t("backup.passphrasePlaceholder"), required: true
});
form.append(feld(t("backup.passphraseLabel"), passInput));
}
let modal;
const restoreBtn = erstelleElement("button", {
type: "submit", klasse: "schaltflaeche warnung", text: t("backup.restoreNow")
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const passphrase = passInput?.value?.trim() || null;
if (passInput && !passphrase) {
zeigeMeldung(t("backup.passphraseRequired"), "warnung");
passInput.focus();
return;
}
restoreBtn.disabled = true;
restoreBtn.textContent = t("backup.restoring");
try {
const res = await stelleWiederHer(backup, { passphrase });
zeigeMeldung(res.meldung || t("backup.restoreSuccess"), "erfolg");
modal.schliessen();
ladeSicherungen();
} catch (err) {
// Our own key does not match — the backup comes from another installation.
// Nothing was stopped for this; the server checks that beforehand.
if (err.code === "fremde_lizenz") {
modal.schliessen();
frageLizenzschluessel(backup, err.message);
return;
}
zeigeMeldung(t("backup.restoreFailed", { error: err.message }), "gefahr");
restoreBtn.disabled = false;
restoreBtn.textContent = t("backup.restoreNow");
}
});
modal = erstelleModal(t("backup.restoreTitle", { file: backup.fileName }), form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: t("common.cancel"), onclick: () => modal.schliessen()
}),
restoreBtn
]);
modal.oeffnen();
}
/**
* Das Fenster, das nach dem Lizenzschlüssel der Quellinstallation fragt.
* Erscheint, wenn der eigene Schlüssel die Sicherung nicht öffnet.
*/
function frageLizenzschluessel(backup, grund) {
const form = erstelleElement("form", { klasse: "formular" });
const eingabe = erstelleElement("input", {
type: "password",
klasse: "eingabe",
placeholder: t("backup.sourceLicensePlaceholder"),
autocomplete: "off",
required: true
});
form.append(
erstelleElement("p", { text: grund }),
feld(t("backup.sourceLicenseLabel"), eingabe, t("backup.sourceLicenseHint"))
);
let modal;
const okBtn = erstelleElement("button", {
type: "submit", klasse: "schaltflaeche warnung", text: t("backup.restoreWithKey")
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const lizenzschluessel = eingabe.value.trim();
if (!lizenzschluessel) { eingabe.focus(); return; }
okBtn.disabled = true;
okBtn.textContent = t("backup.restoring");
try {
const res = await stelleWiederHer(backup, { lizenzschluessel });
zeigeMeldung(res.meldung || t("backup.restoreSuccess"), "erfolg");
modal.schliessen();
ladeSicherungen();
} catch (err) {
zeigeMeldung(err.code === "fremde_lizenz"
? t("backup.keyMismatch")
: t("backup.restoreFailed", { error: err.message }), "gefahr");
okBtn.disabled = false;
okBtn.textContent = t("backup.restoreWithKey");
eingabe.select();
}
});
modal = erstelleModal(t("backup.foreignLicenseTitle"), form, [
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: t("common.cancel"), onclick: () => modal.schliessen()
}),
okBtn
]);
modal.oeffnen();
eingabe.focus();
}
// ─── Löschen ───
function oeffneLoeschenDialog(fileName) {
let modal;
const loeschenBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche gefahr",
text: t("common.delete"),
onclick: async () => {
loeschenBtn.disabled = true;
try {
const res = await api(`/verwaltung/instanzen/${pfadInstanz}/backups/${encodeURIComponent(fileName)}`, {
method: "DELETE"
});
zeigeMeldung(res.meldung || t("backup.deleted"), "erfolg");
modal.schliessen();
ladeSicherungen();
} catch (err) {
zeigeMeldung(t("backup.deleteFailed", { error: err.message }), "gefahr");
loeschenBtn.disabled = false;
}
}
});
modal = erstelleModal(t("backup.deleteTitle"),
erstelleElement("p", { text: t("backup.deleteConfirm", { file: fileName }) }),
[
erstelleElement("button", {
type: "button", klasse: "schaltflaeche", text: t("common.cancel"), onclick: () => modal.schliessen()
}),
loeschenBtn
]);
modal.oeffnen();
}
ladeSicherungen();
}
// ─── FTP-Upload (für alle Instanzen) ───
async function rendereFtp(bereich) {
bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("backup.ftpLoading") }));
let stand;
try {
stand = await api("/verwaltung/sicherung/ftp");
} catch (err) {
bereich.replaceChildren(erstelleKarte(t("backup.ftpTitle"), [
erstelleElement("p", { klasse: "fehler-meldung", text: `Einstellungen nicht ladbar: ${err.message}` })
]));
return;
}
const form = erstelleElement("form", { klasse: "formular" });
const an = checkbox("ftp-an", t("backup.ftpAutoUpload"), stand.enabled);
const host = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.host ?? "", placeholder: "ftp.example.org" });
const port = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.port ?? 21), min: "1", max: "65535" });
const benutzer = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.username ?? "", autocomplete: "off" });
const kennwort = erstelleElement("input", {
type: "password",
klasse: "eingabe",
autocomplete: "new-password",
placeholder: stand.kennwortGesetzt ? t("backup.ftpPasswordSaved") : t("backup.ftpPassword")
});
const ordner = erstelleElement("input", { type: "text", klasse: "eingabe", value: stand.remoteDirectory ?? "/clawd-backups" });
const behalten = erstelleElement("input", { type: "number", klasse: "eingabe", value: String(stand.keepCount ?? 14), min: "1", max: "365" });
const tls = checkbox("ftp-tls", t("backup.ftpRequireTls"), stand.requireTls);
form.append(
erstelleElement("p", {
klasse: "form-hilfe",
text: t("backup.ftpHint")
}),
an.gruppe,
feld(t("backup.ftpServer"), host),
feld(t("backup.ftpPort"), port),
feld(t("backup.ftpUser"), benutzer),
feld(t("backup.ftpPassword"), kennwort),
feld(t("backup.ftpDirectory"), ordner, t("backup.ftpDirectoryHint")),
feld(t("backup.ftpKeepCount"), behalten, t("backup.ftpKeepCountHint")),
tls.gruppe
);
const speichernBtn = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") });
const testBtn = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche",
text: t("backup.ftpTest"),
onclick: async () => {
testBtn.disabled = true;
try {
const res = await api("/verwaltung/sicherung/ftp/test", { method: "POST" });
zeigeMeldung(res.meldung, res.ok ? "erfolg" : "warnung", 8000);
} catch (err) {
zeigeMeldung(t("backup.ftpTestFailed", { error: err.message }), "gefahr");
} finally {
testBtn.disabled = false;
}
}
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
speichernBtn.disabled = true;
try {
const res = await api("/verwaltung/sicherung/ftp", {
method: "PUT",
body: {
enabled: an.box.checked,
host: host.value.trim(),
port: Number(port.value) || 21,
username: benutzer.value.trim(),
password: kennwort.value || null,
remoteDirectory: ordner.value.trim(),
requireTls: tls.box.checked,
keepCount: Number(behalten.value) || 14
}
});
zeigeMeldung(res.meldung || t("backup.ftpSaved"), "erfolg");
kennwort.value = "";
rendereFtp(bereich);
} catch (err) {
zeigeMeldung(t("backup.ftpSaveFailed", { error: err.message }), "gefahr");
} finally {
speichernBtn.disabled = false;
}
});
form.append(erstelleElement("div", { klasse: "gruppe-horizontal" }, testBtn, speichernBtn));
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]));
}