@@ -232,19 +232,25 @@ function AddRunnerForm({
232232
233233function RunnerRow ( {
234234 runner,
235+ selected,
236+ onSelect,
235237 onStart,
236238 onStop,
237239 onSync,
238240 onDelete,
239241 onViewLogs,
240242} : {
241243 runner : Runner ;
244+ selected : boolean ;
245+ onSelect : ( id : string , selected : boolean ) => void ;
242246 onStart : ( id : string ) => void ;
243247 onStop : ( id : string ) => void ;
244248 onSync : ( id : string ) => void ;
245249 onDelete : ( id : string ) => void ;
246250 onViewLogs : ( runner : Runner ) => void ;
247251} ) {
252+ const isOrphaned = runner . ephemeral && ! runner . pool_id ;
253+
248254 const platformIcons : Record < string , string > = {
249255 darwin : '🍎' ,
250256 linux : '🐧' ,
@@ -264,12 +270,27 @@ function RunnerRow({
264270 const canViewLogs = runner . isolation_type === 'docker' || runner . runner_dir ;
265271
266272 return (
267- < tr >
273+ < tr className = { isOrphaned ? 'bg-yellow-900/20' : '' } >
274+ < td >
275+ < input
276+ type = "checkbox"
277+ checked = { selected }
278+ onChange = { ( e ) => onSelect ( runner . id , e . target . checked ) }
279+ className = "w-4 h-4 rounded border-forest-600 bg-forest-800 text-forest-400 focus:ring-forest-500"
280+ />
281+ </ td >
268282 < td >
269283 < div className = "flex items-center gap-3" >
270284 < span className = "text-xl" > { platformIcons [ effectivePlatform ] || '💻' } </ span >
271285 < div >
272- < div className = "font-medium" > { runner . name } </ div >
286+ < div className = "font-medium flex items-center gap-2" >
287+ { runner . name }
288+ { isOrphaned && (
289+ < span className = "px-1.5 py-0.5 bg-yellow-700 rounded text-xs text-yellow-200" title = "Ephemeral runner with no pool - likely orphaned" >
290+ orphaned
291+ </ span >
292+ ) }
293+ </ div >
273294 < div className = "text-xs text-muted" > { runner . target } </ div >
274295 </ div >
275296 </ div >
@@ -363,6 +384,8 @@ function RunnerRow({
363384export function RunnerManager ( ) {
364385 const [ showAddForm , setShowAddForm ] = useState ( false ) ;
365386 const [ selectedRunnerForLogs , setSelectedRunnerForLogs ] = useState < Runner | null > ( null ) ;
387+ const [ selectedRunnerIds , setSelectedRunnerIds ] = useState < Set < string > > ( new Set ( ) ) ;
388+ const [ showOrphanedOnly , setShowOrphanedOnly ] = useState ( false ) ;
366389 const queryClient = useQueryClient ( ) ;
367390
368391 const { data : runnersData , isLoading : runnersLoading } = useQuery ( {
@@ -398,7 +421,10 @@ export function RunnerManager() {
398421
399422 const deleteMutation = useMutation ( {
400423 mutationFn : runnersApi . delete ,
401- onSuccess : ( ) => queryClient . invalidateQueries ( { queryKey : [ 'runners' ] } ) ,
424+ onSuccess : ( ) => {
425+ queryClient . invalidateQueries ( { queryKey : [ 'runners' ] } ) ;
426+ setSelectedRunnerIds ( new Set ( ) ) ;
427+ } ,
402428 } ) ;
403429
404430 const handleDelete = ( id : string ) => {
@@ -410,6 +436,71 @@ export function RunnerManager() {
410436 const runners = runnersData ?. runners || [ ] ;
411437 const credentials = credentialsData ?. credentials || [ ] ;
412438
439+ // Filter runners based on orphaned filter
440+ const filteredRunners = showOrphanedOnly
441+ ? runners . filter ( r => r . ephemeral && ! r . pool_id )
442+ : runners ;
443+
444+ const orphanedCount = runners . filter ( r => r . ephemeral && ! r . pool_id ) . length ;
445+
446+ // Selection helpers
447+ const handleSelectRunner = ( id : string , selected : boolean ) => {
448+ setSelectedRunnerIds ( prev => {
449+ const next = new Set ( prev ) ;
450+ if ( selected ) {
451+ next . add ( id ) ;
452+ } else {
453+ next . delete ( id ) ;
454+ }
455+ return next ;
456+ } ) ;
457+ } ;
458+
459+ const handleSelectAll = ( selected : boolean ) => {
460+ if ( selected ) {
461+ setSelectedRunnerIds ( new Set ( filteredRunners . map ( r => r . id ) ) ) ;
462+ } else {
463+ setSelectedRunnerIds ( new Set ( ) ) ;
464+ }
465+ } ;
466+
467+ const handleBulkDelete = async ( ) => {
468+ const count = selectedRunnerIds . size ;
469+ if ( count === 0 ) return ;
470+
471+ if ( ! confirm ( `Are you sure you want to delete ${ count } runner${ count === 1 ? '' : 's' } ? This will stop the runners and deregister them from GitHub.` ) ) {
472+ return ;
473+ }
474+
475+ // Delete runners in parallel (could be further optimized with a bulk API endpoint)
476+ const ids = Array . from ( selectedRunnerIds ) ;
477+ const results = await Promise . allSettled (
478+ ids . map ( id => runnersApi . delete ( id ) )
479+ ) ;
480+
481+ // Collect and report failures
482+ const failedIds : string [ ] = [ ] ;
483+ results . forEach ( ( result , index ) => {
484+ if ( result . status === 'rejected' ) {
485+ console . error ( `Failed to delete runner ${ ids [ index ] } :` , result . reason ) ;
486+ failedIds . push ( ids [ index ] ) ;
487+ }
488+ } ) ;
489+
490+ if ( failedIds . length > 0 ) {
491+ alert (
492+ `Failed to delete ${ failedIds . length } runner${ failedIds . length === 1 ? '' : 's' } . ` +
493+ 'Please check the console for more details and try again.'
494+ ) ;
495+ }
496+
497+ setSelectedRunnerIds ( new Set ( ) ) ;
498+ queryClient . invalidateQueries ( { queryKey : [ 'runners' ] } ) ;
499+ } ;
500+
501+ const allFilteredSelected = filteredRunners . length > 0 && filteredRunners . every ( r => selectedRunnerIds . has ( r . id ) ) ;
502+ const someFilteredSelected = filteredRunners . some ( r => selectedRunnerIds . has ( r . id ) ) ;
503+
413504 return (
414505 < div className = "space-y-6" >
415506 { /* Header */ }
@@ -451,23 +542,76 @@ export function RunnerManager() {
451542 </ p >
452543 </ div >
453544 ) : (
454- < div className = "card p-0 overflow-hidden" >
455- < table className = "table" >
456- < thead >
457- < tr >
458- < th > Runner</ th >
459- < th > Status</ th >
460- < th > OS/Arch</ th >
461- < th > Isolation</ th >
462- < th > Labels</ th >
545+ < >
546+ { /* Filter and bulk actions bar */ }
547+ < div className = "card flex items-center justify-between" >
548+ < div className = "flex items-center gap-4" >
549+ < label className = "flex items-center gap-2 cursor-pointer" >
550+ < input
551+ type = "checkbox"
552+ checked = { showOrphanedOnly }
553+ onChange = { ( e ) => {
554+ setShowOrphanedOnly ( e . target . checked ) ;
555+ setSelectedRunnerIds ( new Set ( ) ) ;
556+ } }
557+ className = "w-4 h-4 rounded border-forest-600 bg-forest-800 text-forest-400 focus:ring-forest-500"
558+ />
559+ < span className = "text-sm" >
560+ Show orphaned only
561+ { orphanedCount > 0 && (
562+ < span className = "ml-1 px-1.5 py-0.5 bg-yellow-700 rounded text-xs text-yellow-200" >
563+ { orphanedCount }
564+ </ span >
565+ ) }
566+ </ span >
567+ </ label >
568+ { selectedRunnerIds . size > 0 && (
569+ < span className = "text-sm text-muted" >
570+ { selectedRunnerIds . size } selected
571+ </ span >
572+ ) }
573+ </ div >
574+ { selectedRunnerIds . size > 0 && (
575+ < button
576+ onClick = { handleBulkDelete }
577+ className = "btn btn-sm bg-red-700 hover:bg-red-600 text-white"
578+ >
579+ < Trash2 className = "h-4 w-4 mr-1" />
580+ Delete Selected ({ selectedRunnerIds . size } )
581+ </ button >
582+ ) }
583+ </ div >
584+
585+ < div className = "card p-0 overflow-hidden" >
586+ < table className = "table" >
587+ < thead >
588+ < tr >
589+ < th className = "w-10" >
590+ < input
591+ type = "checkbox"
592+ checked = { allFilteredSelected }
593+ ref = { ( el ) => {
594+ if ( el ) el . indeterminate = someFilteredSelected && ! allFilteredSelected ;
595+ } }
596+ onChange = { ( e ) => handleSelectAll ( e . target . checked ) }
597+ className = "w-4 h-4 rounded border-forest-600 bg-forest-800 text-forest-400 focus:ring-forest-500"
598+ />
599+ </ th >
600+ < th > Runner</ th >
601+ < th > Status</ th >
602+ < th > OS/Arch</ th >
603+ < th > Isolation</ th >
604+ < th > Labels</ th >
463605 < th > Actions</ th >
464606 </ tr >
465607 </ thead >
466608 < tbody >
467- { runners . map ( ( runner ) => (
609+ { filteredRunners . map ( ( runner ) => (
468610 < RunnerRow
469611 key = { runner . id }
470612 runner = { runner }
613+ selected = { selectedRunnerIds . has ( runner . id ) }
614+ onSelect = { handleSelectRunner }
471615 onStart = { startMutation . mutate }
472616 onStop = { stopMutation . mutate }
473617 onSync = { syncMutation . mutate }
@@ -478,6 +622,7 @@ export function RunnerManager() {
478622 </ tbody >
479623 </ table >
480624 </ div >
625+ </ >
481626 ) }
482627
483628 { /* Add Form Modal */ }
0 commit comments