Files
OPNexus/frontend/components/OpnexusUpdateSection.tsx
T
Julian Boldt 6964ec6537
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.23.6
- **Versionsprüfung im Webinterface:** Unter Einstellungen › OPNexus aktualisieren lässt sich die Prüfung auf neue Versionen jetzt per Schalter
  und Feed-Adresse einstellen – kein Eingriff in die `.env` mehr nötig (Migration `0019`). Standardmäßig bleibt sie aus. Ein gesetztes
  `UPDATE_CHECK_URL` in der `.env` hat weiter Vorrang und sperrt den Schalter (mit Hinweis).
2026-10-20 14:38:19 +02:00

334 lines
15 KiB
TypeScript

"use client";
import { Badge, badgeToneClasses } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { FieldGroup, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, UpdateCheckSettings, UpdaterStatus } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { useNotAdmin } 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;
/** Versionsprüfung an/aus und Feed-Adresse — statt UPDATE_CHECK_URL in der .env (die weiterhin Vorrang hat). */
function FeedSettings({ onSaved }: { onSaved: () => void }) {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [feed, setFeed] = useState<UpdateCheckSettings | null>(null);
const [url, setUrl] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.updateCheck().then((next) => {
setFeed(next);
setUrl(next.feed_url);
}).catch(() => setFeed(null));
}, []);
if (!feed) return null;
async function save(body: { enabled?: boolean; feed_url?: string }) {
setSaving(true);
try {
const next = await api.updateUpdateCheck(body);
setFeed(next);
setUrl(next.feed_url);
toast.show(t("settings.update.feedSaved"), "success");
onSaved();
} catch (e) {
toast.show(errorMessage(e, "common.requestFailed"), "danger");
} finally {
setSaving(false);
}
}
return (
<div className="space-y-3 rounded-lg border border-slate-200 p-3 text-sm">
<p className="flex items-center gap-2 font-medium">
{t("settings.update.feedTitle")}
<InfoTip text={t("settings.update.feedHint")} />
</p>
{feed.managed_by_server ? (
<>
<p className="text-slate-600">{t("settings.update.feedManaged")}</p>
<code className="block overflow-x-auto rounded bg-slate-50 p-2 text-xs">{feed.feed_url}</code>
</>
) : (
<>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={feed.enabled}
disabled={saving}
onChange={(e) => void save({ enabled: e.target.checked })}
/>
{t("settings.update.feedToggle")}
</label>
<FieldGroup label={t("settings.update.feedUrl")}>
<div className="flex gap-2">
<Input value={url} onChange={(e) => setUrl(e.target.value)} inputMode="url" spellCheck={false} />
<Button variant="secondary" disabled={saving || url.trim() === feed.feed_url} onClick={() => void save({ feed_url: url })}>
{t("settings.update.feedSave")}
</Button>
</div>
</FieldGroup>
</>
)}
</div>
);
}
/**
* 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 = useNotAdmin();
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>
<FeedSettings onSaved={() => void load()} />
{!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.notEnabledImages")}</p>
<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>
);
}