@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.
Files changed (63) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  7. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  8. package/src/components/gitops/detail-helpers.test.ts +58 -13
  9. package/src/components/gitops/detail-helpers.ts +37 -6
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/issues/IssuesView.tsx +14 -9
  12. package/src/components/logs/LogsViewer.test.tsx +104 -59
  13. package/src/components/logs/LogsViewer.tsx +28 -8
  14. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  16. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  17. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  18. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  19. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  20. package/src/components/resources/ResourcesView.tsx +106 -19
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  23. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  24. package/src/components/resources/resource-utils-trivy.ts +122 -0
  25. package/src/components/shared/DetailShell.tsx +9 -7
  26. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  27. package/src/components/shared/ManagedByChip.tsx +24 -4
  28. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  29. package/src/components/shared/ResourceActionsBar.tsx +9 -3
  30. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  31. package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
  32. package/src/components/timeline/TimelineList.tsx +5 -1
  33. package/src/components/ui/FetchResult.test.tsx +19 -2
  34. package/src/components/ui/FetchResult.tsx +13 -1
  35. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  36. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  37. package/src/components/ui/PageHeader.tsx +6 -4
  38. package/src/components/ui/Toast.tsx +33 -26
  39. package/src/components/ui/drawer-components.test.tsx +253 -0
  40. package/src/components/ui/drawer-components.tsx +315 -11
  41. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  42. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  43. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  44. package/src/components/workload/WorkloadView.tsx +54 -6
  45. package/src/hooks/index.ts +2 -0
  46. package/src/hooks/useMediaQuery.ts +21 -0
  47. package/src/hooks/usePhoneLayout.ts +12 -0
  48. package/src/theme/tailwind-theme.css +5 -0
  49. package/src/types/core.ts +66 -2
  50. package/src/types/gitops-flux-inventory.test.ts +24 -0
  51. package/src/types/gitops.ts +5 -5
  52. package/src/types/index.ts +3 -0
  53. package/src/types/read-coverage.ts +65 -0
  54. package/src/types/resource-coverage.ts +40 -0
  55. package/src/utils/format.test.ts +7 -0
  56. package/src/utils/format.ts +6 -1
  57. package/src/utils/gitops-owner.test.ts +42 -1
  58. package/src/utils/gitops-owner.ts +22 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/read-actions.test.ts +22 -0
  61. package/src/utils/read-actions.ts +56 -0
  62. package/src/utils/resource-coverage.test.ts +121 -0
  63. 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 currentMetrics = metricsUnavailable ? undefined : metrics
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:</span>{' '}
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
- {/* Conditions. A completed pod's Ready/ContainersReady flip to False with
974
- reason "PodCompleted" — that's expected for a finished pod, not a failure,
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
- cond?.status === 'False' && cond?.reason === 'PodCompleted' ? 'unknown' : undefined
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
- <div className={clsx('px-6 flex items-start gap-4', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
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. The chip
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 prefix = pending ? 'Resolving' : verified ? 'Managed by' : 'Tracked by'
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
+ })