Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt. Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder. Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass: Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden. Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202 beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf seiner Frist jedes Mal ab. InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist. - ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten, Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb. - GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt genau dann, wenn sie haengt. - Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten, nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz. - EnsureDeadAsync vor jedem Start. Weiteres aus dem Review: - .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/. InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt. - Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein. - Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus. - [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand abstellen konnte, solange E-M5 nicht fertig ist. - Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht. - Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und der Port kommt aus manager.json statt fest aus der Zeile. Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535). 806 Tests gruen, 0 Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
// ClawdDotNet — Datensicherung & Wiederherstellung (Vorhaben #530, Stufe 6)
|
||||
//
|
||||
// Verwaltet Sicherungen (.zip) der aktiven Instanz:
|
||||
// - Auflistung aller Archive im Sicherungsverzeichnis
|
||||
// - Neue Sicherung anlegen (Passphrase-Schutz, Logs, Chatverlauf)
|
||||
// - Wiederherstellung (mit geordnetem Stop/Start der Instanz)
|
||||
// - Löschen von Archiven
|
||||
// Strikt DOM API, kein innerHTML.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.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];
|
||||
}
|
||||
|
||||
export async function rendereSicherung() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const instanzId = holeAktiveInstanz();
|
||||
const layout = erstelleElement("div", { klasse: "sicherung-ansicht" });
|
||||
|
||||
// Kopfzeile
|
||||
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
titelGruppe.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
|
||||
|
||||
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const aktualisierenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Sicherungen neu laden",
|
||||
onclick: () => ladeSicherungen()
|
||||
},
|
||||
erstelleIcon(PFADE.aktualisieren, 16),
|
||||
erstelleElement("span", { text: " Aktualisieren" })
|
||||
);
|
||||
|
||||
const neuBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
title: "Neue Sicherung erstellen",
|
||||
disabled: !instanzId,
|
||||
onclick: () => oeffneSicherungsDialog()
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neue Sicherung" })
|
||||
);
|
||||
|
||||
aktionenGruppe.append(aktualisierenBtn, neuBtn);
|
||||
kopf.append(titelGruppe, aktionenGruppe);
|
||||
layout.append(kopf);
|
||||
|
||||
if (!instanzId) {
|
||||
layout.append(
|
||||
erstelleKarte("Keine Instanz ausgewählt", [
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Sicherungen zu verwalten."
|
||||
})
|
||||
])
|
||||
);
|
||||
container.append(layout);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verzeichnis-Info & Inhalt
|
||||
const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" });
|
||||
const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" });
|
||||
|
||||
layout.append(infoBereich, inhaltBereich);
|
||||
container.append(layout);
|
||||
|
||||
async function ladeSicherungen() {
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Sicherungen…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const daten = await api(`/verwaltung/instanzen/${instanzId}/backups`);
|
||||
const verzeichnis = daten?.directory ?? "—";
|
||||
const backups = daten?.backups ?? [];
|
||||
|
||||
infoBereich.replaceChildren(
|
||||
erstelleKarte("Sicherungsverzeichnis", [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: "Sicherungen werden komprimiert als ZIP-Archiv abgelegt. Die Datenbank (state.db) wird konsistent per VACUUM INTO kopiert." }),
|
||||
erstelleElement("div", { klasse: "ordner-pfad-anzeige" },
|
||||
erstelleIcon(PFADE.server, 14),
|
||||
erstelleElement("code", { text: verzeichnis })
|
||||
)
|
||||
])
|
||||
);
|
||||
|
||||
if (backups.length === 0) {
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte("Vorhandene Sicherungen", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Für diese Instanz sind noch keine Sicherungsarchive vorhanden. Erstelle über die Schaltfläche oben eine neue Sicherung."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" });
|
||||
const thead = erstelleElement("thead", {},
|
||||
erstelleElement("tr", {},
|
||||
erstelleElement("th", { text: "Archivdatei" }),
|
||||
erstelleElement("th", { text: "Erstellt am" }),
|
||||
erstelleElement("th", { text: "Größe" }),
|
||||
erstelleElement("th", { text: "Zugangsdaten" }),
|
||||
erstelleElement("th", { text: "Dateien" }),
|
||||
erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" })
|
||||
)
|
||||
);
|
||||
|
||||
const tbody = erstelleElement("tbody");
|
||||
|
||||
for (const b of backups) {
|
||||
const tr = erstelleElement("tr");
|
||||
|
||||
// Dateiname & Icon
|
||||
const dateiTd = erstelleElement("td", { klasse: "dateiname-zelle" },
|
||||
erstelleIcon(PFADE.sicherung, 16),
|
||||
erstelleElement("strong", { text: b.fileName })
|
||||
);
|
||||
|
||||
// Datum
|
||||
const datumTd = erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) });
|
||||
|
||||
// Größe
|
||||
const groesseTd = erstelleElement("td", { text: formatiereBytes(b.sizeBytes) });
|
||||
|
||||
// Zugangsdaten Modus
|
||||
const hatPassphrase = b.secrets === "Passphrase";
|
||||
const secretsPlakette = erstellePlakette(
|
||||
hatPassphrase ? `Passphrase (${b.secretCount})` : "Ausgeschlossen",
|
||||
hatPassphrase ? "erfolg" : "neutral"
|
||||
);
|
||||
const secretsTd = erstelleElement("td", {}, secretsPlakette);
|
||||
|
||||
// Dateienanzahl
|
||||
const dateienTd = erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" });
|
||||
|
||||
// Aktionen
|
||||
const aktionenTd = erstelleElement("td", { klasse: "spalte-aktionen" });
|
||||
|
||||
const restoreBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche klein sekundär",
|
||||
title: "Diese Sicherung wiederherstellen",
|
||||
onclick: () => oeffneRestoreDialog(b)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Wiederherstellen" })
|
||||
);
|
||||
|
||||
const deleteBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche klein gefahr flach",
|
||||
title: "Sicherungsdatei unwiderruflich löschen",
|
||||
onclick: () => oeffneLoeschenDialog(b.fileName)
|
||||
},
|
||||
erstelleIcon(PFADE.muelleimer, 14)
|
||||
);
|
||||
|
||||
aktionenTd.append(restoreBtn, deleteBtn);
|
||||
tr.append(dateiTd, datumTd, groesseTd, secretsTd, dateienTd, aktionenTd);
|
||||
tbody.append(tr);
|
||||
}
|
||||
|
||||
tabelle.append(thead, tbody);
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])
|
||||
);
|
||||
} catch (err) {
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte("Fehler", [
|
||||
erstelleElement("p", { klasse: "fehler-meldung", text: `Sicherungen konnten nicht geladen werden: ${err.message}` })
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal: Neue Sicherung erstellen ───
|
||||
function oeffneSicherungsDialog() {
|
||||
const form = erstelleElement("form", { klasse: "formular" });
|
||||
|
||||
// Modus Zugangsdaten
|
||||
const secretsGruppe = erstelleElement("div", { klasse: "form-gruppe" });
|
||||
secretsGruppe.append(
|
||||
erstelleElement("label", { text: "Zugangsdaten & Schlüssel" }),
|
||||
erstelleElement("p", { klasse: "form-hilfe", text: "DPAPI-geschützte Daten können mit einer Passphrase geschützt oder sicherheitshalber entfernt werden." })
|
||||
);
|
||||
|
||||
const secretsSelect = erstelleElement("select", { klasse: "eingabe" });
|
||||
const optExclude = erstelleElement("option", { value: "Exclude", text: "Entfernen (gefahrlos ablegbar, Schlüssel müssen neu eingetragen werden)" });
|
||||
const optPassphrase = erstelleElement("option", { value: "Passphrase", text: "Mit Passphrase schützen (vollständig übertragbar)" });
|
||||
secretsSelect.append(optExclude, optPassphrase);
|
||||
secretsGruppe.append(secretsSelect);
|
||||
|
||||
// Passphrase Feld
|
||||
const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { display: "none" } });
|
||||
const passInput = erstelleElement("input", {
|
||||
type: "password",
|
||||
klasse: "eingabe",
|
||||
placeholder: "Passphrase für Verschlüsselung eingeben…"
|
||||
});
|
||||
passGruppe.append(
|
||||
erstelleElement("label", { text: "Passphrase" }),
|
||||
passInput
|
||||
);
|
||||
|
||||
secretsSelect.addEventListener("change", () => {
|
||||
passGruppe.style.display = secretsSelect.value === "Passphrase" ? "block" : "none";
|
||||
if (secretsSelect.value === "Passphrase") passInput.focus();
|
||||
});
|
||||
|
||||
// Checkboxen
|
||||
const chatsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
|
||||
const chatsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-chats", checked: true });
|
||||
const chatsLabel = erstelleElement("label", { for: "backup-chk-chats", text: "Chatverläufe sichern" });
|
||||
chatsGruppe.append(chatsCheckbox, chatsLabel);
|
||||
|
||||
const logsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
|
||||
const logsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-logs", checked: false });
|
||||
const logsLabel = erstelleElement("label", { for: "backup-chk-logs", text: "Protokolldateien (Logs) einschließen" });
|
||||
logsGruppe.append(logsCheckbox, logsLabel);
|
||||
|
||||
form.append(secretsGruppe, passGruppe, chatsGruppe, logsGruppe);
|
||||
|
||||
let modal;
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche",
|
||||
text: "Abbrechen",
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
|
||||
const speichernBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: "Sicherung erstellen"
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const secrets = secretsSelect.value;
|
||||
const passphrase = passInput.value.trim();
|
||||
|
||||
if (secrets === "Passphrase" && !passphrase) {
|
||||
zeigeMeldung("Bitte gib eine Passphrase ein.", "warnung");
|
||||
passInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
speichernBtn.disabled = true;
|
||||
speichernBtn.textContent = "Erstelle Sicherung…";
|
||||
|
||||
try {
|
||||
const res = await api(`/verwaltung/instanzen/${instanzId}/backups`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
secrets,
|
||||
passphrase: passphrase || null,
|
||||
includeLogs: logsCheckbox.checked,
|
||||
includeChatHistory: chatsCheckbox.checked
|
||||
}
|
||||
});
|
||||
zeigeMeldung(res.meldung || "Sicherung erfolgreich erstellt.", "erfolg");
|
||||
modal.schliessen();
|
||||
ladeSicherungen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Sichern: ${err.message}`, "gefahr");
|
||||
speichernBtn.disabled = false;
|
||||
speichernBtn.textContent = "Sicherung erstellen";
|
||||
}
|
||||
});
|
||||
|
||||
modal = erstelleModal("Neue Sicherung erstellen", form, [abbrechenBtn, speichernBtn]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
// ─── Modal: Wiederherstellen ───
|
||||
function oeffneRestoreDialog(backup) {
|
||||
const form = erstelleElement("form", { klasse: "formular" });
|
||||
|
||||
const warnung = erstelleElement("div", { klasse: "hinweis-box warnung" },
|
||||
erstelleIcon(PFADE.ausrufezeichen, 18),
|
||||
erstelleElement("div", {},
|
||||
erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }),
|
||||
erstelleElement("p", {
|
||||
text: `Die Wiederherstellung überschreibt die aktuellen Konfigurationen und Daten der Instanz mit dem Stand vom ${formatiereDatumZeit(backup.createdAt)}.`
|
||||
}),
|
||||
erstelleElement("p", {
|
||||
text: "Die Instanz wird dafür kurz geordnet gestoppt und nach erfolgreicher Rücksicherung wieder hochgefahren."
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
form.append(warnung);
|
||||
|
||||
let passInput = null;
|
||||
if (backup.secrets === "Passphrase") {
|
||||
const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { marginTop: "16px" } });
|
||||
passInput = erstelleElement("input", {
|
||||
type: "password",
|
||||
klasse: "eingabe",
|
||||
placeholder: "Passphrase der Sicherung eingeben…",
|
||||
required: true
|
||||
});
|
||||
passGruppe.append(
|
||||
erstelleElement("label", { text: "Passphrase für Zugangsdaten" }),
|
||||
passInput
|
||||
);
|
||||
form.append(passGruppe);
|
||||
}
|
||||
|
||||
let modal;
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche",
|
||||
text: "Abbrechen",
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
|
||||
const restoreBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche warnung",
|
||||
text: "Jetzt Wiederherstellen"
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const passphrase = passInput?.value?.trim() || null;
|
||||
|
||||
if (backup.secrets === "Passphrase" && !passphrase) {
|
||||
zeigeMeldung("Bitte gib die Passphrase an.", "warnung");
|
||||
passInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = "Stelle wieder her…";
|
||||
|
||||
try {
|
||||
const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(backup.fileName)}/restore`, {
|
||||
method: "POST",
|
||||
body: { passphrase }
|
||||
});
|
||||
zeigeMeldung(res.meldung || "Wiederherstellung erfolgreich abgeschlossen.", "erfolg");
|
||||
modal.schliessen();
|
||||
ladeSicherungen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
restoreBtn.disabled = false;
|
||||
restoreBtn.textContent = "Jetzt Wiederherstellen";
|
||||
}
|
||||
});
|
||||
|
||||
modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [abbrechenBtn, restoreBtn]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
// ─── Modal: Löschen ───
|
||||
function oeffneLoeschenDialog(fileName) {
|
||||
const text = erstelleElement("p", {
|
||||
text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?`
|
||||
});
|
||||
|
||||
let modal;
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche",
|
||||
text: "Abbrechen",
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
|
||||
const loeschenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche gefahr",
|
||||
text: "Löschen",
|
||||
onclick: async () => {
|
||||
loeschenBtn.disabled = true;
|
||||
try {
|
||||
const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg");
|
||||
modal.schliessen();
|
||||
ladeSicherungen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
loeschenBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
modal = erstelleModal("Sicherung löschen", text, [abbrechenBtn, loeschenBtn]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
ladeSicherungen();
|
||||
}
|
||||
Reference in New Issue
Block a user