Skip to content

Commit 6aa27d9

Browse files
feat(gitops): make summary tiles clickable filter shortcuts (#812)
Rename "Applications" → "Total Applications" (clears all filters when clicked); each other tile (Out of sync, Degraded, Suspended, Reconciling, Cross-cluster) now filters the table to that facet only, with an active border highlight when its filter is the only one set. Re-clicking an active tile (or Total Applications) clears. Reconciling needed special handling: its count is OR-of-two (`sync==='Reconciling' || health==='Progressing'`) but the existing syncFilters/healthFilters use AND semantics. Added a dedicated `reconcilingOnly` flag so the click result matches the displayed count. The sidebar facet rail stays in place — both surfaces share the same state, so a tile click also reflects in the sidebar and vice versa. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d91f53b commit 6aa27d9

1 file changed

Lines changed: 168 additions & 15 deletions

File tree

packages/k8s-ui/src/components/gitops/GitOpsTableView.tsx

Lines changed: 168 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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'
22
import { clsx } from 'clsx'
33
import {
44
AlertTriangle,
@@ -118,6 +118,17 @@ export interface GitOpsRow {
118118

119119
export 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

123134
export 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

Comments
 (0)