@skyhook-io/k8s-ui 1.8.15 → 1.9.1
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/logs/useLogSearch.ts +12 -7
- package/src/components/resources/ResourcesView.per-container.test.ts +111 -0
- package/src/components/resources/ResourcesView.tsx +310 -99
- package/src/components/resources/renderers/PodRenderer.tsx +4 -1
- package/src/components/timeline/TimelineList.tsx +7 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +37 -19
- 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/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedValue.ts +29 -0
- package/src/types/core.ts +17 -4
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
|