- 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>
615 lines
23 KiB
JavaScript
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]));
|
|
}
|