66} from '@epic-web/workshop-utils/timing.server'
77import { type SEOHandle } from '@nasa-gcn/remix-seo'
88import { clsx } from 'clsx'
9- import { data , Form , Link , useNavigation } from 'react-router'
9+ import * as React from 'react'
10+ import { data , Form , Link , useFetcher , useNavigation } from 'react-router'
1011import { Icon } from '#app/components/icons.tsx'
1112import { SimpleTooltip } from '#app/components/ui/tooltip.tsx'
1213import {
@@ -19,7 +20,9 @@ import { type Route } from './+types/index.tsx'
1920import {
2021 clearCaches ,
2122 clearData ,
23+ getSidecarLogLines ,
2224 isInspectorRunning ,
25+ restartSidecar ,
2326 startInspector ,
2427 stopInspector ,
2528} from './admin-utils.server.tsx'
@@ -107,6 +110,22 @@ export async function action({ request }: Route.ActionArgs) {
107110 await stopInspector ( )
108111 return { success : true }
109112 }
113+ case 'restart-sidecar' : {
114+ const name = formData . get ( 'name' )
115+ if ( typeof name !== 'string' ) {
116+ throw new Error ( 'Sidecar name is required' )
117+ }
118+ const success = await restartSidecar ( name )
119+ return { success }
120+ }
121+ case 'get-sidecar-logs' : {
122+ const name = formData . get ( 'name' )
123+ if ( typeof name !== 'string' ) {
124+ throw new Error ( 'Sidecar name is required' )
125+ }
126+ const logs = getSidecarLogLines ( name , 1000 )
127+ return { success : true , logs }
128+ }
110129 default : {
111130 throw new Error ( `Unknown intent: ${ intent } ` )
112131 }
@@ -174,35 +193,14 @@ export default function AdminLayout({
174193 < CardDescription > Background sidecar processes</ CardDescription >
175194 </ CardHeader >
176195 < CardContent >
177- < ul className = "scrollbar-thin scrollbar-thumb-scrollbar flex max-h-48 flex-col gap-2 overflow-y-auto " >
196+ < ul className = "flex flex-col gap-2" >
178197 { Object . entries ( data . sidecarProcesses ) . map ( ( [ key , process ] ) => (
179- < li
198+ < SidecarProcessItem
180199 key = { key }
181- className = "border-border bg-muted/30 rounded-md border p-3"
182- >
183- < div className = "flex items-center gap-2" >
184- { process . running ? (
185- < Pinger status = "running" />
186- ) : (
187- < Pinger status = "taken" />
188- ) }
189- < span className = "font-mono text-sm font-semibold" >
190- { key }
191- </ span >
192- </ div >
193- < div className = "text-muted-foreground mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs" >
194- { process . pid && (
195- < span >
196- < span className = "font-medium" > PID:</ span > { ' ' }
197- { process . pid }
198- </ span >
199- ) }
200- < span >
201- < span className = "font-medium" > Status:</ span > { ' ' }
202- { process . running ? 'Running' : 'Failed' }
203- </ span >
204- </ div >
205- </ li >
200+ name = { key }
201+ pid = { process . pid }
202+ running = { process . running }
203+ />
206204 ) ) }
207205 </ ul >
208206 </ CardContent >
@@ -452,6 +450,131 @@ export default function AdminLayout({
452450 )
453451}
454452
453+ function SidecarProcessItem ( {
454+ name,
455+ pid,
456+ running,
457+ } : {
458+ name : string
459+ pid ?: number
460+ running : boolean
461+ } ) {
462+ const restartFetcher = useFetcher ( )
463+ const logsFetcher = useFetcher < { logs ?: string } > ( )
464+ const [ copyStatus , setCopyStatus ] = React . useState < 'idle' | 'copied' > ( 'idle' )
465+ const [ logsOpen , setLogsOpen ] = React . useState ( false )
466+ const [ displayLogs , setDisplayLogs ] = React . useState < string | null > ( null )
467+
468+ const isRestarting = restartFetcher . state !== 'idle'
469+ const isLoadingLogs = logsFetcher . state !== 'idle'
470+
471+ // Update display logs when fetcher returns data
472+ React . useEffect ( ( ) => {
473+ if ( logsFetcher . data ) {
474+ setDisplayLogs ( logsFetcher . data . logs ?? '' )
475+ }
476+ } , [ logsFetcher . data ] )
477+
478+ const handleCopyLogs = ( ) => {
479+ if ( ! displayLogs ) return
480+ navigator . clipboard
481+ . writeText ( displayLogs )
482+ . then ( ( ) => {
483+ setCopyStatus ( 'copied' )
484+ setTimeout ( ( ) => setCopyStatus ( 'idle' ) , 2000 )
485+ } )
486+ . catch ( ( ) => {
487+ // silently fail
488+ } )
489+ }
490+
491+ const handleToggleLogs = ( open : boolean ) => {
492+ setLogsOpen ( open )
493+ if ( open ) {
494+ // Fetch logs when opening
495+ void logsFetcher . submit (
496+ { intent : 'get-sidecar-logs' , name } ,
497+ { method : 'POST' } ,
498+ )
499+ }
500+ }
501+
502+ return (
503+ < li className = "border-border bg-muted/30 rounded-md border p-3" >
504+ < div className = "flex items-center justify-between gap-2" >
505+ < div className = "flex items-center gap-2" >
506+ { running ? < Pinger status = "running" /> : < Pinger status = "taken" /> }
507+ < span className = "font-mono text-sm font-semibold" > { name } </ span >
508+ </ div >
509+ < div className = "flex items-center gap-1" >
510+ < restartFetcher . Form method = "POST" >
511+ < input type = "hidden" name = "intent" value = "restart-sidecar" />
512+ < input type = "hidden" name = "name" value = { name } />
513+ < SimpleTooltip content = "Restart process" >
514+ < button
515+ type = "submit"
516+ disabled = { isRestarting }
517+ className = "text-muted-foreground hover:text-foreground hover:bg-muted rounded p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
518+ >
519+ < Icon
520+ name = "Refresh"
521+ className = { cn ( 'h-4 w-4' , isRestarting && 'animate-spin' ) }
522+ />
523+ </ button >
524+ </ SimpleTooltip >
525+ </ restartFetcher . Form >
526+ </ div >
527+ </ div >
528+ < div className = "text-muted-foreground mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs" >
529+ { pid && (
530+ < span >
531+ < span className = "font-medium" > PID:</ span > { pid }
532+ </ span >
533+ ) }
534+ < span >
535+ < span className = "font-medium" > Status:</ span > { ' ' }
536+ { isRestarting ? 'Restarting...' : running ? 'Running' : 'Failed' }
537+ </ span >
538+ </ div >
539+ < details
540+ className = "mt-3"
541+ open = { logsOpen }
542+ onToggle = { ( e ) => handleToggleLogs ( e . currentTarget . open ) }
543+ >
544+ < summary className = "text-muted-foreground hover:text-foreground cursor-pointer text-xs font-medium" >
545+ { isLoadingLogs ? 'Loading logs...' : 'View logs' }
546+ </ summary >
547+ < div className = "mt-2" >
548+ < div className = "mb-1 flex justify-end" >
549+ < SimpleTooltip
550+ content = { copyStatus === 'copied' ? 'Copied!' : 'Copy logs' }
551+ >
552+ < button
553+ type = "button"
554+ onClick = { handleCopyLogs }
555+ disabled = { ! displayLogs }
556+ className = { cn (
557+ 'text-muted-foreground hover:text-foreground hover:bg-muted rounded p-1 transition-colors disabled:cursor-not-allowed disabled:opacity-50' ,
558+ copyStatus === 'copied' && 'text-success' ,
559+ ) }
560+ >
561+ < Icon
562+ name = { copyStatus === 'copied' ? 'CheckSmall' : 'Copy' }
563+ className = "h-3.5 w-3.5"
564+ />
565+ </ button >
566+ </ SimpleTooltip >
567+ </ div >
568+ < pre className = "scrollbar-thin scrollbar-thumb-scrollbar bg-background max-h-96 overflow-auto rounded border p-2 text-xs" >
569+ { displayLogs ||
570+ ( isLoadingLogs ? 'Loading...' : 'No logs available' ) }
571+ </ pre >
572+ </ div >
573+ </ details >
574+ </ li >
575+ )
576+ }
577+
455578function Card ( {
456579 children,
457580 className,
0 commit comments