@@ -169,6 +169,7 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
169169 const data = useMemo ( ( ) => processJobs ( jobs ) , [ jobs ] ) ;
170170 const [ colorMode , setColorMode ] = useState < "group" | "status" > ( "status" ) ;
171171 const [ locked , setLocked ] = useState ( false ) ;
172+ const [ filter , setFilter ] = useState ( "" ) ;
172173 const [ containerRef , containerWidth ] = useContainerWidth ( ) ;
173174 const theme = useTheme ( ) ;
174175 // Chrome/neutral colors must work on light and dark backgrounds (torchci
@@ -192,8 +193,17 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
192193 }
193194
194195 const { rows, maxEnd, skipped } = data ;
195- const nFail = rows . filter ( ( r ) => r . status === "failure" ) . length ;
196+ // Built from ALL jobs so a job keeps its color when the list is filtered.
196197 const colorMap = buildColorMap ( rows . map ( ( r ) => r . groupKey ) ) ;
198+ // Row-visibility mask only: the time axis stays derived from all jobs (below),
199+ // so filtering never reorients the timeline. Plain substring, not regex.
200+ // Match the displayed row label (the prefix-less job name). full (=name) only
201+ // adds the constant "<workflow> / " prefix, so it can't narrow within a box.
202+ const query = filter . trim ( ) . toLowerCase ( ) ;
203+ const visibleRows = query
204+ ? rows . filter ( ( r ) => r . label . toLowerCase ( ) . includes ( query ) )
205+ : rows ;
206+ const nFail = visibleRows . filter ( ( r ) => r . status === "failure" ) . length ;
197207
198208 // px/minute: fit the (measured) bars pane by default, fixed when locked. The
199209 // plot keeps a readable minimum and scrolls horizontally instead of squishing.
@@ -203,7 +213,7 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
203213
204214 const plotW = maxEnd * pxmin ;
205215 const W = PAD_L + plotW + RIGHT ;
206- const H = AXIS + rows . length * ROW + 8 ;
216+ const H = AXIS + visibleRows . length * ROW + 8 ;
207217 const xat = ( m : number ) => PAD_L + m * pxmin ;
208218
209219 const targetTicks = Math . max ( 2 , Math . round ( plotW / 100 ) ) ;
@@ -219,15 +229,15 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
219229 // legend entries for current mode
220230 const legend =
221231 colorMode === "group"
222- ? Array . from ( new Set ( rows . map ( ( r ) => r . groupKey ) ) )
232+ ? Array . from ( new Set ( visibleRows . map ( ( r ) => r . groupKey ) ) )
223233 . sort ( ( a , b ) =>
224234 a === "build" ? - 1 : b === "build" ? 1 : a . localeCompare ( b )
225235 )
226236 . map ( ( k ) => ( {
227237 color : colorMap [ k ] ,
228238 label : k === "build" ? "build" : shortName ( k ) ,
229239 } ) )
230- : Array . from ( new Set ( rows . map ( ( r ) => r . status ) ) ) . map ( ( s ) => ( {
240+ : Array . from ( new Set ( visibleRows . map ( ( r ) => r . status ) ) ) . map ( ( s ) => ( {
231241 color : statusColor ( s , neutralColor ) ,
232242 label : s ,
233243 } ) ) ;
@@ -252,7 +262,10 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
252262 } }
253263 >
254264 < span style = { { color : muted } } >
255- { rows . length } jobs · { Math . round ( maxEnd ) } min
265+ { query
266+ ? `${ visibleRows . length } of ${ rows . length } jobs`
267+ : `${ rows . length } jobs` } { " " }
268+ · { Math . round ( maxEnd ) } min
256269 { nFail > 0 && (
257270 < span style = { { color : "#e03b3b" } } > · { nFail } failing</ span >
258271 ) }
@@ -285,8 +298,34 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
285298 /> { " " }
286299 Lock scale
287300 </ label >
301+ < span
302+ style = { { display : "inline-flex" , alignItems : "center" , gap : 4 } }
303+ title = "Show only jobs whose name contains this text (case-insensitive substring). The timeline scale is unchanged — matched bars keep their positions."
304+ >
305+ < span style = { { color : muted } } > Filter:</ span >
306+ < input
307+ type = "text"
308+ value = { filter }
309+ onChange = { ( e ) => setFilter ( e . target . value ) }
310+ placeholder = "filter by job name"
311+ aria-label = "Filter jobs by name"
312+ style = { {
313+ fontSize : 12 ,
314+ padding : "2px 6px" ,
315+ width : 200 ,
316+ border : `1px solid ${ borderColor } ` ,
317+ borderRadius : 4 ,
318+ } }
319+ />
320+ </ span >
288321 </ div >
289322
323+ { query && visibleRows . length === 0 && (
324+ < div style = { { padding : "4px 10px" , fontSize : 12 , color : muted } } >
325+ No jobs match “{ filter . trim ( ) } ”.
326+ </ div >
327+ ) }
328+
290329 < div style = { { display : "flex" , alignItems : "flex-start" } } >
291330 { /* Label column: fixed width. Scrolls horizontally ONLY when a job name
292331 is longer than the column, so one long name doesn't widen everything. */ }
@@ -299,7 +338,7 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
299338 } }
300339 >
301340 < div style = { { height : AXIS } } />
302- { rows . map ( ( j , i ) => (
341+ { visibleRows . map ( ( j , i ) => (
303342 < div
304343 key = { i }
305344 title = { j . full }
@@ -353,7 +392,7 @@ export default function WorkflowGantt({ jobs }: { jobs: JobData[] }) {
353392 </ text >
354393 </ g >
355394 ) ) }
356- { rows . map ( ( j , i ) => {
395+ { visibleRows . map ( ( j , i ) => {
357396 const y = AXIS + i * ROW ;
358397 const bh = ROW - 4 ;
359398 const qw = Math . max ( ( j . so - j . co ) * pxmin , 0 ) ;
0 commit comments