@@ -4,12 +4,10 @@ import { IconActivity } from "../icons";
44import { useI18n , type Locale } from "../i18n/shared" ;
55
66/**
7- * Read-only Memory observability card. Polls GET /api/system/memory (the #314 WP3
8- * service-process introspection surface) every 5s and renders scalar diagnostics
9- * only: no sliders, no restart toggle, no PUT. Observed memory is the largest
10- * of RSS, external, and ArrayBuffers so Windows working-set trimming does not
11- * hide committed retention; a rising continuation-store total under rising
12- * observed memory points at conversation retention.
7+ * Memory observability card. Polls GET /api/system/memory (#314 WP3) every 5s
8+ * and renders scalar diagnostics. Also hosts the confirm-gated Drain & restart
9+ * action (#563): longer 60s drain, then respawn via ensure/service — not the
10+ * short /api/stop teardown path.
1311 */
1412
1513interface MemorySample {
@@ -33,6 +31,7 @@ interface ResponseState {
3331}
3432
3533interface SystemMemory {
34+ pid ?: number ;
3635 rss : number ;
3736 heapUsed : number ;
3837 heapTotal : number ;
@@ -43,9 +42,14 @@ interface SystemMemory {
4342 jscHeap : { heapSize : number ; heapCapacity : number ; objectCount : number } | null ;
4443 /** Absent on older proxies whose /api/system/memory predates the continuation-store metrics. */
4544 responseState ?: ResponseState ;
45+ /** Absent on older proxies predating the drain-and-restart action (#563). */
46+ activeTurnCount ?: number ;
47+ isDraining ?: boolean ;
4648 watchdog : { warnThresholdBytes : number ; lastWarnAt : number | null ; observedBytes ?: number ; observedMetric ?: MemoryMetric ; samples : MemorySample [ ] } | null ;
4749}
4850
51+ type RestartPhase = "idle" | "draining" | "reconnecting" | "error" ;
52+
4953/**
5054 * Render a byte count with a binary-scaled unit; non-finite/zero inputs render as "0 B".
5155 * The divisor is 1024, so the labels must be the binary ones (KiB/MiB/...). Labelling a
@@ -127,10 +131,34 @@ function Stat({ label, value }: { label: string; value: string }) {
127131 ) ;
128132}
129133
134+ const DRAIN_TIMEOUT_S = 60 ;
135+ const RECONNECT_POLL_MS = 1500 ;
136+ const RECONNECT_GIVE_UP_MS = 120_000 ;
137+
130138export default function MemoryObservabilityCard ( { apiBase } : { apiBase : string } ) {
131139 const { locale, t } = useI18n ( ) ;
132140 const [ data , setData ] = useState < SystemMemory | null > ( null ) ;
133141 const [ unavailable , setUnavailable ] = useState ( false ) ;
142+ const [ restartPhase , setRestartPhase ] = useState < RestartPhase > ( "idle" ) ;
143+ const [ restartError , setRestartError ] = useState < string | null > ( null ) ;
144+ const [ noSupervisor , setNoSupervisor ] = useState ( false ) ;
145+ const [ supportsRestart , setSupportsRestart ] = useState ( false ) ;
146+ const [ restartFromPid , setRestartFromPid ] = useState < number | null > ( null ) ;
147+
148+ useEffect ( ( ) => {
149+ let cancelled = false ;
150+ void ( async ( ) => {
151+ try {
152+ const res = await fetch ( `${ apiBase } /api/startup-health` ) ;
153+ if ( ! res . ok || cancelled ) return ;
154+ const json = await res . json ( ) as { protection ?: string } ;
155+ if ( ! cancelled ) setNoSupervisor ( json . protection === "none" ) ;
156+ } catch {
157+ /* older proxies / offline — leave warning off */
158+ }
159+ } ) ( ) ;
160+ return ( ) => { cancelled = true ; } ;
161+ } , [ apiBase ] ) ;
134162
135163 useEffect ( ( ) => {
136164 let cancelled = false ;
@@ -160,10 +188,31 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
160188 if ( ! cancelled ) {
161189 setData ( json ) ;
162190 setUnavailable ( false ) ;
191+ setSupportsRestart ( typeof json . activeTurnCount === "number" ) ;
192+ if ( json . isDraining && restartPhase === "idle" ) setRestartPhase ( "draining" ) ;
193+ // Fast recycle can finish between polls with no observed outage — detect pid change.
194+ if (
195+ ( restartPhase === "draining" || restartPhase === "reconnecting" )
196+ && restartFromPid != null
197+ && typeof json . pid === "number"
198+ && json . pid !== restartFromPid
199+ && ! json . isDraining
200+ ) {
201+ setRestartPhase ( "idle" ) ;
202+ setRestartFromPid ( null ) ;
203+ setRestartError ( null ) ;
204+ }
163205 }
164206 } catch {
165207 // Old servers (pre-#314) 404 this route; degrade to a quiet unavailable note.
166- if ( ! cancelled ) setUnavailable ( true ) ;
208+ // During drain/restart the proxy goes away — switch to reconnect polling.
209+ if ( ! cancelled ) {
210+ if ( restartPhase === "draining" || restartPhase === "reconnecting" ) {
211+ setRestartPhase ( "reconnecting" ) ;
212+ } else {
213+ setUnavailable ( true ) ;
214+ }
215+ }
167216 } finally {
168217 if ( timeoutId !== undefined ) clearTimeout ( timeoutId ) ;
169218 if ( activeController === controller ) activeController = null ;
@@ -177,9 +226,80 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
177226 activeController ?. abort ( ) ;
178227 clearInterval ( interval ) ;
179228 } ;
180- } , [ apiBase ] ) ;
229+ } , [ apiBase , restartPhase , restartFromPid ] ) ;
181230
182- if ( unavailable && ! data ) {
231+ useEffect ( ( ) => {
232+ if ( restartPhase !== "reconnecting" ) return ;
233+ let cancelled = false ;
234+ let inFlight = false ;
235+ const started = Date . now ( ) ;
236+ const tick = async ( ) => {
237+ if ( inFlight ) return ;
238+ inFlight = true ;
239+ const controller = new AbortController ( ) ;
240+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , 5_000 ) ;
241+ try {
242+ const res = await fetch ( `${ apiBase } /healthz` , { cache : "no-store" , signal : controller . signal } ) ;
243+ if ( res . ok && ! cancelled ) {
244+ let replaced = restartFromPid == null ;
245+ if ( restartFromPid != null ) {
246+ try {
247+ const health = await res . json ( ) as { pid ?: number } ;
248+ replaced = typeof health . pid === "number" && health . pid !== restartFromPid ;
249+ } catch {
250+ replaced = true ;
251+ }
252+ }
253+ if ( replaced ) {
254+ setRestartPhase ( "idle" ) ;
255+ setRestartFromPid ( null ) ;
256+ setRestartError ( null ) ;
257+ return ;
258+ }
259+ }
260+ } catch {
261+ /* still down / aborted */
262+ } finally {
263+ clearTimeout ( timeoutId ) ;
264+ inFlight = false ;
265+ }
266+ if ( ! cancelled && Date . now ( ) - started >= RECONNECT_GIVE_UP_MS ) {
267+ setRestartPhase ( "error" ) ;
268+ setRestartError ( t ( "dash.mem.restartFailed" ) ) ;
269+ }
270+ } ;
271+ void tick ( ) ;
272+ const interval = setInterval ( ( ) => void tick ( ) , RECONNECT_POLL_MS ) ;
273+ return ( ) => {
274+ cancelled = true ;
275+ clearInterval ( interval ) ;
276+ } ;
277+ } , [ apiBase , restartPhase , restartFromPid , t ] ) ;
278+
279+ const confirmRestart = ( ) => {
280+ const count = data ?. activeTurnCount ?? 0 ;
281+ const lines = [
282+ t ( "dash.mem.restartConfirm" , { count, seconds : DRAIN_TIMEOUT_S } ) ,
283+ ] ;
284+ if ( noSupervisor ) lines . push ( t ( "dash.mem.restartNoSupervisor" ) ) ;
285+ if ( ! window . confirm ( lines . join ( "\n\n" ) ) ) return ;
286+ void ( async ( ) => {
287+ setRestartError ( null ) ;
288+ setRestartFromPid ( typeof data ?. pid === "number" ? data . pid : null ) ;
289+ setRestartPhase ( "draining" ) ;
290+ try {
291+ const res = await fetch ( `${ apiBase } /api/system/restart` , { method : "POST" } ) ;
292+ if ( ! res . ok ) throw new Error ( "restart_failed" ) ;
293+ // Proxy will drain then exit; memory poll will trip reconnecting or pid change.
294+ } catch {
295+ setRestartPhase ( "error" ) ;
296+ setRestartFromPid ( null ) ;
297+ setRestartError ( t ( "dash.mem.restartFailed" ) ) ;
298+ }
299+ } ) ( ) ;
300+ } ;
301+
302+ if ( unavailable && ! data && restartPhase === "idle" ) {
183303 return (
184304 < div className = "panel" style = { { marginBottom : 24 } } >
185305 < div className = "font-semibold" style = { { display : "flex" , alignItems : "center" , gap : 8 } } >
@@ -196,6 +316,8 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
196316 const observedBy = data ? observedMetric ( data ) : null ;
197317 // Optional on purpose: a 200 from an older proxy may lack the responseState field.
198318 const responseState = data ?. responseState ;
319+ const activeTurns = data ?. activeTurnCount ;
320+ const busy = restartPhase === "draining" || restartPhase === "reconnecting" ;
199321
200322 return (
201323 < div className = "panel" style = { { marginBottom : 24 } } >
@@ -249,6 +371,37 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
249371 </ div >
250372 ) }
251373 </ details >
374+
375+ { supportsRestart && (
376+ < div style = { { marginTop : 14 , display : "flex" , flexWrap : "wrap" , alignItems : "center" , gap : 12 } } aria-live = "polite" >
377+ < Stat
378+ label = { t ( "dash.mem.inFlight" ) }
379+ value = { typeof activeTurns === "number" ? plainNumberFormat ( locale ) . format ( activeTurns ) : "—" }
380+ />
381+ < button
382+ type = "button"
383+ className = "btn btn-ghost btn-sm"
384+ disabled = { busy }
385+ onClick = { confirmRestart }
386+ >
387+ { t ( "dash.mem.restart" ) }
388+ </ button >
389+ { restartPhase === "draining" && (
390+ < span className = "muted text-control" >
391+ { t ( "dash.mem.draining" , { count : typeof activeTurns === "number" ? activeTurns : 0 } ) }
392+ </ span >
393+ ) }
394+ { restartPhase === "reconnecting" && (
395+ < span className = "muted text-control" > { t ( "dash.mem.reconnecting" ) } </ span >
396+ ) }
397+ { restartPhase === "error" && restartError && (
398+ < span className = "text-control" style = { { color : "var(--danger, #c44)" } } > { restartError } </ span >
399+ ) }
400+ { noSupervisor && restartPhase === "idle" && (
401+ < span className = "muted text-control" > { t ( "dash.mem.restartNoSupervisor" ) } </ span >
402+ ) }
403+ </ div >
404+ ) }
252405 </ div >
253406 ) ;
254407}
0 commit comments