@@ -12,15 +12,18 @@ import {
1212 AlertTriangle ,
1313 CheckCircle2 ,
1414 Clock ,
15+ Wrench ,
1516} from 'lucide-react'
1617import { Button } from '~/ui'
1718import { Card } from '~/components/Card'
1819import { formatDistanceToNow } from '~/utils/dates'
1920import {
2021 getIntentAdminStats ,
22+ getIntentWorkflowHealth ,
2123 listIntentPackages ,
2224 listFailedVersions ,
2325 listIntentWorkflowRuns ,
26+ repairIntentWorkflowStore ,
2427 triggerIntentDiscover ,
2528 triggerIntentProcess ,
2629 retryIntentVersion ,
@@ -42,6 +45,7 @@ const QK = {
4245 packages : [ 'admin' , 'intent' , 'packages' ] as const ,
4346 failed : [ 'admin' , 'intent' , 'failed' ] as const ,
4447 workflows : [ 'admin' , 'intent' , 'workflows' ] as const ,
48+ health : [ 'admin' , 'intent' , 'workflow-health' ] as const ,
4549}
4650
4751// ---------------------------------------------------------------------------
@@ -77,13 +81,19 @@ function IntentAdminPage() {
7781 refetchInterval : 10_000 ,
7882 } )
7983
84+ const healthQuery = useQuery ( {
85+ queryKey : QK . health ,
86+ queryFn : ( ) => getIntentWorkflowHealth ( ) ,
87+ refetchInterval : 10_000 ,
88+ } )
89+
8090 const discoverMutation = useMutation ( {
8191 mutationFn : ( ) => triggerIntentDiscover ( ) ,
8292 onSuccess : invalidateAll ,
8393 } )
8494
8595 const processMutation = useMutation ( {
86- mutationFn : ( limit : number ) => triggerIntentProcess ( { data : { limit } } ) ,
96+ mutationFn : ( ) => triggerIntentProcess ( ) ,
8797 onSuccess : invalidateAll ,
8898 } )
8999
@@ -92,6 +102,11 @@ function IntentAdminPage() {
92102 onSuccess : invalidateAll ,
93103 } )
94104
105+ const repairWorkflowMutation = useMutation ( {
106+ mutationFn : ( ) => repairIntentWorkflowStore ( ) ,
107+ onSuccess : invalidateAll ,
108+ } )
109+
95110 const seedMutation = useMutation ( {
96111 mutationFn : ( name : string ) => seedIntentPackage ( { data : { name } } ) ,
97112 onSuccess : ( ) => {
@@ -160,19 +175,19 @@ function IntentAdminPage() {
160175 < Button
161176 size = "sm"
162177 color = "green"
163- onClick = { ( ) => processMutation . mutate ( 10 ) }
178+ onClick = { ( ) => processMutation . mutate ( ) }
164179 disabled = {
165180 processMutation . isPending ||
166181 ( stats ?. pendingVersions ?? 0 ) + ( stats ?. failedVersions ?? 0 ) === 0
167182 }
168- title = "Download tarballs and extract skills for up to 10 pending versions "
183+ title = "Download tarballs and extract skills until the workflow nears its time budget "
169184 >
170185 < Play
171186 className = {
172187 processMutation . isPending ? 'animate-pulse w-4 h-4' : 'w-4 h-4'
173188 }
174189 />
175- { processMutation . isPending ? 'Processing...' : 'Process 10 Pending ' }
190+ { processMutation . isPending ? 'Processing...' : 'Process Queue ' }
176191 </ Button >
177192 { ( stats ?. failedVersions ?? 0 ) > 0 && (
178193 < Button
@@ -235,10 +250,17 @@ function IntentAdminPage() {
235250 { processMutation . data && (
236251 < ResultBanner
237252 title = { `Processed ${ processMutation . data . processed } version(s)` }
238- items = { processMutation . data . results . map (
239- ( r ) =>
240- `${ r . packageName } @${ r . version } : ${ r . status === 'synced' ? `${ r . skillCount } skills` : `FAILED — ${ r . error } ` } ` ,
241- ) }
253+ items = { [
254+ ...( processMutation . data . deferred > 0
255+ ? [
256+ `${ processMutation . data . deferred } version(s) deferred by the time budget` ,
257+ ]
258+ : [ ] ) ,
259+ ...processMutation . data . results . map (
260+ ( r ) =>
261+ `${ r . packageName } @${ r . version } : ${ r . status === 'synced' ? `${ r . skillCount } skills` : `FAILED — ${ r . error } ` } ` ,
262+ ) ,
263+ ] }
242264 errors = { processMutation . data . results
243265 . filter ( ( r ) => r . status === 'failed' )
244266 . map ( ( r ) => `${ r . packageName } @${ r . version } : ${ r . error } ` ) }
@@ -253,6 +275,17 @@ function IntentAdminPage() {
253275 onDismiss = { ( ) => resetFailedMutation . reset ( ) }
254276 />
255277 ) }
278+ { repairWorkflowMutation . data && (
279+ < ResultBanner
280+ title = "Workflow store repaired"
281+ items = { [
282+ `${ repairWorkflowMutation . data . staleRunsMarkedErrored } stale run(s) marked errored` ,
283+ `${ repairWorkflowMutation . data . unregisteredSchedulesDeleted } obsolete schedule(s) deleted` ,
284+ ] }
285+ errors = { [ ] }
286+ onDismiss = { ( ) => repairWorkflowMutation . reset ( ) }
287+ />
288+ ) }
256289 { githubDiscoverMutation . data && (
257290 < ResultBanner
258291 title = "GitHub discovery complete"
@@ -332,6 +365,13 @@ function IntentAdminPage() {
332365 />
333366 </ div >
334367
368+ < WorkflowHealthSection
369+ health = { healthQuery . data }
370+ loading = { healthQuery . isLoading }
371+ repairing = { repairWorkflowMutation . isPending }
372+ onRepair = { ( ) => repairWorkflowMutation . mutate ( ) }
373+ />
374+
335375 < WorkflowRunsSection
336376 runs = { workflowsQuery . data ?? [ ] }
337377 loading = { workflowsQuery . isLoading }
@@ -393,6 +433,154 @@ function StatCard({
393433 )
394434}
395435
436+ function WorkflowHealthSection ( {
437+ health,
438+ loading,
439+ repairing,
440+ onRepair,
441+ } : {
442+ readonly health :
443+ | {
444+ checkedAt : Date
445+ staleRunMs : number
446+ staleRuns : Array < {
447+ runId : string
448+ workflowId : string
449+ status : string
450+ updatedAt : Date
451+ leaseExpiresAt : Date | null
452+ } >
453+ unregisteredSchedules : Array < {
454+ scheduleId : string
455+ workflowId : string
456+ enabled : boolean
457+ nextFireAt : Date | null
458+ updatedAt : Date
459+ } >
460+ schedules : Array < {
461+ scheduleId : string
462+ workflowId : string
463+ enabled : boolean
464+ nextFireAt : Date | null
465+ updatedAt : Date
466+ } >
467+ latestRuns : Array < {
468+ runId : string
469+ workflowId : string
470+ status : string
471+ updatedAt : Date
472+ } >
473+ statusCounts : Array < { status : string ; count : number } >
474+ }
475+ | undefined
476+ readonly loading : boolean
477+ readonly repairing : boolean
478+ readonly onRepair : ( ) => void
479+ } ) {
480+ const staleRunCount = health ?. staleRuns . length ?? 0
481+ const obsoleteScheduleCount = health ?. unregisteredSchedules . length ?? 0
482+ const needsRepair = staleRunCount > 0 || obsoleteScheduleCount > 0
483+
484+ return (
485+ < div className = "mb-6" >
486+ < div className = "mb-2 flex items-center justify-between gap-3" >
487+ < h2 className = "text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-1.5" >
488+ < CheckCircle2 className = "w-4 h-4" />
489+ Workflow Health
490+ </ h2 >
491+ < Button
492+ size = "xs"
493+ variant = "secondary"
494+ onClick = { onRepair }
495+ disabled = { repairing || loading || ! needsRepair }
496+ title = "Mark stale runs as errored and delete schedules for workflows that are no longer registered"
497+ >
498+ < Wrench
499+ className = { repairing ? 'w-3.5 h-3.5 animate-pulse' : 'w-3.5 h-3.5' }
500+ />
501+ { repairing ? 'Repairing...' : 'Repair Store' }
502+ </ Button >
503+ </ div >
504+ { loading ? (
505+ < div className = "h-28 rounded-xl bg-gray-100 dark:bg-gray-800 animate-pulse" />
506+ ) : (
507+ < div className = "rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden" >
508+ < div className = "grid grid-cols-2 md:grid-cols-4 divide-x divide-y md:divide-y-0 divide-gray-100 dark:divide-gray-800" >
509+ < HealthMetric
510+ label = "Stale runs"
511+ value = { staleRunCount }
512+ tone = { staleRunCount > 0 ? 'red' : 'green' }
513+ />
514+ < HealthMetric
515+ label = "Obsolete schedules"
516+ value = { obsoleteScheduleCount }
517+ tone = { obsoleteScheduleCount > 0 ? 'amber' : 'green' }
518+ />
519+ < HealthMetric
520+ label = "Registered schedules"
521+ value = { health ?. schedules . length ?? 0 }
522+ tone = "default"
523+ />
524+ < HealthMetric
525+ label = "Tracked runs"
526+ value = {
527+ health ?. statusCounts . reduce (
528+ ( total , row ) => total + row . count ,
529+ 0 ,
530+ ) ?? 0
531+ }
532+ tone = "default"
533+ />
534+ </ div >
535+ { health && needsRepair && (
536+ < div className = "border-t border-gray-100 dark:border-gray-800 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-200" >
537+ { [ ...health . staleRuns , ...health . unregisteredSchedules ]
538+ . slice ( 0 , 4 )
539+ . map ( ( item ) =>
540+ 'runId' in item
541+ ? `${ item . workflowId } : ${ item . status } since ${ formatDistanceToNow (
542+ item . updatedAt ,
543+ { addSuffix : true } ,
544+ ) } `
545+ : `${ item . workflowId } : obsolete schedule ${ item . scheduleId } ` ,
546+ )
547+ . join ( ' | ' ) }
548+ </ div >
549+ ) }
550+ </ div >
551+ ) }
552+ </ div >
553+ )
554+ }
555+
556+ function HealthMetric ( {
557+ label,
558+ value,
559+ tone,
560+ } : {
561+ readonly label : string
562+ readonly value : number
563+ readonly tone : 'default' | 'green' | 'amber' | 'red'
564+ } ) {
565+ const valueClass = {
566+ default : 'text-gray-900 dark:text-white' ,
567+ green : 'text-emerald-600 dark:text-emerald-400' ,
568+ amber : 'text-amber-600 dark:text-amber-400' ,
569+ red : 'text-red-600 dark:text-red-400' ,
570+ } [ tone ]
571+
572+ return (
573+ < div className = "bg-white dark:bg-gray-900 px-3 py-2" >
574+ < div className = "text-xs text-gray-500 dark:text-gray-400 mb-1" >
575+ { label }
576+ </ div >
577+ < div className = { `text-xl font-semibold tabular-nums ${ valueClass } ` } >
578+ { value . toLocaleString ( ) }
579+ </ div >
580+ </ div >
581+ )
582+ }
583+
396584function WorkflowRunsSection ( {
397585 runs,
398586 loading,
0 commit comments