import { Trans, useLingui } from "@lingui/react/macro"; import type { ServerUpdateCheck, ServerUpdateStatus } from "@rakazo/contracts"; import { Button } from "@rakazo/ui-web"; import { useEffect, useState } from "react"; import { rpc } from "../lib/rpc"; import { confirmUpdaterRecreate, isLikelyUpdaterRecreateDisconnect, recreateWaitTimeoutError, } from "../lib/updater-recreate"; import { SuccessPop } from "./ai/primitives"; const RECREATE_POLL_MS = 2_000; const RECREATE_POLL_ATTEMPTS = 90; async function waitForUpdaterStatus(options: { beforeImageTag: string | null }): Promise<{ status: ServerUpdateStatus; confirmed: boolean; reason: "waiting" | "running" | "unchanged" | "changed" | "failed"; }> { let lastError: unknown; let sawApi = false; let sawSidecar = false; for (let attempt = 0; attempt < RECREATE_POLL_ATTEMPTS; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => setTimeout(resolve, RECREATE_POLL_MS)); } try { const next = await rpc.updater.status(); sawApi = true; const verdict = confirmUpdaterRecreate({ beforeImageTag: options.beforeImageTag, afterImageTag: next.imageTag, running: next.running, supported: next.supported, installKind: next.installKind, lastRun: next.lastRun, }); if (verdict.reason === "waiting") continue; sawSidecar = true; if (verdict.reason === "running") continue; return { status: next, confirmed: verdict.confirmed, reason: verdict.reason }; } catch (error) { lastError = error; } } throw recreateWaitTimeoutError({ sawApi, sawSidecar, lastError }); } /** Presentational body for the sidecar update path (unit-testable without RPC). */ export function SoftwareUpdatePanel({ check, busy, error, done, onCheck, onApply, }: { check: ServerUpdateCheck | null; busy: "check" | "apply" | null; error: string | null; done: string | null; onCheck: () => void; onApply: () => void; }) { const updateAvailable = check?.status === "available"; return (
{updateAvailable ? ( ) : null}
{check ? : null} {error ? (

{error}

) : null} {done ? : null}
); } export function SoftwareUpdateSection({ isDeploymentOwner }: { isDeploymentOwner: boolean }) { const { t } = useLingui(); const [status, setStatus] = useState(null); const [check, setCheck] = useState(null); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState<"check" | "apply" | null>(null); const [error, setError] = useState(null); const [done, setDone] = useState(null); useEffect(() => { if (!isDeploymentOwner) return; let cancelled = false; setLoading(true); setError(null); void rpc.updater .status() .then((next) => { if (!cancelled) setStatus(next); }) .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : t`Could not load update status`); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [isDeploymentOwner, t]); if (!isDeploymentOwner) return null; // Wait for status before deciding whether to show the section (compose/source stay hidden). if (loading) return null; if (!status) { if (!error) return null; return (

Software update

{error}

); } if (status.installKind !== "sidecar") return null; async function runCheck() { setBusy("check"); setError(null); setDone(null); try { setCheck(await rpc.updater.check({})); } catch (err) { setError(err instanceof Error ? err.message : t`Check failed`); } finally { setBusy(null); } } async function finishAfterPossibleRecreate( action: () => Promise<{ ok: boolean; error: string | null }>, ) { // Snapshot the live tag right before apply. Panel state can be stale if another tab or // host update moved the image after this section last loaded. let beforeImageTag: string | null = null; try { const before = await rpc.updater.status(); beforeImageTag = before.imageTag ?? null; setStatus(before); const run = await action(); setStatus(await rpc.updater.status()); setCheck(null); if (run.ok) { setError(null); setDone(t`Updated`); } else { setDone(null); setError(run.error ?? t`Update finished with errors`); } } catch (err) { if (!isLikelyUpdaterRecreateDisconnect(err)) { setError(err instanceof Error ? err.message : t`Update failed`); return; } setDone(t`Waiting for the API to come back…`); try { const recovered = await waitForUpdaterStatus({ beforeImageTag }); setStatus(recovered.status); setCheck(null); if (recovered.confirmed) { setError(null); setDone(t`Updated`); } else if (recovered.reason === "failed") { setDone(null); setError( recovered.status.lastRun?.error ?? recovered.status.lastRun?.restartAdvice ?? t`Update finished with errors`, ); } else { setDone(null); setError(t`API is back, but the update did not finish. Check the host logs.`); } } catch (waitError) { setDone(null); setError( waitError instanceof Error ? waitError.message : t`The API did not come back. Refresh this page.`, ); } } } async function runApply() { setBusy("apply"); setError(null); setDone(null); try { await finishAfterPossibleRecreate(() => rpc.updater.apply({})); } finally { setBusy(null); } } return (

Software update

void runCheck()} onApply={() => void runApply()} />
); } function CheckSummary({ check }: { check: ServerUpdateCheck }) { if (check.status === "up-to-date") { return (

Up to date

); } if (check.status === "available") { return (

Update available

); } if (check.status === "dirty") { return (

Checkout has local changes. Clean it before updating.

); } return (

{check.reason ?? Unavailable}

); }