@skyhook-io/k8s-ui 1.8.8 → 1.8.10
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/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +73 -38
- package/src/components/checks/ChecksView.tsx +105 -63
- package/src/components/checks/severity.ts +29 -39
- package/src/components/gitops/GitOpsTableView.tsx +72 -43
- package/src/components/issues/IssuesView.tsx +186 -123
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
- package/src/components/issues/ResourceIssuesSection.tsx +21 -3
- package/src/components/issues/index.ts +0 -2
- package/src/components/issues/issues.test.ts +45 -4
- package/src/components/issues/severity.ts +27 -32
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
- package/src/components/resources/ResourcesSidebar.tsx +24 -6
- package/src/components/resources/ResourcesView.tsx +10 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/severity-tone.ts +56 -0
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/index.ts +4 -0
- package/src/theme/variables.css +14 -0
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/tsconfig.json +2 -2
package/package.json
CHANGED
|
@@ -13,9 +13,11 @@ export interface ApplicationsListProps {
|
|
|
13
13
|
onSelect: (key: string) => void
|
|
14
14
|
/** Leading element in the header actions (e.g. a freshness control). */
|
|
15
15
|
headerActions?: ReactNode
|
|
16
|
+
/** First fetch in flight — chassis renders its shape-stable skeleton. */
|
|
17
|
+
loading?: boolean
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
|
|
20
|
+
export function ApplicationsList({ apps, onSelect, headerActions, loading }: ApplicationsListProps) {
|
|
19
21
|
// Env tokens this CLUSTER proved (identity classifications on the wire) feed
|
|
20
22
|
// the namespace heuristic, so sibling-less rows in discovered env namespaces
|
|
21
23
|
// still label without any hardcoded vocabulary.
|
|
@@ -24,6 +26,7 @@ export function ApplicationsList({ apps, onSelect, headerActions }: Applications
|
|
|
24
26
|
|
|
25
27
|
return (
|
|
26
28
|
<ApplicationsView
|
|
29
|
+
loading={loading}
|
|
27
30
|
variant="single"
|
|
28
31
|
entries={entries}
|
|
29
32
|
onSelect={onSelect}
|
|
@@ -5,7 +5,9 @@ import { clsx } from 'clsx'
|
|
|
5
5
|
import { StatusDot, mapHealthToTone } from '../ui/status-tone'
|
|
6
6
|
import { Tooltip } from '../ui/Tooltip'
|
|
7
7
|
import { EmptyState } from '../ui/EmptyState'
|
|
8
|
+
import { BoardTableSkeleton, BoardRailSkeleton } from '../ui/BoardSkeleton'
|
|
8
9
|
import { SearchBox } from '../ui/SearchBox'
|
|
10
|
+
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
9
11
|
import { PageHeader } from '../ui/PageHeader'
|
|
10
12
|
import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
|
|
11
13
|
import { Facet, type FacetTone } from '../ui/Facet'
|
|
@@ -99,16 +101,34 @@ export interface ApplicationsViewProps {
|
|
|
99
101
|
emptySlot?: ReactNode
|
|
100
102
|
/** Leading element in the header actions cluster (e.g. a freshness control). */
|
|
101
103
|
headerActions?: ReactNode
|
|
104
|
+
/** First fetch in flight — render the shape-stable skeleton (pulsing tiles,
|
|
105
|
+
* rail stubs, table rows) instead of collapsing the chassis. */
|
|
106
|
+
loading?: boolean
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
const APPS_FILTER_SCHEMA = defineFilterSchema({
|
|
110
|
+
health: { param: 'health', type: 'set' },
|
|
111
|
+
class: { param: 'class', type: 'set' },
|
|
112
|
+
type: { param: 'type', type: 'set' },
|
|
113
|
+
env: { param: 'env', type: 'set' },
|
|
114
|
+
source: { param: 'source', type: 'set' },
|
|
115
|
+
q: { param: 'q', type: 'text' },
|
|
116
|
+
system: { param: 'system', type: 'boolean' },
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions, loading }: ApplicationsViewProps) {
|
|
120
|
+
// Facets + search + show-system live in the URL (shareable, bookmarkable) via
|
|
121
|
+
// the shared filter-state contract. Sort stays local: it's a compound
|
|
122
|
+
// {key, dir} view-preference, not a result-narrowing filter, so it isn't
|
|
123
|
+
// forced into the filter vocabulary.
|
|
124
|
+
const filters = useFilterState(APPS_FILTER_SCHEMA)
|
|
125
|
+
const textFilter = filters.values.q
|
|
126
|
+
const fHealth = filters.values.health as Set<AppHealth>
|
|
127
|
+
const fEnv = filters.values.env
|
|
128
|
+
const fSource = filters.values.source as Set<AppSource>
|
|
129
|
+
const fClass = filters.values.class as Set<AppWorkloadClass>
|
|
130
|
+
const fType = filters.values.type as Set<AppCategory>
|
|
131
|
+
const showSystem = filters.values.system
|
|
112
132
|
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
|
|
113
133
|
|
|
114
134
|
// The Show-system toggle keys off per-entry workload namespaces, which only
|
|
@@ -244,12 +264,6 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
244
264
|
return { health, env, source, workloadClass, category }
|
|
245
265
|
}, [all])
|
|
246
266
|
|
|
247
|
-
const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
|
|
248
|
-
const next = new Set(set)
|
|
249
|
-
next.has(v) ? next.delete(v) : next.add(v)
|
|
250
|
-
setter(next)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
267
|
// asc → desc → off (null = default health-worst-first sort).
|
|
254
268
|
const onSort = (key: SortKey) => {
|
|
255
269
|
setSort((prev) => {
|
|
@@ -264,23 +278,24 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
264
278
|
.sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
|
|
265
279
|
.map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
|
|
266
280
|
|
|
281
|
+
// First fetch, nothing to show yet: drive the shape-stable skeleton. The
|
|
282
|
+
// loaded state hides zero-count health tiles, so without this the header
|
|
283
|
+
// (tiles + distribution bar), rail, and table would all pop in at once.
|
|
284
|
+
const initialLoading = Boolean(loading) && allEntries.length === 0
|
|
285
|
+
|
|
267
286
|
// Clickable status tile wired to the health facet — tap to filter to that tier.
|
|
268
287
|
const healthTile = (h: AppHealth, tone: SummaryTone) =>
|
|
269
|
-
|
|
270
|
-
<SummaryTile key={h} label={HEALTH_META[h].label} value={
|
|
288
|
+
initialLoading && (h === 'healthy' || h === 'degraded' || h === 'unhealthy') ? (
|
|
289
|
+
<SummaryTile key={h} label={HEALTH_META[h].label} value={0} tone={tone} loading />
|
|
290
|
+
) : counts.health[h] ? (
|
|
291
|
+
<SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => filters.toggle('health', h)} />
|
|
271
292
|
) : null
|
|
272
293
|
|
|
273
294
|
// showSystem lives in the Filters rail, so Clear resets it too (and its
|
|
274
295
|
// non-default ON state counts as an active filter that surfaces the button).
|
|
275
|
-
const anyFilterActive =
|
|
296
|
+
const anyFilterActive = filters.isActive
|
|
276
297
|
const clearAllFilters = () => {
|
|
277
|
-
|
|
278
|
-
setFHealth(new Set())
|
|
279
|
-
setFClass(new Set())
|
|
280
|
-
setFType(new Set())
|
|
281
|
-
setFSource(new Set())
|
|
282
|
-
setFEnv(new Set())
|
|
283
|
-
setShowSystem(false)
|
|
298
|
+
filters.clearAll()
|
|
284
299
|
}
|
|
285
300
|
|
|
286
301
|
return (
|
|
@@ -297,7 +312,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
297
312
|
actions={
|
|
298
313
|
<>
|
|
299
314
|
{headerActions}
|
|
300
|
-
<SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
|
|
315
|
+
<SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
|
|
301
316
|
{healthTile('unhealthy', 'error')}
|
|
302
317
|
{healthTile('degraded', 'warning')}
|
|
303
318
|
{healthTile('healthy', 'success')}
|
|
@@ -306,11 +321,15 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
306
321
|
</>
|
|
307
322
|
}
|
|
308
323
|
/>
|
|
309
|
-
|
|
310
|
-
className="mt-3"
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
324
|
+
{initialLoading ? (
|
|
325
|
+
<div className="mt-3 h-1.5 w-full animate-pulse rounded-full bg-theme-hover" aria-hidden />
|
|
326
|
+
) : (
|
|
327
|
+
<DistributionBar
|
|
328
|
+
className="mt-3"
|
|
329
|
+
ariaLabel="Health distribution"
|
|
330
|
+
segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
|
|
331
|
+
/>
|
|
332
|
+
)}
|
|
314
333
|
</div>
|
|
315
334
|
|
|
316
335
|
{/* Body: filter sidebar | content (toolbar + table). */}
|
|
@@ -326,18 +345,32 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
326
345
|
)}
|
|
327
346
|
</div>
|
|
328
347
|
<div className="flex-1 overflow-y-auto">
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
348
|
+
{initialLoading ? (
|
|
349
|
+
<BoardRailSkeleton
|
|
350
|
+
sections={[
|
|
351
|
+
['Availability', 5],
|
|
352
|
+
['Class', 3],
|
|
353
|
+
['Type', 4],
|
|
354
|
+
['Environment', 3],
|
|
355
|
+
['Source', 3],
|
|
356
|
+
]}
|
|
357
|
+
/>
|
|
358
|
+
) : (
|
|
359
|
+
<>
|
|
360
|
+
<Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => filters.toggle('health', v)} />
|
|
361
|
+
<Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => filters.toggle('class', v)} />
|
|
362
|
+
<Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => filters.toggle('type', v)} />
|
|
363
|
+
<Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => filters.toggle('env', v)} />
|
|
364
|
+
<Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => filters.toggle('source', v)} />
|
|
334
365
|
{systemCount > 0 && (
|
|
335
366
|
<label className="flex cursor-pointer items-center gap-2 border-b border-theme-border px-3 py-2 text-[11px] text-theme-text-secondary hover:bg-theme-hover">
|
|
336
|
-
<input type="checkbox" checked={showSystem} onChange={(e) =>
|
|
367
|
+
<input type="checkbox" checked={showSystem} onChange={(e) => filters.setBoolean('system', e.target.checked)} className="accent-skyhook-500" />
|
|
337
368
|
<span>Show system namespaces</span>
|
|
338
369
|
<span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
|
|
339
370
|
</label>
|
|
340
371
|
)}
|
|
372
|
+
</>
|
|
373
|
+
)}
|
|
341
374
|
</div>
|
|
342
375
|
</aside>
|
|
343
376
|
|
|
@@ -346,7 +379,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
346
379
|
<div className="flex shrink-0 items-center gap-3 border-b border-theme-border px-4 py-3">
|
|
347
380
|
<SearchBox
|
|
348
381
|
value={textFilter}
|
|
349
|
-
onChange={
|
|
382
|
+
onChange={(v) => filters.setString('q', v)}
|
|
350
383
|
scope="applications"
|
|
351
384
|
shortcutId="applications-search"
|
|
352
385
|
className="max-w-md flex-1"
|
|
@@ -365,7 +398,9 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
|
|
|
365
398
|
</div>
|
|
366
399
|
|
|
367
400
|
<div className="min-w-0 flex-1 overflow-auto bg-theme-base">
|
|
368
|
-
{
|
|
401
|
+
{initialLoading ? (
|
|
402
|
+
<BoardTableSkeleton />
|
|
403
|
+
) : entries.length === 0 ? (
|
|
369
404
|
emptySlot && allEntries.length === 0 ? (
|
|
370
405
|
emptySlot
|
|
371
406
|
) : (
|
|
@@ -1,20 +1,40 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
2
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, DistributionBar, DistributionLegendChip } from '../ui'
|
|
3
|
+
import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ChevronRight, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
|
|
4
|
+
import { CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
5
|
+
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
5
6
|
import type { CheckMeta, CheckReference } from '../audit'
|
|
6
7
|
import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
|
|
7
8
|
import {
|
|
8
9
|
SEVERITY_BADGE_CLASS,
|
|
9
10
|
SEVERITY_FILL_CLASS,
|
|
11
|
+
SEVERITY_HEADER_BAND_CLASS,
|
|
10
12
|
SEVERITY_LABEL,
|
|
11
13
|
SEVERITY_RAIL_CLASS,
|
|
14
|
+
SEVERITY_SOLID_CLASS,
|
|
12
15
|
SEVERITY_TEXT_CLASS,
|
|
13
|
-
categoryBadgeClass,
|
|
14
16
|
} from './severity'
|
|
15
17
|
|
|
16
18
|
const CATEGORIES: readonly string[] = ['Security', 'Reliability', 'Efficiency']
|
|
17
19
|
|
|
20
|
+
// Leading severity glyph, one per tier of the 4-tier ladder: critical = octagon,
|
|
21
|
+
// high = triangle, medium = circle, low = info.
|
|
22
|
+
const CHECK_SEVERITY_ICON: Record<CheckSeverity, ComponentType<{ className?: string }>> = {
|
|
23
|
+
critical: AlertOctagon,
|
|
24
|
+
high: AlertTriangle,
|
|
25
|
+
medium: AlertCircle,
|
|
26
|
+
low: Info,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// An out-of-contract severity the backend might emit is coerced to this tier
|
|
30
|
+
// once, up front (normalizeCheckSeverity), so the icon AND every color map
|
|
31
|
+
// (text/rail/band/pill) resolve together — a raw miss would crash the icon and
|
|
32
|
+
// silently drop the tint everywhere else.
|
|
33
|
+
const CHECK_SEVERITY_FALLBACK: CheckSeverity = 'medium'
|
|
34
|
+
// Object.hasOwn (not `in`) so inherited keys like "toString" don't slip past.
|
|
35
|
+
const normalizeCheckSeverity = (s: CheckSeverity): CheckSeverity =>
|
|
36
|
+
Object.hasOwn(CHECK_SEVERITY_ICON, s) ? s : CHECK_SEVERITY_FALLBACK
|
|
37
|
+
|
|
18
38
|
// Affected-resources shown inline before "View all". A check can fail on
|
|
19
39
|
// thousands of resources; the card stays scannable and only the rare big-list
|
|
20
40
|
// case pays the cost of a full expand.
|
|
@@ -79,11 +99,24 @@ interface FleetCheck {
|
|
|
79
99
|
clusters: Check[]
|
|
80
100
|
}
|
|
81
101
|
|
|
102
|
+
const CHECKS_FILTER_SCHEMA = defineFilterSchema({
|
|
103
|
+
severity: { param: 'severity', type: 'set' },
|
|
104
|
+
category: { param: 'category', type: 'set' },
|
|
105
|
+
framework: { param: 'framework', type: 'set' },
|
|
106
|
+
q: { param: 'q', type: 'text' },
|
|
107
|
+
})
|
|
108
|
+
|
|
82
109
|
export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
110
|
+
// Severity / category / framework / search live in the URL (shareable,
|
|
111
|
+
// bookmarkable audit links) via the shared filter-state contract. The cluster
|
|
112
|
+
// facet is deliberately NOT here — it's a host-controlled seam (see
|
|
113
|
+
// onClusterFilterChange) for the multi-cluster/fleet case and isn't shown in
|
|
114
|
+
// single-cluster OSS, so it stays on its own controlled/internal path.
|
|
115
|
+
const filters = useFilterState(CHECKS_FILTER_SCHEMA)
|
|
116
|
+
const severityFilter = filters.values.severity as Set<CheckSeverity>
|
|
117
|
+
const categoryFilter = filters.values.category
|
|
118
|
+
const frameworkFilter = filters.values.framework
|
|
119
|
+
const search = filters.values.q
|
|
87
120
|
const [openId, setOpenId] = useState<string | null>(null)
|
|
88
121
|
|
|
89
122
|
// Cluster facet is controlled when the host opts in (onClusterFilterChange);
|
|
@@ -209,13 +242,10 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
209
242
|
return next
|
|
210
243
|
})
|
|
211
244
|
|
|
212
|
-
const hasFilters =
|
|
245
|
+
const hasFilters = filters.isActive || clusterFilter.size > 0
|
|
213
246
|
const clearAll = () => {
|
|
214
|
-
|
|
215
|
-
setCategoryFilter(new Set())
|
|
216
|
-
setFrameworkFilter(new Set())
|
|
247
|
+
filters.clearAll()
|
|
217
248
|
setClusterFilter(new Set())
|
|
218
|
-
setSearch('')
|
|
219
249
|
}
|
|
220
250
|
|
|
221
251
|
return (
|
|
@@ -239,13 +269,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
239
269
|
type="text"
|
|
240
270
|
placeholder="Search checks…"
|
|
241
271
|
value={search}
|
|
242
|
-
onChange={(e) =>
|
|
272
|
+
onChange={(e) => filters.setString('q', e.target.value)}
|
|
243
273
|
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
274
|
/>
|
|
245
275
|
{search && (
|
|
246
276
|
<button
|
|
247
277
|
type="button"
|
|
248
|
-
onClick={() =>
|
|
278
|
+
onClick={() => filters.setString('q', '')}
|
|
249
279
|
aria-label="Clear search"
|
|
250
280
|
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
281
|
>
|
|
@@ -259,17 +289,17 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
259
289
|
|
|
260
290
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
261
291
|
{CHECK_SEVERITIES.map((s) => (
|
|
262
|
-
<CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(
|
|
292
|
+
<CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => filters.toggle('severity', s)} />
|
|
263
293
|
))}
|
|
264
294
|
<span className="mx-1.5 h-5 w-px bg-theme-border" />
|
|
265
295
|
{CATEGORIES.map((c) => (
|
|
266
|
-
<FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => toggle(
|
|
296
|
+
<FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => filters.toggle('category', c)} />
|
|
267
297
|
))}
|
|
268
298
|
{frameworks.length > 0 && (
|
|
269
299
|
<>
|
|
270
300
|
<span className="mx-1.5 h-5 w-px bg-theme-border" />
|
|
271
301
|
{frameworks.map((fw) => (
|
|
272
|
-
<FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => toggle(
|
|
302
|
+
<FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => filters.toggle('framework', fw)} />
|
|
273
303
|
))}
|
|
274
304
|
</>
|
|
275
305
|
)}
|
|
@@ -399,17 +429,22 @@ export interface CheckRemediationBlockProps {
|
|
|
399
429
|
layout?: 'columns' | 'stack'
|
|
400
430
|
}
|
|
401
431
|
|
|
432
|
+
// Remediation body: WHY IT MATTERS (info, the description) → HOW TO FIX
|
|
433
|
+
// (wrench/emerald, the remediation + doc links), rendered as icon-led sections
|
|
434
|
+
// for every host. Prose runs through renderProse so `inline-code` spans become
|
|
435
|
+
// mono chips when the catalog copy carries them. The 'stack' layout is the
|
|
436
|
+
// compact single-column variant for narrow hosts.
|
|
402
437
|
export function CheckRemediationBlock({ description, remediation, references, layout = 'columns' }: CheckRemediationBlockProps) {
|
|
403
438
|
if (!description && !remediation && (!references || references.length === 0)) return null
|
|
404
439
|
|
|
405
440
|
if (layout === 'stack') {
|
|
406
441
|
return (
|
|
407
442
|
<div className="flex flex-col gap-2">
|
|
408
|
-
{description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{description}</p>}
|
|
443
|
+
{description && <p className="text-[13px] leading-relaxed text-theme-text-secondary">{renderProse(description)}</p>}
|
|
409
444
|
{remediation && (
|
|
410
445
|
<div>
|
|
411
446
|
<div className="text-[11px] uppercase tracking-wider text-theme-text-tertiary">How to fix</div>
|
|
412
|
-
<p className="mt-0.5 text-[13px] leading-relaxed text-theme-text-secondary">{remediation}</p>
|
|
447
|
+
<p className="mt-0.5 text-[13px] leading-relaxed text-theme-text-secondary">{renderProse(remediation)}</p>
|
|
413
448
|
</div>
|
|
414
449
|
)}
|
|
415
450
|
{references && references.length > 0 && <CheckReferenceLinks references={references} />}
|
|
@@ -418,25 +453,19 @@ export function CheckRemediationBlock({ description, remediation, references, la
|
|
|
418
453
|
}
|
|
419
454
|
|
|
420
455
|
return (
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
{
|
|
424
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
</
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
<p className="text-sm leading-relaxed text-theme-text-secondary">{description}</p>
|
|
435
|
-
</section>
|
|
436
|
-
)}
|
|
437
|
-
</div>
|
|
438
|
-
{references && references.length > 0 && <CheckReferenceLinks references={references} />}
|
|
439
|
-
</>
|
|
456
|
+
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
457
|
+
{description && (
|
|
458
|
+
<CardSection icon={Info} label="Why it matters" tone="neutral">
|
|
459
|
+
<CardBody>{renderProse(description)}</CardBody>
|
|
460
|
+
</CardSection>
|
|
461
|
+
)}
|
|
462
|
+
{(remediation || (references && references.length > 0)) && (
|
|
463
|
+
<CardSection icon={Wrench} label="How to fix" tone="fix">
|
|
464
|
+
{remediation && <CardBody>{renderProse(remediation)}</CardBody>}
|
|
465
|
+
{references && references.length > 0 && <CheckReferenceLinks references={references} />}
|
|
466
|
+
</CardSection>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
440
469
|
)
|
|
441
470
|
}
|
|
442
471
|
|
|
@@ -489,16 +518,27 @@ export function CheckCardShell({
|
|
|
489
518
|
dimmed,
|
|
490
519
|
}: CheckCardShellProps) {
|
|
491
520
|
const Container = as
|
|
521
|
+
const sev = normalizeCheckSeverity(severity)
|
|
522
|
+
const SeverityIcon = CHECK_SEVERITY_ICON[sev]
|
|
492
523
|
return (
|
|
493
524
|
<Container
|
|
494
525
|
className={[
|
|
495
|
-
'overflow-hidden rounded-xl border
|
|
526
|
+
'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
|
|
527
|
+
// The open card lifts via elevation — heavier shadow + a bright
|
|
528
|
+
// emphasis edge that clearly separates it from sibling cards. Severity
|
|
529
|
+
// stays rationed to the band + pill; separation is depth, not more color.
|
|
530
|
+
// ring-1 widens the edge to 2px without the layout shift a border-2
|
|
531
|
+
// swap would cause on expand.
|
|
532
|
+
open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
|
|
496
533
|
dimmed ? 'opacity-60' : '',
|
|
497
534
|
className ?? '',
|
|
498
535
|
]
|
|
499
536
|
.filter(Boolean)
|
|
500
537
|
.join(' ')}
|
|
501
538
|
>
|
|
539
|
+
{/* Leading severity icon is the at-a-glance cue; a trailing chevron shows
|
|
540
|
+
open/closed. Collapsed: neutral row + rail. Expanded: severity-tinted
|
|
541
|
+
band + solid pill — the tint is a focus signal, not per-row alarm. */}
|
|
502
542
|
<div
|
|
503
543
|
role="button"
|
|
504
544
|
tabIndex={0}
|
|
@@ -511,31 +551,33 @@ export function CheckCardShell({
|
|
|
511
551
|
onToggle()
|
|
512
552
|
}
|
|
513
553
|
}}
|
|
514
|
-
className={`group flex cursor-pointer items-
|
|
554
|
+
className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? SEVERITY_HEADER_BAND_CLASS[sev] : SEVERITY_RAIL_CLASS[sev]}`}
|
|
515
555
|
>
|
|
516
|
-
<
|
|
556
|
+
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${SEVERITY_TEXT_CLASS[sev]}`} aria-hidden />
|
|
517
557
|
|
|
518
558
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
519
559
|
<div className="flex flex-wrap items-center gap-2">
|
|
520
560
|
<span className="truncate text-sm font-semibold text-theme-text-primary">{title}</span>
|
|
521
|
-
<span className={`
|
|
561
|
+
<span className={`shrink-0 ${NEUTRAL_CHIP_CLASS}`}>{category}</span>
|
|
522
562
|
</div>
|
|
523
563
|
{description}
|
|
524
564
|
{summary}
|
|
525
565
|
</div>
|
|
526
566
|
|
|
527
|
-
<span className={`badge-sm
|
|
528
|
-
{SEVERITY_LABEL[
|
|
567
|
+
<span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? SEVERITY_SOLID_CLASS[sev] : SEVERITY_BADGE_CLASS[sev]}`}>
|
|
568
|
+
{SEVERITY_LABEL[sev]}
|
|
529
569
|
</span>
|
|
530
570
|
{renderActions?.()}
|
|
571
|
+
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
531
572
|
</div>
|
|
532
573
|
|
|
533
574
|
{/* Kept mounted (not `open &&`) so the grid-rows transition animates the
|
|
534
575
|
collapse too, matching IssueRow; inert when closed so SR + tab skip
|
|
535
|
-
the clipped content.
|
|
576
|
+
the clipped content. Body sits on the card surface (not a recessed grey
|
|
577
|
+
panel) so its text keeps enough contrast. */}
|
|
536
578
|
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
|
|
537
579
|
<div className="overflow-hidden" inert={!open || undefined}>
|
|
538
|
-
<div className="flex flex-col
|
|
580
|
+
<div className="flex flex-col divide-y divide-theme-border/70 border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">{children}</div>
|
|
539
581
|
</div>
|
|
540
582
|
</div>
|
|
541
583
|
</Container>
|
|
@@ -550,7 +592,9 @@ export interface CheckClusterBreakdownGroup {
|
|
|
550
592
|
}
|
|
551
593
|
|
|
552
594
|
export interface CheckClusterBreakdownShellProps<T extends CheckClusterBreakdownGroup> {
|
|
553
|
-
heading
|
|
595
|
+
/** Optional section heading. Omit when the caller already provides one (e.g.
|
|
596
|
+
* a wrapping CardSection owns the "Affected resources" eyebrow). */
|
|
597
|
+
heading?: ReactNode
|
|
554
598
|
groups: T[]
|
|
555
599
|
renderGroupBody: (group: T) => ReactNode
|
|
556
600
|
clusterCap?: number
|
|
@@ -573,7 +617,7 @@ export function CheckClusterBreakdownShell<T extends CheckClusterBreakdownGroup>
|
|
|
573
617
|
|
|
574
618
|
return (
|
|
575
619
|
<section className="flex flex-col gap-1.5">
|
|
576
|
-
<h4 className="text-[11px] font-semibold uppercase tracking-
|
|
620
|
+
{heading ? <h4 className="text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{heading}</h4> : null}
|
|
577
621
|
<ul className="flex flex-col gap-1">
|
|
578
622
|
{shown.map((group) => {
|
|
579
623
|
const isOpen = openClusters.has(group.id)
|
|
@@ -676,18 +720,17 @@ function FleetCheckRow({
|
|
|
676
720
|
>
|
|
677
721
|
<CheckRemediationBlock description={meta?.description} remediation={meta?.remediation} references={meta?.references} />
|
|
678
722
|
|
|
679
|
-
<
|
|
723
|
+
<CardSection
|
|
724
|
+
icon={Layers}
|
|
725
|
+
label="Affected resources"
|
|
726
|
+
labelExtra={single ? `· ${fc.totalResources}` : `· ${fc.totalResources} · ${clusterCount} clusters`}
|
|
727
|
+
>
|
|
680
728
|
{single ? (
|
|
681
|
-
<ResourceList
|
|
682
|
-
label={`Affected resources (${fc.totalResources})`}
|
|
683
|
-
check={fc.clusters[0]}
|
|
684
|
-
resourceHref={resourceHref}
|
|
685
|
-
onResourceClick={onResourceClick}
|
|
686
|
-
/>
|
|
729
|
+
<ResourceList check={fc.clusters[0]} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
687
730
|
) : (
|
|
688
731
|
<ClusterBreakdown fc={fc} clusterLabel={clusterLabel} resourceHref={resourceHref} onResourceClick={onResourceClick} />
|
|
689
732
|
)}
|
|
690
|
-
</
|
|
733
|
+
</CardSection>
|
|
691
734
|
</CheckCardShell>
|
|
692
735
|
)
|
|
693
736
|
}
|
|
@@ -709,11 +752,8 @@ function ClusterBreakdown({
|
|
|
709
752
|
}) {
|
|
710
753
|
return (
|
|
711
754
|
<CheckClusterBreakdownShell
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
Affected resources <span className="tabular-nums">({fc.totalResources})</span> · {fc.clusters.length} clusters
|
|
715
|
-
</>
|
|
716
|
-
}
|
|
755
|
+
// Heading omitted — the wrapping CardSection (Layers · "Affected
|
|
756
|
+
// resources") already owns the eyebrow.
|
|
717
757
|
groups={fc.clusters.map((c) => ({
|
|
718
758
|
id: c.subject.cluster_id,
|
|
719
759
|
label: <ClusterName name={clusterLabel?.(c) || c.subject.cluster_id} />,
|
|
@@ -811,7 +851,9 @@ function FindingLine({
|
|
|
811
851
|
{showMessage && <span className="ml-1 truncate text-xs text-theme-text-tertiary">{finding.message}</span>}
|
|
812
852
|
</>
|
|
813
853
|
)
|
|
814
|
-
|
|
854
|
+
// items-baseline so the smaller mono kind label shares a baseline with the
|
|
855
|
+
// larger resource name (their line-heights differ).
|
|
856
|
+
const cls = 'group/f flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60'
|
|
815
857
|
return (
|
|
816
858
|
<li>
|
|
817
859
|
{onResourceClick ? (
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import type { CheckSeverity } from './types'
|
|
2
2
|
import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge'
|
|
3
|
+
import {
|
|
4
|
+
TONE_FILL_CLASS,
|
|
5
|
+
TONE_HEADER_BAND_CLASS,
|
|
6
|
+
TONE_RAIL_CLASS,
|
|
7
|
+
TONE_SOLID_CLASS,
|
|
8
|
+
TONE_TEXT_CLASS,
|
|
9
|
+
type SeverityTone,
|
|
10
|
+
} from '../ui/severity-tone'
|
|
3
11
|
|
|
4
12
|
// The visual language for the 4-tier Checks severity ladder. One hue per tier:
|
|
5
13
|
// red=critical, orange=high, amber=medium, neutral=low — read the queue's left
|
|
6
|
-
// rail top-to-bottom and severity is obvious without reading a word.
|
|
14
|
+
// rail top-to-bottom and severity is obvious without reading a word. The actual
|
|
15
|
+
// color strings are shared with the Issue card via the tone module; here we only
|
|
16
|
+
// map each tier onto its tone.
|
|
17
|
+
const CHECK_SEVERITY_TONE: Record<CheckSeverity, SeverityTone> = {
|
|
18
|
+
critical: 'red',
|
|
19
|
+
high: 'orange',
|
|
20
|
+
medium: 'amber',
|
|
21
|
+
low: 'slate',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const byTone = <T,>(toneMap: Record<SeverityTone, T>): Record<CheckSeverity, T> => ({
|
|
25
|
+
critical: toneMap[CHECK_SEVERITY_TONE.critical],
|
|
26
|
+
high: toneMap[CHECK_SEVERITY_TONE.high],
|
|
27
|
+
medium: toneMap[CHECK_SEVERITY_TONE.medium],
|
|
28
|
+
low: toneMap[CHECK_SEVERITY_TONE.low],
|
|
29
|
+
})
|
|
7
30
|
|
|
8
31
|
export const SEVERITY_LABEL: Record<CheckSeverity, string> = {
|
|
9
32
|
critical: 'Critical',
|
|
@@ -23,41 +46,8 @@ export const SEVERITY_BADGE_CLASS: Record<CheckSeverity, string> = {
|
|
|
23
46
|
low: sev.neutral,
|
|
24
47
|
}
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
export const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
low: 'bg-slate-400',
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export const SEVERITY_TEXT_CLASS: Record<CheckSeverity, string> = {
|
|
35
|
-
critical: 'text-red-600 dark:text-red-400',
|
|
36
|
-
high: 'text-orange-600 dark:text-orange-400',
|
|
37
|
-
medium: 'text-amber-600 dark:text-amber-400',
|
|
38
|
-
low: 'text-slate-500 dark:text-slate-400',
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Left accent rail on a queue row — the scan-down severity cue. Pairs a colored
|
|
42
|
-
// 2px border with a faint severity-tinted background that deepens on hover.
|
|
43
|
-
export const SEVERITY_RAIL_CLASS: Record<CheckSeverity, string> = {
|
|
44
|
-
critical: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
|
|
45
|
-
high: 'border-l-orange-500 hover:bg-orange-50/40 dark:hover:bg-orange-950/20',
|
|
46
|
-
medium: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
|
|
47
|
-
low: 'border-l-slate-300 dark:border-l-slate-600 hover:bg-theme-hover/40',
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Category accent — a quiet tag (severity is the loud one). Security is the
|
|
51
|
-
// headline beat, so it gets the most distinct hue.
|
|
52
|
-
const CATEGORY_BADGE_CLASS: Record<string, string> = {
|
|
53
|
-
Security: 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
|
|
54
|
-
Reliability: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
|
|
55
|
-
Efficiency: 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 dark:bg-teal-950/40 dark:text-teal-300 dark:ring-teal-900',
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function categoryBadgeClass(category: string): string {
|
|
59
|
-
return (
|
|
60
|
-
CATEGORY_BADGE_CLASS[category] ??
|
|
61
|
-
'bg-theme-elevated text-theme-text-secondary ring-1 ring-theme-border'
|
|
62
|
-
)
|
|
63
|
-
}
|
|
49
|
+
export const SEVERITY_FILL_CLASS = byTone(TONE_FILL_CLASS)
|
|
50
|
+
export const SEVERITY_TEXT_CLASS = byTone(TONE_TEXT_CLASS)
|
|
51
|
+
export const SEVERITY_RAIL_CLASS = byTone(TONE_RAIL_CLASS)
|
|
52
|
+
export const SEVERITY_SOLID_CLASS = byTone(TONE_SOLID_CLASS)
|
|
53
|
+
export const SEVERITY_HEADER_BAND_CLASS = byTone(TONE_HEADER_BAND_CLASS)
|