Skip to content

Commit a582c01

Browse files
authored
Rework GitOps Resources view: filterable table, diagnostic-only issue band (#1137)
1 parent 7e6e117 commit a582c01

8 files changed

Lines changed: 657 additions & 142 deletions

File tree

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState } from 'react'
2-
import { ChevronDown, ChevronRight, Package, ExternalLink } from 'lucide-react'
2+
import { Package, ExternalLink } from 'lucide-react'
33
import { clsx } from 'clsx'
44
import type { ManagedResource, GitOpsHealthStatus } from '../../types/gitops'
55
import { groupManagedResourcesByKind } from '../../types/gitops'
66
import { pluralize } from '../../utils/pluralize'
7+
import { Collapse, CollapseChevron } from '../ui/Collapse'
78

89
interface ManagedResourcesListProps {
910
resources: ManagedResource[]
@@ -45,18 +46,14 @@ export function ManagedResourcesList({
4546
onClick={() => setExpanded(!expanded)}
4647
className="flex items-center gap-2 w-full text-left mb-2 hover:text-theme-text-primary transition-colors"
4748
>
48-
{expanded ? (
49-
<ChevronDown className="w-4 h-4 text-theme-text-tertiary" />
50-
) : (
51-
<ChevronRight className="w-4 h-4 text-theme-text-tertiary" />
52-
)}
49+
<CollapseChevron open={expanded} className="w-4 h-4" />
5350
<Package className="w-4 h-4 text-theme-text-secondary" />
5451
<span className="text-sm font-medium text-theme-text-secondary">
5552
{title} ({resources.length})
5653
</span>
5754
</button>
5855

59-
{expanded && (
56+
<Collapse open={expanded} mountLazily>
6057
<div className="pl-6 space-y-3">
6158
{sortedGroups.map(([kind, kindResources]) => (
6259
<ResourceKindGroup
@@ -77,7 +74,7 @@ export function ManagedResourcesList({
7774
</button>
7875
)}
7976
</div>
80-
)}
77+
</Collapse>
8178
</div>
8279
)
8380
}
@@ -98,12 +95,12 @@ function ResourceKindGroup({ kind, resources, onResourceClick, showHealth }: Res
9895
onClick={() => setExpanded(!expanded)}
9996
className="flex items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors mb-1"
10097
>
101-
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
98+
<CollapseChevron open={expanded} className="w-3 h-3" />
10299
<span className="font-medium">{kind}</span>
103100
<span className="text-theme-text-tertiary">({resources.length})</span>
104101
</button>
105102

106-
{expanded && (
103+
<Collapse open={expanded} mountLazily>
107104
<div className="ml-4 space-y-0.5">
108105
{resources.map((resource, idx) => (
109106
<ResourceItem
@@ -114,7 +111,7 @@ function ResourceKindGroup({ kind, resources, onResourceClick, showHealth }: Res
114111
/>
115112
))}
116113
</div>
117-
)}
114+
</Collapse>
118115
</div>
119116
)
120117
}

packages/k8s-ui/src/components/gitops/insights/GitOpsInsightViews.tsx

Lines changed: 277 additions & 104 deletions
Large diffs are not rendered by default.

packages/k8s-ui/src/components/gitops/insights/insights-helpers.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import { describe, it, expect } from 'vitest'
22
import { SEVERITY_DOT } from '../../../utils/badge-colors'
3-
import { compactSource, entryTone, gitopsToSeverity, messageToPhase, phaseToTone } from './insights-helpers'
3+
import type { GitOpsChange } from '../../../types'
4+
import {
5+
changeMatchesFacets,
6+
changeMatchesSearch,
7+
compactSource,
8+
entryTone,
9+
gitopsToSeverity,
10+
healthStatusRank,
11+
messageToPhase,
12+
phaseToTone,
13+
resourceStatusCounts,
14+
syncStatusRank,
15+
} from './insights-helpers'
16+
17+
function change(partial: Partial<GitOpsChange> & { ref: GitOpsChange['ref'] }): GitOpsChange {
18+
return { category: 'Unknown', hasDesired: true, hasLive: true, partial: false, ...partial }
19+
}
420

521
describe('gitopsToSeverity', () => {
622
it.each([
@@ -96,3 +112,72 @@ describe('compactSource', () => {
96112
expect(compactSource('')).toBe('')
97113
})
98114
})
115+
116+
describe('changeMatchesSearch', () => {
117+
const c = change({ ref: { kind: 'Deployment', name: 'guestbook-ui', namespace: 'demo', group: 'apps' } })
118+
it('matches on kind, name, namespace, and group (case-insensitive)', () => {
119+
expect(changeMatchesSearch(c, 'deploy')).toBe(true)
120+
expect(changeMatchesSearch(c, 'GUESTBOOK')).toBe(true)
121+
expect(changeMatchesSearch(c, 'demo')).toBe(true)
122+
expect(changeMatchesSearch(c, 'apps')).toBe(true)
123+
})
124+
it('empty/whitespace query matches everything', () => {
125+
expect(changeMatchesSearch(c, '')).toBe(true)
126+
expect(changeMatchesSearch(c, ' ')).toBe(true)
127+
})
128+
it('non-matching query is filtered out', () => {
129+
expect(changeMatchesSearch(c, 'nomatch')).toBe(false)
130+
})
131+
})
132+
133+
describe('changeMatchesFacets', () => {
134+
const outOfSync = change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' })
135+
const degraded = change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' })
136+
const missing = change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' })
137+
const healthy = change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' })
138+
139+
it('empty facet set matches everything', () => {
140+
const none = new Set<never>()
141+
expect(changeMatchesFacets(outOfSync, none)).toBe(true)
142+
expect(changeMatchesFacets(healthy, none)).toBe(true)
143+
})
144+
it('outOfSync keys off sync status', () => {
145+
const f = new Set(['outOfSync'] as const)
146+
expect(changeMatchesFacets(outOfSync, f)).toBe(true)
147+
expect(changeMatchesFacets(degraded, f)).toBe(false)
148+
})
149+
it('degraded/missing key off health status', () => {
150+
expect(changeMatchesFacets(degraded, new Set(['degraded'] as const))).toBe(true)
151+
expect(changeMatchesFacets(missing, new Set(['missing'] as const))).toBe(true)
152+
expect(changeMatchesFacets(healthy, new Set(['degraded'] as const))).toBe(false)
153+
})
154+
it('multiple facets union (OR)', () => {
155+
const f = new Set(['degraded', 'missing'] as const)
156+
expect(changeMatchesFacets(degraded, f)).toBe(true)
157+
expect(changeMatchesFacets(missing, f)).toBe(true)
158+
expect(changeMatchesFacets(outOfSync, f)).toBe(false)
159+
})
160+
})
161+
162+
describe('resourceStatusCounts', () => {
163+
it('counts each facet independently (a resource can be both OutOfSync and Missing)', () => {
164+
const counts = resourceStatusCounts([
165+
change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' }),
166+
change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' }),
167+
change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' }),
168+
change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' }),
169+
])
170+
expect(counts).toEqual({ outOfSync: 2, degraded: 1, missing: 1 })
171+
})
172+
})
173+
174+
describe('syncStatusRank / healthStatusRank', () => {
175+
it('ascending sync rank surfaces OutOfSync before Synced', () => {
176+
expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Synced'))
177+
expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Unknown'))
178+
})
179+
it('ascending health rank surfaces Missing/Degraded before Healthy', () => {
180+
expect(healthStatusRank('Missing')).toBeLessThan(healthStatusRank('Degraded'))
181+
expect(healthStatusRank('Degraded')).toBeLessThan(healthStatusRank('Healthy'))
182+
})
183+
})

packages/k8s-ui/src/components/gitops/insights/insights-helpers.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// GitOpsInsightViews.tsx so they can be unit-tested without importing JSX.
33

44
import { SEVERITY_DOT, type Severity } from '../../../utils/badge-colors'
5-
import type { GitOpsHistoryItem } from '../../../types'
5+
import type { GitOpsChange, GitOpsHistoryItem } from '../../../types'
66
import type { SyncStatus, GitOpsHealthStatus } from '../../../types/gitops'
77

88
const SYNC_STATUS_SET = new Set<SyncStatus>(['Synced', 'OutOfSync', 'Reconciling', 'Unknown'])
@@ -23,6 +23,74 @@ export function normalizeHealthStatus(value: string | undefined | null): GitOpsH
2323
return 'Unknown'
2424
}
2525

26+
// --- Resources table: filtering + sorting -------------------------------
27+
// Pure predicates + rank functions backing the Resources list's search box,
28+
// status facets, and sortable columns. Kept here (not inline in the view) so
29+
// the sort/filter behavior is unit-testable without rendering.
30+
31+
// The status facets an operator filters the Resources list by — the three
32+
// states worth isolating on a broadly-drifted app. (These replaced the
33+
// per-resource OutOfSync "issues" that used to restate the whole table.)
34+
export type ResourceStatusFacet = 'outOfSync' | 'degraded' | 'missing'
35+
36+
export type ResourceSortKey = 'order' | 'name' | 'sync' | 'health'
37+
38+
// changeSync/changeHealth normalize a change's raw backend strings onto the
39+
// badge unions. `sync` falls back to `category` to match how ChangeRow and
40+
// the sync badge resolve it.
41+
export function changeSync(change: GitOpsChange): SyncStatus {
42+
return normalizeSyncStatus(change.sync ?? change.category)
43+
}
44+
45+
export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
46+
return normalizeHealthStatus(change.health)
47+
}
48+
49+
// Rank sync/health so an ascending sort surfaces the states an operator cares
50+
// about first (drift, then failures). Mirrors the GitOps application table's
51+
// syncRank/healthRank so the two tables order status the same way.
52+
export function syncStatusRank(sync: SyncStatus): number {
53+
return ({ OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 } as Record<SyncStatus, number>)[sync]
54+
}
55+
56+
export function healthStatusRank(health: GitOpsHealthStatus): number {
57+
return ({ Missing: 0, Degraded: 1, Progressing: 2, Unknown: 3, Suspended: 4, Healthy: 5 } as Record<GitOpsHealthStatus, number>)[health]
58+
}
59+
60+
export function changeMatchesSearch(change: GitOpsChange, query: string): boolean {
61+
const q = query.trim().toLowerCase()
62+
if (!q) return true
63+
const { kind, name, namespace, group } = change.ref
64+
return [kind, name, namespace, group].some((s) => s?.toLowerCase().includes(q))
65+
}
66+
67+
// Union match: a change passes when it satisfies ANY active facet (empty set
68+
// = no filter). OutOfSync keys off sync status; Degraded/Missing off health.
69+
export function changeMatchesFacets(change: GitOpsChange, facets: Set<ResourceStatusFacet>): boolean {
70+
if (facets.size === 0) return true
71+
if (facets.has('outOfSync') && changeSync(change) === 'OutOfSync') return true
72+
if (facets.has('degraded') && changeHealth(change) === 'Degraded') return true
73+
if (facets.has('missing') && changeHealth(change) === 'Missing') return true
74+
return false
75+
}
76+
77+
export interface ResourceStatusCounts {
78+
outOfSync: number
79+
degraded: number
80+
missing: number
81+
}
82+
83+
export function resourceStatusCounts(changes: GitOpsChange[]): ResourceStatusCounts {
84+
const counts: ResourceStatusCounts = { outOfSync: 0, degraded: 0, missing: 0 }
85+
for (const c of changes) {
86+
if (changeSync(c) === 'OutOfSync') counts.outOfSync++
87+
const health = changeHealth(c)
88+
if (health === 'Degraded') counts.degraded++
89+
if (health === 'Missing') counts.missing++
90+
}
91+
return counts
92+
}
93+
2694
// Map GitOps-flavored vocabulary (Argo/Flux phase strings, insight Issue
2795
// severities) onto the canonical Severity tokens used by SEVERITY_BADGE /
2896
// SEVERITY_TEXT / SEVERITY_DOT. Centralizing this keeps call sites from
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useState, type ReactNode } from 'react'
2+
import { ChevronRight } from 'lucide-react'
3+
import { clsx } from 'clsx'
4+
5+
// Animated show/hide using the grid-template-rows 0fr→1fr technique — the
6+
// app-wide standard for expand/collapse (drawers, checks, audit, resources
7+
// sidebar, port-forward). The inner overflow-hidden wrapper is load-bearing:
8+
// it clips the content while the grid row animates between 0 and its natural
9+
// height, so nothing spills before the row is fully open.
10+
//
11+
// Content stays mounted while closed (clipped to zero height) — that's what
12+
// lets it animate. `inert` when closed keeps that clipped content out of the
13+
// tab order and the accessibility tree, so keyboard/screen-reader users don't
14+
// land on invisible rows (matching ChecksView's collapse).
15+
//
16+
// `mountLazily` defers rendering the children until the first open, then keeps
17+
// them mounted so both the open and close animations still run. Use it when the
18+
// collapsed content is heavy and there are many instances (e.g. hundreds of
19+
// resource rows, each with a drift panel) — otherwise every collapsed instance
20+
// pays its render cost up front. Off by default so the common single-instance
21+
// case keeps the simplest behavior.
22+
export function Collapse({
23+
open,
24+
children,
25+
className,
26+
mountLazily = false,
27+
}: {
28+
open: boolean
29+
children: ReactNode
30+
className?: string
31+
mountLazily?: boolean
32+
}) {
33+
// Latch: once opened, stay mounted. Conditional setState-in-render is the
34+
// supported pattern for deriving state from props without an extra commit.
35+
const [hasOpened, setHasOpened] = useState(open)
36+
if (mountLazily && open && !hasOpened) setHasOpened(true)
37+
const render = !mountLazily || hasOpened
38+
return (
39+
<div
40+
className={clsx('grid transition-[grid-template-rows] duration-200 ease-out', className)}
41+
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
42+
>
43+
<div className="overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
44+
</div>
45+
)
46+
}
47+
48+
// CollapseChevron is the disclosure caret that pairs with <Collapse>: a single
49+
// ChevronRight that rotates 90° when open, rather than swapping between two
50+
// icons. Matches the drawer Section / ExpandableSection affordance so every
51+
// collapsible surface animates the same way.
52+
export function CollapseChevron({ open, className }: { open: boolean; className?: string }) {
53+
return (
54+
<ChevronRight
55+
aria-hidden="true"
56+
className={clsx('shrink-0 text-theme-text-tertiary transition-transform duration-200', open && 'rotate-90', className)}
57+
/>
58+
)
59+
}

packages/k8s-ui/src/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { ConfirmDialog } from './ConfirmDialog'
1919
export { HealthRing } from './HealthRing'
2020
export { MetricsChart, MetricsSparkline } from './MetricsChart'
2121
export * from './drawer-components'
22+
export { Collapse, CollapseChevron } from './Collapse'
2223
export { ResourceBar } from './ResourceBar'
2324
export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
2425
export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'

0 commit comments

Comments
 (0)