Instanzmanager Stufe 4: eine Adresse, ein Zugang, alle Instanzen (#528, E-M5)

Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung:
Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten.
Beim Herunterfahren beendet er keine Instanz.

Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche -
ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran
sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt.
wwwroot ist aus der Instanz in den Manager gezogen.

DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager
angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api
geht unveraendert an eine Instanz. Er leitet weiter, er implementiert
nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft
genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt
geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine
abgelehnte.

Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine
Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt
verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht
der Weboberflaeche zum Umschreiben gezwungen.

ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token
200, Instanz ueber den Manager gestartet und angehalten, /api/info und
/api/status unverfaelscht durchgereicht, /events/logs mit
"instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz
laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen
bereits" - uebernommen. E-M6 damit in beide Richtungen belegt.

DREI DEFEKTE, die erst der Lauf gezeigt hat:

1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein
   Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet
   "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein
   Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht
   umgeleitet. Der Manager fragt jetzt gar nicht.
2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben
   ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und
   nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt.
3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in
   dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine
   Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt
   sein Verzeichnis jetzt ueber --config.

SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts,
die dasselbe brauchen, und sie duerfen einander nicht kennen.

NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen -
Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an
wwwroot. Und webServerPort steht noch in der Instanz.

Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 23:19:49 +02:00
co-authored by Claude Opus 5
parent cb56175d20
commit a511480b77
38 changed files with 1352 additions and 7 deletions
@@ -0,0 +1,397 @@
// ClawdDotNet — Aufgaben & Geplante Läufe
//
// Zwei Bereiche: Taskboard (Aufgaben) und Geplante Läufe (Jobs mit Cron & Sofortausführung).
import { api } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
zeigeMeldung
} from "../modules/dom.js";
let aktiverReiter = "jobs"; // "jobs" oder "taskboard"
let jobsListe = [];
let entladeEreignisse = [];
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
entladeEreignisse.forEach((c) => c());
entladeEreignisse = [];
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
// Kopf mit Reiter-Umschaltung und Aktionen
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
const titel = erstelleElement("h1", { text: "Aufgaben & Jobs" });
const reiterLeiste = erstelleElement("div", { klasse: "reiter-leiste" });
const reiterJobs = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "jobs" ? "aktiv" : ""}`,
text: "Geplante Läufe (Jobs)",
onclick: () => wechsleReiter("jobs")
});
const reiterTaskboard = erstelleElement("button", {
klasse: `reiter-schaltflaeche ${aktiverReiter === "taskboard" ? "aktiv" : ""}`,
text: "Taskboard",
onclick: () => wechsleReiter("taskboard")
});
reiterLeiste.append(reiterJobs, reiterTaskboard);
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
const neuerJobBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer",
onclick: oeffneNeuerJobDialog
},
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " Neuer Job" })
);
aktionen.append(neuerJobBtn);
kopfZeile.append(titel, reiterLeiste, aktionen);
const inhaltBereich = erstelleElement("div", { id: "aufgaben-bereich-inhalt" });
layout.append(kopfZeile, inhaltBereich);
container.append(layout);
await ladeAktivenBereich();
entladeEreignisse.push(
abonnieren("instance", (daten) => {
if (daten.kind === "Jobs" && aktiverReiter === "jobs") {
ladeAktivenBereich();
}
})
);
}
function wechsleReiter(reiter) {
aktiverReiter = reiter;
document.querySelectorAll(".reiter-schaltflaeche").forEach((r) => r.classList.remove("aktiv"));
if (reiter === "jobs") {
document.querySelector(".reiter-schaltflaeche:first-child")?.classList.add("aktiv");
} else {
document.querySelector(".reiter-schaltflaeche:last-child")?.classList.add("aktiv");
}
ladeAktivenBereich();
}
async function ladeAktivenBereich() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren();
if (aktiverReiter === "jobs") {
await rendereJobs(container);
} else {
await rendereTaskboard(container);
}
}
// ─── Jobs / Geplante Läufe ───
async function rendereJobs(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade geplante Läufe…" })
);
try {
jobsListe = await api("/jobs");
container.replaceChildren();
if (!jobsListe || jobsListe.length === 0) {
container.append(
erstelleKarte("Keine geplanten Läufe", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Es sind noch keine regelmäßigen Agenten-Läufe oder Tool-Jobs eingerichtet."
})
])
);
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const thead = erstelleElement("thead");
const trKopf = erstelleElement("tr");
trKopf.append(
erstelleElement("th", { text: "Status" }),
erstelleElement("th", { text: "Kennung / Art" }),
erstelleElement("th", { text: "Agent" }),
erstelleElement("th", { text: "Cron-Muster" }),
erstelleElement("th", { text: "Nächster Lauf (UTC)" }),
erstelleElement("th", { text: "Letzter Lauf (Lokal)" }),
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
);
thead.append(trKopf);
const tbody = erstelleElement("tbody");
for (const job of jobsListe) {
const tr = erstelleElement("tr");
// Schalter Ein/Aus
const tdStatus = erstelleElement("td");
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
const schalterInput = erstelleElement("input", {
type: "checkbox",
checked: Boolean(job.enabled),
onchange: async (e) => {
const neuAktiv = e.target.checked;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}/enabled?agentId=${encodeURIComponent(job.agentId)}&enabled=${neuAktiv}`,
{ method: "PUT" }
);
job.enabled = neuAktiv;
zeigeMeldung(`Job '${job.jobId}' ${neuAktiv ? "aktiviert" : "pausiert"}.`, "info");
} catch (err) {
e.target.checked = !neuAktiv;
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
}
}
});
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
tdStatus.append(schalterLabel);
// Name / Art
const tdName = erstelleElement("td");
tdName.append(
erstelleElement("strong", { text: job.jobId }),
erstelleElement("div", {
klasse: "gedaempft text-klein",
text: job.kind === "toolJob" ? `Werkzeug (${job.toolName || ""})` : "Agent-Aufruf"
})
);
// Agent
const tdAgent = erstelleElement("td", { text: job.agentId });
// Cron
const tdCron = erstelleElement("td");
tdCron.append(erstelleElement("code", { text: job.cron }));
// Zeiten
const tdNext = erstelleElement("td", {
text: job.nextRunUtc ? formatiereDatumZeit(job.nextRunUtc) : "—"
});
const tdLast = erstelleElement("td", {
text: job.lastRunLocal ? formatiereDatumZeit(job.lastRunLocal) : "—"
});
// Aktionen (Sofortlauf, Löschen)
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "justify-content: flex-end;" });
const ausfuehrenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche flach klein",
title: "Job jetzt sofort manuell ausführen",
onclick: () => fuehreJobAus(job.jobId)
},
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " Start" })
);
const loeschenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche gefahr flach klein",
title: "Job löschen",
onclick: () => loescheJob(job)
},
erstelleIcon(PFADE.muelleimer, 14)
);
aktionenGruppe.append(ausfuehrenBtn, loeschenBtn);
tdAktionen.append(aktionenGruppe);
tr.append(tdStatus, tdName, tdAgent, tdCron, tdNext, tdLast, tdAktionen);
tbody.append(tr);
}
tabelle.append(thead, tbody);
container.append(tabelle);
} catch (err) {
container.replaceChildren(
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
);
}
}
async function fuehreJobAus(jobId) {
try {
const res = await api(`/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
zeigeMeldung(`Job '${jobId}' angestoßen: ${res?.message ?? "Erfolgreich"}`, "erfolg");
} catch (err) {
zeigeMeldung(`Ausführung fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function loescheJob(job) {
if (!confirm(`Soll der geplante Job '${job.jobId}' gelöscht werden?`)) return;
try {
await api(
`/jobs/${encodeURIComponent(job.jobId)}?agentId=${encodeURIComponent(job.agentId)}&kind=${encodeURIComponent(job.kind)}`,
{ method: "DELETE" }
);
zeigeMeldung(`Job '${job.jobId}' wurde gelöscht.`, "info");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
}
}
async function oeffneNeuerJobDialog() {
let agenten = [];
try {
agenten = await api("/agents");
} catch {}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const agentSelect = erstelleElement("select", { required: true });
for (const a of agenten) {
agentSelect.append(erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }));
}
const kindSelect = erstelleElement("select");
kindSelect.append(
erstelleElement("option", { value: "agentWakeup", text: "Agent Wakeup (Regelmäßiger Aufruf)" }),
erstelleElement("option", { value: "toolJob", text: "Tool Job (Werkzeug-Ausführung)" })
);
const cronInput = erstelleElement("input", {
type: "text",
required: true,
value: "0 * * * *",
placeholder: "z.B. 0 * * * * (stündlich)"
});
const taskMsgInput = erstelleElement("textarea", {
rows: "2",
placeholder: "Optionale Start-Nachricht / Anweisung an den Agenten…"
});
formular.append(
erstelleElement("label", { text: "Ziel-Agent:" }),
agentSelect,
erstelleElement("label", { text: "Art des Jobs:" }),
kindSelect,
erstelleElement("label", { text: "Cron-Ausdruck:" }),
cronInput,
erstelleElement("label", { text: "Aufgaben-Nachricht:" }),
taskMsgInput
);
const abbrechenBtn = erstelleElement(
"button",
{
type: "button",
klasse: "schaltflaeche flach",
onclick: () => modal.schliessen(),
text: "Abbrechen"
}
);
const anlegenBtn = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: "Job erstellen"
});
const modal = erstelleModal("Neuen geplanten Job anlegen", formular, [abbrechenBtn, anlegenBtn]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
anlegenBtn.disabled = true;
try {
await api("/jobs", {
method: "POST",
body: {
agentId: agentSelect.value,
kind: kindSelect.value,
cron: cronInput.value.trim(),
taskMessage: taskMsgInput.value.trim(),
runOnStart: false
}
});
modal.schliessen();
zeigeMeldung("Geplanter Job wurde erstellt.", "erfolg");
await ladeAktivenBereich();
} catch (err) {
zeigeMeldung(`Fehler beim Erstellen: ${err.message}`, "gefahr");
} finally {
anlegenBtn.disabled = false;
}
});
modal.oeffnen();
}
// ─── Taskboard ───
async function rendereTaskboard(container) {
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Taskboard…" })
);
try {
const tasks = await api("/tasks?limit=50");
container.replaceChildren();
if (!tasks || tasks.length === 0) {
container.append(
erstelleKarte("Taskboard leer", [
erstelleElement("p", {
klasse: "gedaempft",
text: "Aktuell liegen keine offenen Aufgaben im gemeinsamen Arbeitsbereich."
})
])
);
return;
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) {
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.agentId || "—"}` }),
erstelleElement("div", {
klasse: "task-status-zeile",
stil: "margin-top: 8px;"
}, erstellePlakette(task.status || "Offen", task.status === "Done" ? "erfolg" : "neutral")),
erstelleElement("div", {
klasse: "gedaempft text-klein",
stil: "margin-top: 8px;",
text: `Erstellt: ${formatiereDatumZeit(task.createdAt)}`
})
]);
raster.append(karte);
}
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard nicht verfügbar", [
erstelleElement("p", {
klasse: "gedaempft",
text: err.message || "In dieser Instanz ist kein Taskboard aktiv (kein gemeinsamer Arbeitsbereich)."
})
])
);
}
}