1- import { useEffect , useState } from "react" ;
1+ import { useEffect , useLayoutEffect , useRef , useState } from "react" ;
22import {
33 AlertCircle ,
44 ArrowUpCircle ,
@@ -18,7 +18,6 @@ import {
1818} from "@/components/ui/card" ;
1919import {
2020 Dialog ,
21- DialogClose ,
2221 DialogContent ,
2322 DialogDescription ,
2423 DialogFooter ,
@@ -28,6 +27,7 @@ import {
2827import {
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+
4749export 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