@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsList.tsx +4 -1
  3. package/src/components/applications/ApplicationsView.tsx +73 -38
  4. package/src/components/checks/ChecksView.tsx +105 -63
  5. package/src/components/checks/severity.ts +29 -39
  6. package/src/components/gitops/GitOpsTableView.tsx +72 -43
  7. package/src/components/issues/IssuesView.tsx +186 -123
  8. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
  9. package/src/components/issues/ResourceIssuesSection.tsx +21 -3
  10. package/src/components/issues/index.ts +0 -2
  11. package/src/components/issues/issues.test.ts +45 -4
  12. package/src/components/issues/severity.ts +27 -32
  13. package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +24 -6
  15. package/src/components/resources/ResourcesView.tsx +10 -2
  16. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  17. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  19. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  20. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  23. package/src/components/ui/BoardSkeleton.tsx +47 -0
  24. package/src/components/ui/CardSection.tsx +117 -0
  25. package/src/components/ui/SummaryTile.tsx +9 -1
  26. package/src/components/ui/Toast.tsx +1 -1
  27. package/src/components/ui/index.ts +2 -0
  28. package/src/components/ui/severity-tone.ts +56 -0
  29. package/src/filter-state/filter-state-core.test.ts +98 -0
  30. package/src/filter-state/filter-state-core.ts +138 -0
  31. package/src/filter-state/filter-state.tsx +127 -0
  32. package/src/filter-state/index.ts +16 -0
  33. package/src/index.ts +4 -0
  34. package/src/theme/variables.css +14 -0
  35. package/src/utils/api-resources.test.ts +54 -0
  36. package/src/utils/api-resources.ts +68 -1
  37. package/tsconfig.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.8",
3
+ "version": "1.8.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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
- export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
105
- const [textFilter, setTextFilter] = useState('')
106
- const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
107
- const [fEnv, setFEnv] = useState<Set<string>>(new Set())
108
- const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
109
- const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
110
- const [fType, setFType] = useState<Set<AppCategory>>(new Set())
111
- const [showSystem, setShowSystem] = useState(false)
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
- counts.health[h] ? (
270
- <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => toggle(fHealth, setFHealth, h)} />
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 = !!(textFilter || fHealth.size || fClass.size || fType.size || fSource.size || fEnv.size || showSystem)
296
+ const anyFilterActive = filters.isActive
276
297
  const clearAllFilters = () => {
277
- setTextFilter('')
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
- <DistributionBar
310
- className="mt-3"
311
- ariaLabel="Health distribution"
312
- segments={HEALTH_ORDER.map((h) => ({ key: h, count: counts.health[h] ?? 0, fillClass: HEALTH_META[h].bar }))}
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
- <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) => toggle(fHealth, setFHealth, v)} />
330
- <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) => toggle(fClass, setFClass, v)} />
331
- <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) => toggle(fType, setFType, v)} />
332
- <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
333
- <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) => toggle(fSource, setFSource, v)} />
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) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
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={setTextFilter}
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
- {entries.length === 0 ? (
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
- 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('')
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 = severityFilter.size > 0 || categoryFilter.size > 0 || frameworkFilter.size > 0 || clusterFilter.size > 0 || search !== ''
245
+ const hasFilters = filters.isActive || clusterFilter.size > 0
213
246
  const clearAll = () => {
214
- setSeverityFilter(new Set())
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) => setSearch(e.target.value)}
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={() => setSearch('')}
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(setSeverityFilter, s)} />
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(setCategoryFilter, c)} />
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(setFrameworkFilter, fw)} />
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
- <div className="flex flex-col gap-4 md:flex-row md:gap-8">
423
- {remediation && (
424
- <section className="md:flex-1">
425
- <h4 className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--color-radar-accent)]">
426
- <Wrench className="h-3.5 w-3.5" /> How to fix
427
- </h4>
428
- <p className="text-sm leading-relaxed text-theme-text-primary">{remediation}</p>
429
- </section>
430
- )}
431
- {description && (
432
- <section className="md:flex-1">
433
- <h4 className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What this checks</h4>
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 border-theme-border bg-theme-surface shadow-theme-sm',
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-start 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[severity]}`}
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
- <ChevronRight className={`mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
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={`badge-sm shrink-0 text-[10px] ${categoryBadgeClass(category)}`}>{category}</span>
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 mt-0.5 shrink-0 text-[10px] font-semibold ${SEVERITY_BADGE_CLASS[severity]}`}>
528
- {SEVERITY_LABEL[severity]}
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 gap-4 border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">{children}</div>
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: ReactNode
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-wide text-theme-text-tertiary">{heading}</h4>
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
- <div className="border-t border-theme-border/70 pt-3">
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
- </div>
733
+ </CardSection>
691
734
  </CheckCardShell>
692
735
  )
693
736
  }
@@ -709,11 +752,8 @@ function ClusterBreakdown({
709
752
  }) {
710
753
  return (
711
754
  <CheckClusterBreakdownShell
712
- heading={
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
- 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'
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
- // Solid fill dots + the proportional distribution bar segments.
27
- export const SEVERITY_FILL_CLASS: Record<CheckSeverity, string> = {
28
- critical: 'bg-red-500',
29
- high: 'bg-orange-500',
30
- medium: 'bg-amber-500',
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)