@@ -23,6 +23,7 @@ interface SandboxInfo {
2323 created_at : Date ;
2424 stopped_at : Date | null ;
2525 cluster_hostname : string ;
26+ labels ?: Record < string , string > ;
2627}
2728
2829interface OrgInfo {
@@ -39,7 +40,8 @@ interface DashboardState {
3940 loading : boolean ;
4041 lastRefresh : Date ;
4142 regionFilter : string | null ;
42- sortBy : "created" | "status" | "region" ;
43+ labelFilter : string | null ;
44+ sortBy : "created" | "status" | "region" | "label" ;
4345 sortAsc : boolean ;
4446 mode : "normal" | "extend" | "org" ;
4547 statusMessage : string | null ;
@@ -134,6 +136,7 @@ function renderScreen(state: DashboardState): string {
134136 let summary = ` ${ total } total` ;
135137 if ( parts . length > 0 ) summary += ` — ${ parts . join ( ", " ) } ` ;
136138 if ( state . regionFilter ) summary += dim ( ` (region: ${ state . regionFilter } )` ) ;
139+ if ( state . labelFilter ) summary += dim ( ` (label: ${ state . labelFilter } )` ) ;
137140 const sortArrow = state . sortAsc ? "↑" : "↓" ;
138141 summary += dim ( ` Sort: ${ state . sortBy } ${ sortArrow } ` ) ;
139142
@@ -191,8 +194,8 @@ function renderScreen(state: DashboardState): string {
191194 }
192195 } else {
193196 // Normal sandbox table
194- const headers = [ "" , "ID" , "REGION" , "STATUS" , "UPTIME" , "CREATED" ] ;
195- const colWidths = [ 2 , 16 , 10 , 10 , 10 , 22 ] ;
197+ const headers = [ "" , "ID" , "REGION" , "STATUS" , "UPTIME" , "CREATED" , "LABELS" ] ;
198+ const colWidths = [ 2 , 16 , 10 , 10 , 10 , 22 , 24 ] ;
196199
197200 // Calculate column widths based on actual data
198201 for ( const sandbox of displayList ) {
@@ -201,19 +204,21 @@ function renderScreen(state: DashboardState): string {
201204 colWidths [ 2 ] = Math . max ( colWidths [ 2 ] , region . length ) ;
202205 }
203206
204- const headerLine = " " + headers . map ( ( h , i ) =>
205- dim ( h . padEnd ( colWidths [ i ] ) )
207+ const headerLine = " " + headers . slice ( 1 ) . map ( ( h , i ) =>
208+ dim ( h . padEnd ( colWidths [ i + 1 ] ) )
206209 ) . join ( " " ) ;
207210 lines . push ( headerLine ) ;
208211
209212 // Sandbox rows — render the filtered+sorted list
210213 if ( displayList . length === 0 && ! state . loading ) {
211214 lines . push ( "" ) ;
212- if ( state . regionFilter ) {
215+ if ( state . regionFilter || state . labelFilter ) {
216+ const filterDesc = [
217+ state . regionFilter ? `region "${ state . regionFilter } "` : null ,
218+ state . labelFilter ? `label "${ state . labelFilter } "` : null ,
219+ ] . filter ( Boolean ) . join ( ", " ) ;
213220 lines . push (
214- dim (
215- ` No sandboxes in region "${ state . regionFilter } ". Press f to cycle filters.` ,
216- ) ,
221+ dim ( ` No sandboxes match ${ filterDesc } . Press f/l to cycle filters.` ) ,
217222 ) ;
218223 } else {
219224 lines . push (
@@ -258,11 +263,20 @@ function renderScreen(state: DashboardState): string {
258263 Math . max ( 0 , colWidths [ 3 ] - stripAnsiCode ( statusText ) . length ) ,
259264 ) ;
260265
266+ const labelEntries = Object . entries ( sandbox . labels ?? { } ) ;
267+ let labelsStr = labelEntries . length > 0
268+ ? labelEntries . map ( ( [ k , v ] ) => `${ k } =${ v } ` ) . join ( " " )
269+ : "—" ;
270+ if ( labelsStr . length > colWidths [ 6 ] ) {
271+ labelsStr = labelsStr . slice ( 0 , colWidths [ 6 ] - 1 ) + "…" ;
272+ }
273+
261274 const row = ` ${ marker } ${ sandbox . id . padEnd ( colWidths [ 1 ] ) } ` +
262275 `${ region . padEnd ( colWidths [ 2 ] ) } ` +
263276 `${ statusPadded } ` +
264277 `${ uptime . padEnd ( colWidths [ 4 ] ) } ` +
265- `${ created } ` ;
278+ `${ created . padEnd ( colWidths [ 5 ] ) } ` +
279+ `${ labelsStr } ` ;
266280
267281 if ( isSelected ) {
268282 lines . push ( INVERSE + row + RESET_STYLE ) ;
@@ -320,6 +334,7 @@ function renderScreen(state: DashboardState): string {
320334 bold ( "e" ) + dim ( " Extend" ) ,
321335 bold ( "c" ) + dim ( " Copy ID" ) ,
322336 bold ( "f" ) + dim ( " Filter" ) ,
337+ bold ( "l" ) + dim ( " Label" ) ,
323338 bold ( "o/O" ) + dim ( " Sort" ) ,
324339 bold ( "t" ) + dim ( " Org" ) ,
325340 bold ( "r" ) + dim ( " Refresh" ) ,
@@ -334,7 +349,7 @@ function renderScreen(state: DashboardState): string {
334349// Returns a new array — doesn't modify the original.
335350function sortSandboxes (
336351 sandboxes : SandboxInfo [ ] ,
337- sortBy : "created" | "status" | "region" ,
352+ sortBy : "created" | "status" | "region" | "label" ,
338353 asc : boolean ,
339354) : SandboxInfo [ ] {
340355 const sorted = [ ...sandboxes ] ;
@@ -361,6 +376,19 @@ function sortSandboxes(
361376 )
362377 ) ;
363378 break ;
379+ case "label" :
380+ // Alphabetical by first label key=value; empty labels sort last
381+ sorted . sort ( ( a , b ) => {
382+ const aLabel = Object . entries ( a . labels ?? { } ) [ 0 ] ;
383+ const bLabel = Object . entries ( b . labels ?? { } ) [ 0 ] ;
384+ const aStr = aLabel ? `${ aLabel [ 0 ] } =${ aLabel [ 1 ] } ` : "" ;
385+ const bStr = bLabel ? `${ bLabel [ 0 ] } =${ bLabel [ 1 ] } ` : "" ;
386+ if ( ! aStr && ! bStr ) return 0 ;
387+ if ( ! aStr ) return 1 ;
388+ if ( ! bStr ) return - 1 ;
389+ return aStr . localeCompare ( bStr ) ;
390+ } ) ;
391+ break ;
364392 }
365393 if ( asc ) sorted . reverse ( ) ;
366394 return sorted ;
@@ -399,6 +427,7 @@ async function* readKeypress(): AsyncGenerator<string> {
399427 else if ( byte === 0x6f ) yield "o" ; // Order/sort
400428 else if ( byte === 0x4f ) yield "O" ; // Toggle sort direction
401429 else if ( byte === 0x63 ) yield "c" ; // Copy
430+ else if ( byte === 0x6c ) yield "l" ; // Label filter
402431 else if ( byte === 0x74 ) yield "t" ; // Team/org picker
403432 else if ( byte === 0x0d ) yield "enter" ; // Enter/Return
404433 else if ( byte === 0x31 ) yield "1" ; // Extend presets
@@ -421,10 +450,17 @@ async function* readKeypress(): AsyncGenerator<string> {
421450// Returns the list of sandboxes after applying the region filter.
422451// Used by both the key handlers (for navigation bounds) and renderScreen.
423452function getFilteredSandboxes ( state : DashboardState ) : SandboxInfo [ ] {
424- if ( state . regionFilter === null ) return state . sandboxes ;
425- return state . sandboxes . filter (
426- ( s ) => s . cluster_hostname . split ( "." ) [ 0 ] === state . regionFilter ,
427- ) ;
453+ let list = state . sandboxes ;
454+ if ( state . regionFilter !== null ) {
455+ list = list . filter (
456+ ( s ) => s . cluster_hostname . split ( "." ) [ 0 ] === state . regionFilter ,
457+ ) ;
458+ }
459+ if ( state . labelFilter !== null ) {
460+ const [ key , value ] = state . labelFilter . split ( "=" ) ;
461+ list = list . filter ( ( s ) => s . labels ?. [ key ] === value ) ;
462+ }
463+ return list ;
428464}
429465
430466// Gets the sandbox that's currently highlighted, accounting for the region filter.
@@ -539,6 +575,7 @@ async function runDashboard(
539575 loading : true ,
540576 lastRefresh : new Date ( ) ,
541577 regionFilter : null ,
578+ labelFilter : null ,
542579 sortBy : "created" ,
543580 sortAsc : false ,
544581 mode : "normal" ,
@@ -682,6 +719,7 @@ async function runDashboard(
682719 state . org = selected . slug ;
683720 state . selectedIndex = 0 ;
684721 state . regionFilter = null ;
722+ state . labelFilter = null ;
685723 state . mode = "normal" ;
686724 await refreshAndRender ( ) ;
687725 } else if ( key === "escape" ) {
@@ -771,12 +809,37 @@ async function runDashboard(
771809 } else {
772810 state . selectedIndex = 0 ;
773811 }
812+ } else if ( key === "l" ) {
813+ // Cycle label filter: all → label1 → label2 → ... → all
814+ const labelPairs = [
815+ ...new Set (
816+ state . sandboxes . flatMap ( ( s ) =>
817+ Object . entries ( s . labels ?? { } ) . map ( ( [ k , v ] ) => `${ k } =${ v } ` )
818+ ) ,
819+ ) ,
820+ ] . sort ( ) ;
821+
822+ if ( state . labelFilter === null ) {
823+ if ( labelPairs . length > 0 ) state . labelFilter = labelPairs [ 0 ] ;
824+ } else {
825+ const idx = labelPairs . indexOf ( state . labelFilter ) ;
826+ state . labelFilter = idx < labelPairs . length - 1
827+ ? labelPairs [ idx + 1 ]
828+ : null ;
829+ }
830+
831+ // Clamp selection to filtered list
832+ const filteredAfterLabel = getFilteredSandboxes ( state ) ;
833+ state . selectedIndex = filteredAfterLabel . length > 0
834+ ? Math . min ( state . selectedIndex , filteredAfterLabel . length - 1 )
835+ : 0 ;
774836 } else if ( key === "o" ) {
775- // Cycle sort: created → status → region → created
776- const order : Array < "created" | "status" | "region" > = [
837+ // Cycle sort: created → status → region → label → created
838+ const order : Array < "created" | "status" | "region" | "label" > = [
777839 "created" ,
778840 "status" ,
779841 "region" ,
842+ "label" ,
780843 ] ;
781844 const idx = order . indexOf ( state . sortBy ) ;
782845 state . sortBy = order [ ( idx + 1 ) % order . length ] ;
0 commit comments