1- import { useEffect , useMemo , useRef , useState , type ComponentType , type ReactNode } from 'react'
1+ import { useCallback , useEffect , useMemo , useRef , useState , type ComponentType , type ReactNode } from 'react'
22import { clsx } from 'clsx'
33import {
44 AlertTriangle ,
@@ -118,6 +118,17 @@ export interface GitOpsRow {
118118
119119export type DestinationFilter = 'all' | 'this-cluster' | 'cross-cluster' | 'unmatched'
120120
121+ type SummaryTone = 'neutral' | 'warning' | 'error' | 'info'
122+
123+ interface SummaryTileSpec {
124+ key : string
125+ label : string
126+ value : number
127+ tone : SummaryTone
128+ active : boolean
129+ apply ?: ( ) => void
130+ }
131+
121132// ----- Component props -------------------------------------------------------
122133
123134export interface GitOpsTableViewProps {
@@ -201,6 +212,7 @@ export function GitOpsTableView({
201212 const [ labelSearch , setLabelSearch ] = useState ( '' )
202213 const [ automationFilter , setAutomationFilter ] = useState < 'all' | 'auto' | 'manual' | 'suspended' > ( 'all' )
203214 const [ lifecycleFilter , setLifecycleFilter ] = useState < 'all' | 'terminating' | 'active' > ( 'all' )
215+ const [ reconcilingOnly , setReconcilingOnly ] = useState ( false )
204216 const [ sortKey , setSortKey ] = useState < SortKey > ( 'health' )
205217
206218 const hasLocalFilters =
@@ -303,6 +315,7 @@ export function GitOpsTableView({
303315 if ( automationFilter === 'suspended' && ! row . suspended ) return false
304316 if ( lifecycleFilter === 'terminating' && ! row . terminating ) return false
305317 if ( lifecycleFilter === 'active' && row . terminating ) return false
318+ if ( reconcilingOnly && row . sync !== 'Reconciling' && row . health !== 'Progressing' ) return false
306319 if ( destinationFilter && destinationFilter !== 'all' ) {
307320 const match = row . _destination ?. match
308321 if ( destinationFilter === 'this-cluster' && match !== 'in_cluster' ) return false
@@ -316,10 +329,53 @@ export function GitOpsTableView({
316329 return true
317330 } )
318331 return [ ...rows ] . sort ( ( a , b ) => compareRows ( a , b , sortKey ) )
319- } , [ allRows , automationFilter , healthFilters , labelFilters , lifecycleFilter , mode , namespaceFilters , projectFilters , search , sortKey , syncFilters , destinationFilter ] )
332+ } , [ allRows , automationFilter , healthFilters , labelFilters , lifecycleFilter , mode , namespaceFilters , projectFilters , search , sortKey , syncFilters , destinationFilter , reconcilingOnly ] )
320333
321334 const terminatingCount = useMemo ( ( ) => allRows . filter ( ( row ) => row . terminating ) . length , [ allRows ] )
322335
336+ const clearAllFilters = useCallback ( ( ) => {
337+ setSearch ( '' )
338+ setSyncFilters ( new Set ( ) )
339+ setHealthFilters ( new Set ( ) )
340+ setProjectFilters ( new Set ( ) )
341+ setNamespaceFilters ( new Set ( ) )
342+ setLabelFilters ( new Set ( ) )
343+ setAutomationFilter ( 'all' )
344+ setLifecycleFilter ( 'all' )
345+ setReconcilingOnly ( false )
346+ onDestinationFilterChange ?.( 'all' )
347+ } , [ onDestinationFilterChange ] )
348+
349+ const noOtherFiltersActive = useCallback (
350+ (
351+ exclude : 'sync' | 'health' | 'automation' | 'destination' | 'reconciling' | null = null ,
352+ ) => {
353+ if ( search !== '' ) return false
354+ if ( exclude !== 'sync' && syncFilters . size > 0 ) return false
355+ if ( exclude !== 'health' && healthFilters . size > 0 ) return false
356+ if ( projectFilters . size > 0 ) return false
357+ if ( namespaceFilters . size > 0 ) return false
358+ if ( labelFilters . size > 0 ) return false
359+ if ( exclude !== 'automation' && automationFilter !== 'all' ) return false
360+ if ( lifecycleFilter !== 'all' ) return false
361+ if ( exclude !== 'destination' && destinationFilter && destinationFilter !== 'all' ) return false
362+ if ( exclude !== 'reconciling' && reconcilingOnly ) return false
363+ return true
364+ } ,
365+ [
366+ search ,
367+ syncFilters ,
368+ healthFilters ,
369+ projectFilters ,
370+ namespaceFilters ,
371+ labelFilters ,
372+ automationFilter ,
373+ lifecycleFilter ,
374+ destinationFilter ,
375+ reconcilingOnly ,
376+ ] ,
377+ )
378+
323379 // Empty-state — when there's truly nothing to show across all kinds.
324380 // `counts` is server-filtered by the global namespace pick, so a
325381 // namespace-scoped zero is NOT the same as cluster-empty. Fall through
@@ -343,6 +399,62 @@ export function GitOpsTableView({
343399
344400 const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
345401
402+ const summaryTiles : SummaryTileSpec [ ] = [
403+ {
404+ key : 'total' ,
405+ label : 'Total Applications' ,
406+ value : allRows . length ,
407+ tone : 'neutral' ,
408+ active : noOtherFiltersActive ( ) ,
409+ } ,
410+ {
411+ key : 'outOfSync' ,
412+ label : 'Out of sync' ,
413+ value : statusSummary . outOfSync ,
414+ tone : 'warning' ,
415+ active :
416+ syncFilters . size === 1 && syncFilters . has ( 'OutOfSync' ) && noOtherFiltersActive ( 'sync' ) ,
417+ apply : ( ) => setSyncFilters ( new Set ( [ 'OutOfSync' ] ) ) ,
418+ } ,
419+ {
420+ key : 'degraded' ,
421+ label : 'Degraded' ,
422+ value : statusSummary . degraded ,
423+ tone : 'error' ,
424+ active :
425+ healthFilters . size === 1 && healthFilters . has ( 'Degraded' ) && noOtherFiltersActive ( 'health' ) ,
426+ apply : ( ) => setHealthFilters ( new Set ( [ 'Degraded' ] ) ) ,
427+ } ,
428+ {
429+ key : 'suspended' ,
430+ label : 'Suspended' ,
431+ value : statusSummary . suspended ,
432+ tone : 'warning' ,
433+ active : automationFilter === 'suspended' && noOtherFiltersActive ( 'automation' ) ,
434+ apply : ( ) => setAutomationFilter ( 'suspended' ) ,
435+ } ,
436+ {
437+ key : 'reconciling' ,
438+ label : 'Reconciling' ,
439+ value : statusSummary . reconciling ,
440+ tone : 'info' ,
441+ active : reconcilingOnly && noOtherFiltersActive ( 'reconciling' ) ,
442+ apply : ( ) => setReconcilingOnly ( true ) ,
443+ } ,
444+ ...( showCrossClusterTile
445+ ? [
446+ {
447+ key : 'crossCluster' ,
448+ label : 'Cross-cluster' ,
449+ value : crossClusterCount ! ,
450+ tone : 'info' as const ,
451+ active : destinationFilter === 'cross-cluster' && noOtherFiltersActive ( 'destination' ) ,
452+ apply : ( ) => onDestinationFilterChange ?.( 'cross-cluster' ) ,
453+ } ,
454+ ]
455+ : [ ] ) ,
456+ ]
457+
346458 return (
347459 < div className = "flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col" >
348460 < GitOpsFilterSidebar
@@ -379,14 +491,19 @@ export function GitOpsTableView({
379491 </ p >
380492 </ div >
381493 < div className = "flex shrink-0 flex-wrap justify-end gap-2" >
382- < SummaryTile label = "Applications" value = { allRows . length } />
383- < SummaryTile label = "Out of sync" value = { statusSummary . outOfSync } tone = "warning" />
384- < SummaryTile label = "Degraded" value = { statusSummary . degraded } tone = "error" />
385- < SummaryTile label = "Suspended" value = { statusSummary . suspended } tone = "warning" />
386- < SummaryTile label = "Reconciling" value = { statusSummary . reconciling } tone = "info" />
387- { showCrossClusterTile && (
388- < SummaryTile label = "Cross-cluster" value = { crossClusterCount ! } tone = "info" />
389- ) }
494+ { summaryTiles . map ( ( tile ) => (
495+ < SummaryTile
496+ key = { tile . key }
497+ label = { tile . label }
498+ value = { tile . value }
499+ tone = { tile . tone }
500+ active = { tile . active }
501+ onClick = { ( ) => {
502+ clearAllFilters ( )
503+ if ( ! tile . active && tile . apply ) tile . apply ( )
504+ } }
505+ />
506+ ) ) }
390507 </ div >
391508 </ div >
392509 </ div >
@@ -1086,18 +1203,54 @@ function GitOpsTile({
10861203 )
10871204}
10881205
1089- function SummaryTile ( { label, value, tone = 'neutral' } : { label : string ; value : number ; tone ?: 'neutral' | 'warning' | 'error' | 'info' } ) {
1206+ function SummaryTile ( {
1207+ label,
1208+ value,
1209+ tone = 'neutral' ,
1210+ onClick,
1211+ active = false ,
1212+ } : {
1213+ label : string
1214+ value : number
1215+ tone ?: SummaryTone
1216+ onClick ?: ( ) => void
1217+ active ?: boolean
1218+ } ) {
10901219 const toneClass = {
10911220 neutral : 'text-theme-text-primary' ,
10921221 warning : 'text-amber-600 dark:text-amber-300' ,
10931222 error : 'text-red-600 dark:text-red-300' ,
10941223 info : 'text-sky-600 dark:text-sky-300' ,
10951224 } [ tone ]
1225+ const activeBorderClass = {
1226+ neutral : 'border-skyhook-500' ,
1227+ warning : 'border-amber-500' ,
1228+ error : 'border-red-500' ,
1229+ info : 'border-sky-500' ,
1230+ } [ tone ]
1231+ const value$ = < div className = { `text-sm font-semibold ${ toneClass } ` } > { value } </ div >
1232+ const label$ = < div className = "text-xs text-theme-text-tertiary" > { label } </ div >
1233+ if ( ! onClick ) {
1234+ return (
1235+ < div className = "rounded-md border border-theme-border bg-theme-base px-3 py-2" >
1236+ { value$ }
1237+ { label$ }
1238+ </ div >
1239+ )
1240+ }
10961241 return (
1097- < div className = "rounded-md border border-theme-border bg-theme-base px-3 py-2" >
1098- < div className = { `text-sm font-semibold ${ toneClass } ` } > { value } </ div >
1099- < div className = "text-xs text-theme-text-tertiary" > { label } </ div >
1100- </ div >
1242+ < button
1243+ type = "button"
1244+ onClick = { onClick }
1245+ aria-pressed = { active }
1246+ className = { clsx (
1247+ 'cursor-pointer rounded-md border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500' ,
1248+ active ? activeBorderClass : 'border-theme-border' ,
1249+ ) }
1250+ >
1251+ { value$ }
1252+ { label$ }
1253+ </ button >
11011254 )
11021255}
11031256
0 commit comments