@skyhook-io/k8s-ui 1.18.1 → 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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +53 -1
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
{
|
|
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'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
|
+
}
|