#795: Updates aus der WebUI ueber den Instanzmanager

Das Update-Angebot gab es nur im Desktop-Fenster. Der Manager prueft jetzt
selbst und spielt ein: erst alle Instanzen geordnet anhalten, dann
- ohne systemd: update-agent mit --wait-for-pid und --restart,
- unter systemd: clawddotnet-update.service anstossen (Vorlage unter
  deploy/systemd), damit der agent keinen zweiten Manager neben dem
  Dienst startet (DC SETUP_INTEGRATION_GUIDE 4A).
Fehlt die Unit, wird nichts angehalten; scheitert die Uebergabe, kommen
die Instanzen wieder hoch. Anleitung in InstanceSetupGuide Abschnitt 8.

Nebenbei: Sprachpaket wird vor dem Kopf geladen (rohe Schluessel im Kopf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 20:15:18 +02:00
co-authored by Claude Opus 5.5
parent 4e41fc12d7
commit d4b7f34200
12 changed files with 630 additions and 12 deletions
+15
View File
@@ -439,6 +439,21 @@ internal static class ManagerEndpoints
}
});
// ─── Updates (#795) ───
verwaltung.MapGet("/update", async (ManagerUpdates updates, HttpContext context, CancellationToken ct) =>
Results.Ok(context.Request.Query.ContainsKey("refresh") || updates.Status.CheckedAt is null
? await updates.CheckAsync(ct)
: updates.Status));
verwaltung.MapPost("/update/install", async (ManagerUpdates updates) =>
{
var result = await updates.InstallAsync();
return result.Ok
? Results.Ok(new OutcomeDto(true, result.Message))
: Results.BadRequest(new OutcomeDto(false, result.Message));
});
// ─── License and manager settings (#764) ───
verwaltung.MapGet("/license", (ManagerCore manager) =>
@@ -54,7 +54,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
/// steht im Protokoll.</para>
/// </summary>
public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory)
public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory, ManagerUpdates? updates = null)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
var settings = manager.Settings;
@@ -82,7 +82,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
try
{
var app = Build(manager, backups, loggerFactory, url, token, forwarding);
var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
app.StartAsync().GetAwaiter().GetResult();
@@ -105,6 +105,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
private static WebApplication Build(
ManagerCore manager,
ManagerBackups backups,
ManagerUpdates? updates,
ILoggerFactory loggerFactory,
string url,
string token,
@@ -130,6 +131,8 @@ public sealed class ManagerWebHost : IAsyncDisposable
builder.Services.AddSingleton(manager);
builder.Services.AddSingleton(manager.Instances);
builder.Services.AddSingleton(backups);
// #795: Updates from the web interface. Without one given (tests), one that knows no version.
builder.Services.AddSingleton(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update")));
builder.Services.AddSingleton(AccessToken.For(token));
builder.Services.AddSingleton(forwarding);
+7 -5
View File
@@ -367,6 +367,13 @@ async function aktualisiereStagingZaehler() {
async function starteHauptAnwendung(instanzen) {
versteckeAnmeldung();
// #661: language packs before the first render. #764: Before the header as well - it stood
// below, so the instance list and the header status were drawn first and showed raw keys
// ("header.instanceOffline", "status-running") until something drew them again.
await loadLanguage();
translateDocument();
if (spracheSchalter) spracheSchalter.value = currentLanguage();
// Instanzenliste initialisieren
await aktualisiereInstanzenListe(Array.isArray(instanzen) ? instanzen : null);
@@ -394,11 +401,6 @@ async function starteHauptAnwendung(instanzen) {
}
});
// #661: language packs before the first render
await loadLanguage();
translateDocument();
if (spracheSchalter) spracheSchalter.value = currentLanguage();
// Router initialisieren
initialisiereRouter();
}
@@ -669,6 +669,19 @@
"logs.empty": "Noch keine Protokolleinträge vorhanden.",
"settings.title": "Einstellungen & Systemarchitektur",
"settings.updateCard": "Update",
"settings.updateInstalled": "Installiert",
"settings.updateLatest": "Neueste",
"settings.updateState": "Zustand",
"settings.updateCurrent": "Aktuell",
"settings.updateAvailable": "Update verfügbar",
"settings.updateCritical": "Wichtiges Update",
"settings.updateProblem": "Nicht prüfbar",
"settings.updateCheckNow": "Jetzt prüfen",
"settings.updateInstall": "Einspielen",
"settings.updateConfirm": "Version {version} einspielen? Zuerst werden alle Instanzen angehalten; der Instanzmanager ist ein bis zwei Minuten nicht erreichbar.",
"settings.updateHint": "Der update-agent spielt das Release ein und startet den Instanzmanager wieder. Instanzen mit Autostart kommen mit ihm zurück.",
"settings.updateHintSystemd": "Läuft als systemd-Dienst: Das Update geht über clawddotnet-update.service (siehe docs/InstanceSetupGuide.md, Abschnitt 8). Instanzen mit Autostart kommen mit dem Manager zurück.",
"settings.telegramCard": "Telegram-Client",
"settings.telegramState.Connecting": "Verbindet",
"settings.telegramState.NeedsCode": "Code nötig",
@@ -669,6 +669,19 @@
"logs.empty": "No log messages yet.",
"settings.title": "Settings & System Architecture",
"settings.updateCard": "Update",
"settings.updateInstalled": "Installed",
"settings.updateLatest": "Latest",
"settings.updateState": "State",
"settings.updateCurrent": "Up to date",
"settings.updateAvailable": "Update available",
"settings.updateCritical": "Important update",
"settings.updateProblem": "Not checkable",
"settings.updateCheckNow": "Check now",
"settings.updateInstall": "Install",
"settings.updateConfirm": "Install version {version}? All instances are stopped first; the instance manager is unreachable for a minute or two.",
"settings.updateHint": "The update agent installs the release and starts the instance manager again. Instances with autostart come back with it.",
"settings.updateHintSystemd": "Runs as a systemd service: the update goes through clawddotnet-update.service (see docs/InstanceSetupGuide.md, section 8). Instances with autostart come back with the manager.",
"settings.telegramCard": "Telegram client",
"settings.telegramState.Connecting": "Connecting",
"settings.telegramState.NeedsCode": "Code needed",
@@ -129,14 +129,16 @@ async function managerSection() {
const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"),
[erstellePlakette(t("settings.managerBadge"), "warnung")], t("settings.managerIntro"));
const [license, manager] = await Promise.all([
const [license, manager, update] = await Promise.all([
api("/verwaltung/license").catch(() => null),
api("/verwaltung/settings").catch(() => null)
api("/verwaltung/settings").catch(() => null),
api("/verwaltung/update").catch(() => null)
]);
const grid = erstelleElement("div", { klasse: "settings-grid" });
grid.append(licenseCard(license));
if (manager) grid.append(managerCard(manager));
if (update) grid.append(updateCard(update));
el.append(grid);
return el;
}
@@ -420,3 +422,81 @@ function telegramCard(initial) {
render(initial);
return card;
}
// ─── Updates (#795) ───
//
// Installing stops every instance first; the manager then hands over to the update agent (or,
// under systemd, to clawddotnet-update.service) and goes away for a while. The page waits for
// it to answer again and reloads.
function updateCard(status) {
const body = erstelleElement("div", { klasse: "formular-raster" });
const card = erstelleKarte(t("settings.updateCard"), [body]);
function render(s) {
const state = s.problem
? erstellePlakette(t("settings.updateProblem"), "warnung")
: s.available
? erstellePlakette(s.critical ? t("settings.updateCritical") : t("settings.updateAvailable"), s.critical ? "gefahr" : "warnung")
: erstellePlakette(t("settings.updateCurrent"), "erfolg");
body.replaceChildren(dataList([
[t("settings.updateInstalled"), s.installedVersion],
[t("settings.updateLatest"), s.latestVersion ?? "—"],
[t("settings.updateState"), state]
]));
if (s.problem) body.append(erstelleElement("p", { klasse: "gedaempft text-klein", text: s.problem }));
if (s.available && s.notes) body.append(erstelleElement("pre", { klasse: "text-klein", text: s.notes }));
const check = erstelleElement("button", {
type: "button", klasse: "schaltflaeche flach klein", text: t("settings.updateCheckNow"),
onclick: async () => {
check.disabled = true;
try { render(await api("/verwaltung/update?refresh=1")); } catch (err) { zeigeMeldung(err.message, "gefahr"); check.disabled = false; }
}
});
const actions = erstelleElement("div", { klasse: "gruppe-horizontal" }, check);
if (s.available) {
actions.append(erstelleElement("button", {
type: "button", klasse: "schaltflaeche primaer klein", text: t("settings.updateInstall"),
onclick: () => install(s)
}));
}
body.append(actions,
erstelleElement("p", { klasse: "gedaempft text-klein", text: s.underSystemd ? t("settings.updateHintSystemd") : t("settings.updateHint") }));
}
async function install(s) {
if (!confirm(t("settings.updateConfirm", { version: s.latestVersion ?? "?" }))) return;
try {
const result = await api("/verwaltung/update/install", { method: "POST" });
body.replaceChildren(erstelleElement("p", { klasse: "hinweis", text: result?.meldung ?? result?.message ?? "" }));
waitForManager();
} catch (err) {
zeigeMeldung(err.message, "gefahr", 10000);
}
}
// The manager is gone for a while; ask until it answers again, then show the new state.
// Reload only after it was gone once — otherwise the old manager, still shutting down,
// would answer and the page would show the old version.
function waitForManager() {
let tries = 0;
let wasDown = false;
const timer = setInterval(async () => {
tries++;
if (!card.isConnected || tries > 120) { clearInterval(timer); return; }
try {
await api("/verwaltung/update");
if (!wasDown) return;
clearInterval(timer);
location.reload();
} catch {
wasDown = true;
}
}, 3000);
}
render(status);
return card;
}