@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type { StatusBadge } from './resource-utils'
9
9
  import { healthColors } from './resource-utils'
10
+ import { getGenericResourceStatus, type GenericStatus } from './generic-status'
11
+ import type { ConditionTone } from '../ui/drawer-components'
10
12
 
11
13
  interface CrossplaneCondition {
12
14
  type: string
@@ -138,15 +140,37 @@ export function getDeletionPolicy(data: any): string | null {
138
140
  return data?.spec?.crossplane?.deletionPolicy ?? data?.spec?.deletionPolicy ?? null
139
141
  }
140
142
 
141
- /**
142
- * Crossplane convention: setting `crossplane.io/paused: "true"` on any MR/XR/Claim
143
- * stops the provider from reconciling it. Universal across every provider, so
144
- * surfacing it as a chip on every Crossplane resource is high-leverage.
145
- */
146
143
  export function isCrossplanePaused(data: any): boolean {
147
144
  return data?.metadata?.annotations?.['crossplane.io/paused'] === 'true'
148
145
  }
149
146
 
147
+ export function isExpectedCrossplanePauseCondition(data: any, condition: CrossplaneCondition): boolean {
148
+ return isCrossplanePaused(data) && condition?.type === 'Synced'
149
+ && condition.status === 'False' && condition.reason === 'ReconcilePaused'
150
+ }
151
+
152
+ export function getCrossplanePauseState(data: any): 'requested' | 'acknowledged' | null {
153
+ if (!isCrossplanePaused(data)) return null
154
+ const conditions = data?.status?.conditions
155
+ return Array.isArray(conditions) && conditions.some((condition: CrossplaneCondition) => isExpectedCrossplanePauseCondition(data, condition))
156
+ ? 'acknowledged' : 'requested'
157
+ }
158
+
159
+ export function getCrossplaneIndependentStatus(data: any): GenericStatus | null {
160
+ if (getCrossplanePauseState(data) !== 'acknowledged') return getGenericResourceStatus(data)
161
+ return getGenericResourceStatus({
162
+ ...data,
163
+ status: {
164
+ ...data.status,
165
+ conditions: data.status.conditions.filter((condition: CrossplaneCondition) => !isExpectedCrossplanePauseCondition(data, condition)),
166
+ },
167
+ })
168
+ }
169
+
170
+ export function getCrossplaneConditionTone(data: any, condition: CrossplaneCondition): ConditionTone | undefined {
171
+ return isExpectedCrossplanePauseCondition(data, condition) ? 'neutral' : undefined
172
+ }
173
+
150
174
  /**
151
175
  * Walk ownerReferences to find the parent Composite for a Managed Resource.
152
176
  * Crossplane sets exactly one ownerRef pointing at the composing XR. Returns
@@ -186,16 +210,24 @@ export function getCrossplaneStatus(data: any): StatusBadge {
186
210
  }
187
211
  const ready = findCondition(data, 'Ready')
188
212
  const synced = findCondition(data, 'Synced')
213
+ const independent = !isManagedResource(data) && !isComposite(data) && getCrossplanePauseState(data) === 'acknowledged'
214
+ ? getCrossplaneIndependentStatus(data) : null
215
+ if (independent && (independent.tone === 'unhealthy' || independent.tone === 'alert' || independent.tone === 'degraded')) {
216
+ return { text: independent.text, color: healthColors[independent.tone], level: independent.tone }
217
+ }
189
218
 
190
219
  if (!ready && !synced) {
191
220
  return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
192
221
  }
193
- if (synced?.status === 'False') {
194
- return { text: 'Out of sync', color: healthColors.alert, level: 'alert' }
222
+ if (synced?.status === 'False' && !isExpectedCrossplanePauseCondition(data, synced)) {
223
+ return { text: synced.reason === 'ReconcilePaused' ? 'ReconcilePaused' : 'Out of sync', color: healthColors.alert, level: 'alert' }
195
224
  }
196
225
  if (ready?.status === 'False') {
197
226
  return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
198
227
  }
228
+ if (getCrossplanePauseState(data) === 'acknowledged') {
229
+ return { text: ready?.status === 'True' ? 'Ready (reported)' : 'Readiness unknown', color: healthColors.neutral, level: 'neutral' }
230
+ }
199
231
  if (ready?.status === 'True' && synced?.status === 'True') {
200
232
  return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
201
233
  }
@@ -211,8 +243,12 @@ export function getCrossplaneStatus(data: any): StatusBadge {
211
243
  * detail views.
212
244
  */
213
245
  export function getCrossplaneStatusReason(data: any): string | null {
246
+ const independent = !isManagedResource(data) && !isComposite(data) && getCrossplanePauseState(data) === 'acknowledged'
247
+ ? getCrossplaneIndependentStatus(data) : null
248
+ if (independent && (independent.tone === 'unhealthy' || independent.tone === 'alert' || independent.tone === 'degraded')) return independent.reason ?? null
214
249
  const ready = findCondition(data, 'Ready')
215
- const synced = findCondition(data, 'Synced')
250
+ const reportedSynced = findCondition(data, 'Synced')
251
+ const synced = reportedSynced && !isExpectedCrossplanePauseCondition(data, reportedSynced) ? reportedSynced : undefined
216
252
  if (synced && synced.status !== 'True' && synced.message) return synced.message
217
253
  if (ready && ready.status !== 'True' && ready.message) return ready.message
218
254
  if (synced && synced.status !== 'True' && synced.reason) return synced.reason
@@ -0,0 +1,82 @@
1
+ import { issueTitle } from '../issues/severity'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { readFileSync } from 'node:fs'
4
+ import { renderToString } from 'react-dom/server'
5
+ import { getFlaggerCanaryStatus, getFlaggerStrategy, getFlaggerTargetRef, getFlaggerAutoscalerRef, getFlaggerMetricTemplateRef } from './resource-utils-flagger'
6
+ import { getGenericResourceStatus } from './generic-status'
7
+ import { getCellFilterValue } from './resource-utils'
8
+ import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
9
+
10
+ const vectors = JSON.parse(readFileSync(new URL('../../../../../pkg/health/testdata/flagger.json', import.meta.url), 'utf8'))
11
+ describe('Flagger recorded rollout state across surfaces', () => {
12
+ it('names the failed operation in Issues', () => {
13
+ expect(issueTitle({ category: 'operator_condition_fail', reason: 'FlaggerCanaryFailed' })).toBe('Canary rollout failed')
14
+ })
15
+ for (const vector of vectors) it(vector.name, () => {
16
+ expect(getFlaggerCanaryStatus(vector.object)).toMatchObject({ text: vector.text, level: vector.level })
17
+ expect(getGenericResourceStatus(vector.object)).toMatchObject({ text: vector.text, tone: vector.level })
18
+ expect(getResourceStatus('canaries', vector.object)).toMatchObject({ text: vector.text, level: vector.level })
19
+ expect(getCellFilterValue(vector.object, 'status', 'canaries')).toBe(vector.text)
20
+ })
21
+ })
22
+
23
+ describe('Flagger controller reference semantics', () => {
24
+ const resource = (spec: any) => ({ metadata: { namespace: 'application' }, spec })
25
+ it('resolves native targets with the controller factory, including the Knative Service collision', () => {
26
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Deployment', apiVersion: 'foreign.example/v1', name: 'app' } }))).toMatchObject({ group: 'apps', name: 'app', namespace: 'application' })
27
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Service', apiVersion: 'serving.knative.dev/v1', name: 'app' } }))).toMatchObject({ group: 'serving.knative.dev' })
28
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Service', apiVersion: 'other.example/v1', name: 'app' } }))).toMatchObject({ group: '' })
29
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'FutureKind', name: 'app' } }))).toBeNull()
30
+ })
31
+ it('links exact supported scalers and always uses the typed MetricTemplate client', () => {
32
+ expect(getFlaggerAutoscalerRef(resource({ autoscalerRef: { kind: 'HorizontalPodAutoscaler', name: 'app' } }))).toMatchObject({ group: 'autoscaling', namespace: 'application' })
33
+ expect(getFlaggerAutoscalerRef(resource({ autoscalerRef: { kind: 'ScaledObject', name: 'app' } }))).toMatchObject({ group: 'keda.sh' })
34
+ expect(getFlaggerMetricTemplateRef(resource({}), { templateRef: { apiVersion: 'ignored.example/v1', kind: 'Ignored', name: 'latency', namespace: 'monitoring' } })).toMatchObject({ group: 'flagger.app', name: 'latency', namespace: 'monitoring' })
35
+ expect(getFlaggerMetricTemplateRef(resource({}), { templateRef: { name: 'latency' } })).toMatchObject({ namespace: 'application' })
36
+ expect(getFlaggerMetricTemplateRef(resource({}), { name: 'request-success-rate' })).toBeNull()
37
+ })
38
+ it('distinguishes strategies, including the actually served deprecated analysis field', () => {
39
+ expect(getFlaggerStrategy(resource({ analysis: { iterations: 3 } }))).toBe('Blue/green')
40
+ expect(getFlaggerStrategy(resource({ analysis: { iterations: 3, match: [{}] } }))).toBe('A/B testing')
41
+ expect(getFlaggerStrategy(resource({ canaryAnalysis: { iterations: 3, mirror: true } }))).toBe('Blue/green with mirroring')
42
+ expect(getFlaggerStrategy(resource({ analysis: { stepWeight: 10 } }))).toBe('Canary')
43
+ })
44
+ })
45
+
46
+ describe('Flagger drawer identity and useful states', () => {
47
+ function render(apiVersion: string, kind: string, spec = {}, status = {}) {
48
+ return renderToString(<ResourceRendererDispatch resource={{ kind: kind === 'Canary' ? 'canaries' : 'metrictemplates', namespace: 'test', name: 'test' }} data={{ apiVersion, kind, metadata: { name: 'test', namespace: 'test' }, spec, status }} onCopy={() => {}} copied={null} showCommonSections={false} />)
49
+ }
50
+ it('preserves zero reported weight, failure under suspension, and keeps built-in metrics distinct', () => {
51
+ const html = render('flagger.app/v1beta1', 'Canary', { suspend: true, analysis: { stepWeight: 10, metrics: [{ name: 'request-success-rate' }, { name: 'custom', query: 'sensitive-inline-query' }] } }, { phase: 'Failed', canaryWeight: 0 })
52
+ expect(html).toContain('0%')
53
+ expect(html).toContain('Failed')
54
+ expect(html).toContain('Built-in provider metric')
55
+ expect(html).toContain('Inline query')
56
+ expect(html).toContain('sensitive-inline-query')
57
+ })
58
+ it('does not show weight for blue/green or provider headers in native details', () => {
59
+ expect(render('flagger.app/v1beta1', 'Canary', { analysis: { iterations: 2 } }, { canaryWeight: 10 })).not.toContain('Reported canary weight')
60
+ expect(render('flagger.app/v1beta1', 'MetricTemplate', { provider: { type: 'prometheus', headers: { 'X-Key': 'private-value' } }, query: 'vector(1)' })).not.toContain('private-value')
61
+ })
62
+ it('shows the controller-default rollout hook when type is omitted', () => {
63
+ const html = render('flagger.app/v1beta1', 'Canary', { analysis: { webhooks: [{ name: 'load-test' }, { name: 'approve', type: 'confirm-rollout' }] } }, { phase: 'Waiting' })
64
+ expect(html).toContain('load-test')
65
+ expect(html).toContain('rollout')
66
+ expect(html).toContain('approve')
67
+ expect(html).toContain('confirm-rollout')
68
+ })
69
+ it('describes an active confirmation wait only while not suspended', () => {
70
+ const analysis = { webhooks: [{ name: 'approve', type: 'confirm-rollout' }] }
71
+ expect(render('flagger.app/v1beta1', 'Canary', { analysis }, { phase: 'Waiting' })).toContain('Flagger is waiting for a confirmation hook')
72
+ const suspended = render('flagger.app/v1beta1', 'Canary', { suspend: true, analysis }, { phase: 'Waiting' })
73
+ expect(suspended).not.toContain('Flagger is waiting for a confirmation hook')
74
+ expect(suspended).toContain('approve')
75
+ expect(suspended).toContain('Recorded phase')
76
+ })
77
+ for (const kind of ['Canary', 'MetricTemplate']) it(`keeps foreign ${kind} generic`, () => {
78
+ const html = render('other.example/v1', kind, { custom: 'generic-visible' })
79
+ expect(html).toContain('generic-visible')
80
+ expect(html).not.toContain(kind === 'Canary' ? 'Canary Rollout' : 'Metric Provider')
81
+ })
82
+ })
@@ -0,0 +1,76 @@
1
+ import { isApiGroup } from './resource-utils-cnpg'
2
+ import type { ResourceRef } from '../../types'
3
+ import { objectReferenceToResourceRef } from '../../utils/navigation'
4
+ import type { StatusBadge } from './resource-utils'
5
+ import { healthColors } from './resource-utils'
6
+
7
+ export function getFlaggerCanaryStatus(data: any): StatusBadge {
8
+ const phase = data.status?.phase
9
+ let text = phase || 'Not reported'
10
+ let level: StatusBadge['level'] = 'unknown'
11
+ if (phase === 'Failed') level = 'unhealthy'
12
+ else if (!phase && data.spec?.suspend === true) { text = 'Suspend requested'; level = 'neutral' }
13
+ else if (phase === 'Succeeded') level = data.spec?.suspend === true ? 'neutral' : 'healthy'
14
+ else if (['Initializing', 'Initialized', 'Waiting', 'Progressing', 'WaitingPromotion', 'Promoting', 'Finalising', 'Terminating', 'Terminated'].includes(phase)) level = 'neutral'
15
+ return { text, level, color: healthColors[level] }
16
+ }
17
+
18
+ // Both fields exist in the served v1beta1 schema. The controller's GetAnalysis
19
+ // explicitly prefers analysis and still accepts deprecated canaryAnalysis.
20
+ export function getFlaggerAnalysis(data: any): any {
21
+ return data.spec?.analysis ?? data.spec?.canaryAnalysis ?? {}
22
+ }
23
+
24
+ export function getFlaggerStrategy(data: any): string {
25
+ const analysis = getFlaggerAnalysis(data)
26
+ if (analysis.iterations > 0) {
27
+ if (analysis.match?.length > 0) return 'A/B testing'
28
+ return analysis.mirror ? 'Blue/green with mirroring' : 'Blue/green'
29
+ }
30
+ return analysis.mirror ? 'Canary with mirroring' : 'Canary'
31
+ }
32
+
33
+ // Match the pinned controller factory: it chooses its native clients by kind,
34
+ // not arbitrary targetRef.apiVersion (except the exact Knative Service case).
35
+ export function getFlaggerTargetRef(data: any): ResourceRef | null {
36
+ const target = data.spec?.targetRef
37
+ if (!target?.name) return null
38
+ let apiVersion: string
39
+ if (target.kind === 'Deployment' || target.kind === 'DaemonSet') apiVersion = 'apps/v1'
40
+ else if (target.kind === 'Service') apiVersion = target.apiVersion === 'serving.knative.dev/v1' ? target.apiVersion : 'v1'
41
+ else return null
42
+ return objectReferenceToResourceRef({ ...target, apiVersion, namespace: data.metadata?.namespace })
43
+ }
44
+
45
+ export function getFlaggerAutoscalerRef(data: any): ResourceRef | null {
46
+ const scaler = data.spec?.autoscalerRef
47
+ const apiVersion = scaler?.kind === 'HorizontalPodAutoscaler' ? 'autoscaling/v2' : scaler?.kind === 'ScaledObject' ? 'keda.sh/v1alpha1' : null
48
+ return apiVersion ? objectReferenceToResourceRef({ ...scaler, apiVersion, namespace: data.metadata?.namespace }) : null
49
+ }
50
+
51
+ // scheduler_metrics.go reads only templateRef.name/namespace through its typed
52
+ // MetricTemplate client. apiVersion/kind are ignored, so do not link elsewhere.
53
+ export function getFlaggerMetricTemplateRef(data: any, metric: any): ResourceRef | null {
54
+ return metric.templateRef?.name ? objectReferenceToResourceRef({ apiVersion: 'flagger.app/v1beta1', kind: 'MetricTemplate', name: metric.templateRef.name, namespace: metric.templateRef.namespace || data.metadata?.namespace }) : null
55
+ }
56
+
57
+ export function getFlaggerColumnValue(data: any, column: string): string | undefined {
58
+ if (!isApiGroup(data?.apiVersion, 'flagger.app')) return undefined
59
+ if (data.kind === 'Canary') {
60
+ switch (column) {
61
+ case 'status': return getFlaggerCanaryStatus(data).text
62
+ case 'strategy': return getFlaggerStrategy(data)
63
+ case 'target': return data.spec?.targetRef?.name ? `${data.spec.targetRef.kind}/${data.spec.targetRef.name}` : ''
64
+ case 'canaryWeight': return getFlaggerAnalysis(data).iterations > 0 ? 'Not weighted' : data.status?.canaryWeight != null ? `${data.status.canaryWeight}%` : 'Not reported'
65
+ case 'suspended': return data.spec?.suspend === true ? 'Yes' : 'No'
66
+ }
67
+ }
68
+ if (data.kind === 'MetricTemplate') {
69
+ switch (column) {
70
+ case 'status': return 'Configured'
71
+ case 'provider': return data.spec?.provider?.type ?? ''
72
+ case 'address': return data.spec?.provider?.address ?? ''
73
+ }
74
+ }
75
+ return undefined
76
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { renderToString } from 'react-dom/server'
4
+ import { getCollectorPipelines, getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from './resource-utils-opentelemetry'
5
+ import { getGenericResourceStatus } from './generic-status'
6
+ import { getCellFilterValue } from './resource-utils'
7
+ import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
8
+
9
+ const vectors = JSON.parse(readFileSync(new URL('../../../../../pkg/health/testdata/opentelemetry.json', import.meta.url), 'utf8'))
10
+
11
+ describe('OpenTelemetry status across surfaces', () => {
12
+ for (const vector of vectors) it(vector.name, () => {
13
+ const resource = vector.object
14
+ const kind = resource.kind === 'Instrumentation' ? 'instrumentations' : 'opentelemetrycollectors'
15
+ const state = resource.kind === 'Instrumentation' ? getOpenTelemetryInstrumentationStatus(resource) : getOpenTelemetryCollectorStatus(resource)
16
+ expect(state).toMatchObject({ text: vector.text, level: vector.level })
17
+ expect(getGenericResourceStatus(resource)).toMatchObject({ text: vector.text, tone: vector.level })
18
+ expect(getCellFilterValue(resource, 'status', kind)).toBe(vector.text)
19
+ expect(getResourceStatus(kind, resource)).toMatchObject({ text: vector.text, level: vector.level })
20
+ })
21
+ })
22
+
23
+ describe('Collector pipeline declarations', () => {
24
+ const config = { connectors: { spanmetrics: {} }, service: { pipelines: { traces: { receivers: ['otlp'], processors: ['batch'], exporters: ['spanmetrics'] }, metrics: { receivers: ['spanmetrics'], exporters: ['debug'] } } } }
25
+ it('reads beta structured and genuinely served alpha YAML configuration', () => {
26
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1beta1', spec: { config } })).toMatchObject({ connectors: ['spanmetrics'], pipelines: [{ name: 'traces', receivers: ['otlp'], processors: ['batch'], exporters: ['spanmetrics'] }, { name: 'metrics' }] })
27
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: 'service:\n pipelines:\n traces:\n receivers: [otlp]\n exporters: [debug]\n' } }).pipelines).toHaveLength(1)
28
+ })
29
+ it('reports unavailable and malformed previews instead of a false empty configuration', () => {
30
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: '[broken' } }).error).toContain('Inspect YAML')
31
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1beta1', spec: { config: 'service: {}' } }).error).toBeTruthy()
32
+ expect(getCollectorPipelines({ spec: { config: { service: { pipelines: [] } } } }).error).toBeTruthy()
33
+ for (const service of ['invalid', 1, false, null, []]) {
34
+ expect(getCollectorPipelines({ spec: { config: { service } } }).error).toContain('Inspect YAML')
35
+ }
36
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: 'service: invalid' } }).error).toContain('Inspect YAML')
37
+ for (const pipeline of ['invalid', 1, false, null, []]) {
38
+ expect(getCollectorPipelines({ spec: { config: { service: { pipelines: { traces: pipeline } } } } }).error).toContain('Inspect YAML')
39
+ }
40
+ expect(getCollectorPipelines({ spec: { config: {} } }).error).toBeUndefined()
41
+ })
42
+ it('bounds alpha YAML size and alias expansion before previewing pipelines', () => {
43
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: ' '.repeat(1_000_001) } }).error).toContain('too large')
44
+ const aliases = 'base: &a [otlp, otlp, otlp, otlp, otlp, otlp, otlp, otlp, otlp, otlp]\nlevel1: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]\nlevel2: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]\nservice:\n pipelines:\n traces:\n receivers: *c\n'
45
+ expect(getCollectorPipelines({ apiVersion: 'opentelemetry.io/v1alpha1', spec: { config: aliases } }).error).toContain('Inspect YAML')
46
+ })
47
+
48
+ })
49
+
50
+ describe('OpenTelemetry drawer identity and meaning', () => {
51
+ function render(apiVersion: string, kind: string, spec = {}, status = {}) {
52
+ return renderToString(<ResourceRendererDispatch resource={{ kind: kind === 'Instrumentation' ? 'instrumentations' : 'opentelemetrycollectors', namespace: 'test', name: 'test' }} data={{ apiVersion, kind, metadata: { name: 'test', namespace: 'test' }, spec, status }} onCopy={() => {}} copied={null} showCommonSections={false} />)
53
+ }
54
+ it('renders native reconciliation/pipeline context only for the exact group', () => {
55
+ expect(render('opentelemetry.io/v1beta1', 'OpenTelemetryCollector')).toContain('Reconciliation')
56
+ expect(render('other.example/v1', 'OpenTelemetryCollector')).not.toContain('Configured Pipelines')
57
+ expect(render('other.example/v1', 'OpenTelemetryCollector', { custom: 'generic-visible' })).toContain('generic-visible')
58
+ })
59
+ it('retains an unmanaged condition without presenting it as a current failure', () => {
60
+ const status = { conditions: [{ type: 'Ready', status: 'False', reason: 'ReconcileError' }] }
61
+ const managed = render('opentelemetry.io/v1beta1', 'OpenTelemetryCollector', { managementState: 'managed' }, status)
62
+ expect(managed).toContain('1 failing')
63
+ const unmanaged = render('opentelemetry.io/v1beta1', 'OpenTelemetryCollector', { managementState: 'unmanaged' }, status)
64
+ expect(unmanaged).toContain('ReconcileError')
65
+ expect(unmanaged).not.toContain('1 failing')
66
+ })
67
+ it('does not claim instrumentation readiness or dump arbitrary env values', () => {
68
+ const html = render('opentelemetry.io/v1alpha1', 'Instrumentation', { env: [{ name: 'TOKEN', value: 'do-not-surface' }] })
69
+ expect(html).toContain('Configured')
70
+ expect(html).toContain('does not report which Pods')
71
+ expect(html).not.toContain('do-not-surface')
72
+ expect(render('other.example/v1', 'Instrumentation', { custom: 'generic-visible' })).toContain('generic-visible')
73
+ })
74
+ })
@@ -0,0 +1,97 @@
1
+ import { isApiGroup } from './resource-utils-cnpg'
2
+ import { parseDocument } from 'yaml'
3
+ import type { StatusBadge } from './resource-utils'
4
+ import { healthColors } from './resource-utils'
5
+
6
+ // Ready describes reconciliation only. In the operator, Ready=True can coexist
7
+ // with 0/1 ready Pods. Never derive runtime health from this condition or scale.
8
+ export function getOpenTelemetryCollectorStatus(data: any): StatusBadge {
9
+ const badge = (text: string, level: StatusBadge['level']): StatusBadge => ({ text, level, color: healthColors[level] })
10
+ if (data.spec?.managementState === 'unmanaged') return badge('Unmanaged', 'neutral')
11
+ const ready = data.status?.conditions?.find((c: any) => c.type === 'Ready')
12
+ if (!ready) return badge('Not reported', 'unknown')
13
+ const observed = data.status?.observedGeneration
14
+ if (observed > 0 && observed < data.metadata?.generation) return badge('Status stale', 'unknown')
15
+ if (ready.status === 'False') return badge(ready.reason === 'StatusUpdateFailed' ? 'Status unavailable' : 'Reconcile failed', ready.reason === 'StatusUpdateFailed' ? 'unknown' : 'unhealthy')
16
+ if (ready.status === 'True') return badge('Reconciled', 'neutral')
17
+ return badge('Reconciliation unknown', 'unknown')
18
+ }
19
+
20
+ export function getOpenTelemetryInstrumentationStatus(data: any): StatusBadge {
21
+ const blocked = Object.keys(data.status?.upgradeBlockedVersions ?? {}).length > 0
22
+ const level = 'neutral'
23
+ return { text: blocked ? 'Upgrade blocked' : 'Configured', level, color: healthColors[level] }
24
+ }
25
+
26
+ export interface CollectorPipeline {
27
+ name: string
28
+ receivers: string[]
29
+ processors: string[]
30
+ exporters: string[]
31
+ }
32
+
33
+ export interface CollectorPipelines {
34
+ pipelines: CollectorPipeline[]
35
+ connectors: string[]
36
+ error?: string
37
+ }
38
+
39
+ export function getCollectorPipelines(data: any): CollectorPipelines {
40
+ let config = data.spec?.config
41
+ // These are two genuinely served API versions, not a response-shape fallback.
42
+ if (data.apiVersion === 'opentelemetry.io/v1alpha1' && typeof config === 'string') {
43
+ if (config.length > 1_000_000) return { pipelines: [], connectors: [], error: 'Configuration is too large for a pipeline preview. Inspect YAML.' }
44
+ try {
45
+ const document = parseDocument(config)
46
+ if (document.errors.length) throw new Error('Invalid YAML')
47
+ config = document.toJS({ maxAliasCount: 100 })
48
+ } catch {
49
+ return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
50
+ }
51
+ }
52
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
53
+ return { pipelines: [], connectors: [], error: 'No structured pipeline configuration is available. Inspect YAML.' }
54
+ }
55
+ const service = config.service
56
+ if (service !== undefined && (!service || typeof service !== 'object' || Array.isArray(service))) {
57
+ return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
58
+ }
59
+ const declared = service?.pipelines
60
+ if (declared != null && (typeof declared !== 'object' || Array.isArray(declared))) {
61
+ return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
62
+ }
63
+ if (Object.values(declared ?? {}).some(pipeline => !pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline))) {
64
+ return { pipelines: [], connectors: [], error: 'Pipeline configuration could not be read. Inspect YAML.' }
65
+ }
66
+ const names = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
67
+ return {
68
+ pipelines: Object.entries(declared ?? {}).map(([name, pipeline]: [string, any]) => ({
69
+ name, receivers: names(pipeline?.receivers), processors: names(pipeline?.processors), exporters: names(pipeline?.exporters),
70
+ })),
71
+ connectors: Object.keys(config.connectors ?? {}),
72
+ }
73
+ }
74
+
75
+ // A single reader backs native cells, their filters and sorting.
76
+ export function getOpenTelemetryColumnValue(data: any, column: string): string | undefined {
77
+ if (!isApiGroup(data?.apiVersion, 'opentelemetry.io')) return undefined
78
+ if (data.kind === 'OpenTelemetryCollector') {
79
+ switch (column) {
80
+ case 'status': return getOpenTelemetryCollectorStatus(data).text
81
+ case 'mode': return data.spec?.mode ?? ''
82
+ case 'reportedPods': return data.status?.scale?.statusReplicas ?? ''
83
+ case 'version': return data.status?.version ?? ''
84
+ case 'image': return data.status?.image ?? ''
85
+ case 'management': return data.spec?.managementState ?? ''
86
+ }
87
+ }
88
+ if (data.kind === 'Instrumentation') {
89
+ switch (column) {
90
+ case 'status': return getOpenTelemetryInstrumentationStatus(data).text
91
+ case 'endpoint': return data.spec?.exporter?.endpoint ?? ''
92
+ case 'sampler': return data.spec?.sampler?.type ?? ''
93
+ case 'samplerArgument': return data.spec?.sampler?.argument ?? ''
94
+ }
95
+ }
96
+ return undefined
97
+ }
@@ -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
  // ============================================================================