Skip to content

Commit 70b64ce

Browse files
heyjorgedevclaude
andcommitted
Show upgrade progress and result inline in the upgrade dialog
The upgrade dialog now stays open after Confirm and transitions in place through running/done/failed states, streaming the installer log with auto-scroll. The card-level progress block remains for ambient awareness, and the primary button becomes "View upgrade…" when an upgrade is in flight so the dialog can be re-opened without a second confirm. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent dd6ed85 commit 70b64ce

1 file changed

Lines changed: 221 additions & 64 deletions

File tree

‎frontend/app/components/system-update-card.tsx‎

Lines changed: 221 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
22
import {
33
AlertCircle,
44
ArrowUpCircle,
@@ -18,7 +18,6 @@ import {
1818
} from "@/components/ui/card";
1919
import {
2020
Dialog,
21-
DialogClose,
2221
DialogContent,
2322
DialogDescription,
2423
DialogFooter,
@@ -28,6 +27,7 @@ import {
2827
import {
2928
type SystemAbout,
3029
type SystemSettings,
30+
type UpgradeStatus,
3131
useRefreshLatestRelease,
3232
useRequestUpgrade,
3333
useSystemSettings,
@@ -44,44 +44,45 @@ type Props = {
4444
onUpgradeFinished?: () => void;
4545
};
4646

47+
type DialogView = "closed" | "confirm" | "progress";
48+
4749
export function SystemUpdateCard({ about, onUpgradeFinished }: Props) {
48-
const [confirmOpen, setConfirmOpen] = useState(false);
50+
const [dialogView, setDialogView] = useState<DialogView>("closed");
51+
52+
const refresh = useRefreshLatestRelease();
53+
const requestUpgrade = useRequestUpgrade();
54+
const settings = useSystemSettings();
55+
const updateSettings = useUpdateSystemSettings();
4956

50-
// Poll the upgrade status only when the user has at least kicked one
51-
// off (or when the runner reports it's still busy). 1.5s is brisk
52-
// enough for log streaming without hammering the disk.
53-
const [active, setActive] = useState(false);
5457
const upgradeStatus = useUpgradeStatus({
5558
enabled: true,
56-
refetchMs: active ? 1500 : undefined,
59+
refetchMs: 1500,
5760
});
5861
const status = upgradeStatus.data;
5962

63+
// keep showing running on transient query errors — install.sh restarts the
64+
// API mid-upgrade, so polls will fail for a few seconds; we don't want to
65+
// flash failed during that window.
66+
const busy =
67+
requestUpgrade.isPending ||
68+
status?.state === "queued" ||
69+
status?.state === "running";
70+
6071
useEffect(() => {
6172
if (!status) return;
62-
const busy = status.state === "queued" || status.state === "running";
63-
setActive(busy);
6473
if (status.state === "done" || status.state === "failed") {
6574
onUpgradeFinished?.();
6675
}
6776
}, [status, onUpgradeFinished]);
6877

69-
const refresh = useRefreshLatestRelease();
70-
const requestUpgrade = useRequestUpgrade();
71-
72-
const settings = useSystemSettings();
73-
const updateSettings = useUpdateSystemSettings();
74-
7578
const latest = about.latest_release;
7679
const updateAvailable = about.update_available;
7780

7881
const onUpgrade = async () => {
7982
if (!latest) return;
8083
try {
8184
await requestUpgrade.mutateAsync(latest.version);
82-
setActive(true);
83-
setConfirmOpen(false);
84-
toast.success(`Upgrade to ${latest.version} queued.`);
85+
setDialogView("progress");
8586
} catch (err) {
8687
toast.error(
8788
err instanceof Error ? err.message : "Failed to start upgrade.",
@@ -101,6 +102,14 @@ export function SystemUpdateCard({ about, onUpgradeFinished }: Props) {
101102
}
102103
};
103104

105+
const onPrimaryClick = () => {
106+
setDialogView(busy ? "progress" : "confirm");
107+
};
108+
109+
const onDialogOpenChange = (open: boolean) => {
110+
if (!open) setDialogView("closed");
111+
};
112+
104113
return (
105114
<>
106115
<Card>
@@ -135,15 +144,21 @@ export function SystemUpdateCard({ about, onUpgradeFinished }: Props) {
135144
</CardAction>
136145
</CardHeader>
137146
<CardContent className="space-y-4">
138-
{updateAvailable && latest ? (
147+
{(updateAvailable && latest) || busy ? (
139148
<div className="flex items-start justify-between gap-4 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
140149
<div className="flex items-start gap-2 text-sm">
141150
<ArrowUpCircle className="mt-0.5 size-4 shrink-0 text-blue-500" />
142151
<div>
143152
<p className="font-medium text-foreground">
144-
{latest.version} is available
153+
{busy
154+
? `Upgrading${
155+
status?.target_version
156+
? ` to ${status.target_version}`
157+
: "…"
158+
}`
159+
: `${latest!.version} is available`}
145160
</p>
146-
{latest.url && (
161+
{!busy && latest?.url && (
147162
<a
148163
href={latest.url}
149164
target="_blank"
@@ -155,15 +170,11 @@ export function SystemUpdateCard({ about, onUpgradeFinished }: Props) {
155170
)}
156171
</div>
157172
</div>
158-
<Button
159-
size="sm"
160-
onClick={() => setConfirmOpen(true)}
161-
disabled={active}
162-
>
163-
{active ? (
173+
<Button size="sm" onClick={onPrimaryClick}>
174+
{busy ? (
164175
<>
165176
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
166-
Upgrading
177+
View upgrade…
167178
</>
168179
) : (
169180
"Update now"
@@ -172,7 +183,7 @@ export function SystemUpdateCard({ about, onUpgradeFinished }: Props) {
172183
</div>
173184
) : null}
174185

175-
<UpgradeProgress status={status} />
186+
<UpgradeProgressBlock status={status} />
176187

177188
<AutoUpdateRow
178189
settings={settings.data}
@@ -183,50 +194,196 @@ export function SystemUpdateCard({ about, onUpgradeFinished }: Props) {
183194
</CardContent>
184195
</Card>
185196

186-
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
197+
<Dialog
198+
open={dialogView !== "closed"}
199+
onOpenChange={onDialogOpenChange}
200+
>
187201
<DialogContent>
188-
<DialogHeader>
189-
<DialogTitle>Upgrade DeployKit?</DialogTitle>
190-
<DialogDescription>
191-
{latest && (
192-
<>
193-
This will install{" "}
194-
<span className="font-mono">{latest.version}</span> and
195-
restart the API. You may briefly lose connection — the page
196-
will reconnect once the new version is up.
197-
</>
198-
)}
199-
</DialogDescription>
200-
</DialogHeader>
201-
<DialogFooter>
202-
<DialogClose
203-
render={(props) => (
204-
<Button {...props} variant="outline">
205-
Cancel
206-
</Button>
207-
)}
202+
{dialogView === "confirm" ? (
203+
<ConfirmView
204+
version={latest?.version}
205+
onCancel={() => setDialogView("closed")}
206+
onConfirm={onUpgrade}
207+
starting={requestUpgrade.isPending}
208208
/>
209-
<Button onClick={onUpgrade} disabled={requestUpgrade.isPending}>
210-
{requestUpgrade.isPending ? (
211-
<>
212-
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
213-
Starting
214-
</>
215-
) : (
216-
"Upgrade"
217-
)}
218-
</Button>
219-
</DialogFooter>
209+
) : (
210+
<ProgressView
211+
status={status}
212+
starting={requestUpgrade.isPending}
213+
onClose={() => setDialogView("closed")}
214+
/>
215+
)}
220216
</DialogContent>
221217
</Dialog>
222218
</>
223219
);
224220
}
225221

226-
function UpgradeProgress({
222+
function ConfirmView({
223+
version,
224+
onCancel,
225+
onConfirm,
226+
starting,
227+
}: {
228+
version: string | undefined;
229+
onCancel: () => void;
230+
onConfirm: () => void;
231+
starting: boolean;
232+
}) {
233+
return (
234+
<>
235+
<DialogHeader>
236+
<DialogTitle>Upgrade DeployKit?</DialogTitle>
237+
<DialogDescription>
238+
{version && (
239+
<>
240+
This will install{" "}
241+
<span className="font-mono">{version}</span> and restart the API.
242+
You may briefly lose connection — the page will reconnect once
243+
the new version is up.
244+
</>
245+
)}
246+
</DialogDescription>
247+
</DialogHeader>
248+
<DialogFooter>
249+
<Button variant="outline" onClick={onCancel} disabled={starting}>
250+
Cancel
251+
</Button>
252+
<Button onClick={onConfirm} disabled={starting}>
253+
{starting ? (
254+
<>
255+
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
256+
Starting
257+
</>
258+
) : (
259+
"Upgrade"
260+
)}
261+
</Button>
262+
</DialogFooter>
263+
</>
264+
);
265+
}
266+
267+
function ProgressView({
268+
status,
269+
starting,
270+
onClose,
271+
}: {
272+
status: UpgradeStatus | undefined;
273+
starting: boolean;
274+
onClose: () => void;
275+
}) {
276+
const stage = deriveStage(status, starting);
277+
278+
let title = "Starting upgrade…";
279+
let description: React.ReactNode = null;
280+
let icon = (
281+
<Loader2 className="size-5 animate-spin text-blue-500" />
282+
);
283+
284+
if (stage === "running") {
285+
title = status?.target_version
286+
? `Installing ${status.target_version}…`
287+
: "Installing…";
288+
description = (
289+
<>
290+
The API will briefly restart. This dialog will keep streaming logs and
291+
report the result when it's done.
292+
</>
293+
);
294+
} else if (stage === "done") {
295+
title = status?.target_version
296+
? `Upgrade complete — now running ${status.target_version}`
297+
: "Upgrade complete";
298+
description = "The new version is live.";
299+
icon = <CheckCircle2 className="size-5 text-emerald-500" />;
300+
} else if (stage === "failed") {
301+
title = "Upgrade failed";
302+
description = status?.error ?? "The installer reported an error.";
303+
icon = <AlertCircle className="size-5 text-destructive" />;
304+
} else if (stage === "starting") {
305+
description = "Queuing the install request…";
306+
}
307+
308+
return (
309+
<>
310+
<DialogHeader>
311+
<div className="flex items-start gap-3">
312+
{icon}
313+
<div className="flex-1 min-w-0">
314+
<DialogTitle>{title}</DialogTitle>
315+
{description && (
316+
<DialogDescription className="mt-1 break-words">
317+
{description}
318+
</DialogDescription>
319+
)}
320+
</div>
321+
</div>
322+
</DialogHeader>
323+
324+
<LogStream logTail={status?.log_tail} />
325+
326+
<DialogFooter>
327+
{stage === "running" || stage === "starting" ? (
328+
<Button variant="outline" onClick={onClose}>
329+
Hide
330+
</Button>
331+
) : (
332+
<Button onClick={onClose}>Close</Button>
333+
)}
334+
</DialogFooter>
335+
</>
336+
);
337+
}
338+
339+
type Stage = "starting" | "running" | "done" | "failed";
340+
341+
function deriveStage(
342+
status: UpgradeStatus | undefined,
343+
starting: boolean,
344+
): Stage {
345+
if (status?.state === "done") return "done";
346+
if (status?.state === "failed") return "failed";
347+
if (status?.state === "queued" || status?.state === "running") {
348+
return "running";
349+
}
350+
if (starting) return "starting";
351+
return "starting";
352+
}
353+
354+
function LogStream({ logTail }: { logTail: string | undefined }) {
355+
const ref = useRef<HTMLPreElement>(null);
356+
const stickyRef = useRef(true);
357+
358+
useLayoutEffect(() => {
359+
const el = ref.current;
360+
if (!el || !stickyRef.current) return;
361+
el.scrollTop = el.scrollHeight;
362+
}, [logTail]);
363+
364+
const onScroll = () => {
365+
const el = ref.current;
366+
if (!el) return;
367+
const distanceFromBottom =
368+
el.scrollHeight - el.scrollTop - el.clientHeight;
369+
stickyRef.current = distanceFromBottom < 16;
370+
};
371+
372+
return (
373+
<pre
374+
ref={ref}
375+
onScroll={onScroll}
376+
className="max-h-64 min-h-32 overflow-auto rounded bg-muted/50 p-3 font-mono text-xs leading-snug whitespace-pre-wrap break-words"
377+
>
378+
{logTail?.trim() ? logTail : "Waiting for installer output…"}
379+
</pre>
380+
);
381+
}
382+
383+
function UpgradeProgressBlock({
227384
status,
228385
}: {
229-
status: ReturnType<typeof useUpgradeStatus>["data"];
386+
status: UpgradeStatus | undefined;
230387
}) {
231388
if (!status || status.state === "idle") return null;
232389

0 commit comments

Comments
 (0)