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,433 @@
|
||||
// ClawdDotNet — Instanzverwaltung
|
||||
//
|
||||
// Leitfaden: Übersicht, Anlegen, Starten, Anhalten, Desktop-Fenster nach vorn holen,
|
||||
// Auswahl der aktiven Instanz für das WebUI. Kein innerHTML, strikt textContent.
|
||||
|
||||
import { api, holeAktiveInstanz, setzeAktiveInstanz } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstellePlakette,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
instanzZustandInfo,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let aktualisierungsIntervall = null;
|
||||
|
||||
export async function rendereInstanzen() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
stoppePolling();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "instanzen-ansicht" });
|
||||
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
titelGruppe.append(erstelleElement("h1", { text: "Instanzverwaltung" }));
|
||||
|
||||
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const aktualisierenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Instanzliste manuell aktualisieren",
|
||||
onclick: () => ladeInstanzen()
|
||||
},
|
||||
erstelleIcon(PFADE.aktualisieren, 16),
|
||||
erstelleElement("span", { text: " Aktualisieren" })
|
||||
);
|
||||
|
||||
const neuBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
title: "Neue Instanz anlegen",
|
||||
onclick: oeffneNeueInstanzDialog
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neue Instanz" })
|
||||
);
|
||||
|
||||
aktionenGruppe.append(aktualisierenBtn, neuBtn);
|
||||
kopfZeile.append(titelGruppe, aktionenGruppe);
|
||||
|
||||
const raster = erstelleElement("div", { klasse: "instanzen-raster", id: "instanzen-raster" });
|
||||
|
||||
layout.append(kopfZeile, raster);
|
||||
container.append(layout);
|
||||
|
||||
await ladeInstanzen();
|
||||
|
||||
// Hintergrund-Polling alle 4 Sekunden, da sich Zustände ohne Klick ändern können
|
||||
aktualisierungsIntervall = setInterval(() => {
|
||||
ladeInstanzen(true);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
export function stoppePolling() {
|
||||
if (aktualisierungsIntervall) {
|
||||
clearInterval(aktualisierungsIntervall);
|
||||
aktualisierungsIntervall = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeInstanzen(istHintergrund = false) {
|
||||
const raster = document.getElementById("instanzen-raster");
|
||||
if (!raster) {
|
||||
stoppePolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!istHintergrund && raster.children.length === 0) {
|
||||
raster.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Instanzen…" })
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const instanzen = await api("/verwaltung/instanzen");
|
||||
if (!raster) return;
|
||||
raster.replaceChildren();
|
||||
|
||||
if (!Array.isArray(instanzen) || instanzen.length === 0) {
|
||||
raster.append(
|
||||
erstelleKarte("Keine Instanzen gefunden", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Es sind noch keine Instanzen eingerichtet. Lege mit 'Neue Instanz' eine an."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
|
||||
for (const instanz of instanzen) {
|
||||
const istAktiv = instanz.id === aktiveId;
|
||||
const zustandInfo = instanzZustandInfo(instanz.zustand);
|
||||
const istLaufend = instanz.zustand === "Running";
|
||||
const istStumm = instanz.zustand === "Silent";
|
||||
const istAbgestuerzt = instanz.zustand === "Crashed";
|
||||
|
||||
// Instanzkarte
|
||||
const karteKopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
if (istAktiv) {
|
||||
karteKopfAktionen.append(erstellePlakette("Aktiv im WebUI", "erfolg"));
|
||||
}
|
||||
karteKopfAktionen.append(erstellePlakette(zustandInfo.text, zustandInfo.art));
|
||||
|
||||
// Details
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dl.append(
|
||||
erstelleElement("dt", { text: "Instanz-Kennung" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: instanz.id })),
|
||||
erstelleElement("dt", { text: "Zustand" }),
|
||||
erstelleElement("dd", { text: zustandInfo.text }),
|
||||
erstelleElement("dt", { text: "Prozess (PID)" }),
|
||||
erstelleElement("dd", { text: instanz.prozess ? String(instanz.prozess) : "—" }),
|
||||
erstelleElement("dt", { text: "Autostart durch Manager" }),
|
||||
erstelleElement("dd", {
|
||||
text: instanz.autostart ? "Aktiviert" : "Aus",
|
||||
klasse: instanz.autostart ? "" : "gedaempft"
|
||||
})
|
||||
);
|
||||
|
||||
// Aktionsleiste
|
||||
const fussAktionen = erstelleElement("div", { klasse: "instanz-aktionen" });
|
||||
|
||||
// Auswählen-Knopf
|
||||
const waehlenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: istAktiv ? "schaltflaeche klein" : "schaltflaeche primaer klein",
|
||||
disabled: istAktiv,
|
||||
title: istAktiv ? "Diese Instanz wird aktuell im WebUI bedient" : "Diese Instanz für das WebUI auswählen",
|
||||
onclick: () => {
|
||||
setzeAktiveInstanz(instanz.id);
|
||||
zeigeMeldung(`Instanz '${instanz.name}' im WebUI ausgewählt.`, "info");
|
||||
ladeInstanzen();
|
||||
}
|
||||
},
|
||||
erstelleElement("span", { text: istAktiv ? "Ausgewählt" : "Auswählen" })
|
||||
);
|
||||
|
||||
// Starten-Knopf
|
||||
const startenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach klein",
|
||||
disabled: istLaufend,
|
||||
title: istLaufend ? "Instanz läuft bereits" : "Instanz starten",
|
||||
onclick: () => starteInstanz(instanz)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Starten" })
|
||||
);
|
||||
|
||||
// Anhalten-Knopf
|
||||
const anhaltenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach klein",
|
||||
disabled: !istLaufend && !istStumm,
|
||||
title: (!istLaufend && !istStumm) ? "Instanz läuft nicht" : "Instanz geordnet anhalten",
|
||||
onclick: () => stoppeInstanz(instanz)
|
||||
},
|
||||
erstelleIcon(PFADE.stop, 14),
|
||||
erstelleElement("span", { text: " Anhalten" })
|
||||
);
|
||||
|
||||
// Fenster nach vorn
|
||||
const fensterBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach klein",
|
||||
disabled: !istLaufend,
|
||||
title: !istLaufend ? "Instanz läuft nicht" : "Desktop-Fenster dieser Instanz in den Vordergrund holen",
|
||||
onclick: () => holeFensterNachVorn(instanz)
|
||||
},
|
||||
erstelleIcon(PFADE.fenster, 14),
|
||||
erstelleElement("span", { text: " Fenster" })
|
||||
);
|
||||
|
||||
fussAktionen.append(waehlenBtn, startenBtn, anhaltenBtn, fensterBtn);
|
||||
|
||||
const karte = erstelleKarte(instanz.name, [dl, fussAktionen], {
|
||||
aktionen: karteKopfAktionen,
|
||||
klasse: istAktiv ? "instanz-karte aktiv" : "instanz-karte"
|
||||
});
|
||||
|
||||
raster.append(karte);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!istHintergrund) {
|
||||
raster.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: `Fehler beim Laden: ${err.message}` })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function starteInstanz(instanz) {
|
||||
try {
|
||||
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/start`, {
|
||||
method: "POST"
|
||||
});
|
||||
zeigeMeldung(antwort?.meldung || `Instanz '${instanz.name}' wird gestartet…`, "erfolg");
|
||||
// Wenn bisher keine Instanz im WebUI gewählt war, diese direkt wählen
|
||||
if (!holeAktiveInstanz()) {
|
||||
setzeAktiveInstanz(instanz.id);
|
||||
}
|
||||
await ladeInstanzen();
|
||||
} catch (err) {
|
||||
if (err.lizenzFehlt) {
|
||||
zeigeMeldung("Start abgelehnt: Ohne gültige Lizenz startet keine Instanz (HTTP 402).", "gefahr", 6000);
|
||||
} else {
|
||||
zeigeMeldung(`Start fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stoppeInstanz(instanz) {
|
||||
oeffneSicherheitsStoppDialog(instanz);
|
||||
}
|
||||
|
||||
function oeffneSicherheitsStoppDialog(instanz) {
|
||||
let verbleibend = 10;
|
||||
let timerId = null;
|
||||
|
||||
const inhalt = erstelleElement("div", { klasse: "formular-raster" });
|
||||
|
||||
// Sicherheits-Warnbox
|
||||
const sicherheitsBox = erstelleElement("div", { klasse: "sicherheits-box" });
|
||||
const boxTitel = erstelleElement(
|
||||
"h3",
|
||||
{},
|
||||
erstelleIcon(PFADE.ausrufezeichen, 18),
|
||||
erstelleElement("span", { text: "Achtung: Produktivinstanz beenden" })
|
||||
);
|
||||
const boxText1 = erstelleElement("p", {
|
||||
text: "ClawdDotNet-Instanzen sind auf Dauerbetrieb ausgelegt. Das Beenden stoppt alle aktiven Agenten, Automationen, Web-Zugriffe und Hintergrundaufgaben dieser Instanz geordnet."
|
||||
});
|
||||
const boxText2 = erstelleElement("p", {
|
||||
text: "Um ein versehentliches Beenden durch unbedachtes Klicken zu verhindern, ist die Bestätigung für 10 Sekunden gesperrt."
|
||||
});
|
||||
sicherheitsBox.append(boxTitel, boxText1, boxText2);
|
||||
|
||||
// Instanzdetails
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dl.append(
|
||||
erstelleElement("dt", { text: "Instanz" }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: instanz.name })),
|
||||
erstelleElement("dt", { text: "Kennung" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: instanz.id })),
|
||||
erstelleElement("dt", { text: "Prozess (PID)" }),
|
||||
erstelleElement("dd", { text: instanz.prozess ? String(instanz.prozess) : "—" })
|
||||
);
|
||||
|
||||
// Countdown-Anzeige
|
||||
const countdownBox = erstelleElement("div", {
|
||||
klasse: "sicherheits-countdown",
|
||||
text: `Bestätigung gesperrt: Noch ${verbleibend} Sekunden…`
|
||||
});
|
||||
|
||||
inhalt.append(sicherheitsBox, dl, countdownBox);
|
||||
|
||||
// Aktionen
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen (Instanz läuft weiter)"
|
||||
});
|
||||
|
||||
const anhaltenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
klasse: "schaltflaeche gefahr",
|
||||
disabled: true
|
||||
},
|
||||
erstelleIcon(PFADE.stop, 14),
|
||||
erstelleElement("span", { text: ` Instanz anhalten (${verbleibend}s)` })
|
||||
);
|
||||
|
||||
const modal = erstelleModal(
|
||||
`Instanz anhalten — ${instanz.name}`,
|
||||
inhalt,
|
||||
[abbrechenBtn, anhaltenBtn],
|
||||
{
|
||||
onSchliessen: () => {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
timerId = setInterval(() => {
|
||||
verbleibend--;
|
||||
if (verbleibend > 0) {
|
||||
countdownBox.textContent = `Bestätigung gesperrt: Noch ${verbleibend} Sekunden…`;
|
||||
const textSpan = anhaltenBtn.querySelector("span");
|
||||
if (textSpan) textSpan.textContent = ` Instanz anhalten (${verbleibend}s)`;
|
||||
} else {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
anhaltenBtn.disabled = false;
|
||||
const textSpan = anhaltenBtn.querySelector("span");
|
||||
if (textSpan) textSpan.textContent = " Instanz jetzt endgültig anhalten";
|
||||
countdownBox.className = "sicherheits-countdown abgelaufen";
|
||||
countdownBox.textContent = "Sperre aufgehoben — Bestätigung jetzt möglich.";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
anhaltenBtn.addEventListener("click", async () => {
|
||||
if (verbleibend > 0) return;
|
||||
anhaltenBtn.disabled = true;
|
||||
const textSpan = anhaltenBtn.querySelector("span");
|
||||
if (textSpan) textSpan.textContent = " Wird angehalten…";
|
||||
|
||||
try {
|
||||
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/stop`, {
|
||||
method: "POST"
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung(antwort?.meldung || `Instanz '${instanz.name}' beendet.`, "info");
|
||||
await ladeInstanzen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Anhalten fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
anhaltenBtn.disabled = false;
|
||||
if (textSpan) textSpan.textContent = " Instanz jetzt endgültig anhalten";
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
async function holeFensterNachVorn(instanz) {
|
||||
try {
|
||||
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/fenster`, {
|
||||
method: "POST"
|
||||
});
|
||||
zeigeMeldung(antwort?.meldung || `Fenster von '${instanz.name}' nach vorn geholt.`, "info");
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fensteraufruf fehlgeschlagen: ${err.message}`, "warnung");
|
||||
}
|
||||
}
|
||||
|
||||
function oeffneNeueInstanzDialog() {
|
||||
const formular = erstelleElement("form", {
|
||||
klasse: "formular-raster",
|
||||
id: "neue-instanz-formular"
|
||||
});
|
||||
|
||||
const nameInput = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. Produktion oder Agenten-Labor",
|
||||
autocomplete: "off"
|
||||
});
|
||||
|
||||
const hinweis = erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Der Instanzmanager legt dafür ein eigenes Verzeichnis mit getrennten Protokollen und Einstellungen an."
|
||||
});
|
||||
|
||||
formular.append(
|
||||
erstelleElement("label", { text: "Name der neuen Instanz:" }),
|
||||
nameInput,
|
||||
hinweis
|
||||
);
|
||||
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen"
|
||||
});
|
||||
|
||||
// Kein eigenes onclick und kein form-Attribut: erstelleModal verknüpft Submit-Knöpfe
|
||||
// im Fuß selbst mit dem Formular. Beides zusammen löste requestSubmit() zweimal aus —
|
||||
// ein Klick, zwei POST, zwei Instanzen. `disabled` hilft dagegen nicht, denn
|
||||
// form.requestSubmit() schert sich nicht um den Zustand eines Knopfes.
|
||||
const erstellenBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: "Instanz anlegen"
|
||||
});
|
||||
|
||||
const modal = erstelleModal("Neue Instanz anlegen", formular, [abbrechenBtn, erstellenBtn]);
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
erstellenBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const antwort = await api("/verwaltung/instanzen", {
|
||||
method: "POST",
|
||||
body: { name }
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung(antwort?.meldung || `Instanz '${name}' erfolgreich angelegt.`, "erfolg");
|
||||
await ladeInstanzen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
|
||||
} finally {
|
||||
erstellenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
Reference in New Issue
Block a user