@skyhook-io/k8s-ui 1.18.0 → 1.18.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/README.md +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +66 -2
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -21,6 +21,70 @@ const pod = {
|
|
|
21
21
|
status: { phase: 'Running' },
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
describe('PodRenderer completed readiness conditions', () => {
|
|
25
|
+
const completionConditions = [
|
|
26
|
+
{ type: 'PodReadyToStartContainers', status: 'False' },
|
|
27
|
+
{ type: 'Initialized', status: 'True', reason: 'PodCompleted' },
|
|
28
|
+
{ type: 'Ready', status: 'False', reason: 'PodCompleted' },
|
|
29
|
+
{ type: 'ContainersReady', status: 'False', reason: 'PodCompleted' },
|
|
30
|
+
{ type: 'PodScheduled', status: 'True' },
|
|
31
|
+
]
|
|
32
|
+
const renderConditions = (phase: string | undefined, conditions = completionConditions) => renderToString(
|
|
33
|
+
<PodRenderer
|
|
34
|
+
data={{ ...pod, status: { phase, conditions } }}
|
|
35
|
+
onCopy={() => undefined}
|
|
36
|
+
copied={null}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
it('keeps completed readiness observations visible without counting them as failing', () => {
|
|
41
|
+
const html = renderConditions('Succeeded')
|
|
42
|
+
|
|
43
|
+
expect(html).toContain('Conditions (5)')
|
|
44
|
+
expect(html).not.toContain('failing')
|
|
45
|
+
expect(html).toContain('PodReadyToStartContainers')
|
|
46
|
+
expect(html).toContain('ContainersReady')
|
|
47
|
+
expect(html).toContain('PodCompleted')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('uses successful completion even when readiness reasons are stale or absent', () => {
|
|
51
|
+
const html = renderConditions('Succeeded', [
|
|
52
|
+
{ type: 'PodReadyToStartContainers', status: 'False' },
|
|
53
|
+
{ type: 'Ready', status: 'False', reason: 'ContainersNotReady' },
|
|
54
|
+
{ type: 'ContainersReady', status: 'False' },
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
expect(html).toContain('Conditions (3)')
|
|
58
|
+
expect(html).not.toContain('failing')
|
|
59
|
+
expect(html).toContain('ContainersNotReady')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each(['Running', 'Failed', undefined])('retains readiness failures when phase is %s', (phase) => {
|
|
63
|
+
const html = renderConditions(phase, [
|
|
64
|
+
{ type: 'PodReadyToStartContainers', status: 'False' },
|
|
65
|
+
{ type: 'Ready', status: 'False', reason: 'PodFailed' },
|
|
66
|
+
{ type: 'ContainersReady', status: 'False' },
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
expect(html).toContain('Conditions (3) · 3 failing')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not let a completion reason hide readiness failures on a failed Pod', () => {
|
|
73
|
+
expect(renderConditions('Failed')).toContain('Conditions (5) · 3 failing')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('retains unrelated false conditions after successful completion', () => {
|
|
77
|
+
const html = renderConditions('Succeeded', [
|
|
78
|
+
...completionConditions,
|
|
79
|
+
{ type: 'example.com/controller-ready', status: 'False', reason: 'ControllerFailed' },
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
expect(html).toContain('Conditions (6) · 1 failing')
|
|
83
|
+
expect(html).toContain('example.com/controller-ready')
|
|
84
|
+
expect(html).toContain('ControllerFailed')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
24
88
|
describe('PodRenderer envFrom expansion', () => {
|
|
25
89
|
it('keeps same-name ConfigMap and Secret values separate', () => {
|
|
26
90
|
const resolvedEnvFrom: ResolvedEnvFrom = {
|
|
@@ -482,3 +546,54 @@ it('shows reported running-but-unready nginx readiness as degraded', () => {
|
|
|
482
546
|
expect(html).toMatch(/bg-amber-100[^>]*>Not Ready</)
|
|
483
547
|
expect(html).toMatch(/>running</)
|
|
484
548
|
})
|
|
549
|
+
|
|
550
|
+
describe('finished Pod resource usage',()=>{
|
|
551
|
+
it.each(['Succeeded','Failed'])('explains ended live usage for %s without cluster repair advice',phase=>{
|
|
552
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase}}} onCopy={()=>{}} copied={null} metricsUnavailable metricsHistory={{containers:[],metricsUnavailableReason:'not found'}} />)
|
|
553
|
+
expect(html).toContain('Live resource usage has ended for this Pod')
|
|
554
|
+
expect(html).toContain('No recorded usage is available.')
|
|
555
|
+
expect(html).not.toContain('Metrics collection error:')
|
|
556
|
+
expect(html).not.toContain('Radar cannot read metrics.k8s.io')
|
|
557
|
+
expect(html).not.toContain('No usage yet: this Pod is not running')
|
|
558
|
+
})
|
|
559
|
+
it('keeps a genuine collector error qualified as current on a finished Pod',()=>{
|
|
560
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null} metricsHistory={{containers:[],collectionError:'forbidden: list pods.metrics.k8s.io'}} />)
|
|
561
|
+
expect(html).toContain('Current cluster metrics collection error:')
|
|
562
|
+
expect(html).toContain('forbidden: list pods.metrics.k8s.io')
|
|
563
|
+
expect(html).toContain('Live resource usage has ended for this Pod')
|
|
564
|
+
})
|
|
565
|
+
it('keeps recorded history and hides cached live usage after completion',()=>{
|
|
566
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null}
|
|
567
|
+
metrics={{timestamp:'2026-10-25T12:00:00Z',containers:[{name:'cached-live',usage:{cpu:'100m',memory:'1Mi'}}]}}
|
|
568
|
+
metricsHistory={{containers:[{name:'recorded-history',dataPoints:[{timestamp:'2026-10-25T11:59:00Z',cpu:100,memory:1024}]}]}} />)
|
|
569
|
+
expect(html).toContain('recorded-history')
|
|
570
|
+
expect(html).not.toContain('cached-live')
|
|
571
|
+
expect(html).not.toContain('Last updated:')
|
|
572
|
+
})
|
|
573
|
+
it('distinguishes loading from a loaded empty terminal history',()=>{
|
|
574
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null} metricsHistoryLoading />)
|
|
575
|
+
expect(html).toContain('Loading recorded usage...')
|
|
576
|
+
expect(html).not.toContain('No recorded usage is available.')
|
|
577
|
+
})
|
|
578
|
+
it('shows a failed terminal history fetch without claiming empty history',()=>{
|
|
579
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Failed'}}} onCopy={()=>{}} copied={null} metricsHistoryError={new Error('Metrics history not available')} />)
|
|
580
|
+
expect(html).toContain('Recorded usage could not be loaded')
|
|
581
|
+
expect(html).toContain('Metrics history not available')
|
|
582
|
+
expect(html).not.toContain('No recorded usage is available.')
|
|
583
|
+
})
|
|
584
|
+
it('retains cached history when its refresh fails',()=>{
|
|
585
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null}
|
|
586
|
+
metricsHistory={{containers:[{name:'recorded-history',dataPoints:[{timestamp:'2026-10-25T11:59:00Z',cpu:100,memory:1024}]}]}}
|
|
587
|
+
metricsHistoryError={new Error('Network request failed')} />)
|
|
588
|
+
expect(html).toContain('Recorded usage could not be refreshed')
|
|
589
|
+
expect(html).toContain('recorded-history')
|
|
590
|
+
expect(html).not.toContain('No recorded usage is available.')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('keeps usage absent when the host wires no metrics state',()=>{
|
|
594
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null} />)
|
|
595
|
+
expect(html).not.toContain('Resource Usage')
|
|
596
|
+
expect(html).not.toContain('Live resource usage has ended')
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
})
|
|
@@ -87,6 +87,8 @@ interface PodRendererProps {
|
|
|
87
87
|
// Metrics data injection
|
|
88
88
|
metrics?: { containers?: any[]; timestamp?: string }
|
|
89
89
|
metricsHistory?: { metricsAPIReachable?: boolean; containers?: any[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
|
|
90
|
+
metricsHistoryLoading?: boolean
|
|
91
|
+
metricsHistoryError?: Error | null
|
|
90
92
|
metricsUnavailable?: boolean
|
|
91
93
|
hideMetricsServer?: boolean
|
|
92
94
|
// Filesystem browser render props
|
|
@@ -286,6 +288,8 @@ export function PodRenderer({
|
|
|
286
288
|
renderPortAction,
|
|
287
289
|
metrics,
|
|
288
290
|
metricsHistory,
|
|
291
|
+
metricsHistoryLoading,
|
|
292
|
+
metricsHistoryError,
|
|
289
293
|
metricsUnavailable,
|
|
290
294
|
hideMetricsServer,
|
|
291
295
|
renderImageBrowser,
|
|
@@ -320,6 +324,7 @@ export function PodRenderer({
|
|
|
320
324
|
const namespace = data.metadata?.namespace
|
|
321
325
|
const podName = data.metadata?.name
|
|
322
326
|
const isRunning = data.status?.phase === 'Running'
|
|
327
|
+
const isTerminal = data.status?.phase === 'Succeeded' || data.status?.phase === 'Failed'
|
|
323
328
|
|
|
324
329
|
// Check for problems. Suppressed when the detail already shows the dedicated
|
|
325
330
|
// Operational Issues section (the Issues pipeline covers the same pod failures,
|
|
@@ -329,9 +334,10 @@ export function PodRenderer({
|
|
|
329
334
|
const hasProblems = podProblems.length > 0 && !operationalIssuesShown
|
|
330
335
|
const notRunning = data.status?.phase === 'Pending' && ![...containerStatuses, ...initContainerStatuses].some((s: any) => s.state?.running)
|
|
331
336
|
const noUsageYet = notRunning && metricsHistory?.metricsAPIReachable === true && !metricsHistory.collectionError && !metricsHistory.containers?.length && !metrics?.containers?.length
|
|
332
|
-
const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError && !noUsageYet
|
|
337
|
+
const showMetricsUnavailable = !isTerminal && !!metricsUnavailable && !metricsHistory?.collectionError && !noUsageYet
|
|
333
338
|
const hasMetricsHistory = !!metricsHistory?.containers?.length
|
|
334
|
-
const
|
|
339
|
+
const showTerminalUsage = isTerminal && (metricsHistory !== undefined || metricsHistoryLoading || !!metricsHistoryError || !!metrics || metricsUnavailable)
|
|
340
|
+
const currentMetrics = isTerminal || metricsUnavailable ? undefined : metrics
|
|
335
341
|
|
|
336
342
|
// Image filesystem modal state
|
|
337
343
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
|
@@ -872,15 +878,30 @@ export function PodRenderer({
|
|
|
872
878
|
)}
|
|
873
879
|
|
|
874
880
|
{/* Resource Usage (from metrics-server) — hidden when Prometheus has CPU/memory data */}
|
|
875
|
-
{!hideMetricsServer && !!(currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable || noUsageYet) && (
|
|
881
|
+
{!hideMetricsServer && !!(showTerminalUsage || currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable || noUsageYet) && (
|
|
876
882
|
<Section title="Resource Usage" icon={Activity} defaultExpanded>
|
|
883
|
+
{isTerminal && (
|
|
884
|
+
<div className="mb-3 text-sm text-theme-text-tertiary">
|
|
885
|
+
Live resource usage has ended for this Pod.{metricsHistory && !hasMetricsHistory && !metricsHistoryLoading && !metricsHistoryError && ' No recorded usage is available.'}
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
{isTerminal && metricsHistoryLoading && (
|
|
889
|
+
<p className="mb-3 text-sm text-theme-text-tertiary">Loading recorded usage...</p>
|
|
890
|
+
)}
|
|
891
|
+
{isTerminal && metricsHistoryError && (
|
|
892
|
+
<AlertBanner
|
|
893
|
+
variant="warning"
|
|
894
|
+
title={metricsHistory ? 'Recorded usage could not be refreshed' : 'Recorded usage could not be loaded'}
|
|
895
|
+
message={metricsHistoryError.message}
|
|
896
|
+
/>
|
|
897
|
+
)}
|
|
877
898
|
{noUsageYet && <MetricsUnavailableNotice noUsageYet />}
|
|
878
899
|
{showMetricsUnavailable && (
|
|
879
900
|
<MetricsUnavailableNotice rawError={metricsHistory?.metricsUnavailableReason} diagnosis={metricsHistory?.metricsUnavailableDiagnosis} />
|
|
880
901
|
)}
|
|
881
902
|
{metricsHistory?.collectionError && (
|
|
882
903
|
<div className="mb-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-3 py-2 text-xs text-yellow-400">
|
|
883
|
-
<span className="font-medium">Metrics collection error
|
|
904
|
+
<span className="font-medium">{isTerminal ? 'Current cluster metrics collection error:' : 'Metrics collection error:'}</span>{' '}
|
|
884
905
|
<span className="break-all">{metricsHistory.collectionError}</span>
|
|
885
906
|
</div>
|
|
886
907
|
)}
|
|
@@ -970,14 +991,16 @@ export function PodRenderer({
|
|
|
970
991
|
</Section>
|
|
971
992
|
)}
|
|
972
993
|
|
|
973
|
-
{/*
|
|
974
|
-
|
|
975
|
-
so tone it neutral (gray) instead of red. Gated on the PodCompleted reason
|
|
976
|
-
so a genuinely not-ready pod (any other reason) still reads red. */}
|
|
994
|
+
{/* Sandbox teardown and lost serving readiness are expected after success;
|
|
995
|
+
custom conditions retain their own meaning. */}
|
|
977
996
|
<ConditionsSection
|
|
978
997
|
conditions={data.status?.conditions}
|
|
979
998
|
getConditionTone={(cond) =>
|
|
980
|
-
|
|
999
|
+
data.status?.phase === 'Succeeded'
|
|
1000
|
+
&& cond?.status === 'False'
|
|
1001
|
+
&& ['Ready', 'ContainersReady', 'PodReadyToStartContainers'].includes(cond?.type)
|
|
1002
|
+
? 'unknown'
|
|
1003
|
+
: undefined
|
|
981
1004
|
}
|
|
982
1005
|
/>
|
|
983
1006
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { searchTrivyFindings, trivyReportImageRef } from './resource-utils-trivy'
|
|
3
|
+
|
|
4
|
+
const vuln = {
|
|
5
|
+
metadata: { name: 'replicaset-billing-5cfccf7776-php-nginx', namespace: 'legacy' },
|
|
6
|
+
report: {
|
|
7
|
+
registry: { server: 'index.docker.io' },
|
|
8
|
+
artifact: { repository: 'trafex/php-nginx', tag: '3.6.0', digest: 'sha256:abc' },
|
|
9
|
+
vulnerabilities: [
|
|
10
|
+
{ vulnerabilityID: 'CVE-2025-0001', resource: 'libssl3', installedVersion: '3.3.2-r0', fixedVersion: '3.3.3-r0' },
|
|
11
|
+
{ vulnerabilityID: 'CVE-2026-109618', resource: 'php83', installedVersion: '8.3.20-r0', fixedVersion: '8.3.36-r0' },
|
|
12
|
+
{ vulnerabilityID: 'CVE-2026-0002', resource: 'busybox', installedVersion: '1.36.1-r29' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
const secrets = {
|
|
17
|
+
metadata: { name: 'replicaset-billing-secrets', namespace: 'legacy' },
|
|
18
|
+
report: { secrets: [{ ruleID: 'aws-access-key-id', target: '/var/www/html/.env', match: 'AWS_ACCESS_KEY_ID=AKIAREVIEWFIXTURE01' }] },
|
|
19
|
+
}
|
|
20
|
+
const audit = {
|
|
21
|
+
metadata: { name: 'replicaset-api-8467889d75', namespace: 'team-b' },
|
|
22
|
+
report: { checks: [{ checkID: 'KSV012', success: false }, { checkID: 'KSV001', success: true }] },
|
|
23
|
+
}
|
|
24
|
+
const sbom = {
|
|
25
|
+
metadata: { name: 'replicaset-web-sbom', namespace: 'shop' },
|
|
26
|
+
report: { components: { components: [{ name: 'guzzlehttp/guzzle', version: '7.8.1' }] } },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('searchTrivyFindings', () => {
|
|
30
|
+
it('matches a CVE and names the finding with its versions', () => {
|
|
31
|
+
const { matches, truncated } = searchTrivyFindings([vuln, secrets], 'cve-2026-109618')
|
|
32
|
+
expect(truncated).toBe(false)
|
|
33
|
+
expect([...matches.keys()]).toEqual([vuln])
|
|
34
|
+
expect(matches.get(vuln)).toEqual({ site: 'finding', text: 'CVE-2026-109618 php83 8.3.20-r0 → 8.3.36-r0' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('matches packages, unfixed findings, SBOM components and failed checks', () => {
|
|
38
|
+
const text = (r: any, term: string) => searchTrivyFindings([r], term).matches.get(r)?.text
|
|
39
|
+
expect(text(vuln, 'php83')).toContain('php83')
|
|
40
|
+
expect(text(vuln, 'busybox')).toBe('CVE-2026-0002 busybox 1.36.1-r29 → unfixed')
|
|
41
|
+
expect(text(sbom, 'guzzle')).toBe('guzzlehttp/guzzle 7.8.1')
|
|
42
|
+
expect(text(audit, 'ksv012')).toBe('KSV012')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('tells an image match apart from a finding', () => {
|
|
46
|
+
expect(searchTrivyFindings([vuln], 'sha256:abc').matches.get(vuln)).toEqual({ site: 'image', text: 'index.docker.io/trafex/php-nginx:3.6.0@sha256:abc' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('treats the term as text, not a pattern', () => {
|
|
50
|
+
expect(searchTrivyFindings([vuln], '8.3.20-r0 →').matches.has(vuln)).toBe(true)
|
|
51
|
+
expect(searchTrivyFindings([vuln], 'php8.').matches.size).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('caps a finding at 256 UTF-8 bytes for phrases, like server search', () => {
|
|
55
|
+
const wide = { metadata: { name: 'r', namespace: 'n' }, report: { components: { components: [{ name: 'é'.repeat(128), version: 'needle' }] } } }
|
|
56
|
+
expect(searchTrivyFindings([wide], 'é needle').matches.size).toBe(0)
|
|
57
|
+
expect(searchTrivyFindings([wide], 'needle').matches.size).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('matches a phrase across one finding, never across findings', () => {
|
|
61
|
+
expect(searchTrivyFindings([vuln], 'php83 8.3.20').matches.has(vuln)).toBe(true)
|
|
62
|
+
expect(searchTrivyFindings([vuln], 'libssl3 8.3.20').matches.has(vuln)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never searches a passing check or a secret finding\'s matched text', () => {
|
|
66
|
+
expect(searchTrivyFindings([audit], 'ksv001').matches.size).toBe(0)
|
|
67
|
+
expect(searchTrivyFindings([secrets], 'akiareview').matches.size).toBe(0)
|
|
68
|
+
expect(searchTrivyFindings([secrets], 'aws-access-key-id').matches.get(secrets)?.text).toBe('aws-access-key-id /var/www/html/.env')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('skips rows whose name or namespace already match', () => {
|
|
72
|
+
expect(searchTrivyFindings([vuln], 'legacy').matches.size).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('stops at the findings limit and says the search was cut short', () => {
|
|
76
|
+
const { matches, truncated } = searchTrivyFindings([vuln, vuln], 'cve-2026-0002', 4)
|
|
77
|
+
expect(truncated).toBe(true)
|
|
78
|
+
expect(matches.size).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('trivyReportImageRef', () => {
|
|
83
|
+
it('renders registry/repository:tag@digest like server search', () => {
|
|
84
|
+
expect(trivyReportImageRef(vuln)).toBe('index.docker.io/trafex/php-nginx:3.6.0@sha256:abc')
|
|
85
|
+
expect(trivyReportImageRef({ report: {} })).toBe('')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -55,6 +55,128 @@ export function getVulnerabilityReportContainer(report: any): string {
|
|
|
55
55
|
return report.metadata?.labels?.['trivy-operator.container.name'] || '-'
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// TRIVY OPERATOR — TABLE SEARCH OVER FINDINGS
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export const TRIVY_GROUP = 'aquasecurity.github.io'
|
|
63
|
+
|
|
64
|
+
// Bounds one table search over findings. The filter runs on the main thread,
|
|
65
|
+
// and a cluster can hold thousands of reports with hundreds of findings each.
|
|
66
|
+
const MAX_TABLE_FINDINGS = 200_000
|
|
67
|
+
|
|
68
|
+
// Server search caps one indexed finding at 256 bytes (internal/search/trivy.go).
|
|
69
|
+
const MAX_FINDING_LABEL = 256
|
|
70
|
+
|
|
71
|
+
export interface TrivyFindingMatch {
|
|
72
|
+
site: 'finding' | 'image'
|
|
73
|
+
text: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TrivyFindingsSearch {
|
|
77
|
+
// Rows that matched only through a finding or the scanned image.
|
|
78
|
+
matches: Map<object, TrivyFindingMatch>
|
|
79
|
+
truncated: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function str(v: unknown): string {
|
|
83
|
+
return typeof v === 'string' ? v : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asArray(v: unknown): any[] {
|
|
87
|
+
return Array.isArray(v) ? v : []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// findingLabel joins a finding's parts and caps it at MAX_FINDING_LABEL
|
|
91
|
+
// UTF-8 bytes on a character boundary, as server search does, so a phrase
|
|
92
|
+
// that falls past the cap matches in neither place.
|
|
93
|
+
function findingLabel(...parts: string[]): string {
|
|
94
|
+
const label = parts.filter(Boolean).join(' ')
|
|
95
|
+
// A UTF-16 unit is at most 3 UTF-8 bytes, so short labels need no count.
|
|
96
|
+
if (label.length * 3 <= MAX_FINDING_LABEL) return label
|
|
97
|
+
let bytes = 0
|
|
98
|
+
let end = 0
|
|
99
|
+
for (const ch of label) {
|
|
100
|
+
const cp = ch.codePointAt(0) ?? 0
|
|
101
|
+
const size = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
|
|
102
|
+
if (bytes + size > MAX_FINDING_LABEL) break
|
|
103
|
+
bytes += size
|
|
104
|
+
end += ch.length
|
|
105
|
+
}
|
|
106
|
+
return label.slice(0, end)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// trivyReportImageRef renders a report's scanned image the way search
|
|
110
|
+
// indexes it: registry/repository:tag@digest.
|
|
111
|
+
export function trivyReportImageRef(resource: any): string {
|
|
112
|
+
const report = resource?.report
|
|
113
|
+
const repo = str(report?.artifact?.repository)
|
|
114
|
+
if (!repo) return ''
|
|
115
|
+
const registry = str(report?.registry?.server)
|
|
116
|
+
let ref = registry ? `${registry}/${repo}` : repo
|
|
117
|
+
const tag = str(report?.artifact?.tag)
|
|
118
|
+
if (tag) ref += `:${tag}`
|
|
119
|
+
const digest = str(report?.artifact?.digest)
|
|
120
|
+
if (digest) ref += `@${digest}`
|
|
121
|
+
return ref
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// searchTrivyFindings finds reports whose findings contain term, over the
|
|
125
|
+
// entries server search indexes (internal/search/trivy.go): CVE IDs with
|
|
126
|
+
// package and versions, SBOM components, failed check IDs, secret rule IDs
|
|
127
|
+
// and targets, plus the scanned image. A secret finding's `match` is the
|
|
128
|
+
// matched line itself and is never searched. A term with a space matches
|
|
129
|
+
// within one finding, never across two. Rows whose name or namespace already
|
|
130
|
+
// contain term are skipped, since they match without a finding.
|
|
131
|
+
export function searchTrivyFindings(resources: readonly any[], term: string, limit = MAX_TABLE_FINDINGS): TrivyFindingsSearch {
|
|
132
|
+
const matches = new Map<object, TrivyFindingMatch>()
|
|
133
|
+
const re = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
134
|
+
const spaced = term.includes(' ')
|
|
135
|
+
const hit = (...parts: string[]): boolean =>
|
|
136
|
+
spaced ? re.test(findingLabel(...parts)) : parts.some((p) => p !== '' && re.test(p))
|
|
137
|
+
let left = limit
|
|
138
|
+
for (const r of resources) {
|
|
139
|
+
const meta = r?.metadata
|
|
140
|
+
if (re.test(str(meta?.name)) || re.test(str(meta?.namespace))) continue
|
|
141
|
+
const report = r?.report
|
|
142
|
+
if (!report || typeof report !== 'object') continue
|
|
143
|
+
const image = trivyReportImageRef(r)
|
|
144
|
+
if (image && re.test(image)) {
|
|
145
|
+
matches.set(r, { site: 'image', text: image })
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
let found = ''
|
|
149
|
+
let truncated = false
|
|
150
|
+
scan: {
|
|
151
|
+
for (const v of asArray(report.vulnerabilities)) {
|
|
152
|
+
if (--left < 0) { truncated = true; break scan }
|
|
153
|
+
const id = str(v?.vulnerabilityID)
|
|
154
|
+
if (!id) continue
|
|
155
|
+
const parts = [id, str(v.resource), str(v.installedVersion), '→', str(v.fixedVersion) || 'unfixed']
|
|
156
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
157
|
+
}
|
|
158
|
+
for (const c of asArray(report.components?.components)) {
|
|
159
|
+
if (--left < 0) { truncated = true; break scan }
|
|
160
|
+
const parts = [str(c?.name), str(c?.version)]
|
|
161
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
162
|
+
}
|
|
163
|
+
for (const c of asArray(report.checks)) {
|
|
164
|
+
if (--left < 0) { truncated = true; break scan }
|
|
165
|
+
if (c?.success !== false) continue
|
|
166
|
+
if (hit(str(c.checkID))) { found = findingLabel(str(c.checkID)); break scan }
|
|
167
|
+
}
|
|
168
|
+
for (const sec of asArray(report.secrets)) {
|
|
169
|
+
if (--left < 0) { truncated = true; break scan }
|
|
170
|
+
const parts = [str(sec?.ruleID), str(sec?.target)]
|
|
171
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (found) matches.set(r, { site: 'finding', text: found })
|
|
175
|
+
if (truncated) return { matches, truncated: true }
|
|
176
|
+
}
|
|
177
|
+
return { matches, truncated: false }
|
|
178
|
+
}
|
|
179
|
+
|
|
58
180
|
// ============================================================================
|
|
59
181
|
// TRIVY OPERATOR — CONFIG AUDIT REPORT UTILITIES
|
|
60
182
|
// ============================================================================
|
|
@@ -77,22 +77,24 @@ export function DetailShell<TId extends string = string>({
|
|
|
77
77
|
<div className="shrink-0 border-b border-theme-border bg-theme-base">
|
|
78
78
|
{!compactHeader && (
|
|
79
79
|
<>
|
|
80
|
-
{breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
|
|
81
|
-
|
|
80
|
+
{breadcrumb && <div className="px-6 pt-2.5 phone:px-4">{breadcrumb}</div>}
|
|
81
|
+
{/* Phones always wrap: the identity takes the row beside the back
|
|
82
|
+
button and the actions drop below it. */}
|
|
83
|
+
<div className={clsx('px-6 flex items-start gap-4 phone:px-4 phone:flex-wrap phone:gap-x-3 phone:gap-y-2', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
|
|
82
84
|
{nav}
|
|
83
85
|
{/* Wrapping on, the identity's basis is its one-line width, so the
|
|
84
86
|
actions move below whenever the whole title line would not fit
|
|
85
87
|
beside them — never squeezing the name to keep them up. */}
|
|
86
|
-
<div className={clsx('min-w-0', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
|
|
88
|
+
<div className={clsx('min-w-0 phone:basis-[calc(100%-3rem)]', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
|
|
87
89
|
{wrapHeader ? <div className="ml-auto flex shrink-0 items-start gap-4">{headerActions}</div> : headerActions}
|
|
88
90
|
</div>
|
|
89
|
-
{subheader && <div className="-mt-1 px-6 pb-3">{subheader}</div>}
|
|
91
|
+
{subheader && <div className="-mt-1 px-6 pb-3 phone:px-4">{subheader}</div>}
|
|
90
92
|
</>
|
|
91
93
|
)}
|
|
92
94
|
|
|
93
95
|
{/* Tabs (left) + scope controls / actions (right) */}
|
|
94
|
-
<div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
|
|
95
|
-
<div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto', compact ? 'gap-0' : 'gap-1')} role="tablist">
|
|
96
|
+
<div className={clsx('flex items-center phone:flex-wrap', compactHeader ? 'px-0' : 'border-t border-theme-border px-6 phone:px-4')}>
|
|
97
|
+
<div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto phone:basis-full', compact ? 'gap-0' : 'gap-1')} role="tablist">
|
|
96
98
|
{visibleTabs.map((t) => (
|
|
97
99
|
<DetailShellTabButton key={t.id} active={activeTab === t.id} compact={compact} onClick={() => onTabChange(t.id)}>
|
|
98
100
|
{!compact && t.icon}
|
|
@@ -102,7 +104,7 @@ export function DetailShell<TId extends string = string>({
|
|
|
102
104
|
))}
|
|
103
105
|
</div>
|
|
104
106
|
{(scopeControls || tabStripEnd) && (
|
|
105
|
-
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2">
|
|
107
|
+
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2 phone:py-2">
|
|
106
108
|
{scopeControls}
|
|
107
109
|
{tabStripEnd}
|
|
108
110
|
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ManagedByChip } from './ManagedByChip'
|
|
5
|
+
|
|
6
|
+
const owner = { tool: 'argocd' as const, kind: 'applications' as const, namespace: 'argocd', name: 'billing' }
|
|
7
|
+
|
|
8
|
+
describe('ManagedByChip', () => {
|
|
9
|
+
it('says Managed by for a verified owner its status confirms', () => {
|
|
10
|
+
const html = renderToString(<ManagedByChip owner={owner} verified listing={{ state: 'confirmed' }} />)
|
|
11
|
+
expect(html).toContain('Managed by')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('says Tracked by, with a warning, for an owner whose status does not list the resource while another does', () => {
|
|
15
|
+
const html = renderToString(
|
|
16
|
+
<ManagedByChip owner={owner} verified listing={{ state: 'conflict', listedBy: [{ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'hub' }] }} />,
|
|
17
|
+
)
|
|
18
|
+
expect(html).toContain('Tracked by')
|
|
19
|
+
expect(html).not.toContain('Managed by')
|
|
20
|
+
expect(html).toMatch(/rounded-full[^"]*bg-amber/)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { GitBranch, Package } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
-
import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
3
|
+
import type { GitOpsOwnerRef, ManagerListing } from '../../utils/gitops-owner'
|
|
4
4
|
import type { GitOpsStatus } from '../../types/gitops'
|
|
5
5
|
import { StatusDot, type StatusTone } from '../ui/status-tone'
|
|
6
6
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -11,7 +11,10 @@ export type HelmOwnerRef = {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
// ManagedByChip renders the "Managed by <ArgoCD/FluxCD app>" affordance for
|
|
14
|
-
// resources detected (via labels/annotations) to be GitOps-managed.
|
|
14
|
+
// resources detected (via labels/annotations) to be GitOps-managed. `listing`
|
|
15
|
+
// compares that claim with the managers whose status lists the resource: a
|
|
16
|
+
// manager that doesn't list it while another does is shown as "Tracked by"
|
|
17
|
+
// with a warning, since the claim is all that names it. The chip
|
|
15
18
|
// is clickable when the host wires `onOpen`; integrators that don't surface
|
|
16
19
|
// a GitOps tab can omit the handler and the chip degrades to a passive badge
|
|
17
20
|
// so the relationship is still visible.
|
|
@@ -26,6 +29,7 @@ export function ManagedByChip({
|
|
|
26
29
|
verified = true,
|
|
27
30
|
pending = false,
|
|
28
31
|
source,
|
|
32
|
+
listing,
|
|
29
33
|
variant = 'inline',
|
|
30
34
|
}: {
|
|
31
35
|
owner: GitOpsOwnerRef
|
|
@@ -34,13 +38,15 @@ export function ManagedByChip({
|
|
|
34
38
|
verified?: boolean
|
|
35
39
|
pending?: boolean
|
|
36
40
|
source?: string | null
|
|
41
|
+
listing?: ManagerListing
|
|
37
42
|
variant?: 'inline' | 'block'
|
|
38
43
|
}) {
|
|
39
44
|
const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
|
|
40
45
|
const ownerKindLabel = gitOpsOwnerKindLabel(owner)
|
|
41
46
|
const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
|
|
42
47
|
const statusLabel = status ? gitOpsStatusLabel(status) : null
|
|
43
|
-
const
|
|
48
|
+
const conflict = listing?.state === 'conflict' ? listing.listedBy : null
|
|
49
|
+
const prefix = pending ? 'Resolving' : verified && !conflict ? 'Managed by' : 'Tracked by'
|
|
44
50
|
const tooltipContent = (
|
|
45
51
|
<span className="flex flex-col gap-1">
|
|
46
52
|
<span>
|
|
@@ -48,10 +54,23 @@ export function ManagedByChip({
|
|
|
48
54
|
<>
|
|
49
55
|
Looking up matching {ownerKindLabel} <TooltipCode>{label}</TooltipCode> in this cluster.
|
|
50
56
|
</>
|
|
57
|
+
) : conflict ? (
|
|
58
|
+
<>
|
|
59
|
+
This resource's {toolLabel} tracking metadata names <TooltipCode>{label}</TooltipCode>, but that {ownerKindLabel}'s status doesn't list it.{' '}
|
|
60
|
+
{conflict.length === 1 ? 'The status of ' : 'The statuses of '}
|
|
61
|
+
{conflict.map((m, i) => (
|
|
62
|
+
<span key={`${m.namespace}/${m.name}`}>
|
|
63
|
+
{i > 0 && ', '}
|
|
64
|
+
<TooltipCode>{m.namespace ? `${m.namespace}/${m.name}` : m.name}</TooltipCode>
|
|
65
|
+
</span>
|
|
66
|
+
))}{' '}
|
|
67
|
+
{conflict.length === 1 ? 'does.' : 'do.'}
|
|
68
|
+
</>
|
|
51
69
|
) : verified ? (
|
|
52
70
|
<>
|
|
53
71
|
Managed by {toolLabel} · <TooltipCode>{label}</TooltipCode>
|
|
54
72
|
{statusLabel && <> · {statusLabel}{status?.message ? `: ${status.message}` : ''}</>}
|
|
73
|
+
{listing?.state === 'confirmed' && <>. Its status lists this resource too.</>}
|
|
55
74
|
</>
|
|
56
75
|
) : (
|
|
57
76
|
<>
|
|
@@ -80,6 +99,7 @@ export function ManagedByChip({
|
|
|
80
99
|
)}
|
|
81
100
|
>
|
|
82
101
|
<GitBranch className="h-3 w-3 shrink-0" />
|
|
102
|
+
{conflict && <StatusDot tone="degraded" size="xs" />}
|
|
83
103
|
<span className="shrink-0 text-theme-text-tertiary">{prefix}</span>
|
|
84
104
|
<span className="truncate max-w-[180px]">{label}</span>
|
|
85
105
|
{statusLabel && (
|
|
@@ -141,7 +161,7 @@ export function HelmManagedByChip({
|
|
|
141
161
|
)
|
|
142
162
|
}
|
|
143
163
|
|
|
144
|
-
function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
|
|
164
|
+
export function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
|
|
145
165
|
if (owner.tool === 'argocd') return 'Application'
|
|
146
166
|
if (owner.kind === 'helmreleases') return 'HelmRelease'
|
|
147
167
|
if (owner.kind === 'kustomizations') return 'Kustomization'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ResourceActionsBar } from './ResourceActionsBar'
|
|
4
|
+
|
|
5
|
+
const noop = () => {}
|
|
6
|
+
|
|
7
|
+
function actions(kind: string, apiVersion: string) {
|
|
8
|
+
return renderToString(
|
|
9
|
+
<ResourceActionsBar
|
|
10
|
+
resource={{ kind, name: 'example', namespace: 'test' }}
|
|
11
|
+
data={{ apiVersion, metadata: { name: 'example', namespace: 'test' } }}
|
|
12
|
+
onFluxReconcile={noop}
|
|
13
|
+
onFluxSuspend={noop}
|
|
14
|
+
onFluxResume={noop}
|
|
15
|
+
onArgoSync={noop}
|
|
16
|
+
onArgoRefresh={noop}
|
|
17
|
+
onArgoSuspend={noop}
|
|
18
|
+
onArgoResume={noop}
|
|
19
|
+
canPortForward
|
|
20
|
+
renderPortForward={() => <button>Port forward</button>}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('resource actions use the loaded API group', () => {
|
|
26
|
+
it.each(['app.k8s.io/v1beta1', 'notargoproj.io/v1alpha1', ''])('does not offer Argo actions for Application in %s', (apiVersion) => {
|
|
27
|
+
const html = actions('applications', apiVersion)
|
|
28
|
+
expect(html).not.toContain('>Sync<')
|
|
29
|
+
expect(html).not.toContain('>Refresh<')
|
|
30
|
+
expect(html).not.toContain('Enable Auto-Sync')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('retains Argo actions for its exact Application group', () => {
|
|
34
|
+
const html = actions('applications', 'argoproj.io/v1alpha1')
|
|
35
|
+
expect(html).toContain('>Sync<')
|
|
36
|
+
expect(html).toContain('>Refresh<')
|
|
37
|
+
expect(html).toContain('Enable Auto-Sync')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it.each(['serving.knative.dev/v1', 'vendor.serving.knative.dev/v1', 'vendor.io/v1', ''])('does not port-forward a non-core Service in %s', (apiVersion) => {
|
|
41
|
+
expect(actions('services', apiVersion)).not.toContain('Port forward')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('retains port forwarding for core Service', () => {
|
|
45
|
+
expect(actions('services', 'v1')).toContain('Port forward')
|
|
46
|
+
})
|
|
47
|
+
it.each([
|
|
48
|
+
['gitrepositories', 'source.toolkit.fluxcd.io'],
|
|
49
|
+
['ocirepositories', 'source.toolkit.fluxcd.io'],
|
|
50
|
+
['helmrepositories', 'source.toolkit.fluxcd.io'],
|
|
51
|
+
['kustomizations', 'kustomize.toolkit.fluxcd.io'],
|
|
52
|
+
['helmreleases', 'helm.toolkit.fluxcd.io'],
|
|
53
|
+
['alerts', 'notification.toolkit.fluxcd.io'],
|
|
54
|
+
])('only offers Flux actions for %s in its exact group', (kind, group) => {
|
|
55
|
+
expect(actions(kind, `${group}/v1`)).toContain('>Reconcile<')
|
|
56
|
+
for (const apiVersion of ['', 'vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
|
|
57
|
+
const html = actions(kind, apiVersion)
|
|
58
|
+
expect(html).not.toContain('>Reconcile<')
|
|
59
|
+
expect(html).not.toContain('>Suspend<')
|
|
60
|
+
expect(html).not.toContain('>Resume<')
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
})
|