Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js
T
RichardandClaude Opus 5 dcffcdcff5 Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)
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>
2026-09-10 11:15:59 +02:00

174 lines
4.4 KiB
JavaScript

// ClawdDotNet — API-Zugriffsschicht
//
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
export const TOKEN_SCHLUESSEL = "clawd.token";
export const INSTANZ_SCHLUESSEL = "clawd.instanz";
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
let aktiveInstanz = sessionStorage.getItem(INSTANZ_SCHLUESSEL) ?? "";
const unberechtigtCallbacks = new Set();
const instanzWechselCallbacks = new Set();
/**
* Gibt das aktuell gespeicherte Token zurück.
* @returns {string}
*/
export function holeToken() {
return token;
}
/**
* Speichert ein neues Token im Speicher und sessionStorage.
* @param {string} neuesToken
*/
export function setzeToken(neuesToken) {
token = (neuesToken || "").trim();
if (token) {
sessionStorage.setItem(TOKEN_SCHLUESSEL, token);
} else {
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
}
}
/**
* Löscht das Token und die aktive Instanz (Abmeldung).
*/
export function loescheToken() {
token = "";
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
loescheAktiveInstanz();
}
/**
* Gibt die aktuell ausgewählte Instanz-ID zurück.
* @returns {string}
*/
export function holeAktiveInstanz() {
return aktiveInstanz;
}
/**
* Speichert die aktive Instanz im Speicher und sessionStorage.
* @param {string} neueInstanzId
*/
export function setzeAktiveInstanz(neueInstanzId) {
const id = (neueInstanzId || "").trim();
if (aktiveInstanz === id) return;
aktiveInstanz = id;
if (aktiveInstanz) {
sessionStorage.setItem(INSTANZ_SCHLUESSEL, aktiveInstanz);
} else {
sessionStorage.removeItem(INSTANZ_SCHLUESSEL);
}
for (const cb of instanzWechselCallbacks) {
try {
cb(aktiveInstanz);
} catch (err) {
console.error("Fehler im InstanzWechsel-Callback:", err);
}
}
}
/**
* Entfernt die aktive Instanz.
*/
export function loescheAktiveInstanz() {
setzeAktiveInstanz("");
}
/**
* Registriert einen Rückruf für Instanzwechsel.
* @param {(instanzId: string) => void} callback
*/
export function beiInstanzWechsel(callback) {
instanzWechselCallbacks.add(callback);
return () => instanzWechselCallbacks.delete(callback);
}
/**
* Prüft, ob ein Token im Speicher liegt.
* @returns {boolean}
*/
export function hatToken() {
return Boolean(token && token.length > 0);
}
/**
* Registriert einen Rückruf für den Fall einer 401-Ablehnung (sofortiger Login-Sprung).
* @param {() => void} callback
*/
export function beiUnberechtigt(callback) {
unberechtigtCallbacks.add(callback);
return () => unberechtigtCallbacks.delete(callback);
}
function meldeUnberechtigt() {
loescheToken();
for (const cb of unberechtigtCallbacks) {
try {
cb();
} catch (err) {
console.error("Fehler im Unberechtigt-Callback:", err);
}
}
}
/**
* Führt einen Aufruf gegen die API aus.
* Sendet bei gesetzter aktiver Instanz automatisch die X-Instanz-Kopfzeile mit.
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
* @param {RequestInit} [optionen={}]
* @returns {Promise<any>}
*/
export async function api(pfad, optionen = {}) {
const kopfzeilen = {
...(optionen.headers ?? {}),
Authorization: `Bearer ${token}`
};
if (aktiveInstanz && !kopfzeilen["X-Instanz"] && !pfad.startsWith("/verwaltung")) {
kopfzeilen["X-Instanz"] = aktiveInstanz;
}
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
kopfzeilen["Content-Type"] = "application/json";
optionen.body = JSON.stringify(optionen.body);
}
const antwort = await fetch(`/api${pfad}`, {
...optionen,
headers: kopfzeilen
});
if (antwort.status === 401) {
meldeUnberechtigt();
const fehler = new Error("Kein gültiges Token. Bitte neu anmelden.");
fehler.abgelehnt = true;
fehler.status = 401;
throw fehler;
}
if (!antwort.ok) {
let meldung = `HTTP ${antwort.status}`;
try {
const rumpf = await antwort.json();
meldung = rumpf?.message ?? rumpf?.meldung ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
} catch {
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
}
const fehler = new Error(meldung);
fehler.status = antwort.status;
if (antwort.status === 402) fehler.lizenzFehlt = true;
if (antwort.status === 409) fehler.instanzKonflikt = true;
if (antwort.status === 502) fehler.instanzUnerreichbar = true;
throw fehler;
}
if (antwort.status === 204) {
return null;
}
return antwort.json();
}