@skyhook-io/k8s-ui 1.7.3 → 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.
- package/package.json +1 -1
- package/src/components/audit/AuditFindingsTable.tsx +7 -0
- package/src/components/checks/ChecksView.tsx +899 -0
- package/src/components/checks/checks.test.ts +40 -0
- package/src/components/checks/index.ts +3 -0
- package/src/components/checks/severity.ts +63 -0
- package/src/components/checks/types.ts +138 -0
- package/src/components/gitops/GitOpsTableView.tsx +246 -28
- package/src/components/resources/ResourcesView.tsx +61 -0
- package/src/index.ts +5 -0
|
@@ -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
|
+
}
|