@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
@@ -108,8 +108,8 @@ describe('curated column ownership', () => {
108
108
  // Brace-counting, not parsing: assert the extraction actually saw the whole
109
109
  // table, or a silently-missed key would let the guard above pass vacuously.
110
110
  it('extracted every curated key', () => {
111
- expect(CURATED.length).toBe(240)
112
- expect(OWNED.size).toBe(202)
111
+ expect(CURATED.length).toBe(244)
112
+ expect(OWNED.size).toBe(206)
113
113
  expect(CURATED).toContain('pods')
114
114
  expect(CURATED).toContain('crossplanemanagedresources')
115
115
  expect([...OWNED]).toContain('awsmanagedcontrolplanes')
@@ -166,7 +166,7 @@ describe('every declaration resolves', () => {
166
166
  .map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const)
167
167
 
168
168
  it('covers the whole ownership table', () => {
169
- expect(DECLARED.length).toBe(202)
169
+ expect(DECLARED.length).toBe(206)
170
170
  })
171
171
 
172
172
  it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => {
@@ -1,3 +1,6 @@
1
+ import { getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from './resource-utils-opentelemetry'
2
+ import { getFlaggerCanaryStatus } from './resource-utils-flagger'
3
+ import { isApiGroup } from './resource-utils-cnpg'
1
4
  import type { HealthLevel } from './resource-utils'
2
5
  import { getGatewayPolicyStatus } from './gateway-policy-status'
3
6
 
@@ -88,6 +91,18 @@ function fromCondition(cond: any, polarityKnown: boolean): GenericStatus | null
88
91
  * callers render their own placeholder rather than inventing one.
89
92
  */
90
93
  export function getGenericResourceStatus(resource: any): GenericStatus | null {
94
+ if (isApiGroup(resource?.apiVersion, 'opentelemetry.io')) {
95
+ const native = resource.kind === 'OpenTelemetryCollector' ? getOpenTelemetryCollectorStatus(resource)
96
+ : resource.kind === 'Instrumentation' ? getOpenTelemetryInstrumentationStatus(resource) : null
97
+ if (native) return { text: native.text, tone: native.level }
98
+ }
99
+ if (isApiGroup(resource?.apiVersion, 'flagger.app')) {
100
+ if (resource.kind === 'Canary') {
101
+ const native = getFlaggerCanaryStatus(resource)
102
+ return { text: native.text, tone: native.level }
103
+ }
104
+ if (resource.kind === 'MetricTemplate') return { text: 'Configured', tone: 'neutral' }
105
+ }
91
106
  const status = resource?.status
92
107
  if (!status || typeof status !== 'object') return null
93
108
 
@@ -296,3 +296,39 @@ describe('printerTableKey collision safety', () => {
296
296
  })
297
297
  })
298
298
 
299
+
300
+
301
+ describe('Crossplane condition source provenance', () => {
302
+ const resource = {
303
+ metadata: { annotations: { 'crossplane.io/paused': 'true' } },
304
+ spec: { resourceRefs: [] },
305
+ status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }, { type: 'Ready', status: 'False', reason: 'Unavailable' }] },
306
+ }
307
+ const synced = { name: 'Synced', jsonPath: ".status.conditions[?(@.type=='Synced')].status" }
308
+ const ready = { name: 'Ready', jsonPath: '.status.conditions[?(@.type=="Ready")].status' }
309
+
310
+ it('keeps condition source when sanitizing the table response', () => {
311
+ const table = sanitizePrinterTable({ columns: [synced, ready], cells: { a: ['False', 'False'] } })
312
+ expect(table?.columns[0].jsonPath).toBe(synced.jsonPath)
313
+ expect(table?.cells.a).toEqual(['False', 'False'])
314
+ })
315
+
316
+ it('neutralizes only the actual expected Synced observation and recorded Ready=True', () => {
317
+ expect(printerCellTone(synced, 'False', resource)).toBe('neutral')
318
+ expect(printerCellTone(ready, 'True', resource)).toBe('neutral')
319
+ expect(printerCellTone(ready, 'False', resource)).toBe('unhealthy')
320
+ expect(printerCellTone(ready, 'Unknown', resource)).toBe('unknown')
321
+ })
322
+
323
+ it.each([undefined, '.status.unrelated', '.spec.synced'])('keeps same-named unrelated or older-server columns conservative: %s', jsonPath => {
324
+ expect(printerCellTone({ name: 'Synced', jsonPath }, 'False', resource)).toBe('unhealthy')
325
+ expect(printerCellTone({ name: 'Ready', jsonPath }, 'True', resource)).toBe('healthy')
326
+ })
327
+
328
+ it('retains reconcile errors and missing annotation', () => {
329
+ const error = { ...resource, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcileError' }] } }
330
+ expect(printerCellTone(synced, 'False', error)).toBe('unhealthy')
331
+ expect(printerCellTone(synced, 'False', { ...resource, metadata: {} })).toBe('unhealthy')
332
+ expect(printerCellTone(synced, 'False', { ...resource, spec: {} })).toBe('neutral')
333
+ })
334
+ })
@@ -1,4 +1,5 @@
1
1
  import type { HealthLevel } from './resource-utils'
2
+ import { getCrossplanePauseState } from './resource-utils-crossplane'
2
3
  import { KNOWN_NEGATIVE_CONDITIONS, KNOWN_POSITIVE_CONDITIONS } from './generic-status'
3
4
 
4
5
  // Vendor-declared table columns from a CRD's additionalPrinterColumns,
@@ -9,6 +10,8 @@ import { KNOWN_NEGATIVE_CONDITIONS, KNOWN_POSITIVE_CONDITIONS } from './generic-
9
10
  /** One column definition, as the CRD declared it. */
10
11
  export interface PrinterColumnDef {
11
12
  name: string
13
+ /** Source provenance; absent when a supported older Radar omits it. Never evaluated by the UI. */
14
+ jsonPath?: string
12
15
  type?: string
13
16
  format?: string
14
17
  /** The vendor's own text. Absent for most CRDs, which declare none. */
@@ -70,6 +73,7 @@ export function sanitizePrinterTable(raw: any): PrinterTable | null {
70
73
  columns.push({
71
74
  name,
72
75
  type: typeof c.type === 'string' ? c.type : undefined,
76
+ ...(typeof c.jsonPath === 'string' ? { jsonPath: c.jsonPath } : {}),
73
77
  format: typeof c.format === 'string' ? c.format : undefined,
74
78
  description: typeof c.description === 'string' ? c.description : undefined,
75
79
  priority: typeof c.priority === 'number' ? c.priority : 0,
@@ -230,7 +234,7 @@ function truthiness(value: unknown): 'true' | 'false' | 'unknown' | null {
230
234
  * fine; rendering that as grey text throws away the only scannable signal on
231
235
  * the kinds that have no curated Status column.
232
236
  */
233
- export function printerCellTone(def: PrinterColumnDef, value: unknown): HealthLevel | null {
237
+ export function printerCellTone(def: PrinterColumnDef, value: unknown, resource?: any): HealthLevel | null {
234
238
  const state = truthiness(value)
235
239
  if (!state) return null
236
240
  // The NAME decides whether we say anything at all — including for Unknown.
@@ -241,6 +245,13 @@ export function printerCellTone(def: PrinterColumnDef, value: unknown): HealthLe
241
245
  const degraded = DEGRADED_COLUMN_NAMES.has(name)
242
246
  const terminal = TERMINAL_COLUMN_NAMES.has(name)
243
247
  if (!positive && !degraded && !terminal) return null
248
+ // A vendor column name alone does not establish which condition it reads.
249
+ const conditionSource = /^\.status\.conditions\[\?\(@\.type==(['"])(Synced|Ready)\1\)\]\.status$/.exec(def.jsonPath ?? '')?.[2]
250
+ if (positive && conditionSource
251
+ && getCrossplanePauseState(resource) === 'acknowledged'
252
+ && ((conditionSource === 'Synced' && state === 'false') || (conditionSource === 'Ready' && state === 'true'))) {
253
+ return 'neutral'
254
+ }
244
255
  // Unknown is not a failure — the controller could not evaluate it.
245
256
  if (state === 'unknown') return 'unknown'
246
257
  if (positive) return state === 'true' ? 'healthy' : 'unhealthy'
@@ -0,0 +1,95 @@
1
+ import clockVectors from '../../../../../pkg/health/testdata/readiness_gate_clock.json'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { getPodPhaseDisplay, getPodStatus, getPodProblems, podMatchesProblemCategory } from './resource-utils'
4
+ afterEach(() => vi.useRealTimers())
5
+ const makePod = (): any => ({ metadata: { creationTimestamp: '2026-10-25T10:00:00Z' }, spec: { readinessGates: [{ conditionType: 'example.com/ready' }] }, status: { phase: 'Running', conditions: [
6
+ { type: 'Ready', status: 'False', reason: 'ReadinessGatesNotReady', message: 'gate has not reported ready', lastTransitionTime: '2026-10-25T11:50:00Z' },
7
+ { type: 'ContainersReady', status: 'True', lastTransitionTime: '2026-10-25T11:53:00Z' },
8
+ ], containerStatuses: [{ name: 'app', ready: true, state: { running: {} } }] } })
9
+ describe('declared readiness gates', () => {
10
+ it.each(['missing', 'False', 'True', 'Unknown'])('handles %s gate', status => {
11
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
12
+ const p=makePod();if(status!=='missing')p.status.conditions.push({ type:'example.com/ready',status })
13
+ const blocked=status!=='True'
14
+ expect(getPodStatus(p).level).toBe(blocked?'degraded':'healthy')
15
+ expect(getPodPhaseDisplay(p).level).toBe(blocked?'degraded':'healthy')
16
+ const problems=getPodProblems(p)
17
+ expect(podMatchesProblemCategory(problems,0,'Not Ready')).toBe(blocked)
18
+ expect(podMatchesProblemCategory(problems,0,'Other')).toBe(false)
19
+ if(blocked)expect(getPodPhaseDisplay(p)).toMatchObject({text:'Running — Readiness gate blocked',hint:'gate has not reported ready'})
20
+ })
21
+ it.each([0,1])('keeps grace for transition %s', index => {
22
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
23
+ const p=makePod();p.status.conditions[index].lastTransitionTime='2026-10-25T11:59:00Z'
24
+ expect(getPodStatus(p).level).toBe('healthy');expect(getPodPhaseDisplay(p).level).toBe('healthy')
25
+ })
26
+ it('leaves graceful termination to the lifecycle overlay',()=>{
27
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
28
+ const p=makePod();p.metadata.deletionTimestamp='2026-10-25T11:59:00Z'
29
+ expect(getPodStatus(p).level).toBe('neutral');expect(getPodPhaseDisplay(p).text).toContain('Terminating')
30
+ expect(podMatchesProblemCategory(getPodProblems(p),0,'Not Ready')).toBe(false)
31
+ p.status.containerStatuses[0].state={waiting:{reason:'ImagePullBackOff'}}
32
+ expect(getPodStatus(p).level).toBe('unhealthy');expect(getPodPhaseDisplay(p).level).toBe('unhealthy')
33
+ })
34
+ it('requires a declaration even with an older summary payload',()=>{
35
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
36
+ const p=makePod();delete p.spec.readinessGates;expect(getPodStatus(p).level).toBe('healthy')
37
+ })
38
+ it('preserves other readiness meanings and fatal failures',()=>{
39
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
40
+ const p=makePod();p.status.conditions[0].reason='NodeNotReady';expect(getPodStatus(p).level).toBe('healthy')
41
+ p.status.conditions[0].reason='ReadinessGatesNotReady';p.status.containerStatuses[0].state={waiting:{reason:'ImagePullBackOff'}}
42
+ expect(getPodStatus(p).level).toBe('unhealthy');expect(getPodPhaseDisplay(p).level).toBe('unhealthy')
43
+ })
44
+ it('keeps recent crash history ahead of gate text',()=>{
45
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
46
+ const p=makePod();p.status.containerStatuses[0]={name:'app',ready:true,restartCount:2,state:{running:{startedAt:'2026-10-25T11:59:55Z'}},lastState:{terminated:{reason:'Error',exitCode:1,finishedAt:'2026-10-25T11:59:50Z'}}}
47
+ expect(getPodStatus(p).text).not.toContain('gate')
48
+ expect(getPodPhaseDisplay(p).text).not.toContain('gate')
49
+ })
50
+
51
+ it.each([['recent',false,'2026-10-25T11:59:00Z','unhealthy'],['settled',false,'2026-10-25T11:54:00Z','degraded'],['probe passed',true,'2026-10-25T11:59:00Z','degraded']])('keeps %s OOM recovery ahead of gate status',(_name,probe,startedAt,want)=>{
52
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
53
+ const p=makePod();p.spec.containers=[{name:'app',...(probe?{readinessProbe:{}}:{})}]
54
+ p.status.containerStatuses[0]={name:'app',ready:true,restartCount:1,state:{running:{startedAt}},lastState:{terminated:{reason:'OOMKilled',exitCode:137,finishedAt:'2026-10-25T11:58:00Z'}}}
55
+ expect(getPodStatus(p).level).toBe(want);expect(getPodPhaseDisplay(p).level).toBe(want)
56
+ if(want==='unhealthy'){expect(getPodStatus(p).text).toBe('OOMKilled');expect(getPodPhaseDisplay(p).text).toContain('OOMKilled')}
57
+ })
58
+
59
+ it.each(['missing', 'recent', 'settled', 'completed'])('handles %s init OOM recovery with a gate', state => {
60
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
61
+ const p = makePod()
62
+ p.status.initContainerStatuses = [{name:'init',lastState:{terminated:{reason:'OOMKilled'}},state:state==='completed'?{terminated:{exitCode:0}}:{running:state==='missing'?{}:{startedAt:state==='recent'?'2026-10-25T11:59:00Z':'2026-10-25T11:54:00Z'}}}]
63
+ const want = state==='missing'||state==='recent'?'unhealthy':'degraded'
64
+ expect(getPodStatus(p).level).toBe(want)
65
+ expect(getPodPhaseDisplay(p).level).toBe(want)
66
+ })
67
+
68
+ it.each([0,1])('does not grant grace for future transition %s', index => {
69
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
70
+ const p = makePod()
71
+ p.status.conditions[index].lastTransitionTime='2026-10-25T13:00:00Z'
72
+ p.status.conditions[1-index].lastTransitionTime='2026-10-25T11:53:00Z'
73
+ expect(getPodStatus(p).level).toBe('degraded')
74
+ expect(getPodPhaseDisplay(p).level).toBe('degraded')
75
+ })
76
+ it('uses recent creation only as grace for missing transition times', () => {
77
+ vi.useFakeTimers().setSystemTime(new Date('2026-10-25T12:00:00Z'))
78
+ const p = makePod()
79
+ p.status.conditions.forEach((c:any) => delete c.lastTransitionTime)
80
+ p.metadata.creationTimestamp='2026-10-25T11:59:00Z'
81
+ expect(getPodStatus(p).level).toBe('healthy')
82
+ p.metadata.creationTimestamp='2026-10-25T13:00:00Z'
83
+ expect(getPodStatus(p).level).toBe('degraded')
84
+ })
85
+
86
+ })
87
+
88
+ describe('readiness-gate clock contract',()=>{
89
+ it.each(clockVectors.vectors)('$name',v=>{
90
+ vi.useFakeTimers().setSystemTime(new Date(clockVectors.now))
91
+ expect(getPodStatus(v.object).level).toBe(v.blocked?'degraded':'healthy')
92
+ expect(getPodPhaseDisplay(v.object).level).toBe(v.blocked?'degraded':'healthy')
93
+ expect(podMatchesProblemCategory(getPodProblems(v.object),0,'Not Ready')).toBe(v.blocked)
94
+ })
95
+ })
@@ -1,5 +1,5 @@
1
1
  import { clsx } from 'clsx'
2
- import { Layers, GitBranch, Box, ChevronRight, Pause } from 'lucide-react'
2
+ import { Layers, GitBranch, Box, ChevronRight } from 'lucide-react'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
4
4
  import { Tooltip } from '../../ui/Tooltip'
5
5
  import {
@@ -11,9 +11,11 @@ import {
11
11
  getCrossplaneResourceRefs,
12
12
  getBoundXRRef,
13
13
  isClaim,
14
- isCrossplanePaused,
14
+ getCrossplaneConditionTone,
15
+ getCrossplanePauseState,
15
16
  type CrossplaneResourceRef,
16
17
  } from '../resource-utils-crossplane'
18
+ import { CrossplanePauseNotice, CrossplanePauseChip } from './CrossplanePauseContext'
17
19
  import { kindToPluralWithGroup } from '../../../utils/navigation'
18
20
  import type { StatusBadge } from '../resource-utils'
19
21
 
@@ -33,6 +35,7 @@ export interface ComposedRefStatus {
33
35
  ref: CrossplaneResourceRef
34
36
  /** undefined means we haven't fetched yet (or the host doesn't support it) */
35
37
  status?: StatusBadge
38
+ pauseState?: ReturnType<typeof getCrossplanePauseState>
36
39
  /** true while the fetch is in flight */
37
40
  loading?: boolean
38
41
  /** 404 — composed resource not created yet (normal during reconciliation) */
@@ -98,20 +101,12 @@ export function CompositeRenderer({ data, onNavigate, composedRefStatuses, compo
98
101
  const resourceRefs = composedRefs ?? getCrossplaneResourceRefs(data)
99
102
  const claim = isClaim(data)
100
103
  const boundXR = claim ? getBoundXRRef(data) : null
101
- const paused = isCrossplanePaused(data)
102
104
  const alertVariant = status.level === 'unhealthy' || status.level === 'alert' ? status.level : null
103
105
  const operationalIssuesShown = useOperationalIssuesShown()
104
106
 
105
107
  return (
106
108
  <>
107
- {paused && (
108
- <AlertBanner
109
- variant="warning"
110
- icon={Pause}
111
- title="Reconciliation paused"
112
- message="The crossplane.io/paused annotation is set. Composed resources will not be reconciled until the annotation is removed."
113
- />
114
- )}
109
+ <CrossplanePauseNotice data={data} />
115
110
 
116
111
  {alertVariant && reason && !operationalIssuesShown && (
117
112
  <AlertBanner
@@ -190,7 +185,7 @@ export function CompositeRenderer({ data, onNavigate, composedRefStatuses, compo
190
185
  )}
191
186
  </Section>
192
187
 
193
- <ConditionsSection conditions={data?.status?.conditions} />
188
+ <ConditionsSection conditions={data?.status?.conditions} getConditionTone={condition => getCrossplaneConditionTone(data, condition)} />
194
189
  </>
195
190
  )
196
191
  }
@@ -263,6 +258,7 @@ function ComposedRefRow({
263
258
  <span className="text-xs text-theme-text-tertiary truncate max-w-[8rem] shrink-0 text-right">{ref_.namespace}</span>
264
259
  )}
265
260
  <StatusCellInline statusEntry={statusEntry} />
261
+ <CrossplanePauseChip state={statusEntry?.pauseState ?? null} compact />
266
262
  <ChevronRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
267
263
  </div>
268
264
  )
@@ -0,0 +1,153 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { expect, it, vi } from 'vitest'
3
+ import { ManagedResourceRenderer } from './ManagedResourceRenderer'
4
+ import { CompositeRenderer } from './CompositeRenderer'
5
+ import { ManagedResourceCell, CompositeResourceCell } from './crossplane-cells'
6
+ import { CrossplanePauseNotice } from './CrossplanePauseContext'
7
+ import { WorkloadView } from '../../workload/WorkloadView'
8
+ import { ResourceRendererDispatch, diagnoseHealthHint, getResourceStatus } from '../../shared/ResourceRendererDispatch'
9
+ import { getCellFilterValue } from '../resource-utils'
10
+ import { getGenericResourceStatus } from '../generic-status'
11
+ import { getCrossplanePauseState, getCrossplaneStatusReason } from '../resource-utils-crossplane'
12
+
13
+ const pause = { type: 'Synced', status: 'False', reason: 'ReconcilePaused', message: 'controller pause observation' }
14
+ const base = {
15
+ apiVersion: 'test.crossplane.io/v1', kind: 'Bucket',
16
+ metadata: { name: 'archive', namespace: 'test', annotations: { 'crossplane.io/paused': 'true' } },
17
+ spec: { providerConfigRef: { name: 'default' } },
18
+ status: { conditions: [{ type: 'Ready', status: 'True' }, pause] },
19
+ }
20
+
21
+ it.each([
22
+ ['MR', ManagedResourceRenderer, base.spec],
23
+ ['XR', CompositeRenderer, { resourceRefs: [] }],
24
+ ['Claim', CompositeRenderer, { resourceRef: { kind: 'Archive', name: 'archive-xr' }, compositionRef: { name: 'archive' } }],
25
+ ] as const)('shows one muted own-reconciler explanation for %s, no warning or failing pause', (_kind, Renderer, spec) => {
26
+ const html = renderToStaticMarkup(<Renderer data={{ ...base, spec }} />)
27
+ expect(html).toContain("This resource&#x27;s controller last reported reconciliation paused")
28
+ expect(html).toContain('Readiness and errors are last reported')
29
+ expect(html).toContain('ReconcilePaused')
30
+ expect(html).not.toContain('failing')
31
+ expect(html).not.toContain('text-red-')
32
+ expect(html).not.toContain('bg-red-')
33
+ expect(html).not.toContain('status-warning')
34
+ expect(html).not.toContain('Composed resources will not')
35
+ expect(html).not.toContain('Their own controllers')
36
+ })
37
+
38
+ it('retains one independent failing condition and its real banner message', () => {
39
+ const data = { ...base, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable', message: 'external API access denied' }] } }
40
+ const html = renderToStaticMarkup(<ManagedResourceRenderer data={data} />)
41
+ expect(html).toContain('1 failing')
42
+ expect(html).toContain('Unavailable')
43
+ expect(html.match(/external API access denied/g)).toHaveLength(2)
44
+ expect(html.match(/controller pause observation/g)).toHaveLength(1)
45
+ })
46
+
47
+ it.each([undefined, 'True', 'Unknown', 'ReconcileError'])('distinguishes requested intent with Synced=%s', synced => {
48
+ const conditions = synced ? [{ type: 'Synced', status: synced === 'ReconcileError' ? 'False' : synced, reason: synced }] : []
49
+ const data = { ...base, status: { conditions } }
50
+ expect(getCrossplanePauseState(data)).toBe('requested')
51
+ const html = renderToStaticMarkup(<ManagedResourceRenderer data={data} />)
52
+ expect(html).toContain('the recorded conditions do not confirm the pause')
53
+ expect(html).not.toContain('controller last reported reconciliation paused')
54
+ })
55
+
56
+ it('warns specifically about requested deletion without promising a blocker or changing status', () => {
57
+ const data = { ...base, metadata: { ...base.metadata, deletionTimestamp: '2026-10-26T08:00:00Z' } }
58
+ const html = renderToStaticMarkup(<CrossplanePauseNotice data={data} />)
59
+ expect(html).toContain('Deletion has been requested. Paused reconciliation may defer')
60
+ expect(html).toContain('text-warning-text')
61
+ expect(getResourceStatus('buckets', data)?.text).toBe('Terminating')
62
+ })
63
+
64
+ it.each([false, true])('keeps pause context in the %s expanded header', expanded => {
65
+ const html = renderToStaticMarkup(<WorkloadView kind="buckets" namespace="test" name="archive" expanded={expanded} onBack={vi.fn()} resource={base} />)
66
+ expect(html).toContain('Reconciliation paused')
67
+ expect(html).toContain('Ready (reported)')
68
+ })
69
+
70
+ it.each([
71
+ [ManagedResourceCell, base.spec],
72
+ [CompositeResourceCell, { resourceRefs: [] }],
73
+ ] as const)('keeps independent primary status in cells and status filters', (Cell, spec) => {
74
+ const resource = { ...base, spec }
75
+ const html = renderToStaticMarkup(<Cell resource={resource} column="status" />)
76
+ expect(html).toContain('Ready (reported)')
77
+ expect(getCellFilterValue(resource, 'status', 'buckets')).toBe('Ready (reported)')
78
+ const failed = { ...resource, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
79
+ expect(getCellFilterValue(failed, 'status', 'buckets')).toBe('Unavailable')
80
+ expect(renderToStaticMarkup(<Cell resource={failed} column="status" />)).toContain('Unavailable')
81
+ })
82
+
83
+ it('gives composed-resource status and Diagnose the same quiet recorded state', () => {
84
+ expect(getResourceStatus('buckets', base)).toMatchObject({ text: 'Ready (reported)', level: 'neutral' })
85
+ expect(diagnoseHealthHint('buckets', base)).toBe('unknown')
86
+ })
87
+
88
+ it('keeps pause context on a composed child beside its independent status', () => {
89
+ const ref = { apiVersion: base.apiVersion, kind: base.kind, name: 'child', namespace: 'test' }
90
+ const html = renderToStaticMarkup(<CompositeRenderer
91
+ data={{ ...base, spec: { resourceRefs: [ref] } }}
92
+ composedRefStatuses={new Map([[`${ref.apiVersion}/${ref.kind}/${ref.namespace}/${ref.name}`, {
93
+ ref, status: { text: 'Unavailable', color: 'status-unhealthy', level: 'unhealthy' }, pauseState: 'acknowledged',
94
+ }]])}
95
+ />)
96
+ expect(html).toContain('Unavailable')
97
+ expect(html).toContain('Paused')
98
+ })
99
+
100
+ it.each(['EarlyXR', 'EarlyClaim'])('qualifies recorded pause before %s has references without inferring its resource type', kind => {
101
+ const resource = { ...base, kind, spec: {}, status: { conditions: [pause] } }
102
+ const html = renderToStaticMarkup(<ResourceRendererDispatch resource={{ kind: 'earlyresources', namespace: 'test', name: 'archive' }} data={resource} onCopy={() => {}} copied={null} showCommonSections={false} />)
103
+ expect(html).toContain('last reported reconciliation paused')
104
+ expect(html).toContain('ReconcilePaused')
105
+ expect(html).toContain('False')
106
+ expect(html).not.toContain('status-red')
107
+ expect(html).not.toContain('Managed Resource')
108
+ expect(html).not.toContain('Composed Resources')
109
+ expect(getResourceStatus('earlyresources', resource)).toMatchObject({ text: 'Readiness unknown', level: 'neutral' })
110
+ const header = renderToStaticMarkup(<WorkloadView kind="earlyresources" namespace="test" name="archive" onBack={vi.fn()} resource={resource} />)
111
+ expect(header).toContain('Reconciliation paused')
112
+ })
113
+
114
+ it('does not infer Crossplane from an annotation-only request without shape or acknowledgement', () => {
115
+ const resource = { ...base, kind: 'Unknown', spec: {}, status: { conditions: [] } }
116
+ const html = renderToStaticMarkup(<WorkloadView kind="unknowns" namespace="test" name="archive" onBack={vi.fn()} resource={resource} />)
117
+ expect(html).not.toContain('Pause requested')
118
+ })
119
+
120
+ it('does not infer controller acknowledgement from a non-list condition field on an uncurated object', () => {
121
+ expect(getCrossplanePauseState({ ...base, spec: {}, status: { conditions: { Synced: 'False' } } })).toBe('requested')
122
+ })
123
+
124
+ it.each([
125
+ ['Failed', { phase: 'Failed', message: 'independent phase failure', conditions: [{ type: 'Ready', status: 'True' }, pause] }],
126
+ ['Not Healthy', { conditions: [{ type: 'Healthy', status: 'False', message: 'independent health failure' }, pause] }],
127
+ ] as const)('preserves independent generic %s evidence while qualifying early pause without mutating the resource', (label, reportedStatus) => {
128
+ const resource = { ...base, kind: 'EarlyXR', spec: {}, status: reportedStatus }
129
+ const before = structuredClone(resource)
130
+ Object.freeze(resource.status.conditions)
131
+ Object.freeze(resource.status)
132
+ Object.freeze(resource)
133
+ const html = renderToStaticMarkup(<ResourceRendererDispatch resource={{ kind: 'earlyresources', namespace: 'test', name: 'archive' }} data={resource} onCopy={() => {}} copied={null} showCommonSections={false} />)
134
+ expect(html).toContain(label)
135
+ expect(html).toContain('independent')
136
+ expect(html).toContain('last reported reconciliation paused')
137
+ expect(getResourceStatus('earlyresources', resource)).toMatchObject({ text: label, level: 'unhealthy' })
138
+ expect(getCrossplaneStatusReason(resource)).toContain('independent')
139
+ expect(resource).toEqual(before)
140
+ })
141
+
142
+
143
+ it('retains curated Knative revision status ahead of acknowledgement-only pause context', () => {
144
+ const resource = {
145
+ apiVersion: 'serving.knative.dev/v1', kind: 'Revision',
146
+ metadata: { name: 'scaled-down', annotations: { 'crossplane.io/paused': 'true' } },
147
+ spec: {},
148
+ status: { conditions: [{ type: 'Ready', status: 'True' }, { type: 'Active', status: 'False', reason: 'NoTraffic' }, pause] },
149
+ }
150
+ expect(getResourceStatus('knativerevisions', resource)?.text).toBe('Scaled to Zero')
151
+ expect(getCellFilterValue(resource, 'status', 'knativerevisions')).toBe(getGenericResourceStatus(resource)?.text)
152
+ expect(diagnoseHealthHint('knativerevisions', resource)).toBe('unknown')
153
+ })
@@ -0,0 +1,39 @@
1
+ import { AlertTriangle, Pause } from 'lucide-react'
2
+ import { Badge } from '../../ui/Badge'
3
+ import { Tooltip } from '../../ui/Tooltip'
4
+ import { getCrossplanePauseState } from '../resource-utils-crossplane'
5
+
6
+ function pauseDescription(state: 'requested' | 'acknowledged'): string {
7
+ return state === 'acknowledged'
8
+ ? "This resource's controller last reported reconciliation paused. Changes are deferred while paused. Readiness and errors are last reported."
9
+ : "The pause annotation is set for this resource; the recorded conditions do not confirm the pause. Changes may be deferred while paused. Readiness and errors are last reported."
10
+ }
11
+
12
+ export function CrossplanePauseChip({ state, compact = false }: { state: ReturnType<typeof getCrossplanePauseState>; compact?: boolean }) {
13
+ if (!state) return null
14
+ return (
15
+ <Tooltip content={pauseDescription(state)}>
16
+ <Badge severity="neutral" size={compact ? 'sm' : 'default'} className="gap-1 whitespace-nowrap shrink-0">
17
+ <Pause className="w-3 h-3 shrink-0" aria-hidden />
18
+ {state === 'requested' ? 'Pause requested' : compact ? 'Paused' : 'Reconciliation paused'}
19
+ </Badge>
20
+ </Tooltip>
21
+ )
22
+ }
23
+
24
+ export function CrossplanePauseNotice({ data }: { data: any }) {
25
+ const state = getCrossplanePauseState(data)
26
+ if (!state) return null
27
+ if (data?.metadata?.deletionTimestamp) {
28
+ return (
29
+ <div className="space-y-1">
30
+ <p className="flex items-start gap-1.5 text-sm text-warning-text">
31
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" aria-hidden />
32
+ Deletion has been requested. Paused reconciliation may defer this resource’s controller cleanup.
33
+ </p>
34
+ <p className="text-sm text-theme-text-tertiary">{pauseDescription(state)}</p>
35
+ </div>
36
+ )
37
+ }
38
+ return <p className="text-sm text-theme-text-tertiary">{pauseDescription(state)}</p>
39
+ }
@@ -0,0 +1,96 @@
1
+ import { useState } from 'react'
2
+ import { Badge } from '../../ui/Badge'
3
+ import { healthToSeverity } from '../../../utils/badge-colors'
4
+ import { Section, Property, PropertyList, ResourceLink, ConditionsSection, AlertBanner, useOperationalIssuesShown } from '../../ui/drawer-components'
5
+ import type { ResourceRef } from '../../../types'
6
+ import { getFlaggerAnalysis, getFlaggerCanaryStatus, getFlaggerStrategy, getFlaggerTargetRef, getFlaggerAutoscalerRef, getFlaggerMetricTemplateRef } from '../resource-utils-flagger'
7
+
8
+ interface Props { data: any; onNavigate?: (ref: ResourceRef) => void }
9
+
10
+ export function FlaggerCanaryRenderer({ data, onNavigate }: Props) {
11
+ const state = getFlaggerCanaryStatus(data)
12
+ const analysis = getFlaggerAnalysis(data)
13
+ const target = getFlaggerTargetRef(data)
14
+ const scaler = getFlaggerAutoscalerRef(data)
15
+ const strategy = getFlaggerStrategy(data)
16
+ const weighted = !(analysis.iterations > 0)
17
+ const metrics = analysis.metrics ?? []
18
+ const [showAllMetrics, setShowAllMetrics] = useState(false)
19
+ const [showAllHooks, setShowAllHooks] = useState(false)
20
+ const operationalIssuesShown = useOperationalIssuesShown()
21
+ return <>
22
+ <Section title="Canary Rollout" defaultExpanded>
23
+ <PropertyList>
24
+ <Property label="Rollout state" value={<Badge severity={healthToSeverity(state.level)}>{state.text}</Badge>} />
25
+ <Property label="Recorded phase" value={data.status?.phase} />
26
+ <Property label="Suspend requested" value={data.spec?.suspend === true ? 'Yes' : 'No'} />
27
+ <Property label="Strategy" value={strategy} />
28
+ <Property label="Canary target" value={target ? <ResourceLink {...target} onNavigate={onNavigate} /> : data.spec?.targetRef?.name} />
29
+ {data.spec?.autoscalerRef && <Property label="Canary autoscaler" value={scaler ? <ResourceLink {...scaler} onNavigate={onNavigate} /> : data.spec.autoscalerRef.name} />}
30
+ {weighted && <Property label="Reported canary weight" value={data.status?.canaryWeight != null ? `${data.status.canaryWeight}%` : 'Not reported'} />}
31
+ <Property label="Reported failed checks" value={data.status?.failedChecks ?? 'Not reported'} />
32
+ </PropertyList>
33
+ <p className="mt-2 text-xs text-theme-text-tertiary">Phase and counters describe Flagger’s last reported rollout, not a live traffic measurement or proof that the latest configuration was reconciled.</p>
34
+ {data.spec?.targetRef?.kind === 'Deployment' && <p className="mt-2 text-xs text-theme-text-tertiary">Flagger normally scales the canary target to zero between analyses. The primary Deployment continues serving; find observed primary resources in Related Resources.</p>}
35
+ {data.spec?.targetRef?.kind === 'DaemonSet' && <p className="mt-2 text-xs text-theme-text-tertiary">Flagger parks the canary DaemonSet with a node selector between analyses. Inspect the primary DaemonSet in Related Resources.</p>}
36
+ {data.status?.phase === 'Failed' && !operationalIssuesShown && <AlertBanner variant="warning" title="Canary rollout failed" message="Inspect this Canary’s events for failed metric checks or webhooks; the primary may still be serving the previous version." />}
37
+ </Section>
38
+ <Section title="Analysis" defaultExpanded>
39
+ <PropertyList>
40
+ <Property label="Interval" value={analysis.interval || '1m (default)'} />
41
+ <Property label="Failure threshold" value={analysis.threshold > 0 ? analysis.threshold : '1 (default)'} />
42
+ <Property label="Skip analysis" value={data.spec?.skipAnalysis === true || (!data.spec?.analysis && !data.spec?.canaryAnalysis) ? 'Yes' : 'No'} />
43
+ {weighted ? <>
44
+ <Property label="Step weight" value={analysis.stepWeights?.length ? analysis.stepWeights.map((weight: number) => `${weight}%`).join(', ') : analysis.stepWeight != null ? `${analysis.stepWeight}%` : 'Not specified'} />
45
+ <Property label="Maximum weight" value={analysis.maxWeight != null ? `${analysis.maxWeight}%` : 'Not specified'} />
46
+ </> : <Property label="Configured iterations" value={analysis.iterations} />}
47
+ {!weighted && <Property label="Reported iteration" value={data.status?.iterations ?? 'Not reported'} />}
48
+ </PropertyList>
49
+ </Section>
50
+ {metrics.length > 0 && <Section title={`Analysis Metrics (${metrics.length})`} defaultExpanded>
51
+ <div className="space-y-3">{(showAllMetrics ? metrics : metrics.slice(0, 5)).map((metric: any, index: number) => {
52
+ const template = getFlaggerMetricTemplateRef(data, metric)
53
+ return <div key={index} className="card-inner break-words"><PropertyList>
54
+ <Property label="Metric" value={metric.name} />
55
+ <Property label="Source" value={template ? <ResourceLink {...template} onNavigate={onNavigate} /> : metric.query ? 'Inline query' : ['request-success-rate', 'request-duration'].includes(metric.name) ? 'Built-in provider metric' : 'No query or template declared'} />
56
+ <Property label="Query interval" value={metric.interval || '1m (default)'} />
57
+ {metric.thresholdRange?.min != null && <Property label="Minimum" value={metric.thresholdRange.min} />}
58
+ {metric.thresholdRange?.max != null && <Property label="Maximum" value={metric.thresholdRange.max} />}
59
+ {!metric.thresholdRange && metric.threshold != null && <Property label="Threshold (deprecated)" value={metric.threshold} />}
60
+ </PropertyList>{metric.query && !template && <div className="mt-2"><FlaggerQueryPreview query={metric.query} /></div>}</div>
61
+ })}</div>
62
+ {metrics.length > 5 && <button type="button" className="mt-2 text-sm text-accent-text hover:underline" onClick={() => setShowAllMetrics(!showAllMetrics)}>{showAllMetrics ? 'Show fewer metrics' : `Show all ${metrics.length} metrics`}</button>}
63
+ </Section>}
64
+ {analysis.webhooks?.length > 0 && <Section title={`Analysis Hooks (${analysis.webhooks.length})`} defaultExpanded={['Waiting', 'WaitingPromotion'].includes(data.status?.phase)}>
65
+ <PropertyList>{(showAllHooks ? analysis.webhooks : analysis.webhooks.slice(0, 5)).map((hook: any, index: number) => <Property key={index} label={hook.name} value={hook.type || 'rollout'} />)}</PropertyList>
66
+ {analysis.webhooks.length > 5 && <button type="button" className="mt-2 text-sm text-accent-text hover:underline" onClick={() => setShowAllHooks(!showAllHooks)}>{showAllHooks ? 'Show fewer hooks' : `Show all ${analysis.webhooks.length} hooks`}</button>}
67
+ {data.spec?.suspend !== true && ['Waiting', 'WaitingPromotion'].includes(data.status?.phase) && <p className="mt-2 text-xs text-theme-text-tertiary">Flagger is waiting for a confirmation hook. Inspect the confirm-rollout or confirm-promotion hook and this Canary’s events.</p>}
68
+ </Section>}
69
+ <ConditionsSection conditions={data.status?.conditions} getConditionTone={condition => condition.type === 'Promoted' ? data.status?.phase === 'Failed' ? 'fail' : condition.status === 'True' ? 'ok' : 'neutral' : 'unknown'} />
70
+ </>
71
+ }
72
+
73
+ export function FlaggerMetricTemplateRenderer({ data, onNavigate }: Props) {
74
+ const provider = data.spec?.provider ?? {}
75
+ const query = data.spec?.query ?? ''
76
+ return <>
77
+ <Section title="Metric Provider" defaultExpanded><PropertyList>
78
+ <Property label="Type" value={provider.type} />
79
+ {provider.address && <Property label="Address" value={<span className="break-all">{provider.address}</span>} />}
80
+ {provider.region && <Property label="Region" value={provider.region} />}
81
+ <Property label="TLS certificate verification" value={provider.insecureSkipVerify ? 'Disabled' : 'Enabled'} />
82
+ {provider.secretRef?.name && <Property label="Credentials Secret" value={<ResourceLink name={provider.secretRef.name} kind="Secret" group="" namespace={data.metadata?.namespace} onNavigate={onNavigate} />} />}
83
+ </PropertyList><p className="mt-2 text-xs text-theme-text-tertiary">Declared provider and query configuration. This view does not execute the query or verify provider connectivity.</p></Section>
84
+ <Section title="Query Template" defaultExpanded>
85
+ {query ? <FlaggerQueryPreview query={query} /> : <p className="text-sm text-theme-text-tertiary">No query is declared.</p>}
86
+ </Section>
87
+ <ConditionsSection conditions={data.status?.conditions} getConditionTone={() => 'unknown'} />
88
+ </>
89
+ }
90
+
91
+ function FlaggerQueryPreview({ query }: { query: string }) {
92
+ const [showFullQuery, setShowFullQuery] = useState(false)
93
+ return <><pre className="whitespace-pre-wrap break-all text-xs text-theme-text-secondary">{showFullQuery ? query : query.slice(0, 2000)}</pre>
94
+ {query.length > 2000 && <button type="button" className="mt-2 text-sm text-accent-text hover:underline" onClick={() => setShowFullQuery(!showFullQuery)}>{showFullQuery ? 'Show less' : `Show full query (${query.length} characters)`}</button>}
95
+ </>
96
+ }