@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 +1 -1
- package/src/components/applications/ApplicationDetail.test.tsx +28 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -0
- package/src/components/audit/AuditAlerts.tsx +13 -11
- package/src/components/audit/AuditBadgeTooltip.test.tsx +6 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +9 -6
- package/src/components/audit/AuditCard.tsx +15 -13
- package/src/components/audit/AuditFindingsTable.tsx +33 -31
- package/src/components/audit/AuditSeverityPresentation.test.tsx +64 -0
- package/src/components/checks/types.ts +4 -4
- package/src/components/issues/types.ts +4 -0
- package/src/components/resources/ResourcesView.tsx +9 -7
- package/src/components/timeline/TimelineSwimlanes.tsx +28 -15
- package/src/components/timeline/index.ts +0 -2
- package/src/components/timeline/timeline-live.test.ts +0 -37
- package/src/components/timeline/timeline-live.ts +2 -24
- package/src/components/topology/K8sResourceNode.tsx +11 -9
- package/src/components/ui/FilterPill.tsx +3 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
41
|
-
const
|
|
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
|
-
{
|
|
55
|
-
<span className={clsx('text-xs font-medium tabular-nums',
|
|
56
|
+
{highCount > 0 && (
|
|
57
|
+
<span className={clsx('text-xs font-medium tabular-nums', SEVERITY_TEXT_CLASS.high)}>{highCount} high</span>
|
|
56
58
|
)}
|
|
57
|
-
{
|
|
58
|
-
<span className={clsx('text-xs font-medium tabular-nums',
|
|
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
|
|
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
|
-
{
|
|
74
|
-
<ShieldAlert className={clsx('w-3.5 h-3.5 shrink-0 mt-0.5',
|
|
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',
|
|
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 {
|
|
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
|
-
* (
|
|
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
|
|
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
|
|
31
|
-
const Icon =
|
|
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',
|
|
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' | '
|
|
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
|
|
22
|
-
if (
|
|
23
|
-
return '
|
|
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
|
-
|
|
30
|
-
|
|
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',
|
|
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',
|
|
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 ?
|
|
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',
|
|
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',
|
|
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 {
|
|
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
|
|
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
|
|
98
|
-
const
|
|
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
|
|
170
|
-
const
|
|
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
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
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="
|
|
235
|
-
<SummaryBadge label="
|
|
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
|
-
{
|
|
269
|
+
{RAW_SEVERITIES.map(sev => (
|
|
268
270
|
<FilterPill
|
|
269
271
|
key={sev}
|
|
270
|
-
label={sev === 'danger' ? '
|
|
272
|
+
label={sev === 'danger' ? 'High' : 'Medium'}
|
|
271
273
|
active={severityFilter.has(sev)}
|
|
272
|
-
tone={sev === 'danger' ? '
|
|
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
|
|
322
|
-
const
|
|
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
|
-
{
|
|
342
|
-
{
|
|
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
|
|
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
|
-
{
|
|
408
|
-
<ShieldAlert className={clsx('w-4 h-4 shrink-0',
|
|
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',
|
|
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
|
|
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
|
-
{
|
|
458
|
-
<ShieldAlert className={clsx('w-4 h-4 shrink-0',
|
|
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',
|
|
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',
|
|
480
|
-
{g.warning > 0 && <span className={clsx('text-xs font-semibold tabular-nums',
|
|
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
|
|
504
|
-
const severityColor =
|
|
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
|
-
{
|
|
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
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
1833
|
-
|
|
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
|
-
|
|
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}
|
|
5385
|
-
<span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ?
|
|
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-
|
|
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:
|
|
1086
|
-
windowEnd:
|
|
1098
|
+
windowStart: visibleWindow.start,
|
|
1099
|
+
windowEnd: visibleWindow.end,
|
|
1087
1100
|
scoreOf: (lane) => lane.scoreBreakdown?.total ?? calculateInterestingness(lane),
|
|
1088
1101
|
}),
|
|
1089
|
-
[lanes, sort,
|
|
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 } =
|
|
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,
|
|
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 } =
|
|
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,
|
|
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 } =
|
|
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,
|
|
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 } =
|
|
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,
|
|
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 } =
|
|
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
|
-
}, [
|
|
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 } =
|
|
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
|
-
[
|
|
1289
|
+
[visibleWindow]
|
|
1277
1290
|
)
|
|
1278
1291
|
|
|
1279
1292
|
// Vertical grid line positions (x-percent) shared by every lane backdrop.
|
|
@@ -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
|
|
22
|
-
//
|
|
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
|
|
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.
|
|
468
|
-
|
|
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
|
|
472
|
+
const auditMedium =
|
|
471
473
|
typeof nodeData.auditWarning === "number" ? nodeData.auditWarning : 0;
|
|
472
|
-
const auditTotal =
|
|
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"}${
|
|
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
|
-
|
|
664
|
-
?
|
|
665
|
-
:
|
|
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
|
}
|