feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console password is re-verified per apply (login throttle shared, stripped before forwarding) so a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating request (login CSRF included). The card's apply flow: confirm dialog → live-session force escalation → download/verify/restart progress rendered from the last snapshot while polls fail (the host and, on Windows, this very server restart mid-flow) → durable success/failure from last_result. Docs: the one-click section + kill switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,8 +37,21 @@ systemctl --user restart punktfunk-host
|
||||
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
|
||||
restart hint when it's needed.)
|
||||
|
||||
One-click updating from the console is on the way, per install method — the card will grow an
|
||||
**Apply** button where the platform supports it.
|
||||
## One-click updating (Windows)
|
||||
|
||||
On a Windows host the card shows an **Update now** button instead of a command. It asks for the
|
||||
console password again (a saved login alone can't restart your host), then the host downloads
|
||||
the installer, verifies it against the signed release manifest **and** its code signature, and
|
||||
runs it silently — the service restarts at the end and the page reconnects by itself. If a
|
||||
stream is live you'll be warned first: updating drops it.
|
||||
|
||||
Every attempt leaves a result in the card (and an installer log under
|
||||
`C:\ProgramData\punktfunk\logs\update-<version>.log`) — including across the restart, so a
|
||||
failed update is never silent. To disable the button entirely on a host, set
|
||||
`PUNKTFUNK_UPDATE_APPLY=0` in `host.env`; the card then shows the manual command instead.
|
||||
|
||||
One-click updating for the Linux install methods is on the way — it will be opt-in per host
|
||||
(joining a `punktfunk-update` group) because it needs a narrowly-scoped root helper.
|
||||
|
||||
## Turning the check off
|
||||
|
||||
|
||||
+22
-1
@@ -456,5 +456,26 @@
|
||||
"update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).",
|
||||
"update_error": "Letzte Prüfung fehlgeschlagen:",
|
||||
"update_copy": "Kopieren",
|
||||
"update_copied": "Kopiert"
|
||||
"update_copied": "Kopiert",
|
||||
"update_apply_ready": "Version {version} kann von hier aus installiert werden — der Host lädt, prüft und startet sich selbst neu.",
|
||||
"update_apply_button": "Jetzt aktualisieren",
|
||||
"update_apply_confirm_title": "Host auf {version} aktualisieren?",
|
||||
"update_apply_confirm_body": "Mit dem Konsolen-Passwort bestätigen. Der Host startet am Ende neu; diese Seite verbindet sich selbst wieder.",
|
||||
"update_apply_force_warning": "Gerade läuft eine Streaming-Sitzung — das Update bricht sie ab (wer spielt, fliegt raus). Falls du diese Seite über den Stream siehst, verlierst du ihn ebenfalls.",
|
||||
"update_apply_force_button": "Trotzdem aktualisieren (bricht den Stream ab)",
|
||||
"update_apply_password_label": "Konsolen-Passwort",
|
||||
"update_apply_wrong_password": "Falsches Passwort.",
|
||||
"update_apply_throttled": "Zu viele Versuche — kurz warten und erneut versuchen.",
|
||||
"update_apply_working": "Starte…",
|
||||
"update_apply_timeout": "Der Host hat sich noch nicht zurückgemeldet. Möglicherweise installiert er noch — falls das anhält, den Dienst auf der Maschine und das Installer-Log im punktfunk-Datenverzeichnis prüfen (logs\\update-*.log).",
|
||||
"update_applying_title": "Aktualisiere auf {version}",
|
||||
"update_stage_downloading": "Lade den Installer… {pct}%",
|
||||
"update_stage_downloading_indeterminate": "Lade den Installer…",
|
||||
"update_stage_verifying": "Prüfe den Download…",
|
||||
"update_stage_applying": "Übergabe an den Installer…",
|
||||
"update_stage_restarting": "Der Host startet neu…",
|
||||
"update_stage_reconnecting": "Warte, bis der Host wieder da ist…",
|
||||
"update_result_ok": "Aktualisiert {from} → {to}.",
|
||||
"update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.",
|
||||
"update_result_log": "Installer-Log:"
|
||||
}
|
||||
|
||||
+22
-1
@@ -456,5 +456,26 @@
|
||||
"update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).",
|
||||
"update_error": "Last check failed:",
|
||||
"update_copy": "Copy",
|
||||
"update_copied": "Copied"
|
||||
"update_copied": "Copied",
|
||||
"update_apply_ready": "Version {version} can be installed from here — the host downloads, verifies, and restarts itself.",
|
||||
"update_apply_button": "Update now",
|
||||
"update_apply_confirm_title": "Update the host to {version}?",
|
||||
"update_apply_confirm_body": "Confirm with the console password. The host will restart at the end; this page reconnects by itself.",
|
||||
"update_apply_force_warning": "A streaming session is live right now — updating will drop it (whoever is playing gets cut off). If that stream is how you're viewing this page, you'll lose it too.",
|
||||
"update_apply_force_button": "Update anyway (drops the stream)",
|
||||
"update_apply_password_label": "Console password",
|
||||
"update_apply_wrong_password": "Wrong password.",
|
||||
"update_apply_throttled": "Too many attempts — wait a moment and try again.",
|
||||
"update_apply_working": "Starting…",
|
||||
"update_apply_timeout": "The host hasn't come back yet. It may still be installing — if this persists, check the service on the machine and the installer log under the punktfunk data directory (logs\\update-*.log).",
|
||||
"update_applying_title": "Updating to {version}",
|
||||
"update_stage_downloading": "Downloading the installer… {pct}%",
|
||||
"update_stage_downloading_indeterminate": "Downloading the installer…",
|
||||
"update_stage_verifying": "Verifying the download…",
|
||||
"update_stage_applying": "Handing over to the installer…",
|
||||
"update_stage_restarting": "The host is restarting…",
|
||||
"update_stage_reconnecting": "Waiting for the host to come back…",
|
||||
"update_result_ok": "Updated {from} → {to}.",
|
||||
"update_result_failed": "Update to {to} failed during {stage}.",
|
||||
"update_result_log": "Installer log:"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
|
||||
import {
|
||||
defineEventHandler,
|
||||
getRequestHeader,
|
||||
getRequestURL,
|
||||
sendRedirect,
|
||||
setResponseStatus,
|
||||
@@ -18,6 +19,24 @@ import {
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { pathname } = getRequestURL(event);
|
||||
|
||||
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
|
||||
// added with the update-apply route where CSRF ≈ code execution — design
|
||||
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
|
||||
// from a page; absent (curl, very old browsers) ⇒ allowed — the console's threat here is
|
||||
// a BROWSER being ridden cross-site, and every riding browser sends the header.
|
||||
// `same-site` is rejected too: with an IP-address origin, another port on the same box
|
||||
// counts as same-site, and nothing on another port has business mutating the console.
|
||||
// Applies to public paths as well (login CSRF), before any session logic.
|
||||
const method = event.method?.toUpperCase?.() ?? "GET";
|
||||
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
||||
const site = getRequestHeader(event, "sec-fetch-site")?.toLowerCase();
|
||||
if (site && site !== "same-origin" && site !== "none") {
|
||||
setResponseStatus(event, 403);
|
||||
return { error: "cross-site request refused" };
|
||||
}
|
||||
}
|
||||
|
||||
if (isPublicPath(pathname)) return;
|
||||
|
||||
// Misconfigured: refuse everything rather than serve open on the LAN.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// POST /api/v1/update/apply — the ONE proxied route with an extra gate: the console password
|
||||
// must be re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session
|
||||
// cookie alone must not be able to update-and-restart the host; the password is verified HERE
|
||||
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login
|
||||
// throttle's per-IP budget, so apply can't be used as a password oracle.
|
||||
//
|
||||
// This specific file wins over the `[...]` catch-all (h3 route specificity) — verified in the
|
||||
// U1 gate; everything else about proxying (bearer injection, loopback TLS scoping, 401→502)
|
||||
// mirrors ../../[...].ts.
|
||||
import {
|
||||
createError,
|
||||
defineEventHandler,
|
||||
getRequestIP,
|
||||
readBody,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import {
|
||||
isLoopbackUrl,
|
||||
mgmtToken,
|
||||
mgmtUrl,
|
||||
timingSafeEqual,
|
||||
uiPassword,
|
||||
} from "../../../../util/auth";
|
||||
import {
|
||||
recordLoginFailure,
|
||||
recordLoginSuccess,
|
||||
throttleRetryAfterMs,
|
||||
} from "../../../../util/loginThrottle";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const expected = uiPassword();
|
||||
if (!expected) {
|
||||
throw createError({ statusCode: 503, statusMessage: "auth not configured" });
|
||||
}
|
||||
const ip = getRequestIP(event) ?? "unknown";
|
||||
const wait = throttleRetryAfterMs(ip);
|
||||
if (wait > 0) {
|
||||
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: "too many attempts — try again shortly",
|
||||
});
|
||||
}
|
||||
|
||||
const body = await readBody<{ password?: string; force?: boolean }>(event);
|
||||
const password = String(body?.password ?? "");
|
||||
if (!timingSafeEqual(password, expected)) {
|
||||
recordLoginFailure(ip);
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "password confirmation failed",
|
||||
});
|
||||
}
|
||||
recordLoginSuccess(ip);
|
||||
|
||||
const token = mgmtToken();
|
||||
if (!token) {
|
||||
setResponseStatus(event, 503);
|
||||
return { error: "management token not configured" };
|
||||
}
|
||||
const base = mgmtUrl();
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
// The password stops here — the host only ever sees the force flag.
|
||||
body: JSON.stringify({ force: body?.force === true }),
|
||||
};
|
||||
if (isLoopbackUrl(base)) {
|
||||
// Bun.fetch extension (see ../../[...].ts for why this is scoped per-request).
|
||||
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
const upstream = await fetch(`${base}/api/v1/update/apply`, init);
|
||||
if (upstream.status === 401) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage:
|
||||
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
|
||||
});
|
||||
}
|
||||
setResponseStatus(event, upstream.status);
|
||||
setResponseHeader(event, "content-type", "application/json");
|
||||
return upstream.text();
|
||||
});
|
||||
@@ -10,21 +10,57 @@ import type { UpdateStatus } from "@/api/gen/model";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** Give up waiting for the host to come back this long after apply started. */
|
||||
const APPLY_TIMEOUT_MS = 8 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Container: the host update-check card (U0 — notify-only). Reading status is what keeps the
|
||||
* host's manifest cache warm (the host kicks its own background refresh when the cache is >6 h
|
||||
* old), so a modest poll doubles as the check cadence while the console is open. Apply buttons
|
||||
* arrive with the per-channel apply legs (U1 Windows, U2 Linux helper); until then the card's
|
||||
* action is the exact update command for this install kind.
|
||||
* Container: the host update card. Check everywhere (U0) + one-click apply where the host
|
||||
* reports `apply: "full"` (U1 — Windows installer). The apply flow deliberately survives the
|
||||
* console's own backends dying: once an apply is accepted, the card renders from the LAST
|
||||
* status snapshot (React Query keeps data across failed polls) and treats poll errors as "the
|
||||
* host is restarting", not as failures — until the target version answers or a timeout.
|
||||
*/
|
||||
export const UpdateSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const status = useGetUpdateStatus({ query: { refetchInterval: 60_000 } });
|
||||
const [applying, setApplying] = useState<{
|
||||
target: string;
|
||||
startedAt: number;
|
||||
} | null>(null);
|
||||
|
||||
const status = useGetUpdateStatus({
|
||||
// Poll fast while an apply is in flight so the post-restart status lands promptly.
|
||||
query: { refetchInterval: applying ? 3_000 : 60_000 },
|
||||
});
|
||||
const check = useForceUpdateCheck();
|
||||
|
||||
const s = status.data;
|
||||
// The apply resolved: the host answers with the target version (success — reconcile wrote
|
||||
// last_result), or reports a failed attempt at our target.
|
||||
if (applying && s) {
|
||||
if (
|
||||
s.current_version === applying.target ||
|
||||
(s.last_result &&
|
||||
s.last_result.to === applying.target &&
|
||||
!s.last_result.ok)
|
||||
) {
|
||||
setApplying(null);
|
||||
}
|
||||
}
|
||||
|
||||
const checkNow = () =>
|
||||
check.mutate(undefined, {
|
||||
onSuccess: (fresh) => {
|
||||
@@ -32,25 +68,45 @@ export const UpdateSection: FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return <UpdateCard state={status} onCheck={checkNow} busy={check.isPending} />;
|
||||
return (
|
||||
<UpdateCard
|
||||
state={status}
|
||||
onCheck={checkNow}
|
||||
checkBusy={check.isPending}
|
||||
applying={applying}
|
||||
onApplied={(target) =>
|
||||
setApplying({ target, startedAt: Date.now() })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpdateCard: FC<{
|
||||
state: Loadable<UpdateStatus>;
|
||||
onCheck: () => void;
|
||||
busy: boolean;
|
||||
}> = ({ state, onCheck, busy }) => {
|
||||
checkBusy: boolean;
|
||||
applying: { target: string; startedAt: number } | null;
|
||||
onApplied: (target: string) => void;
|
||||
}> = ({ state, onCheck, checkBusy, applying, onApplied }) => {
|
||||
const s = state.data;
|
||||
const inFlight = Boolean(applying) || Boolean(s?.job);
|
||||
const timedOut =
|
||||
applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{m.update_title()}</CardTitle>
|
||||
{s?.available && <Badge>{m.update_available_badge()}</Badge>}
|
||||
{s?.available && !inFlight && (
|
||||
<Badge>{m.update_available_badge()}</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QueryState
|
||||
isLoading={state.isLoading}
|
||||
error={state.error}
|
||||
// While an apply restarts the host (and, on Windows, this console server),
|
||||
// failed polls are EXPECTED — keep rendering the last snapshot instead of
|
||||
// swapping the card for an error box.
|
||||
error={inFlight ? undefined : state.error}
|
||||
refetch={state.refetch}
|
||||
>
|
||||
{s && (
|
||||
@@ -94,11 +150,21 @@ export const UpdateCard: FC<{
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{s.available ? (
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<p className="text-sm">{m.update_how()}</p>
|
||||
<CommandLine command={s.channel_hint} />
|
||||
</div>
|
||||
{inFlight ? (
|
||||
<ApplyProgress
|
||||
status={s}
|
||||
reconnecting={Boolean(state.error)}
|
||||
timedOut={timedOut}
|
||||
/>
|
||||
) : s.available ? (
|
||||
s.apply === "full" ? (
|
||||
<ApplyPanel status={s} onApplied={onApplied} />
|
||||
) : (
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<p className="text-sm">{m.update_how()}</p>
|
||||
<CommandLine command={s.channel_hint} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
s.manifest && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -107,12 +173,16 @@ export const UpdateCard: FC<{
|
||||
)
|
||||
)}
|
||||
|
||||
{!inFlight && s.last_result && (
|
||||
<LastResult result={s.last_result} />
|
||||
)}
|
||||
|
||||
{s.manifest?.stale && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
{m.update_stale()}
|
||||
</p>
|
||||
)}
|
||||
{s.last_error && (
|
||||
{!inFlight && s.last_error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{m.update_error()} {s.last_error}
|
||||
</p>
|
||||
@@ -122,22 +192,24 @@ export const UpdateCard: FC<{
|
||||
{m.update_disabled()}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCheck}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? m.update_checking() : m.update_check_now()}
|
||||
</Button>
|
||||
{s.last_checked_unix != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.update_last_checked()}{" "}
|
||||
{new Date(s.last_checked_unix * 1000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
!inFlight && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCheck}
|
||||
disabled={checkBusy}
|
||||
>
|
||||
{checkBusy ? m.update_checking() : m.update_check_now()}
|
||||
</Button>
|
||||
{s.last_checked_unix != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{m.update_last_checked()}{" "}
|
||||
{new Date(s.last_checked_unix * 1000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -147,6 +219,205 @@ export const UpdateCard: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/** The one-click leg: the Update button + its password-confirm dialog. */
|
||||
const ApplyPanel: FC<{
|
||||
status: UpdateStatus;
|
||||
onApplied: (target: string) => void;
|
||||
}> = ({ status, onApplied }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [needsForce, setNeedsForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const target = status.manifest?.version ?? "";
|
||||
|
||||
const submit = async (force: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Plain fetch on purpose: apiFetch treats ANY 401 as "session expired → /login",
|
||||
// but here a 401 is just a wrong password confirmation.
|
||||
const res = await fetch("/api/v1/update/apply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ password, force }),
|
||||
});
|
||||
if (res.status === 202) {
|
||||
setOpen(false);
|
||||
setPassword("");
|
||||
onApplied(target);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
if (res.status === 401) {
|
||||
setError(m.update_apply_wrong_password());
|
||||
} else if (res.status === 429) {
|
||||
setError(m.update_apply_throttled());
|
||||
} else if (res.status === 409 && body?.error?.includes("force")) {
|
||||
// The host refused because a stream is live — escalate to the explicit
|
||||
// "drop the stream" confirmation instead of showing a raw error.
|
||||
setNeedsForce(true);
|
||||
} else {
|
||||
setError(body?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch {
|
||||
setError(m.common_error());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<p className="text-sm">{m.update_apply_ready({ version: target })}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
{m.update_apply_button()}
|
||||
</Button>
|
||||
<CommandLine command={status.channel_hint} />
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
setOpen(o);
|
||||
if (!o) {
|
||||
setPassword("");
|
||||
setError(null);
|
||||
setNeedsForce(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{m.update_apply_confirm_title({ version: target })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{needsForce
|
||||
? m.update_apply_force_warning()
|
||||
: m.update_apply_confirm_body()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit(needsForce);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="update-apply-password">
|
||||
{m.update_apply_password_label()}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-apply-password"
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={needsForce ? "destructive" : "default"}
|
||||
disabled={busy || password.length === 0}
|
||||
>
|
||||
{busy
|
||||
? m.update_apply_working()
|
||||
: needsForce
|
||||
? m.update_apply_force_button()
|
||||
: m.update_apply_button()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The in-flight panel: download progress → applying → restarting → reconnecting. */
|
||||
const ApplyProgress: FC<{
|
||||
status: UpdateStatus;
|
||||
reconnecting: boolean;
|
||||
timedOut: boolean;
|
||||
}> = ({ status, reconnecting, timedOut }) => {
|
||||
const job = status.job;
|
||||
const pct =
|
||||
job?.total_bytes && job.total_bytes > 0
|
||||
? Math.min(100, Math.round((job.received_bytes / job.total_bytes) * 100))
|
||||
: null;
|
||||
const stageLabel = (() => {
|
||||
if (reconnecting) return m.update_stage_reconnecting();
|
||||
switch (job?.stage) {
|
||||
case "downloading":
|
||||
return pct === null
|
||||
? m.update_stage_downloading_indeterminate()
|
||||
: m.update_stage_downloading({ pct: String(pct) });
|
||||
case "verifying":
|
||||
return m.update_stage_verifying();
|
||||
case "applying":
|
||||
return m.update_stage_applying();
|
||||
default:
|
||||
return m.update_stage_restarting();
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner className="size-4" />
|
||||
<p className="text-sm font-medium">
|
||||
{m.update_applying_title({
|
||||
version: job?.target_version ?? status.manifest?.version ?? "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{stageLabel}</p>
|
||||
{job?.stage === "downloading" && pct !== null && (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-full rounded bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{timedOut && (
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
{m.update_apply_timeout()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Durable outcome of the last apply (written by the host across its own restart). */
|
||||
const LastResult: FC<{
|
||||
result: NonNullable<UpdateStatus["last_result"]>;
|
||||
}> = ({ result }) =>
|
||||
result.ok ? (
|
||||
<p className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
|
||||
{m.update_result_ok({ from: result.from, to: result.to })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<p className="font-medium text-destructive">
|
||||
{m.update_result_failed({ to: result.to, stage: result.stage ?? "?" })}
|
||||
</p>
|
||||
{result.error && <p>{result.error}</p>}
|
||||
{result.log_path && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.update_result_log()} <code>{result.log_path}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const UpdateRow: FC<{ label: string; value: ReactNode }> = ({
|
||||
label,
|
||||
value,
|
||||
@@ -172,7 +443,7 @@ const CommandLine: FC<{ command: string }> = ({ command }) => {
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">
|
||||
{command}
|
||||
</code>
|
||||
|
||||
Reference in New Issue
Block a user