Files
OPNexus/frontend/components/OpnexusUpdateSection.tsx
T
Julian Boldt c662a58426
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.15.0
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch
„Update aus dem Webinterface (optional)“:

- **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die
  neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige,
  automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“
  (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner
  **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater`
  in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig
  **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater
  hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann
  ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“,
  „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten.
  Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images.
- `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`,
  Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten
  zusätzlich `opnexus-updater`.
- Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
2026-10-10 00:24:01 +02:00

259 lines
12 KiB
TypeScript

"use client";
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, UpdaterStatus } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useReadOnly } from "@/lib/useReadOnly";
import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react";
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
const POLL_MS = 2000;
const RELOAD_DELAY_MS = 3500;
/**
* Settings card "OPNexus aktualisieren": zeigt die verfügbare Version und löst
* das Update über den optionalen Updater-Container aus (siehe updater/server.py).
* Das Backend selbst fasst den Host nie an. Während des Updates starten Backend
* und Proxy neu — die Karte fragt weiter nach und übersteht die Lücke.
*/
export function OpnexusUpdateSection() {
const { t, errorMessage, locale } = useLanguage();
const readOnly = useReadOnly();
const [status, setStatus] = useState<UpdaterStatus | null>(null);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState<"update" | "rollback" | null>(null);
const [busy, setBusy] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
// Wurde in dieser Sitzung ein laufender Auftrag gesehen? Nur dann ist ein
// "succeeded" Anlass, die Seite neu zu laden (neue Frontend-Version).
const sawRunning = useRef(false);
const reloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async () => {
try {
const next = await api.updaterStatus();
setStatus(next);
setReconnecting(false);
setError("");
if (next.job?.state === "running") sawRunning.current = true;
return next;
} catch (e) {
if (sawRunning.current) {
setReconnecting(true); // Dienste starten gerade neu — weiter versuchen
} else {
setError(errorMessage(e, "common.requestFailed"));
}
return null;
}
}, [errorMessage]);
useEffect(() => {
if (readOnly) return;
void load();
}, [load, readOnly]);
const running = status?.job?.state === "running" || reconnecting;
useEffect(() => {
if (!running) return;
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [running, load]);
const job = status?.job ?? null;
const finishedNow = !!job && job.state === "succeeded" && sawRunning.current;
useEffect(() => {
if (!finishedNow) return;
reloadTimer.current = setTimeout(() => window.location.reload(), RELOAD_DELAY_MS);
return () => {
if (reloadTimer.current) clearTimeout(reloadTimer.current);
};
}, [finishedNow]);
if (readOnly) return null;
async function start(kind: "update" | "rollback") {
setBusy(true);
setError("");
try {
if (kind === "update" && status?.latest) await api.updaterUpdate(status.latest);
else if (kind === "rollback") await api.updaterRollback();
sawRunning.current = true;
setConfirm(null);
await load();
} catch (e) {
setConfirm(null);
setError(errorMessage(e, "settings.update.startFailed"));
} finally {
setBusy(false);
}
}
const phases = status?.phases ?? [];
const steps = phases.filter((p, i) => p !== phases[i - 1]);
const isRollbackJob = job?.action === "rollback";
const finishedAt = job?.finished_at ? new Date(job.finished_at).toLocaleString(locale) : "";
const releaseUrl = status?.release_url && /^https?:\/\//.test(status.release_url) ? status.release_url : null;
return (
<section id="opnexus-update" className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">
{t("settings.update.title")}
<InfoTip text={t("settings.update.hint")} />
</h2>
{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
{!status && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{status && (
<>
<dl className="grid grid-cols-2 gap-3 text-sm">
<dt>{t("settings.update.installed")}</dt>
<dd>{status.current}</dd>
<dt>{t("settings.update.latest")}</dt>
<dd className="flex flex-wrap items-center gap-2">
{status.latest ?? "—"}
{status.update_available && <Badge tone="info">{t("settings.update.badge")}</Badge>}
{releaseUrl && (
<a href={releaseUrl} target="_blank" rel="noopener noreferrer" className="font-medium text-brand-600 underline">
{t("settings.update.notes")}
</a>
)}
</dd>
</dl>
{!status.feed_enabled && <p className="text-sm text-slate-600">{t("settings.update.feedOff")}</p>}
{status.feed_enabled && status.feed_error && <p className="text-sm text-amber-700">{t("settings.update.feedError")}</p>}
{status.feed_enabled && !status.feed_error && !status.update_available && (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 size={16} aria-hidden="true" /> {t("settings.update.upToDate")}
{status.checked_at && <span className="text-slate-500">· {t("settings.update.checked", { time: new Date(status.checked_at).toLocaleString(locale) })}</span>}
</p>
)}
{!status.available && (
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
{status.reason === "not_enabled" || status.reason === "unauthorized" ? (
<>
<p className="font-medium">{t("settings.update.notEnabledTitle")}</p>
<p>{t("settings.update.notEnabledBody")}</p>
<p>{t("settings.update.notEnabledStep1")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">COMPOSE_PROFILES=updater</pre>
<p>{t("settings.update.notEnabledStep2")}</p>
<pre className="overflow-x-auto rounded bg-white p-2 text-xs">docker compose up -d</pre>
<p className="text-slate-500">{t("settings.update.notEnabledCli")}</p>
</>
) : (
<p>{t("settings.update.unreachable")}</p>
)}
</div>
)}
{status.available && status.mode === "local" && (
<p className="text-sm text-slate-600">{t("settings.update.localMode")}</p>
)}
{status.available && status.major_change && status.latest && (
<p className={clsx("flex items-start gap-2 rounded-lg border p-3 text-sm", badgeToneClasses.warning)}>
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
{t("settings.update.majorChange", { latest: status.latest })}
</p>
)}
{running && (
<div role="status" className={clsx("space-y-2 rounded-lg border p-3 text-sm", badgeToneClasses.info)}>
<p className="flex items-center gap-2 font-medium">
<Loader2 size={16} className="animate-spin motion-reduce:animate-none" aria-hidden="true" />
{isRollbackJob ? t("settings.update.rollingBack") : t("settings.update.running", { target: job?.target ?? status.latest ?? "" })}
</p>
{steps.length > 0 && (
<ol className="list-inside list-decimal text-xs">
{steps.map((step) => (
<li key={step}>{t(`settings.update.phase.${step}`)}</li>
))}
</ol>
)}
{reconnecting && <p className="text-xs">{t("settings.update.reconnecting")}</p>}
</div>
)}
{!running && job && job.state === "succeeded" && sawRunning.current && (
<p role="status" className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 size={16} aria-hidden="true" />
{isRollbackJob
? t("settings.update.result.succeededRollback", { version: status.current })
: t("settings.update.result.succeededUpdate", { version: status.current })}
</p>
)}
{!running && job?.state === "rolled_back" && (
<p role="alert" className={clsx("rounded-lg border p-3 text-sm", badgeToneClasses.warning)}>
{t("settings.update.result.rolledBack", { target: job.target ?? "" })}
{finishedAt && <span className="mt-1 block text-xs opacity-80">{t("settings.update.resultTime", { time: finishedAt })}</span>}
</p>
)}
{!running && job?.state === "failed" && (
<p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{t("settings.update.result.failed")}
{finishedAt && <span className="mt-1 block text-xs opacity-80">{t("settings.update.resultTime", { time: finishedAt })}</span>}
</p>
)}
{status.log.length > 0 && (job?.state !== "succeeded" || running) && (
<details className="text-xs" open={!running && job?.state !== "succeeded"}>
<summary className="cursor-pointer text-slate-600">{t("settings.update.showLog")}</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-slate-50 p-3">{status.log.join("\n")}</pre>
</details>
)}
{status.can_update && status.latest && !running && (
<Button onClick={() => setConfirm("update")} disabled={busy}>
{t("settings.update.button", { latest: status.latest })}
</Button>
)}
{status.available && status.mode === "release" && status.rollback_available && !running && (
<div className="flex flex-wrap items-center gap-3 border-t border-slate-100 pt-3 text-sm">
<Button variant="outline" onClick={() => setConfirm("rollback")} disabled={busy}>
{t("settings.update.rollback.button")}
</Button>
<span className="text-slate-500">{t("settings.update.rollback.hint", { version: status.rollback_version ?? "—" })}</span>
</div>
)}
</>
)}
{confirm === "update" && status?.latest && (
<Modal title={t("settings.update.confirmTitle", { latest: status.latest })} onClose={() => setConfirm(null)}>
<div className="space-y-3 text-sm text-slate-700">
<p>{t("settings.update.confirmBody")}</p>
<p>{t("settings.update.confirmSafety")}</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void start("update")} loading={busy}>{t("settings.update.confirm")}</Button>
</div>
</div>
</Modal>
)}
{confirm === "rollback" && status && (
<Modal title={t("settings.update.rollback.confirmTitle", { version: status.rollback_version ?? "—" })} onClose={() => setConfirm(null)}>
<div className="space-y-3 text-sm text-slate-700">
<p className={status.rollback_loses_data ? clsx("rounded-lg border p-3", badgeToneClasses.warning) : ""}>
{status.rollback_loses_data ? t("settings.update.rollback.dataLoss") : t("settings.update.rollback.noDataLoss")}
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>{t("common.cancel")}</Button>
<Button variant={status.rollback_loses_data ? "danger" : "primary"} onClick={() => void start("rollback")} loading={busy}>
{t("settings.update.rollback.confirm")}
</Button>
</div>
</div>
</Modal>
)}
</section>
);
}