@skyhook-io/k8s-ui 1.8.15 → 1.9.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.15",
3
+ "version": "1.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -278,6 +278,34 @@ describe("ApplicationDetail shell", () => {
278
278
  expect(html).not.toContain('Recording coverage is incomplete')
279
279
  })
280
280
 
281
+ it('reports ring truncation with its own copy, only in retained mode', () => {
282
+ const base = {
283
+ selectedView: 'history' as const,
284
+ onSelectView: () => {},
285
+ selectedWorkloadKey: null,
286
+ onSelectWorkload: () => {},
287
+ historyRange: '7d' as const,
288
+ historyRangeOptions: [{ value: '7d' as const, label: '7 days' }],
289
+ historyRingTruncated: true,
290
+ historyItems: [{
291
+ id: 'ring-change',
292
+ category: 'change' as const,
293
+ title: 'Deployment updated',
294
+ timestamp: '2026-07-13T09:00:00.000Z',
295
+ }],
296
+ }
297
+ const retained = renderDetail({ ...base, historyMode: 'retained' })
298
+ expect(retained).toContain('the retained window exceeded')
299
+ // Distinct cause, distinct copy — not the per-query 10k note.
300
+ expect(retained).not.toContain('Showing the 10,000 most recent events')
301
+
302
+ const local = renderDetail({ ...base, historyMode: 'local' })
303
+ expect(local).not.toContain('the retained window exceeded')
304
+ // Local can be ring-truncated too (the binary's 10k ring) - same warning,
305
+ // source-appropriate copy.
306
+ expect(local).toContain('ring is full, so the oldest activity is not loaded')
307
+ })
308
+
281
309
  it('shows a single honest error state when runtime history fails without source history', () => {
282
310
  const html = renderDetail({
283
311
  selectedView: 'history',
@@ -238,6 +238,9 @@ export type ApplicationDetailProps = {
238
238
  }>;
239
239
  historyCoverageRecordCount?: number;
240
240
  historyRuntimeLimited?: boolean;
241
+ // The retained ring itself was row-capped: the OLDEST part of the retention
242
+ // window is not loaded, independent of the per-query event limit.
243
+ historyRingTruncated?: boolean;
241
244
  onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
242
245
  onOpenTimeline?: (timestamp?: string) => void;
243
246
  onOpenSource?: (source: AppSourceRef) => void;
@@ -330,6 +333,7 @@ export function ApplicationDetail({
330
333
  historyRangeOptions,
331
334
  historyCoverageRecordCount,
332
335
  historyRuntimeLimited,
336
+ historyRingTruncated,
333
337
  onHistoryRangeChange,
334
338
  onOpenTimeline,
335
339
  onOpenSource,
@@ -750,6 +754,7 @@ export function ApplicationDetail({
750
754
  historyRangeOptions={historyRangeOptions}
751
755
  historyCoverageRecordCount={historyCoverageRecordCount}
752
756
  historyRuntimeLimited={historyRuntimeLimited}
757
+ historyRingTruncated={historyRingTruncated}
753
758
  onHistoryRangeChange={onHistoryRangeChange}
754
759
  onOpenTimeline={onOpenTimeline}
755
760
  onOpenSource={onOpenSource}
@@ -797,6 +802,7 @@ function ApplicationWorkspace({
797
802
  historyRangeOptions,
798
803
  historyCoverageRecordCount,
799
804
  historyRuntimeLimited,
805
+ historyRingTruncated,
800
806
  onHistoryRangeChange,
801
807
  onOpenTimeline,
802
808
  onOpenSource,
@@ -848,6 +854,7 @@ function ApplicationWorkspace({
848
854
  }>;
849
855
  historyCoverageRecordCount?: number;
850
856
  historyRuntimeLimited?: boolean;
857
+ historyRingTruncated?: boolean;
851
858
  onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
852
859
  onOpenTimeline?: (timestamp?: string) => void;
853
860
  onOpenSource?: (source: AppSourceRef) => void;
@@ -916,6 +923,7 @@ function ApplicationWorkspace({
916
923
  rangeOptions={historyRangeOptions}
917
924
  coverageRecordCount={historyCoverageRecordCount}
918
925
  runtimeLimited={historyRuntimeLimited}
926
+ ringTruncated={historyRingTruncated}
919
927
  workloads={workloads}
920
928
  onSelectWorkload={onSelectWorkload}
921
929
  onNavigateToResource={onNavigateToResource}
@@ -2146,6 +2154,7 @@ function ApplicationHistoryView({
2146
2154
  rangeOptions,
2147
2155
  coverageRecordCount,
2148
2156
  runtimeLimited,
2157
+ ringTruncated,
2149
2158
  workloads,
2150
2159
  onSelectWorkload,
2151
2160
  onNavigateToResource,
@@ -2163,6 +2172,7 @@ function ApplicationHistoryView({
2163
2172
  rangeOptions?: Array<{ value: ApplicationHistoryRange; label: string }>;
2164
2173
  coverageRecordCount?: number;
2165
2174
  runtimeLimited?: boolean;
2175
+ ringTruncated?: boolean;
2166
2176
  workloads: AppWorkload[];
2167
2177
  onSelectWorkload: (workload: AppWorkload) => void;
2168
2178
  onNavigateToResource?: (resource: ResourceRef) => void;
@@ -2372,6 +2382,13 @@ function ApplicationHistoryView({
2372
2382
  application activity in this range may not be included.
2373
2383
  </div>
2374
2384
  )}
2385
+ {ringTruncated && (
2386
+ <div>
2387
+ {mode === "retained"
2388
+ ? "Event history may be incomplete: the retained window exceeded its size limit, so the oldest activity is not loaded."
2389
+ : "Event history may be incomplete: the event store's ring is full, so the oldest activity is not loaded."}
2390
+ </div>
2391
+ )}
2375
2392
  </div>
2376
2393
  </section>
2377
2394
  </div>
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
2
  import { ClipboardCheck, ChevronRight, ShieldAlert, AlertTriangle, ArrowRight } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
4
+ import { BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
5
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
5
6
 
6
7
  export interface AuditFinding {
7
8
  kind: string
@@ -13,6 +14,7 @@ export interface AuditFinding {
13
14
  name: string
14
15
  checkID: string
15
16
  category: string
17
+ /** Raw detector compatibility value: danger renders as High, warning as Medium. */
16
18
  severity: string
17
19
  message: string
18
20
  /** Cluster context — set when findings come from multiple clusters
@@ -37,8 +39,8 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
37
39
 
38
40
  if (findings.length === 0) return null
39
41
 
40
- const dangers = findings.filter(f => f.severity === 'danger').length
41
- const warnings = findings.filter(f => f.severity === 'warning').length
42
+ const highCount = findings.filter(f => f.severity === 'danger').length
43
+ const mediumCount = findings.filter(f => f.severity === 'warning').length
42
44
 
43
45
  return (
44
46
  <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
@@ -51,11 +53,11 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
51
53
  <ClipboardCheck className="w-4 h-4 text-theme-text-secondary" />
52
54
  <span className="text-sm font-semibold text-theme-text-primary">Audit Findings</span>
53
55
  <div className="flex items-center gap-2 ml-1">
54
- {dangers > 0 && (
55
- <span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT.error)}>{dangers} critical</span>
56
+ {highCount > 0 && (
57
+ <span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT_CLASS.high)}>{highCount} high</span>
56
58
  )}
57
- {warnings > 0 && (
58
- <span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT.warning)}>{warnings} warning</span>
59
+ {mediumCount > 0 && (
60
+ <span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT_CLASS.medium)}>{mediumCount} medium</span>
59
61
  )}
60
62
  </div>
61
63
  </button>
@@ -67,13 +69,13 @@ export function AuditAlerts({ findings, onViewAll }: AuditAlertsProps) {
67
69
  <div className="overflow-hidden">
68
70
  <div className="flex flex-col gap-0.5 pt-3">
69
71
  {findings.map((f, i) => {
70
- const isDanger = f.severity === 'danger'
72
+ const isHigh = f.severity === 'danger'
71
73
  return (
72
74
  <div key={`${f.checkID}-${i}`} className="flex items-start gap-2 py-1">
73
- {isDanger ? (
74
- <ShieldAlert className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT.error)} />
75
+ {isHigh ? (
76
+ <ShieldAlert className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT_CLASS.high)} />
75
77
  ) : (
76
- <AlertTriangle className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT.warning)} />
78
+ <AlertTriangle className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5', SEVERITY_TEXT_CLASS.medium)} />
77
79
  )}
78
80
  <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
79
81
  <span className="text-xs text-theme-text-secondary">{f.message}</span>
@@ -23,6 +23,12 @@ describe('AuditBadgeTooltip', () => {
23
23
  expect(html).not.toContain('A fourth finding')
24
24
  })
25
25
 
26
+ it('sorts High first before applying the cap', () => {
27
+ const html = renderToString(<AuditBadgeTooltip messages={msgs} max={1} />)
28
+ expect(html).toContain('Ingress references missing Service')
29
+ expect(html).not.toContain('Service selector matches no pods')
30
+ })
31
+
26
32
  it('shows the click hint by default and omits it when disabled', () => {
27
33
  expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} />)).toContain('Click to open')
28
34
  expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} clickHint={false} />)).not.toContain('Click to open')
@@ -1,8 +1,9 @@
1
1
  import { ShieldAlert, AlertTriangle } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { SEVERITY_TEXT } from '../../utils/badge-colors'
3
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
4
4
 
5
5
  export interface AuditBadgeMessage {
6
+ /** Raw detector compatibility value: danger renders as High, warning as Medium. */
6
7
  severity: string
7
8
  message: string
8
9
  }
@@ -17,21 +18,23 @@ interface AuditBadgeTooltipProps {
17
18
 
18
19
  /**
19
20
  * Inline tooltip body for an audit badge: lists the actual finding messages
20
- * (danger-first) so the operator reads WHAT is wrong on hover, instead of a
21
+ * (High-first) so the operator reads WHAT is wrong on hover, instead of a
21
22
  * content-free "N findings". Shared by the resource-list and topology-node
22
23
  * badges so the two can't drift.
23
24
  */
24
25
  export function AuditBadgeTooltip({ messages, max = 3, clickHint = true }: AuditBadgeTooltipProps) {
25
- const shown = messages.slice(0, max)
26
+ const shown = [...messages]
27
+ .sort((a, b) => Number(b.severity === 'danger') - Number(a.severity === 'danger'))
28
+ .slice(0, max)
26
29
  const overflow = messages.length - shown.length
27
30
  return (
28
31
  <div className="flex flex-col gap-1 text-left">
29
32
  {shown.map((m, i) => {
30
- const isDanger = m.severity === 'danger'
31
- const Icon = isDanger ? ShieldAlert : AlertTriangle
33
+ const isHigh = m.severity === 'danger'
34
+ const Icon = isHigh ? ShieldAlert : AlertTriangle
32
35
  return (
33
36
  <div key={i} className="flex items-start gap-1.5">
34
- <Icon className={clsx('w-3 h-3 shrink-0 mt-0.5', isDanger ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
37
+ <Icon className={clsx('w-3 h-3 shrink-0 mt-0.5', isHigh ? SEVERITY_TEXT_CLASS.high : SEVERITY_TEXT_CLASS.medium)} />
35
38
  <span>{m.message}</span>
36
39
  </div>
37
40
  )
@@ -1,11 +1,14 @@
1
1
  import { ClipboardCheck, ArrowRight, Check } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { SEVERITY_TEXT, SEVERITY_DOT } from '../../utils/badge-colors'
4
+ import { SEVERITY_FILL_CLASS, SEVERITY_TEXT_CLASS } from '../checks/severity'
4
5
 
5
6
  export interface AuditCardData {
6
7
  passing: number
8
+ /** Raw compatibility counts; warning renders as Medium and danger as High. */
7
9
  warning: number
8
10
  danger: number
11
+ /** Nested warning/danger counts follow the same Medium/High mapping. */
9
12
  categories: Record<string, { passing: number; warning: number; danger: number }>
10
13
  }
11
14
 
@@ -14,20 +17,19 @@ interface AuditCardProps {
14
17
  onNavigate: () => void
15
18
  }
16
19
 
17
- type SeverityLevel = 'success' | 'warning' | 'error'
20
+ type SeverityLevel = 'success' | 'high' | 'medium'
18
21
 
19
22
  function getSeverityLevel(data: AuditCardData): SeverityLevel {
20
23
  if (data.warning + data.danger === 0) return 'success'
21
- const dangerRatio = data.danger / (data.warning + data.danger)
22
- if (dangerRatio > 0.2) return 'error'
23
- return 'warning'
24
+ const highRatio = data.danger / (data.warning + data.danger)
25
+ if (highRatio > 0.2) return 'high'
26
+ return 'medium'
24
27
  }
25
28
 
26
- // Card-specific accent backgrounds (light opacity variants, work in both themes)
27
29
  const ACCENT_BG: Record<SeverityLevel, string> = {
28
30
  success: 'bg-green-500/10',
29
- warning: 'bg-yellow-500/10',
30
- error: 'bg-red-500/10',
31
+ high: 'bg-orange-500/10',
32
+ medium: 'bg-yellow-500/10',
31
33
  }
32
34
 
33
35
  export function AuditCard({ data, onNavigate }: AuditCardProps) {
@@ -35,7 +37,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
35
37
  const issueCount = data.warning + data.danger
36
38
  const allPassing = issueCount === 0
37
39
  const level = getSeverityLevel(data)
38
- const accentColor = SEVERITY_TEXT[level]
40
+ const accentColor = level === 'success' ? SEVERITY_TEXT.success : SEVERITY_TEXT_CLASS[level]
39
41
  const accentBg = ACCENT_BG[level]
40
42
 
41
43
  return (
@@ -77,10 +79,10 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
77
79
  <div className="flex-1 h-3 rounded-full overflow-hidden bg-theme-hover flex">
78
80
  <div className={clsx('h-full', SEVERITY_DOT.success)} style={{ width: `${(data.passing / total) * 100}%` }} />
79
81
  {data.warning > 0 && (
80
- <div className={clsx('h-full', SEVERITY_DOT.warning)} style={{ width: `${(data.warning / total) * 100}%` }} />
82
+ <div className={clsx('h-full', SEVERITY_FILL_CLASS.medium)} style={{ width: `${(data.warning / total) * 100}%` }} />
81
83
  )}
82
84
  {data.danger > 0 && (
83
- <div className={clsx('h-full', SEVERITY_DOT.error)} style={{ width: `${(data.danger / total) * 100}%` }} />
85
+ <div className={clsx('h-full', SEVERITY_FILL_CLASS.high)} style={{ width: `${(data.danger / total) * 100}%` }} />
84
86
  )}
85
87
  </div>
86
88
  </div>
@@ -90,7 +92,7 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
90
92
  <div className="grid grid-cols-1 gap-y-2 mt-4 w-full">
91
93
  {Object.entries(data.categories).map(([category, counts]) => {
92
94
  const catIssues = counts.warning + counts.danger
93
- const dotColor = counts.danger > 0 ? SEVERITY_DOT.error : counts.warning > 0 ? SEVERITY_DOT.warning : SEVERITY_DOT.success
95
+ const dotColor = counts.danger > 0 ? SEVERITY_FILL_CLASS.high : counts.warning > 0 ? SEVERITY_FILL_CLASS.medium : SEVERITY_DOT.success
94
96
  return (
95
97
  <div key={category} className="flex items-center gap-2">
96
98
  <span className={clsx('w-2 h-2 rounded-full shrink-0', dotColor)} />
@@ -98,10 +100,10 @@ export function AuditCard({ data, onNavigate }: AuditCardProps) {
98
100
  {catIssues > 0 ? (
99
101
  <div className="flex items-center gap-2">
100
102
  {counts.danger > 0 && (
101
- <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.error)}>{counts.danger} critical</span>
103
+ <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT_CLASS.high)}>{counts.danger} high</span>
102
104
  )}
103
105
  {counts.warning > 0 && (
104
- <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.warning)}>{counts.warning} warning</span>
106
+ <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT_CLASS.medium)}>{counts.warning} medium</span>
105
107
  )}
106
108
  </div>
107
109
  ) : (
@@ -2,19 +2,21 @@ import { useState, useMemo, useRef, useEffect, type Dispatch, type SetStateActio
2
2
  import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { AuditFinding } from './AuditAlerts'
5
- import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
5
+ import { BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
6
6
  import { EmptyState } from '../ui/EmptyState'
7
7
  import { SearchBox } from '../ui/SearchBox'
8
8
  import { FilterPill } from '../ui/FilterPill'
9
9
  import { pluralize } from '../../utils/pluralize'
10
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
10
11
 
11
12
  const CATEGORIES = ['Security', 'Reliability', 'Efficiency'] as const
12
- const SEVERITIES = ['danger', 'warning'] as const
13
+ const RAW_SEVERITIES = ['danger', 'warning'] as const
13
14
 
14
15
  export interface ResourceGroup {
15
16
  kind: string
16
17
  namespace: string
17
18
  name: string
19
+ /** Raw compatibility counts; warning renders as Medium and danger as High. */
18
20
  warning: number
19
21
  danger: number
20
22
  findings: AuditFinding[]
@@ -94,8 +96,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
94
96
  return findings ?? []
95
97
  }, [groups, findings])
96
98
 
97
- const totalDangerCount = allFindings.filter(f => f.severity === 'danger').length
98
- const totalWarningCount = allFindings.filter(f => f.severity === 'warning').length
99
+ const totalHighCount = allFindings.filter(f => f.severity === 'danger').length
100
+ const totalMediumCount = allFindings.filter(f => f.severity === 'warning').length
99
101
 
100
102
  // Derive available frameworks from checks metadata
101
103
  const frameworks = useMemo(() => {
@@ -166,8 +168,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
166
168
  const filteredAllFindings = filteredGroups
167
169
  ? filteredGroups.flatMap(g => g.findings)
168
170
  : filteredFindings ?? []
169
- const dangerCount = hasActiveFilters ? filteredAllFindings.filter(f => f.severity === 'danger').length : totalDangerCount
170
- const warningCount = hasActiveFilters ? filteredAllFindings.filter(f => f.severity === 'warning').length : totalWarningCount
171
+ const highCount = hasActiveFilters ? filteredAllFindings.filter(f => f.severity === 'danger').length : totalHighCount
172
+ const mediumCount = hasActiveFilters ? filteredAllFindings.filter(f => f.severity === 'warning').length : totalMediumCount
171
173
 
172
174
  // Group resources by namespace when enabled
173
175
  const namespacedGroups = useMemo(() => {
@@ -181,9 +183,9 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
181
183
  }
182
184
  // Sort namespaces: most severe first
183
185
  return Array.from(nsMap.entries()).sort((a, b) => {
184
- const aDanger = a[1].reduce((n, g) => n + g.danger, 0)
185
- const bDanger = b[1].reduce((n, g) => n + g.danger, 0)
186
- if (aDanger !== bDanger) return bDanger - aDanger
186
+ const aHigh = a[1].reduce((n, g) => n + g.danger, 0)
187
+ const bHigh = b[1].reduce((n, g) => n + g.danger, 0)
188
+ if (aHigh !== bHigh) return bHigh - aHigh
187
189
  return a[0].localeCompare(b[0])
188
190
  })
189
191
  }, [groupByNS, filteredGroups])
@@ -231,8 +233,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
231
233
  <div className="flex flex-col gap-2 px-4 py-3 border-b border-theme-border bg-theme-base rounded-xl shrink-0">
232
234
  {/* Row 1: Counts + Search + View toggle */}
233
235
  <div className="flex items-center gap-4">
234
- <SummaryBadge label="Critical" count={dangerCount} color={SEVERITY_TEXT.error} />
235
- <SummaryBadge label="Warning" count={warningCount} color={SEVERITY_TEXT.warning} />
236
+ <SummaryBadge label="High" count={highCount} color={SEVERITY_TEXT_CLASS.high} />
237
+ <SummaryBadge label="Medium" count={mediumCount} color={SEVERITY_TEXT_CLASS.medium} />
236
238
 
237
239
  <SearchBox value={searchTerm} onChange={setSearchTerm} scope="audit" shortcutId="audit-search" className="w-64" />
238
240
 
@@ -264,12 +266,12 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
264
266
  <FilterPill key={cat} label={cat} active={categoryFilter.has(cat)} onClick={() => toggleInSet(setCategoryFilter, cat)} />
265
267
  ))}
266
268
  <span className="w-px h-5 bg-theme-border mx-2" />
267
- {SEVERITIES.map(sev => (
269
+ {RAW_SEVERITIES.map(sev => (
268
270
  <FilterPill
269
271
  key={sev}
270
- label={sev === 'danger' ? 'Critical' : 'Warning'}
272
+ label={sev === 'danger' ? 'High' : 'Medium'}
271
273
  active={severityFilter.has(sev)}
272
- tone={sev === 'danger' ? 'danger' : 'warn'}
274
+ tone={sev === 'danger' ? 'high' : 'medium'}
273
275
  onClick={() => toggleInSet(setSeverityFilter, sev)}
274
276
  />
275
277
  ))}
@@ -318,8 +320,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
318
320
  <div className="flex flex-col gap-1">
319
321
  {namespacedGroups.map(([ns, nsGroups]) => {
320
322
  const nsExpanded = expandedNS.has(ns)
321
- const nsDanger = nsGroups.reduce((n, g) => n + g.danger, 0)
322
- const nsWarning = nsGroups.reduce((n, g) => n + g.warning, 0)
323
+ const nsHigh = nsGroups.reduce((n, g) => n + g.danger, 0)
324
+ const nsMedium = nsGroups.reduce((n, g) => n + g.warning, 0)
323
325
  return (
324
326
  <div key={ns}>
325
327
  <div
@@ -338,8 +340,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
338
340
  <span className="text-xs text-theme-text-tertiary">{pluralize(nsGroups.length, 'resource')}</span>
339
341
  <span className="flex-1" />
340
342
  <div className="flex items-center gap-3 shrink-0">
341
- {nsDanger > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.error)}>{nsDanger} critical</span>}
342
- {nsWarning > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.warning)}>{nsWarning} warning</span>}
343
+ {nsHigh > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT_CLASS.high)}>{nsHigh} high</span>}
344
+ {nsMedium > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT_CLASS.medium)}>{nsMedium} medium</span>}
343
345
  </div>
344
346
  {onHideNamespace && ns !== '(cluster-scoped)' && (
345
347
  <ContextMenu items={[{ label: `Hide ${ns} namespace`, onClick: () => onHideNamespace(ns) }]} />
@@ -392,7 +394,7 @@ function FindingDetail({ finding, meta, onHideCheck, onHideCategory }: {
392
394
  onHideCheck?: (checkID: string, title: string) => void
393
395
  onHideCategory?: (category: string) => void
394
396
  }) {
395
- const isDanger = finding.severity === 'danger'
397
+ const isHigh = finding.severity === 'danger'
396
398
  const menuItems: ContextMenuItem[] = []
397
399
  if (onHideCheck) {
398
400
  menuItems.push({ label: `Hide "${meta?.title || finding.checkID}" check`, onClick: () => onHideCheck(finding.checkID, meta?.title || finding.checkID) })
@@ -404,10 +406,10 @@ function FindingDetail({ finding, meta, onHideCheck, onHideCategory }: {
404
406
  return (
405
407
  <div className="flex flex-col gap-0.5 px-3 py-2 rounded group/finding">
406
408
  <div className="flex items-center gap-3">
407
- {isDanger ? (
408
- <ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.error)} />
409
+ {isHigh ? (
410
+ <ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT_CLASS.high)} />
409
411
  ) : (
410
- <AlertTriangle className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.warning)} />
412
+ <AlertTriangle className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT_CLASS.medium)} />
411
413
  )}
412
414
  <span className="text-sm text-theme-text-primary flex-1 min-w-0">{finding.message}</span>
413
415
  <span className={clsx('badge-sm text-[10px]', BP_CATEGORY_BADGE[finding.category] || DEFAULT_BADGE_COLOR)}>
@@ -438,7 +440,7 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
438
440
  }) {
439
441
  const key = `${g.kind}/${g.namespace}/${g.name}`
440
442
  const isExpanded = expanded.has(key)
441
- const hasDanger = g.danger > 0
443
+ const hasHigh = g.danger > 0
442
444
 
443
445
  return (
444
446
  <div>
@@ -454,10 +456,10 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
454
456
  className="group flex items-center gap-3 w-full px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
455
457
  >
456
458
  <ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary shrink-0 transition-transform duration-200', isExpanded && 'rotate-90')} />
457
- {hasDanger ? (
458
- <ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.error)} />
459
+ {hasHigh ? (
460
+ <ShieldAlert className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT_CLASS.high)} />
459
461
  ) : (
460
- <AlertTriangle className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT.warning)} />
462
+ <AlertTriangle className={clsx('w-4 h-4 shrink-0', SEVERITY_TEXT_CLASS.medium)} />
461
463
  )}
462
464
  <span className="text-xs text-theme-text-tertiary shrink-0">{g.kind}</span>
463
465
  {onResourceClick ? (
@@ -476,8 +478,8 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
476
478
  )}
477
479
  <span className="flex-1" />
478
480
  <div className="flex items-center gap-3 shrink-0">
479
- {g.danger > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.error)}>{g.danger} critical</span>}
480
- {g.warning > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT.warning)}>{g.warning} warning</span>}
481
+ {g.danger > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT_CLASS.high)}>{g.danger} high</span>}
482
+ {g.warning > 0 && <span className={clsx('text-xs font-semibold tabular-nums', SEVERITY_TEXT_CLASS.medium)}>{g.warning} medium</span>}
481
483
  </div>
482
484
  {showNamespace && onHideNamespace && g.namespace && (
483
485
  <ContextMenu items={[{ label: `Hide ${g.namespace} namespace`, onClick: () => onHideNamespace(g.namespace) }]} />
@@ -500,12 +502,12 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
500
502
  }
501
503
 
502
504
  function FlatFindingRow({ finding, onResourceClick, showCluster, onClusterClick }: { finding: AuditFinding; onResourceClick?: (kind: string, namespace: string, name: string) => void; showCluster?: boolean; onClusterClick?: (clusterId: string) => void }) {
503
- const isDanger = finding.severity === 'danger'
504
- const severityColor = isDanger ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning
505
+ const isHigh = finding.severity === 'danger'
506
+ const severityColor = isHigh ? SEVERITY_TEXT_CLASS.high : SEVERITY_TEXT_CLASS.medium
505
507
 
506
508
  return (
507
509
  <div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors">
508
- {isDanger ? (
510
+ {isHigh ? (
509
511
  <ShieldAlert className={clsx('w-4 h-4 shrink-0', severityColor)} />
510
512
  ) : (
511
513
  <AlertTriangle className={clsx('w-4 h-4 shrink-0', severityColor)} />
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { AuditAlerts, type AuditFinding } from './AuditAlerts'
4
+ import { AuditCard } from './AuditCard'
5
+ import { AuditFindingsTable } from './AuditFindingsTable'
6
+
7
+ const findings: AuditFinding[] = [
8
+ {
9
+ kind: 'Deployment',
10
+ namespace: 'prod',
11
+ name: 'api',
12
+ checkID: 'privileged',
13
+ category: 'Security',
14
+ severity: 'danger',
15
+ message: 'Container is privileged',
16
+ },
17
+ {
18
+ kind: 'Deployment',
19
+ namespace: 'prod',
20
+ name: 'api',
21
+ checkID: 'cpuRequestMissing',
22
+ category: 'Efficiency',
23
+ severity: 'warning',
24
+ message: 'CPU request is missing',
25
+ },
26
+ ]
27
+
28
+ describe('legacy audit severity presentation', () => {
29
+ it('labels raw summary counts as High and Medium', () => {
30
+ const html = renderToString(
31
+ <AuditCard
32
+ data={{
33
+ passing: 3,
34
+ danger: 1,
35
+ warning: 2,
36
+ categories: {
37
+ Security: { passing: 1, danger: 1, warning: 0 },
38
+ Efficiency: { passing: 2, danger: 0, warning: 2 },
39
+ },
40
+ }}
41
+ onNavigate={() => {}}
42
+ />,
43
+ )
44
+ expect(html).toMatch(/1(?:<!-- -->)? high/)
45
+ expect(html).toMatch(/2(?:<!-- -->)? medium/)
46
+ expect(html).toContain('bg-orange-500/10')
47
+ expect(html).not.toContain('critical')
48
+ })
49
+
50
+ it('uses High and Medium in resource audit summaries', () => {
51
+ const html = renderToString(<AuditAlerts findings={findings} />)
52
+ expect(html).toMatch(/1(?:<!-- -->)? high/)
53
+ expect(html).toMatch(/1(?:<!-- -->)? medium/)
54
+ expect(html).not.toContain('critical')
55
+ })
56
+
57
+ it('uses High and Medium in audit totals and filters', () => {
58
+ const html = renderToString(<AuditFindingsTable findings={findings} />)
59
+ expect(html).toContain('High')
60
+ expect(html).toContain('Medium')
61
+ expect(html).not.toContain('Critical')
62
+ expect(html).not.toContain('Warning')
63
+ })
64
+ })
@@ -7,12 +7,12 @@
7
7
  //
8
8
  // Mirrors Radar OSS's resource-key convention (radar/pkg/audit.ResourceKey).
9
9
 
10
- /** Canonical Checks severity ladder distinct from the raw detector severity
11
- * (danger/warning) so operational criticality and compliance risk stay
12
- * separate axes. */
10
+ /** Canonical user-facing Checks severity ladder. Live issues remain a separate
11
+ * operational-health axis even where the same severity word appears. */
13
12
  export type CheckSeverity = 'critical' | 'high' | 'medium' | 'low';
14
13
 
15
- /** Raw detector severity Radar emits. */
14
+ /** Raw detector values retained by existing audit transport/public-prop
15
+ * compatibility contracts. Checks presentation maps them before display. */
16
16
  export type RadarSeverity = 'danger' | 'warning';
17
17
 
18
18
  /** Ordered worst→least, for rendering severity filters/sorts consistently. */
@@ -128,6 +128,10 @@ export interface IssueRecentChange {
128
128
  timestamp: string;
129
129
  change_category?: 'spec_config' | 'lifecycle' | 'runtime_status' | string;
130
130
  rank_reason?: string;
131
+ /** Ranking hint for workload runtime configuration (including the image) or directly consumed ConfigMap data; not a causal claim. */
132
+ application_configuration_change?: boolean;
133
+ /** Set only on top-level recent_changes in an eligible, unfiltered issues response with complete linkage evidence. */
134
+ not_linked_to_returned_issues?: boolean;
131
135
  fields?: IssueRecentChangeField[];
132
136
  /** Workloads that mount/reference this ConfigMap directly ("Deployment/flagd").
133
137
  * Direct spec references only — runtime consumers via an intermediary
@@ -133,13 +133,14 @@ import {
133
133
  podMatchesProblemCategory,
134
134
  SEVERITY_DOT_COLOR,
135
135
  } from './resource-utils'
136
- import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
136
+ import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
137
137
  import { pluralize } from '../../utils/pluralize'
138
138
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
139
139
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
140
140
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
141
141
  import { Tooltip } from '../ui/Tooltip'
142
142
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
143
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
143
144
  // CRD-specific cell components (extracted)
144
145
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
145
146
  import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
@@ -1829,8 +1830,8 @@ interface ResourcesViewData {
1829
1830
  onNavigate?: (path: string, options?: { replace?: boolean }) => void
1830
1831
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1831
1832
  certExpiryError?: boolean
1832
- // Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
1833
- // list shows one kind at a time, so ns/name is unambiguous). Host-injected.
1833
+ /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
1834
+ * counts render danger as High and warning as Medium. */
1834
1835
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1835
1836
  onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
1836
1837
  onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
@@ -1889,7 +1890,8 @@ interface ResourcesViewProps {
1889
1890
  topNodeMetrics?: TopNodeMetrics[]
1890
1891
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1891
1892
  certExpiryError?: boolean
1892
- // Cluster Audit findings for the selected kind, keyed by "namespace/name".
1893
+ /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
1894
+ * counts render danger as High and warning as Medium. */
1893
1895
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1894
1896
  // Pinned kinds
1895
1897
  pinned?: Array<{ name: string; kind: string; group: string }>
@@ -5381,8 +5383,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5381
5383
  {auditTotal > 0 && audit && (
5382
5384
  <Tooltip content={audit.messages && audit.messages.length > 0
5383
5385
  ? <AuditBadgeTooltip messages={audit.messages} />
5384
- : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
5385
- <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>
5386
+ : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} high` : ''}${audit.warning > 0 ? ` · ${audit.warning} medium` : ''}`}>
5387
+ <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT_CLASS.high : SEVERITY_TEXT_CLASS.medium)}>
5386
5388
  <AlertTriangle className="w-3 h-3" />
5387
5389
  {auditTotal}
5388
5390
  </span>
@@ -6265,7 +6267,7 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
6265
6267
  const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
6266
6268
  if (flagged && flagged.danger + flagged.warning > 0) {
6267
6269
  return (
6268
- <span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
6270
+ <span className={clsx('badge', flagged.danger > 0 ? 'status-alert' : 'status-degraded')}>
6269
6271
  No endpoints
6270
6272
  </span>
6271
6273
  )
@@ -1077,16 +1077,29 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1077
1077
  return { start, end, windowMs, now: effectiveNow }
1078
1078
  }, [zoom, panOffset, effectiveNow, viewWindow])
1079
1079
 
1080
+ // The visible window WITHOUT the live-`now` field. Keyed on the primitive
1081
+ // bounds so its identity changes only when the window actually moves — a live
1082
+ // `now` tick (30s) advances `visibleTimeRange.now` for the now-line but must
1083
+ // NOT invalidate the lane-ordering / window-clip memos below, which only care
1084
+ // about [start,end]. This stops the heavy build from re-running on each tick
1085
+ // while the window is static — the controlled retained lens or a paused view.
1086
+ // (In uncontrolled live mode start/end track `effectiveNow`, so they advance
1087
+ // every tick regardless; the win is for the static-window cases this targets.)
1088
+ const visibleWindow = useMemo(
1089
+ () => ({ start: visibleTimeRange.start, end: visibleTimeRange.end, windowMs: visibleTimeRange.windowMs }),
1090
+ [visibleTimeRange.start, visibleTimeRange.end, visibleTimeRange.windowMs],
1091
+ )
1092
+
1080
1093
  // Apply the chosen ordering to the top-level lanes. 'recent' needs the visible
1081
1094
  // window (newest-in-view first), so this lives after visibleTimeRange. The
1082
1095
  // pinned section is ordered separately (strict pin order) and never flows here.
1083
1096
  const orderedLanes = useMemo(
1084
1097
  () => sortTimelineLanes(lanes, sort, {
1085
- windowStart: visibleTimeRange.start,
1086
- windowEnd: visibleTimeRange.end,
1098
+ windowStart: visibleWindow.start,
1099
+ windowEnd: visibleWindow.end,
1087
1100
  scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
1088
1101
  }),
1089
- [lanes, sort, visibleTimeRange],
1102
+ [lanes, sort, visibleWindow],
1090
1103
  )
1091
1104
 
1092
1105
  // Live-mode order hysteresis. The importance rank recomputes every poll (recency
@@ -1147,17 +1160,17 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1147
1160
  // "events" count so it's view-scoped like "resources" — a lens sitting in a
1148
1161
  // recording gap reads "0 resources · 0 events", not "0 resources · N events".
1149
1162
  const eventsInWindow = useMemo(() => {
1150
- const { start, end } = visibleTimeRange
1163
+ const { start, end } = visibleWindow
1151
1164
  return filteredEvents.filter((e) => {
1152
1165
  const t = new Date(e.timestamp).getTime()
1153
1166
  return t >= start && t <= end
1154
1167
  })
1155
- }, [filteredEvents, visibleTimeRange])
1168
+ }, [filteredEvents, visibleWindow])
1156
1169
 
1157
1170
  // Recording gaps clipped to the visible window, for the hatched lane bands.
1158
1171
  const visibleGaps = useMemo(() => {
1159
1172
  if (!gaps || gaps.length === 0) return []
1160
- const { start, end } = visibleTimeRange
1173
+ const { start, end } = visibleWindow
1161
1174
  const out: TimeWindow[] = []
1162
1175
  for (const g of gaps) {
1163
1176
  const fromMs = Math.max(g.fromMs, start)
@@ -1165,7 +1178,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1165
1178
  if (toMs > fromMs) out.push({ fromMs, toMs })
1166
1179
  }
1167
1180
  return out
1168
- }, [gaps, visibleTimeRange])
1181
+ }, [gaps, visibleWindow])
1169
1182
 
1170
1183
  // Pin MOVES a row: pinned lanes (and pinned children inside groups) leave
1171
1184
  // the regular list entirely — the pinned section is their only home.
@@ -1181,7 +1194,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1181
1194
 
1182
1195
  // Filter out lanes with no events in the visible time window
1183
1196
  const visibleLanes = useMemo(() => {
1184
- const { start, end } = visibleTimeRange
1197
+ const { start, end } = visibleWindow
1185
1198
  return unpinnedLanes.filter(lane => {
1186
1199
  const allLaneEvents = lane.allEventsSorted || []
1187
1200
  return allLaneEvents.some(e => {
@@ -1189,7 +1202,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1189
1202
  return t >= start && t <= end
1190
1203
  })
1191
1204
  })
1192
- }, [unpinnedLanes, visibleTimeRange])
1205
+ }, [unpinnedLanes, visibleWindow])
1193
1206
 
1194
1207
  // Pinned rows: resolved from the FULL lane list (pre-window-filter, any
1195
1208
  // grouping) so they stay put while the lens moves and even when they have no
@@ -1203,7 +1216,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1203
1216
  // Honest counts while filtered to pins: only pinned rows' in-window events.
1204
1217
  const pinnedEventsInWindow = useMemo(() => {
1205
1218
  if (pinnedLaneRows.length === 0) return 0
1206
- const { start, end } = visibleTimeRange
1219
+ const { start, end } = visibleWindow
1207
1220
  let n = 0
1208
1221
  for (const lane of pinnedLaneRows) {
1209
1222
  for (const e of lane.allEventsSorted || []) {
@@ -1212,7 +1225,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1212
1225
  }
1213
1226
  }
1214
1227
  return n
1215
- }, [pinnedLaneRows, visibleTimeRange])
1228
+ }, [pinnedLaneRows, visibleWindow])
1216
1229
 
1217
1230
  const pinnedIdSet = useMemo(() => new Set((pinnedLanes ?? []).map((p) => p.id)), [pinnedLanes])
1218
1231
  // A pin button for a lane, or null when the host wired no pin handler. A pinned
@@ -1236,7 +1249,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1236
1249
 
1237
1250
  // Generate time axis ticks
1238
1251
  const axisTicks = useMemo(() => {
1239
- const { start, end } = visibleTimeRange
1252
+ const { start, end } = visibleWindow
1240
1253
  const ticks: { time: number; label: string }[] = []
1241
1254
  const span = end - start
1242
1255
  if (span <= 0) return ticks
@@ -1262,18 +1275,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1262
1275
  }
1263
1276
 
1264
1277
  return ticks
1265
- }, [visibleTimeRange])
1278
+ }, [visibleWindow])
1266
1279
 
1267
1280
  // Convert timestamp to X position (0-100%)
1268
1281
  const timeToX = useCallback(
1269
1282
  (timestamp: number): number => {
1270
- const { start, windowMs } = visibleTimeRange
1283
+ const { start, windowMs } = visibleWindow
1271
1284
  // A zero-width window (host bounds with fromMs === toMs) would divide by
1272
1285
  // zero and emit NaN into `left:` CSS — pin everything to the left edge.
1273
1286
  if (windowMs <= 0) return 0
1274
1287
  return ((timestamp - start) / windowMs) * 100
1275
1288
  },
1276
- [visibleTimeRange]
1289
+ [visibleWindow]
1277
1290
  )
1278
1291
 
1279
1292
  // Vertical grid line positions (x-percent) shared by every lane backdrop.
@@ -20,8 +20,6 @@ export {
20
20
  advanceLatchedLens,
21
21
  deriveLiveSelection,
22
22
  isLensLatched,
23
- quantizeBaseWindow,
24
23
  LIVE_TICK_MS,
25
- BASE_QUANTIZE_STEP_MS,
26
24
  type TimelineLiveState,
27
25
  } from './timeline-live'
@@ -1,12 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
- BASE_QUANTIZE_STEP_MS,
4
3
  LENS_LATCH_EPSILON_MS,
5
4
  STALE_AMBER_AFTER_MS,
6
5
  advanceLatchedLens,
7
6
  deriveLiveSelection,
8
7
  isLensLatched,
9
- quantizeBaseWindow,
10
8
  } from './timeline-live'
11
9
  import { clampSelection, type ScrubberRange } from './scrubber-math'
12
10
 
@@ -20,41 +18,6 @@ describe('deriveLiveSelection', () => {
20
18
  })
21
19
  })
22
20
 
23
- describe('quantizeBaseWindow', () => {
24
- it('floors both edges to the step', () => {
25
- const step = BASE_QUANTIZE_STEP_MS
26
- const q = quantizeBaseWindow(step * 3 + 12_345, step * 10 + 4_000, step)
27
- expect(q).toEqual({ fromMs: step * 3, toMs: step * 10 })
28
- })
29
-
30
- it('is stable across two ticks inside the same step', () => {
31
- const step = BASE_QUANTIZE_STEP_MS
32
- const base = step * 100
33
- // Two "now" values 30s apart but inside the same 5-minute step.
34
- const a = quantizeBaseWindow(base - HOUR + 10_000, base + 10_000, step)
35
- const b = quantizeBaseWindow(base - HOUR + 40_000, base + 40_000, step)
36
- expect(a).toEqual(b)
37
- })
38
-
39
- it('advances once the edge crosses a step boundary', () => {
40
- const step = BASE_QUANTIZE_STEP_MS
41
- const before = quantizeBaseWindow(0, step - 1, step)
42
- const after = quantizeBaseWindow(0, step + 1, step)
43
- expect(before.toMs).toBe(0)
44
- expect(after.toMs).toBe(step)
45
- })
46
-
47
- it('trailing seam never exceeds one step (covered by the 10-min live poll)', () => {
48
- const step = BASE_QUANTIZE_STEP_MS
49
- const now = step * 42 + 137_000
50
- const q = quantizeBaseWindow(now - HOUR, now, step)
51
- // Gap between the quantized right edge and now is < one step, and one step
52
- // (5min) is well inside the 10-min live poll window ⇒ no data hole.
53
- expect(now - q.toMs).toBeLessThan(step)
54
- expect(step).toBeLessThan(10 * MIN)
55
- })
56
- })
57
-
58
21
  describe('isLensLatched', () => {
59
22
  const sel: ScrubberRange = { fromMs: 0, toMs: 100 * HOUR }
60
23
 
@@ -18,8 +18,8 @@ export type TimelineLiveState =
18
18
  | { kind: 'frozen'; asOfMs: number; newEventCount?: number }
19
19
 
20
20
  // Cadence the host slides a live selection at. Coarse on purpose: the precise
21
- // [from,to] is re-derived every tick, but the base fetch is quantized so the
22
- // query key only churns every QUANTIZE step (see quantizeBaseWindow).
21
+ // [from,to] is re-derived every tick without refetching data arrives via the
22
+ // source's own delta polling, so the tick only moves the visible window.
23
23
  export const LIVE_TICK_MS = 30_000
24
24
 
25
25
  // A frozen selection older than this reads as stale — the chip turns amber.
@@ -30,33 +30,11 @@ export const STALE_AMBER_AFTER_MS = 15 * 60_000
30
30
  // LIVE_TICK_MS so a lens that just slid with the tick still reads as latched.
31
31
  export const LENS_LATCH_EPSILON_MS = 60_000
32
32
 
33
- // Base-fetch quantization step. The react-query key is keyed on the quantized
34
- // window, so it only changes when the live edge crosses a 5-minute boundary —
35
- // the seam between the quantized edge and now is covered by the 10-minute live
36
- // poll (5min quantization lag < 10min poll window ⇒ no hole).
37
- export const BASE_QUANTIZE_STEP_MS = 5 * 60_000
38
-
39
33
  /** LIVE selection: a width pinned to now. */
40
34
  export function deriveLiveSelection(widthMs: number, nowMs: number): ScrubberRange {
41
35
  return { fromMs: nowMs - widthMs, toMs: nowMs }
42
36
  }
43
37
 
44
- /**
45
- * Quantize a sliding [from,to] down to fixed steps so the value is stable across
46
- * ticks within one step (identical output ⇒ stable react-query key). Both edges
47
- * floor to the step; the trailing seam to `now` is filled by the live poll.
48
- */
49
- export function quantizeBaseWindow(
50
- fromMs: number,
51
- toMs: number,
52
- stepMs: number = BASE_QUANTIZE_STEP_MS,
53
- ): ScrubberRange {
54
- return {
55
- fromMs: Math.floor(fromMs / stepMs) * stepMs,
56
- toMs: Math.floor(toMs / stepMs) * stepMs,
57
- }
58
- }
59
-
60
38
  /**
61
39
  * True when the lens rides the selection's live edge (so it should slide with
62
40
  * the tick). A lens the user dragged into the past sits further than epsilon
@@ -10,13 +10,14 @@ import {
10
10
  import { clsx } from 'clsx'
11
11
  import type { NodeKind, HealthStatus, PodSummary } from '../../types'
12
12
  import { displayKind } from '../../types'
13
- import { healthToSeverity, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
13
+ import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
14
14
  import { workloadHue } from '../../utils/workload-colors'
15
15
  import { ownershipOf } from '../../utils/topology-neighborhood'
16
16
  import { midTruncate } from '../../utils/format'
17
17
  import { getTopologyIcon } from '../../utils/resource-icons'
18
18
  import { Tooltip } from '../ui/Tooltip'
19
19
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
20
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
20
21
  import argoCdLogo from '../../assets/gitops/argocd.png'
21
22
  import fluxLogo from '../../assets/gitops/flux.svg'
22
23
 
@@ -464,12 +465,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
464
465
  // node's data by auditKey). The host only counts "badge-worthy" findings —
465
466
  // reference-integrity / lifecycle, "this resource is actually broken" — not the
466
467
  // posture/best-practice nags that fire near-universally, so the indicator stays
467
- // a signal. Colored by worst severity (danger red, else warning amber).
468
- const auditDanger =
468
+ // a signal. The counters retain the raw transport names, but presentation
469
+ // follows the canonical Checks severity ladder (High, then Medium).
470
+ const auditHigh =
469
471
  typeof nodeData.auditDanger === "number" ? nodeData.auditDanger : 0;
470
- const auditWarning =
472
+ const auditMedium =
471
473
  typeof nodeData.auditWarning === "number" ? nodeData.auditWarning : 0;
472
- const auditTotal = auditDanger + auditWarning;
474
+ const auditTotal = auditHigh + auditMedium;
473
475
  const auditMessages = Array.isArray(nodeData.auditMessages)
474
476
  ? (nodeData.auditMessages as AuditBadgeMessage[])
475
477
  : [];
@@ -652,7 +654,7 @@ export const K8sResourceNode = memo(function K8sResourceNode({
652
654
  clickHint={false}
653
655
  />
654
656
  ) : (
655
- `${auditTotal} audit ${auditTotal === 1 ? "finding" : "findings"}${auditDanger > 0 ? ` · ${auditDanger} danger` : ""}`
657
+ `${auditTotal} audit ${auditTotal === 1 ? "finding" : "findings"}${auditHigh > 0 ? ` · ${auditHigh} high` : ""}${auditMedium > 0 ? ` · ${auditMedium} medium` : ""}`
656
658
  )
657
659
  }
658
660
  position="right"
@@ -660,9 +662,9 @@ export const K8sResourceNode = memo(function K8sResourceNode({
660
662
  <TriangleAlert
661
663
  className={clsx(
662
664
  "w-3 h-3 cursor-help",
663
- auditDanger > 0
664
- ? SEVERITY_TEXT.error
665
- : SEVERITY_TEXT.warning,
665
+ auditHigh > 0
666
+ ? SEVERITY_TEXT_CLASS.high
667
+ : SEVERITY_TEXT_CLASS.medium,
666
668
  )}
667
669
  />
668
670
  </Tooltip>
@@ -22,7 +22,7 @@ import { Tooltip } from './Tooltip'
22
22
  // screen readers announce pressed/unpressed correctly. Optional tooltip
23
23
  // describes the toggle action ("Click to stop filtering by danger").
24
24
 
25
- export type FilterPillTone = 'neutral' | 'danger' | 'warn' | 'ok' | 'brand'
25
+ export type FilterPillTone = 'neutral' | 'danger' | 'warn' | 'high' | 'medium' | 'ok' | 'brand'
26
26
 
27
27
  interface Props {
28
28
  label: ReactNode
@@ -49,6 +49,8 @@ const TONE_ACTIVE: Record<FilterPillTone, string> = {
49
49
  neutral: 'bg-theme-text-primary/10 border-theme-text-primary/25 text-theme-text-primary',
50
50
  danger: 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300',
51
51
  warn: 'bg-amber-500/15 border-amber-500/40 text-amber-800 dark:text-amber-300',
52
+ high: 'bg-orange-500/15 border-orange-500/40 text-orange-800 dark:text-orange-300',
53
+ medium: 'bg-yellow-500/15 border-yellow-500/40 text-yellow-800 dark:text-yellow-300',
52
54
  ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
53
55
  brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
54
56
  }