@skyhook-io/k8s-ui 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,899 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
+ import { ClusterName, EmptyState, FilterPill } from '../ui'
5
+ import type { CheckMeta } from '../audit'
6
+ import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
7
+ import {
8
+ SEVERITY_BADGE_CLASS,
9
+ SEVERITY_FILL_CLASS,
10
+ SEVERITY_LABEL,
11
+ SEVERITY_RAIL_CLASS,
12
+ SEVERITY_TEXT_CLASS,
13
+ categoryBadgeClass,
14
+ } from './severity'
15
+
16
+ const CATEGORIES: readonly string[] = ['Security', 'Reliability', 'Efficiency']
17
+
18
+ // Affected-resources shown inline before "View all". A check can fail on
19
+ // thousands of resources; the card stays scannable and only the rare big-list
20
+ // case pays the cost of a full expand.
21
+ const RESOURCE_CAP = 8
22
+ // Clusters shown before "View all clusters" when a check spans the fleet.
23
+ const CLUSTER_CAP = 12
24
+ // At/below this many clusters, a check's cluster groups open with their
25
+ // resources visible; above it they start collapsed (just cluster · count).
26
+ const AUTO_EXPAND_CLUSTERS = 2
27
+
28
+ export interface ChecksViewProps {
29
+ /** Per-(cluster, check) rows. The component groups them by check itself, so
30
+ * the same check across many clusters collapses to one fleet row (cluster
31
+ * becomes a drill-down sub-level). Single-cluster hosts pass one row per
32
+ * check and get a flat resource list with no sub-level. */
33
+ checks: Check[]
34
+ /** Check catalog (checkID → definition): how-to-fix / description / framework
35
+ * filter / reference links. */
36
+ catalog: Record<string, CheckMeta>
37
+ /** True when at least one source returned audit data. */
38
+ anyData: boolean
39
+ /** Deep-link href for a resource (host routing). Omit for non-link text. */
40
+ resourceHref?: (ref: CheckResourceRef) => string
41
+ /** In-app resource navigation (client-side). Takes precedence over href. */
42
+ onResourceClick?: (ref: CheckResourceRef) => void
43
+ /** Display label for a check's source cluster. Omit (single-cluster OSS) to
44
+ * drop the cluster sub-level + cluster filter entirely. */
45
+ clusterLabel?: (check: Check) => string | undefined
46
+ /** Controlled cluster facet. When `onClusterFilterChange` is supplied the
47
+ * selection is owned by the host (e.g. synced to a `?clusters=` URL param);
48
+ * otherwise ChecksView keeps it in internal state. `clusterFilter` is the
49
+ * current selection (cluster IDs); ignored unless controlled.
50
+ *
51
+ * Controlled contract: apply the update *synchronously* (the new value is
52
+ * read back via `clusterFilter` on the next render). Toggling computes from
53
+ * the current `clusterFilter`, so a host that *defers* propagation (e.g.
54
+ * `startTransition`) could let a rapid follow-up toggle read stale state. */
55
+ clusterFilter?: string[]
56
+ onClusterFilterChange?: (clusterIds: string[]) => void
57
+ /** Resolve a cluster ID → display label for the selected-cluster chips.
58
+ * Needed because a filter can target a cluster with no checks (offline /
59
+ * errored / clean) that won't appear in the in-data cluster options; the
60
+ * host knows the name regardless. Falls back to the id when unresolved. */
61
+ clusterLabelById?: (clusterId: string) => string | undefined
62
+ /** Empty-state CTA when there's no data. */
63
+ emptyAction?: ReactNode
64
+ /** Optional per-check "hide" actions (OSS local tuning; Hub omits — Policy
65
+ * tab governs hiding there). Operate on the whole check, fleet-wide. */
66
+ onHideCheck?: (checkID: string, title: string) => void
67
+ onHideCategory?: (category: string) => void
68
+ }
69
+
70
+ // A check rolled up across every cluster it fires on — one row of the fleet
71
+ // queue. `clusters` holds the per-cluster Check rows underneath.
72
+ interface FleetCheck {
73
+ checkID: string
74
+ title: string
75
+ category: string
76
+ severity: CheckSeverity
77
+ totalResources: number
78
+ totalFindings: number
79
+ clusters: Check[]
80
+ }
81
+
82
+ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
83
+ const [severityFilter, setSeverityFilter] = useState<Set<CheckSeverity>>(new Set())
84
+ const [categoryFilter, setCategoryFilter] = useState<Set<string>>(new Set())
85
+ const [frameworkFilter, setFrameworkFilter] = useState<Set<string>>(new Set())
86
+ const [search, setSearch] = useState('')
87
+ const [openId, setOpenId] = useState<string | null>(null)
88
+
89
+ // Cluster facet is controlled when the host opts in (onClusterFilterChange);
90
+ // otherwise it lives in internal state. Either way the rest of the component
91
+ // reads `clusterFilter` (a Set) and writes via `setClusterFilter`, which
92
+ // accepts both value and updater forms like a useState setter.
93
+ const [clusterFilterInternal, setClusterFilterInternal] = useState<Set<string>>(new Set())
94
+ const clusterControlled = onClusterFilterChange !== undefined
95
+ const clusterFilterKey = (clusterFilterProp ?? []).join(',')
96
+ const clusterFilter = useMemo(
97
+ () => (clusterControlled ? new Set(clusterFilterProp ?? []) : clusterFilterInternal),
98
+ // clusterFilterKey collapses array identity churn from the host to its contents.
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ [clusterControlled, clusterFilterKey, clusterFilterInternal],
101
+ )
102
+ const setClusterFilter = (action: React.SetStateAction<Set<string>>) => {
103
+ if (clusterControlled) {
104
+ const next = typeof action === 'function' ? action(clusterFilter) : action
105
+ onClusterFilterChange!([...next])
106
+ } else {
107
+ setClusterFilterInternal(action)
108
+ }
109
+ }
110
+
111
+ // Clusters present in the data (id → label), for the cluster facet. Empty
112
+ // when the host doesn't supply clusterLabel (single-cluster OSS).
113
+ const clusterOptions = useMemo(() => {
114
+ if (!clusterLabel) return []
115
+ const m = new Map<string, string>()
116
+ for (const c of checks) {
117
+ const id = c.subject.cluster_id
118
+ if (!m.has(id)) m.set(id, clusterLabel(c) || id)
119
+ }
120
+ return [...m.entries()].map(([id, label]) => ({ id, label })).sort((a, b) => a.label.localeCompare(b.label))
121
+ }, [checks, clusterLabel])
122
+ const multiCluster = clusterOptions.length > 1
123
+
124
+ const frameworks = useMemo(() => {
125
+ const set = new Set<string>()
126
+ for (const m of Object.values(catalog)) m.frameworks?.forEach((f) => set.add(f))
127
+ return Array.from(set).sort()
128
+ }, [catalog])
129
+
130
+ const searchLower = search.toLowerCase()
131
+
132
+ // Filter the per-cluster rows, then group by checkID into fleet rows.
133
+ const fleetChecks = useMemo(() => {
134
+ const kept = checks.filter((c) => {
135
+ if (severityFilter.size > 0 && !severityFilter.has(c.effectiveSeverity)) return false
136
+ if (categoryFilter.size > 0 && !categoryFilter.has(c.category)) return false
137
+ if (clusterFilter.size > 0 && !clusterFilter.has(c.subject.cluster_id)) return false
138
+ if (frameworkFilter.size > 0) {
139
+ const fws = catalog[c.checkID]?.frameworks
140
+ if (!fws || !fws.some((f) => frameworkFilter.has(f))) return false
141
+ }
142
+ if (searchLower) {
143
+ const hay = `${c.title} ${c.checkID} ${c.message} ${c.subject.namespace} ${c.subject.name}`.toLowerCase()
144
+ if (!hay.includes(searchLower)) return false
145
+ }
146
+ return true
147
+ })
148
+
149
+ const byCheck = new Map<string, Check[]>()
150
+ for (const c of kept) {
151
+ const arr = byCheck.get(c.checkID)
152
+ if (arr) arr.push(c)
153
+ else byCheck.set(c.checkID, [c])
154
+ }
155
+
156
+ const out: FleetCheck[] = []
157
+ for (const [checkID, group] of byCheck) {
158
+ // Worst severity wins for the fleet row (a check's tier is set by check +
159
+ // org policy, so it's consistent across clusters; this is just defensive).
160
+ const severity = group.reduce<CheckSeverity>(
161
+ (worst, c) => (CHECK_SEVERITY_RANK[c.effectiveSeverity] > CHECK_SEVERITY_RANK[worst] ? c.effectiveSeverity : worst),
162
+ 'low',
163
+ )
164
+ const clusters = [...group].sort((a, b) => {
165
+ const r = CHECK_SEVERITY_RANK[b.effectiveSeverity] - CHECK_SEVERITY_RANK[a.effectiveSeverity]
166
+ if (r !== 0) return r
167
+ if (b.affectedResources !== a.affectedResources) return b.affectedResources - a.affectedResources
168
+ return (clusterLabel?.(a) || '').localeCompare(clusterLabel?.(b) || '')
169
+ })
170
+ out.push({
171
+ checkID,
172
+ title: group[0].title,
173
+ category: group[0].category,
174
+ severity,
175
+ totalResources: group.reduce((n, c) => n + c.affectedResources, 0),
176
+ totalFindings: group.reduce((n, c) => n + c.affectedFindings, 0),
177
+ clusters,
178
+ })
179
+ }
180
+ // Worst-first across the whole queue (severity, then blast radius, then title).
181
+ return out.sort((a, b) => {
182
+ const r = CHECK_SEVERITY_RANK[b.severity] - CHECK_SEVERITY_RANK[a.severity]
183
+ if (r !== 0) return r
184
+ if (b.totalResources !== a.totalResources) return b.totalResources - a.totalResources
185
+ return a.title.localeCompare(b.title)
186
+ })
187
+ }, [checks, catalog, severityFilter, categoryFilter, frameworkFilter, clusterFilter, searchLower, clusterLabel])
188
+
189
+ const { totals, totalFindings, clusterCount } = useMemo(() => {
190
+ const totals: Record<CheckSeverity, number> = { critical: 0, high: 0, medium: 0, low: 0 }
191
+ let totalFindings = 0
192
+ // Distinct clusters spanned by the *filtered* queue — so the header count
193
+ // tracks the cluster facet rather than the full fleet (a deep-link or
194
+ // facet pick to one cluster reads "1 cluster", not the unfiltered total).
195
+ const clusterIds = new Set<string>()
196
+ for (const fc of fleetChecks) {
197
+ totals[fc.severity] += 1
198
+ totalFindings += fc.totalFindings
199
+ for (const c of fc.clusters) clusterIds.add(c.subject.cluster_id)
200
+ }
201
+ return { totals, totalFindings, clusterCount: clusterIds.size }
202
+ }, [fleetChecks])
203
+
204
+ const toggle = <T,>(setter: React.Dispatch<React.SetStateAction<Set<T>>>, v: T) =>
205
+ setter((prev) => {
206
+ const next = new Set(prev)
207
+ if (next.has(v)) next.delete(v)
208
+ else next.add(v)
209
+ return next
210
+ })
211
+
212
+ const hasFilters = severityFilter.size > 0 || categoryFilter.size > 0 || frameworkFilter.size > 0 || clusterFilter.size > 0 || search !== ''
213
+ const clearAll = () => {
214
+ setSeverityFilter(new Set())
215
+ setCategoryFilter(new Set())
216
+ setFrameworkFilter(new Set())
217
+ setClusterFilter(new Set())
218
+ setSearch('')
219
+ }
220
+
221
+ return (
222
+ <div className="flex flex-col gap-4">
223
+ {/* Triage header: distribution bar + filter chips + search. */}
224
+ <div className="flex flex-col gap-3.5 rounded-2xl border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
225
+ <div className="flex flex-wrap items-end justify-between gap-3">
226
+ <div className="flex items-baseline gap-2">
227
+ <span className="text-2xl font-semibold tabular-nums text-theme-text-primary">{fleetChecks.length}</span>
228
+ <span className="text-sm text-theme-text-secondary">
229
+ {fleetChecks.length === 1 ? 'check' : 'checks'}
230
+ {totalFindings > fleetChecks.length && <span className="text-theme-text-tertiary"> · {totalFindings} findings</span>}
231
+ {clusterOptions.length > 1 && clusterCount > 0 && (
232
+ <span className="text-theme-text-tertiary"> · {clusterCount} {clusterCount === 1 ? 'cluster' : 'clusters'}</span>
233
+ )}
234
+ </span>
235
+ </div>
236
+ <div className="relative">
237
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-tertiary" />
238
+ <input
239
+ type="text"
240
+ placeholder="Search checks…"
241
+ value={search}
242
+ onChange={(e) => setSearch(e.target.value)}
243
+ className="w-64 rounded-lg border border-theme-border-light bg-theme-base py-1.5 pl-9 pr-8 text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-[var(--color-radar-accent)]"
244
+ />
245
+ {search && (
246
+ <button
247
+ type="button"
248
+ onClick={() => setSearch('')}
249
+ aria-label="Clear search"
250
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
251
+ >
252
+ <X className="h-3.5 w-3.5" />
253
+ </button>
254
+ )}
255
+ </div>
256
+ </div>
257
+
258
+ <SeverityBar totals={totals} />
259
+
260
+ <div className="flex flex-wrap items-center gap-1.5">
261
+ {CHECK_SEVERITIES.map((s) => (
262
+ <SeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(setSeverityFilter, s)} />
263
+ ))}
264
+ <span className="mx-1.5 h-5 w-px bg-theme-border" />
265
+ {CATEGORIES.map((c) => (
266
+ <FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => toggle(setCategoryFilter, c)} />
267
+ ))}
268
+ {frameworks.length > 0 && (
269
+ <>
270
+ <span className="mx-1.5 h-5 w-px bg-theme-border" />
271
+ {frameworks.map((fw) => (
272
+ <FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => toggle(setFrameworkFilter, fw)} />
273
+ ))}
274
+ </>
275
+ )}
276
+ {multiCluster && (
277
+ <>
278
+ <span className="mx-1.5 h-5 w-px bg-theme-border" />
279
+ <ClusterFilter options={clusterOptions} selected={clusterFilter} onToggle={(id) => toggle(setClusterFilter, id)} onClear={() => setClusterFilter(new Set())} />
280
+ </>
281
+ )}
282
+ {/* Selected clusters as removable chips. The dropdown is the
283
+ add/discover affordance (clusters are high-cardinality); the chips
284
+ make the *active* selection visible + clearable. Gated on the
285
+ selection itself — NOT on multiCluster — because a deep-link can
286
+ filter to a cluster while ≤1 cluster currently has checks (so the
287
+ dropdown is hidden); the chip is then the only thing that reveals
288
+ and clears the filter, which is exactly the deep-link case it's
289
+ for. Own leading divider when the dropdown isn't rendering. */}
290
+ {clusterFilter.size > 0 && (
291
+ <>
292
+ {!multiCluster && <span className="mx-1.5 h-5 w-px bg-theme-border" />}
293
+ {[...clusterFilter].map((id) => {
294
+ const label = clusterLabelById?.(id) ?? clusterOptions.find((o) => o.id === id)?.label ?? id
295
+ return (
296
+ <span
297
+ key={id}
298
+ className="inline-flex items-center gap-1 rounded-full border border-[var(--color-radar-accent)]/30 bg-[var(--color-radar-accent)]/10 py-1 pl-2.5 pr-1 text-xs text-theme-text-primary"
299
+ >
300
+ <span className="min-w-0 max-w-[12rem] truncate">
301
+ <ClusterName name={label} />
302
+ </span>
303
+ <button
304
+ type="button"
305
+ onClick={() => toggle(setClusterFilter, id)}
306
+ aria-label={`Remove ${label} filter`}
307
+ className="rounded-full p-0.5 text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
308
+ >
309
+ <X className="h-3 w-3" />
310
+ </button>
311
+ </span>
312
+ )
313
+ })}
314
+ </>
315
+ )}
316
+ </div>
317
+ </div>
318
+
319
+ {fleetChecks.length === 0 ? (
320
+ hasFilters ? (
321
+ <EmptyState
322
+ tone="filtered"
323
+ variant="card"
324
+ headline="No checks match the current filters"
325
+ body="Clear a filter to see more of the queue."
326
+ action={
327
+ <button
328
+ type="button"
329
+ onClick={clearAll}
330
+ className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary transition-colors hover:bg-theme-hover"
331
+ >
332
+ Clear all filters
333
+ </button>
334
+ }
335
+ />
336
+ ) : anyData ? (
337
+ <EmptyState
338
+ tone="healthy"
339
+ variant="card"
340
+ icon={ShieldCheck}
341
+ headline="Nothing to remediate"
342
+ body="Every audited resource passed its checks."
343
+ />
344
+ ) : (
345
+ <EmptyState headline="No check data yet" body="Run an audit to populate the remediation queue." action={emptyAction} />
346
+ )
347
+ ) : (
348
+ <ol className="flex flex-col gap-1.5">
349
+ {fleetChecks.map((fc) => (
350
+ <FleetCheckRow
351
+ key={fc.checkID}
352
+ fc={fc}
353
+ meta={catalog[fc.checkID]}
354
+ clusterLabel={clusterLabel}
355
+ open={openId === fc.checkID}
356
+ onToggle={() => setOpenId((cur) => (cur === fc.checkID ? null : fc.checkID))}
357
+ resourceHref={resourceHref}
358
+ onResourceClick={onResourceClick}
359
+ onHideCheck={onHideCheck}
360
+ onHideCategory={onHideCategory}
361
+ />
362
+ ))}
363
+ </ol>
364
+ )}
365
+ </div>
366
+ )
367
+ }
368
+
369
+ function SeverityBar({ totals }: { totals: Record<CheckSeverity, number> }) {
370
+ const sum = CHECK_SEVERITIES.reduce((n, s) => n + totals[s], 0)
371
+ return (
372
+ <div className="flex h-1.5 overflow-hidden rounded-full bg-theme-elevated" role="img" aria-label="Severity distribution">
373
+ {sum === 0
374
+ ? null
375
+ : CHECK_SEVERITIES.map((s) =>
376
+ totals[s] > 0 ? (
377
+ <div key={s} className={`${SEVERITY_FILL_CLASS[s]} transition-[width] duration-500 ease-out`} style={{ width: `${(totals[s] / sum) * 100}%` }} />
378
+ ) : null,
379
+ )}
380
+ </div>
381
+ )
382
+ }
383
+
384
+ function SeverityChip({ severity, count, active, onClick }: { severity: CheckSeverity; count: number; active: boolean; onClick: () => void }) {
385
+ return (
386
+ <button
387
+ type="button"
388
+ onClick={onClick}
389
+ aria-pressed={active}
390
+ className={[
391
+ 'group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
392
+ active ? 'border-theme-border bg-theme-elevated text-theme-text-primary' : 'border-transparent text-theme-text-secondary hover:bg-theme-hover/60',
393
+ ].join(' ')}
394
+ >
395
+ <span className={`h-2 w-2 rounded-full ${SEVERITY_FILL_CLASS[severity]} ${count === 0 ? 'opacity-30' : ''}`} />
396
+ <span className={`font-semibold tabular-nums ${count > 0 ? SEVERITY_TEXT_CLASS[severity] : 'text-theme-text-tertiary'}`}>{count}</span>
397
+ <span>{SEVERITY_LABEL[severity]}</span>
398
+ </button>
399
+ )
400
+ }
401
+
402
+ function FleetCheckRow({
403
+ fc,
404
+ meta,
405
+ clusterLabel,
406
+ open,
407
+ onToggle,
408
+ resourceHref,
409
+ onResourceClick,
410
+ onHideCheck,
411
+ onHideCategory,
412
+ }: {
413
+ fc: FleetCheck
414
+ meta?: CheckMeta
415
+ clusterLabel?: (check: Check) => string | undefined
416
+ open: boolean
417
+ onToggle: () => void
418
+ resourceHref?: (ref: CheckResourceRef) => string
419
+ onResourceClick?: (ref: CheckResourceRef) => void
420
+ onHideCheck?: (checkID: string, title: string) => void
421
+ onHideCategory?: (category: string) => void
422
+ }) {
423
+ const clusterCount = fc.clusters.length
424
+ const single = clusterCount <= 1
425
+
426
+ const menuItems: { label: string; onClick: () => void }[] = []
427
+ if (onHideCheck) menuItems.push({ label: `Hide "${fc.title}" check`, onClick: () => onHideCheck(fc.checkID, fc.title) })
428
+ if (onHideCategory) menuItems.push({ label: `Hide all ${fc.category} checks`, onClick: () => onHideCategory(fc.category) })
429
+
430
+ return (
431
+ <li className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
432
+ <div
433
+ role="button"
434
+ tabIndex={0}
435
+ aria-expanded={open}
436
+ onClick={onToggle}
437
+ onKeyDown={(e) => {
438
+ if (e.target !== e.currentTarget) return
439
+ if (e.key === 'Enter' || e.key === ' ') {
440
+ e.preventDefault()
441
+ onToggle()
442
+ }
443
+ }}
444
+ className={`group flex cursor-pointer items-center gap-3 border-l-2 py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${SEVERITY_RAIL_CLASS[fc.severity]}`}
445
+ >
446
+ <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
447
+
448
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
449
+ <div className="flex items-center gap-2">
450
+ <span className="truncate text-sm font-medium text-theme-text-primary">{fc.title}</span>
451
+ <span className={`badge-sm shrink-0 text-[10px] ${categoryBadgeClass(fc.category)}`}>{fc.category}</span>
452
+ </div>
453
+ <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
454
+ <span className="shrink-0 font-medium text-theme-text-secondary tabular-nums">
455
+ {fc.totalResources} {fc.totalResources === 1 ? 'resource' : 'resources'}
456
+ </span>
457
+ {clusterCount > 1 && (
458
+ <>
459
+ <span aria-hidden>·</span>
460
+ <span className="shrink-0 tabular-nums">{clusterCount} clusters</span>
461
+ </>
462
+ )}
463
+ </div>
464
+ </div>
465
+
466
+ <span className={`badge-sm shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[fc.severity]}`}>{SEVERITY_LABEL[fc.severity]}</span>
467
+ {menuItems.length > 0 && <RowMenu items={menuItems} />}
468
+ </div>
469
+
470
+ {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
471
+ collapse too; inert when closed so SR + tab skip the clipped content. */}
472
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
473
+ <div className="overflow-hidden" inert={!open || undefined}>
474
+ <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
475
+ <div className="flex flex-col gap-4">
476
+ <div className="flex flex-col gap-4 md:flex-row md:gap-8">
477
+ {meta?.remediation && (
478
+ <section className="md:flex-1">
479
+ <h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-radar-accent)]">
480
+ <Wrench className="h-3.5 w-3.5" /> How to fix
481
+ </h4>
482
+ <p className="text-sm leading-relaxed text-theme-text-primary">{meta.remediation}</p>
483
+ </section>
484
+ )}
485
+ {meta?.description && (
486
+ <section className="md:flex-1">
487
+ <h4 className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What this checks</h4>
488
+ <p className="text-sm leading-relaxed text-theme-text-secondary">{meta.description}</p>
489
+ </section>
490
+ )}
491
+ </div>
492
+
493
+ {meta?.references && meta.references.length > 0 && (
494
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
495
+ {meta.references.map((r) => (
496
+ <a
497
+ key={r.url}
498
+ href={r.url}
499
+ target="_blank"
500
+ rel="noreferrer"
501
+ className="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
502
+ >
503
+ {r.label}
504
+ <ExternalLink className="h-3 w-3" />
505
+ </a>
506
+ ))}
507
+ </div>
508
+ )}
509
+
510
+ <div className="border-t border-theme-border/70 pt-3">
511
+ {single ? (
512
+ <ResourceList
513
+ label={`Affected resources (${fc.totalResources})`}
514
+ check={fc.clusters[0]}
515
+ resourceHref={resourceHref}
516
+ onResourceClick={onResourceClick}
517
+ />
518
+ ) : (
519
+ <ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
520
+ )}
521
+ </div>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </li>
527
+ )
528
+ }
529
+
530
+ // Per-cluster sub-groups for a check that spans the fleet. Each cluster is a
531
+ // collapsible row ("cluster · K resources"); open it for that cluster's
532
+ // resources. Caps the cluster list so a check across hundreds of clusters
533
+ // stays scannable.
534
+ function ClusterBreakdown({
535
+ fc,
536
+ clusterLabel,
537
+ resourceHref,
538
+ onResourceClick,
539
+ }: {
540
+ fc: FleetCheck
541
+ clusterLabel?: (check: Check) => string | undefined
542
+ resourceHref?: (ref: CheckResourceRef) => string
543
+ onResourceClick?: (ref: CheckResourceRef) => void
544
+ }) {
545
+ const [openClusters, setOpenClusters] = useState<Set<string>>(
546
+ () => new Set(fc.clusters.length <= AUTO_EXPAND_CLUSTERS ? fc.clusters.map((c) => c.subject.cluster_id) : []),
547
+ )
548
+ const [showAllClusters, setShowAllClusters] = useState(false)
549
+ const shown = showAllClusters ? fc.clusters : fc.clusters.slice(0, CLUSTER_CAP)
550
+ const hiddenClusters = fc.clusters.length - shown.length
551
+
552
+ return (
553
+ <section className="flex flex-col gap-1.5">
554
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
555
+ Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
556
+ </h4>
557
+ <ul className="flex flex-col gap-1">
558
+ {shown.map((c) => {
559
+ const id = c.subject.cluster_id
560
+ const isOpen = openClusters.has(id)
561
+ const env = c.environment
562
+ return (
563
+ <li key={id} className="rounded-lg border border-theme-border/70 bg-theme-surface">
564
+ <button
565
+ type="button"
566
+ aria-expanded={isOpen}
567
+ onClick={() =>
568
+ setOpenClusters((prev) => {
569
+ const next = new Set(prev)
570
+ if (next.has(id)) next.delete(id)
571
+ else next.add(id)
572
+ return next
573
+ })
574
+ }
575
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-theme-hover/50"
576
+ >
577
+ <ChevronDown className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${isOpen ? '' : '-rotate-90'}`} />
578
+ <span className="min-w-0 max-w-[260px] truncate text-sm font-medium text-theme-text-primary">
579
+ <ClusterName name={clusterLabel?.(c) || id} />
580
+ </span>
581
+ {env && (
582
+ <span className="shrink-0 rounded bg-theme-elevated px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-secondary ring-1 ring-theme-border">
583
+ {env}
584
+ </span>
585
+ )}
586
+ <span className="flex-1" />
587
+ <span className="shrink-0 text-xs tabular-nums text-theme-text-secondary">
588
+ {c.affectedResources} {c.affectedResources === 1 ? 'resource' : 'resources'}
589
+ </span>
590
+ </button>
591
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: isOpen ? '1fr' : '0fr' }}>
592
+ <div className="overflow-hidden" inert={!isOpen || undefined}>
593
+ <div className="px-2.5 pb-2 pl-7">
594
+ <ResourceList check={c} resourceHref={resourceHref} onResourceClick={onResourceClick} />
595
+ </div>
596
+ </div>
597
+ </div>
598
+ </li>
599
+ )
600
+ })}
601
+ </ul>
602
+ {hiddenClusters > 0 && (
603
+ <button
604
+ type="button"
605
+ onClick={() => setShowAllClusters(true)}
606
+ className="mt-0.5 inline-flex w-fit items-center gap-1 rounded px-2 py-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
607
+ >
608
+ View all {fc.clusters.length} clusters →
609
+ </button>
610
+ )}
611
+ </section>
612
+ )
613
+ }
614
+
615
+ // The resource lines for one cluster's check. `label` (set in the single-cluster
616
+ // case) renders a section heading; in the cluster-breakdown case the cluster row
617
+ // already provides the heading, so it's omitted.
618
+ function ResourceList({
619
+ check,
620
+ label,
621
+ resourceHref,
622
+ onResourceClick,
623
+ }: {
624
+ check: Check
625
+ label?: string
626
+ resourceHref?: (ref: CheckResourceRef) => string
627
+ onResourceClick?: (ref: CheckResourceRef) => void
628
+ }) {
629
+ const [showAll, setShowAll] = useState(false)
630
+ // The per-finding message only earns a place when it adds something the line
631
+ // doesn't already show. Normalize each message by removing its own resource
632
+ // name, then compare: all-same → it repeats the check or varies only by the
633
+ // object name (already on the line) → drop it; still-different → real new info
634
+ // (e.g. a container name) → keep it.
635
+ const showMessage = useMemo(() => {
636
+ if (check.findings.length === 0) return false
637
+ const norm = (f: EffectiveCheckFinding) => {
638
+ const n = f.resource.name
639
+ return n ? (f.message ?? '').split(n).join('') : f.message ?? ''
640
+ }
641
+ const first = norm(check.findings[0])
642
+ return check.findings.some((f) => norm(f) !== first)
643
+ }, [check.findings])
644
+ const list = showAll ? check.findings : check.findings.slice(0, RESOURCE_CAP)
645
+ const hidden = check.findings.length - list.length
646
+
647
+ return (
648
+ <section className="flex flex-col gap-1.5">
649
+ {label && <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</h4>}
650
+ <ul className="flex flex-col gap-px">
651
+ {list.map((f, i) => (
652
+ <FindingLine
653
+ key={`${f.resource.group}/${f.resource.kind}/${f.resource.namespace}/${f.resource.name}#${i}`}
654
+ finding={f}
655
+ showMessage={showMessage}
656
+ resourceHref={resourceHref}
657
+ onResourceClick={onResourceClick}
658
+ />
659
+ ))}
660
+ </ul>
661
+ {hidden > 0 && (
662
+ <button
663
+ type="button"
664
+ onClick={() => setShowAll(true)}
665
+ className="mt-0.5 inline-flex w-fit items-center gap-1 rounded px-2 py-1 text-xs font-medium text-[var(--color-radar-accent)] hover:underline"
666
+ >
667
+ View all {check.findings.length} →
668
+ </button>
669
+ )}
670
+ </section>
671
+ )
672
+ }
673
+
674
+ function FindingLine({
675
+ finding,
676
+ showMessage,
677
+ resourceHref,
678
+ onResourceClick,
679
+ }: {
680
+ finding: EffectiveCheckFinding
681
+ showMessage?: boolean
682
+ resourceHref?: (ref: CheckResourceRef) => string
683
+ onResourceClick?: (ref: CheckResourceRef) => void
684
+ }) {
685
+ const r = finding.resource
686
+ const linkable = !!(onResourceClick || resourceHref)
687
+ const body = (
688
+ <>
689
+ <span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
690
+ <span className={`shrink-0 font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
691
+ {r.namespace ? `${r.namespace} / ` : ''}
692
+ {r.name}
693
+ </span>
694
+ {linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/f:opacity-100" />}
695
+ {showMessage && <span className="ml-1 truncate text-xs text-theme-text-tertiary">{finding.message}</span>}
696
+ </>
697
+ )
698
+ const cls = 'group/f flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60'
699
+ return (
700
+ <li>
701
+ {onResourceClick ? (
702
+ <button type="button" onClick={() => onResourceClick(r)} className={cls}>
703
+ {body}
704
+ </button>
705
+ ) : resourceHref ? (
706
+ <a href={resourceHref(r)} className={cls}>
707
+ {body}
708
+ </a>
709
+ ) : (
710
+ <span className="flex items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
711
+ )}
712
+ </li>
713
+ )
714
+ }
715
+
716
+ // Multi-select cluster facet — clusters are high-cardinality, so a dropdown
717
+ // (portaled out of the row, like the row menu) rather than chips.
718
+ function ClusterFilter({
719
+ options,
720
+ selected,
721
+ onToggle,
722
+ onClear,
723
+ }: {
724
+ options: { id: string; label: string }[]
725
+ selected: Set<string>
726
+ onToggle: (id: string) => void
727
+ onClear: () => void
728
+ }) {
729
+ const [menu, setMenu] = useState<{ top: number; left: number } | null>(null)
730
+ const btnRef = useRef<HTMLButtonElement>(null)
731
+ useEffect(() => {
732
+ if (!menu) return
733
+ const close = () => setMenu(null)
734
+ const onDown = (e: MouseEvent) => {
735
+ if (btnRef.current?.contains(e.target as Node)) return
736
+ // keep open when clicking inside the menu
737
+ if ((e.target as HTMLElement)?.closest?.('[data-cluster-menu]')) return
738
+ close()
739
+ }
740
+ document.addEventListener('mousedown', onDown)
741
+ window.addEventListener('scroll', close, true)
742
+ window.addEventListener('resize', close)
743
+ return () => {
744
+ document.removeEventListener('mousedown', onDown)
745
+ window.removeEventListener('scroll', close, true)
746
+ window.removeEventListener('resize', close)
747
+ }
748
+ }, [menu])
749
+
750
+ const open = (e: React.MouseEvent) => {
751
+ e.stopPropagation()
752
+ if (menu) {
753
+ setMenu(null)
754
+ return
755
+ }
756
+ const r = btnRef.current?.getBoundingClientRect()
757
+ if (r) setMenu({ top: r.bottom + 4, left: r.left })
758
+ }
759
+ const active = selected.size > 0
760
+
761
+ return (
762
+ <>
763
+ <button
764
+ ref={btnRef}
765
+ type="button"
766
+ aria-haspopup="listbox"
767
+ aria-expanded={menu != null}
768
+ onClick={open}
769
+ className={[
770
+ 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
771
+ active ? 'border-theme-border bg-theme-elevated text-theme-text-primary' : 'border-theme-border-light text-theme-text-secondary hover:bg-theme-hover/60',
772
+ ].join(' ')}
773
+ >
774
+ Clusters
775
+ <ChevronDown className="h-3 w-3" />
776
+ </button>
777
+ {menu &&
778
+ createPortal(
779
+ <div
780
+ data-cluster-menu
781
+ role="listbox"
782
+ aria-multiselectable
783
+ className="fixed z-[60] max-h-80 w-64 overflow-auto rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl"
784
+ style={{ top: menu.top, left: menu.left }}
785
+ >
786
+ {active && (
787
+ <button
788
+ type="button"
789
+ onClick={onClear}
790
+ className="flex w-full items-center px-3 py-1.5 text-left text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
791
+ >
792
+ Clear selection
793
+ </button>
794
+ )}
795
+ {options.map((o) => {
796
+ const on = selected.has(o.id)
797
+ return (
798
+ <button
799
+ key={o.id}
800
+ type="button"
801
+ role="option"
802
+ aria-selected={on}
803
+ onClick={() => onToggle(o.id)}
804
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
805
+ >
806
+ <span className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border ${on ? 'border-[var(--color-radar-accent)] bg-[var(--color-radar-accent)] text-white' : 'border-theme-border'}`}>
807
+ {on && <span className="text-[9px] leading-none">✓</span>}
808
+ </span>
809
+ <span className="min-w-0 truncate">
810
+ <ClusterName name={o.label} />
811
+ </span>
812
+ </button>
813
+ )
814
+ })}
815
+ </div>,
816
+ document.body,
817
+ )}
818
+ </>
819
+ )
820
+ }
821
+
822
+ // Quiet per-row overflow menu for the OSS local-tuning actions (hide check /
823
+ // category). Portaled to document.body so the row's overflow-hidden can't clip
824
+ // it; position captured at open time, any scroll/resize closes it.
825
+ function RowMenu({ items }: { items: { label: string; onClick: () => void }[] }) {
826
+ const [menu, setMenu] = useState<{ top: number; right: number } | null>(null)
827
+ const btnRef = useRef<HTMLButtonElement>(null)
828
+
829
+ useEffect(() => {
830
+ if (!menu) return
831
+ const close = () => setMenu(null)
832
+ const onDown = (e: MouseEvent) => {
833
+ if (btnRef.current?.contains(e.target as Node)) return
834
+ close()
835
+ }
836
+ document.addEventListener('mousedown', onDown)
837
+ window.addEventListener('scroll', close, true)
838
+ window.addEventListener('resize', close)
839
+ return () => {
840
+ document.removeEventListener('mousedown', onDown)
841
+ window.removeEventListener('scroll', close, true)
842
+ window.removeEventListener('resize', close)
843
+ }
844
+ }, [menu])
845
+
846
+ const toggle = (e: React.MouseEvent) => {
847
+ e.stopPropagation()
848
+ if (menu) {
849
+ setMenu(null)
850
+ return
851
+ }
852
+ const r = btnRef.current?.getBoundingClientRect()
853
+ if (r) setMenu({ top: r.bottom + 4, right: window.innerWidth - r.right })
854
+ }
855
+
856
+ return (
857
+ <>
858
+ <button
859
+ ref={btnRef}
860
+ type="button"
861
+ aria-label="More actions"
862
+ aria-haspopup="menu"
863
+ aria-expanded={menu != null}
864
+ onClick={toggle}
865
+ onKeyDown={(e) => e.stopPropagation()}
866
+ className="shrink-0 rounded p-1 text-theme-text-tertiary opacity-0 transition-opacity hover:bg-theme-hover hover:text-theme-text-secondary group-hover:opacity-100"
867
+ >
868
+ <MoreHorizontal className="h-4 w-4" />
869
+ </button>
870
+ {menu &&
871
+ createPortal(
872
+ <div
873
+ role="menu"
874
+ className="fixed z-[60] min-w-48 rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl"
875
+ style={{ top: menu.top, right: menu.right }}
876
+ onClick={(e) => e.stopPropagation()}
877
+ >
878
+ {items.map((it, i) => (
879
+ <button
880
+ key={i}
881
+ type="button"
882
+ role="menuitem"
883
+ onClick={(e) => {
884
+ e.stopPropagation()
885
+ it.onClick()
886
+ setMenu(null)
887
+ }}
888
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
889
+ >
890
+ <EyeOff className="h-3.5 w-3.5 shrink-0" />
891
+ {it.label}
892
+ </button>
893
+ ))}
894
+ </div>,
895
+ document.body,
896
+ )}
897
+ </>
898
+ )
899
+ }