@skyhook-io/k8s-ui 1.12.0 → 1.13.0
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/package.json +4 -4
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +410 -5
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +12 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +3 -0
- package/src/types/core.ts +29 -0
- package/src/types/policy.ts +153 -2
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -3,6 +3,33 @@ export * from './resource-utils-hpa'
|
|
|
3
3
|
export * from './resource-utils-argo'
|
|
4
4
|
export * from './resource-utils-certmanager'
|
|
5
5
|
export * from './resource-utils-cnpg'
|
|
6
|
+
export {
|
|
7
|
+
CALICO_GROUPS,
|
|
8
|
+
type CalicoApiGroup,
|
|
9
|
+
isCalicoApiGroup,
|
|
10
|
+
isCalicoApiVersion,
|
|
11
|
+
isCalicoPolicyKind,
|
|
12
|
+
isCalicoNetworkPolicyKind,
|
|
13
|
+
isCalicoStagedKubernetesNetworkPolicyKind,
|
|
14
|
+
formatKubernetesLabelSelector,
|
|
15
|
+
isCalicoPolicyResource,
|
|
16
|
+
isCoreNetworkPolicyApiVersion,
|
|
17
|
+
isCoreNetworkPolicyResource,
|
|
18
|
+
isCoreNetworkPolicyKind,
|
|
19
|
+
getCalicoPolicyKindLabel,
|
|
20
|
+
getCalicoPolicyTypes,
|
|
21
|
+
getCalicoPolicyRuleCount,
|
|
22
|
+
getCalicoPolicySelector,
|
|
23
|
+
getCalicoIPPoolAllowedUses,
|
|
24
|
+
getCalicoIPPoolBlockSize,
|
|
25
|
+
getCalicoIPPoolEncapsulation,
|
|
26
|
+
getCalicoStagedAction,
|
|
27
|
+
isCalicoStagedDeletion,
|
|
28
|
+
isCalicoStagedIgnored,
|
|
29
|
+
CALICO_SELECTOR_NOT_APPLICABLE,
|
|
30
|
+
getCalicoPolicyNamespaceSelector,
|
|
31
|
+
getCalicoPolicyServiceAccountSelector,
|
|
32
|
+
} from './resource-utils-calico'
|
|
6
33
|
export * from './resource-utils-crossplane'
|
|
7
34
|
export * from './resource-utils-eso'
|
|
8
35
|
export * from './resource-utils-flux'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getKyvernoPolicyAction } from './resource-utils-kyverno'
|
|
3
|
+
|
|
4
|
+
function cpol(spec: any) {
|
|
5
|
+
return { apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', metadata: { name: 'p' }, spec }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The Enforce cases here were confirmed against a live Kyverno 1.18 admission
|
|
10
|
+
* controller: each one creates a pod the apiserver actually rejects, while
|
|
11
|
+
* `spec.validationFailureAction` on the same policy reads Audit.
|
|
12
|
+
*/
|
|
13
|
+
describe('getKyvernoPolicyAction', () => {
|
|
14
|
+
it('lets a rule-level failureAction override the spec-level one', () => {
|
|
15
|
+
// Live result: pod REJECTED by validate.kyverno.svc-fail.
|
|
16
|
+
expect(
|
|
17
|
+
getKyvernoPolicyAction(
|
|
18
|
+
cpol({
|
|
19
|
+
validationFailureAction: 'Audit',
|
|
20
|
+
rules: [{ name: 'r', validate: { failureAction: 'Enforce' } }],
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
).toBe('Enforce')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('reads the failureAction on an image-verification rule', () => {
|
|
27
|
+
// Live result: pod REJECTED by mutate.kyverno.svc-fail. Kyverno had
|
|
28
|
+
// defaulted spec.validationFailureAction to Audit on its own.
|
|
29
|
+
expect(
|
|
30
|
+
getKyvernoPolicyAction(
|
|
31
|
+
cpol({
|
|
32
|
+
validationFailureAction: 'Audit',
|
|
33
|
+
rules: [{ name: 'r', verifyImages: [{ failureAction: 'Enforce' }] }],
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
).toBe('Enforce')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not claim Enforce when every rule overrides it away', () => {
|
|
40
|
+
expect(
|
|
41
|
+
getKyvernoPolicyAction(
|
|
42
|
+
cpol({
|
|
43
|
+
validationFailureAction: 'Enforce',
|
|
44
|
+
rules: [{ name: 'r', validate: { failureAction: 'Audit' } }],
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
).toBe('Audit')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('applies the spec-level action to rules that declare none', () => {
|
|
51
|
+
expect(
|
|
52
|
+
getKyvernoPolicyAction(
|
|
53
|
+
cpol({
|
|
54
|
+
validationFailureAction: 'Enforce',
|
|
55
|
+
rules: [{ name: 'r', validate: { pattern: {} } }],
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
).toBe('Enforce')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Only validate and verifyImages rules can reject, so only they inherit the
|
|
62
|
+
// spec-level action. A mutate rule alongside them would otherwise report a
|
|
63
|
+
// policy as blocking when its one validating rule audits.
|
|
64
|
+
it('does not let a mutate rule carry the spec-level Enforce', () => {
|
|
65
|
+
expect(
|
|
66
|
+
getKyvernoPolicyAction(
|
|
67
|
+
cpol({
|
|
68
|
+
validationFailureAction: 'Enforce',
|
|
69
|
+
rules: [{ name: 'v', validate: { failureAction: 'Audit' } }, { name: 'm', mutate: {} }],
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
).toBe('Audit')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('reports Audit for a policy whose rules cannot reject at all', () => {
|
|
76
|
+
expect(
|
|
77
|
+
getKyvernoPolicyAction(
|
|
78
|
+
cpol({ validationFailureAction: 'Enforce', rules: [{ name: 'g', generate: {} }] }),
|
|
79
|
+
),
|
|
80
|
+
).toBe('Audit')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('still inherits the spec action for a validating rule that declares none', () => {
|
|
84
|
+
expect(
|
|
85
|
+
getKyvernoPolicyAction(
|
|
86
|
+
cpol({
|
|
87
|
+
validationFailureAction: 'Enforce',
|
|
88
|
+
rules: [{ name: 'm', mutate: {} }, { name: 'v', validate: { pattern: {} } }],
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
).toBe('Enforce')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('falls back to Audit when nothing declares an action', () => {
|
|
95
|
+
expect(getKyvernoPolicyAction(cpol({ rules: [{ name: 'r', validate: {} }] }))).toBe('Audit')
|
|
96
|
+
expect(getKyvernoPolicyAction(cpol({}))).toBe('Audit')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { AnalysisRunRenderer } from './AnalysisRunRenderer'
|
|
4
|
+
import { getAnalysisRunStatus, summarizeAnalysisMetrics } from '../resource-utils'
|
|
5
|
+
|
|
6
|
+
// SSR splits interpolated text with <!-- --> markers; strip so assertions read as prose.
|
|
7
|
+
function render(element: Parameters<typeof renderToString>[0]): string {
|
|
8
|
+
return renderToString(element).replace(/<!-- -->/g, '')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function analysisRun(overrides: {
|
|
12
|
+
phase?: string
|
|
13
|
+
message?: string
|
|
14
|
+
labels?: Record<string, string>
|
|
15
|
+
metricResults?: any[]
|
|
16
|
+
runSummary?: Record<string, number>
|
|
17
|
+
metrics?: any[]
|
|
18
|
+
}) {
|
|
19
|
+
return {
|
|
20
|
+
metadata: {
|
|
21
|
+
name: 'web-6c4f-2',
|
|
22
|
+
namespace: 'prod',
|
|
23
|
+
labels: overrides.labels,
|
|
24
|
+
ownerReferences: [{ kind: 'Rollout', name: 'web' }],
|
|
25
|
+
},
|
|
26
|
+
spec: { metrics: overrides.metrics ?? [] },
|
|
27
|
+
status: {
|
|
28
|
+
phase: overrides.phase,
|
|
29
|
+
message: overrides.message,
|
|
30
|
+
metricResults: overrides.metricResults,
|
|
31
|
+
runSummary: overrides.runSummary,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('AnalysisRunRenderer verdict banner', () => {
|
|
37
|
+
it('escalates Failed to an error banner naming the abort consequence', () => {
|
|
38
|
+
const html = render(
|
|
39
|
+
<AnalysisRunRenderer
|
|
40
|
+
data={analysisRun({
|
|
41
|
+
phase: 'Failed',
|
|
42
|
+
metricResults: [{ name: 'success-rate', phase: 'Failed', message: 'result 0 breached fail if result == 0' }],
|
|
43
|
+
})}
|
|
44
|
+
/>,
|
|
45
|
+
)
|
|
46
|
+
expect(html).toContain('the rollout will be aborted')
|
|
47
|
+
expect(html).toContain('result 0 breached fail if result == 0')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('distinguishes Error (could not run) from Failed (metric breached)', () => {
|
|
51
|
+
const html = render(<AnalysisRunRenderer data={analysisRun({ phase: 'Error' })} />)
|
|
52
|
+
expect(html).toContain('Analysis could not run')
|
|
53
|
+
expect(html).not.toContain('the rollout will be aborted')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('frames Inconclusive as awaiting a human decision, not a failure', () => {
|
|
57
|
+
const html = render(
|
|
58
|
+
<AnalysisRunRenderer
|
|
59
|
+
data={analysisRun({
|
|
60
|
+
phase: 'Inconclusive',
|
|
61
|
+
metricResults: [{ name: 'success-rate', phase: 'Inconclusive', message: 'result 2 met neither condition' }],
|
|
62
|
+
})}
|
|
63
|
+
/>,
|
|
64
|
+
)
|
|
65
|
+
expect(html).toContain('paused for a human decision')
|
|
66
|
+
expect(html).toContain('result 2 met neither condition')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('falls back to a generic reason when no metric carried a message', () => {
|
|
70
|
+
const html = render(
|
|
71
|
+
<AnalysisRunRenderer data={analysisRun({ phase: 'Inconclusive', metricResults: [{ name: 'm', phase: 'Inconclusive' }] })} />,
|
|
72
|
+
)
|
|
73
|
+
expect(html).toContain('none met its success condition either')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('renders no banner for a run still in flight', () => {
|
|
77
|
+
for (const phase of ['Pending', 'Running', 'Successful']) {
|
|
78
|
+
const html = render(<AnalysisRunRenderer data={analysisRun({ phase })} />)
|
|
79
|
+
expect(html).not.toContain('paused for a human decision')
|
|
80
|
+
expect(html).not.toContain('the rollout will be aborted')
|
|
81
|
+
expect(html).not.toContain('Analysis could not run')
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('AnalysisRunRenderer detail', () => {
|
|
87
|
+
it('surfaces the trigger and canary step the controller labelled the run with', () => {
|
|
88
|
+
const html = render(
|
|
89
|
+
<AnalysisRunRenderer
|
|
90
|
+
data={analysisRun({ phase: 'Running', labels: { 'rollout-type': 'Step', 'step-index': '3' } })}
|
|
91
|
+
/>,
|
|
92
|
+
)
|
|
93
|
+
expect(html).toContain('Triggered as')
|
|
94
|
+
expect(html).toContain('Step')
|
|
95
|
+
expect(html).toContain('Canary step')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('pairs each metric result with its condition from the spec', () => {
|
|
99
|
+
const html = render(
|
|
100
|
+
<AnalysisRunRenderer
|
|
101
|
+
data={analysisRun({
|
|
102
|
+
phase: 'Inconclusive',
|
|
103
|
+
metrics: [
|
|
104
|
+
{ name: 'success-rate', successCondition: 'result == 1', failureCondition: 'result == 0', failureLimit: 2 },
|
|
105
|
+
],
|
|
106
|
+
metricResults: [
|
|
107
|
+
{
|
|
108
|
+
name: 'success-rate',
|
|
109
|
+
phase: 'Inconclusive',
|
|
110
|
+
count: 3,
|
|
111
|
+
inconclusive: 3,
|
|
112
|
+
measurements: [{ value: '2', phase: 'Inconclusive' }],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
})}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
expect(html).toContain('success if: result == 1')
|
|
119
|
+
expect(html).toContain('fail if: result == 0')
|
|
120
|
+
expect(html).toContain('failure limit 2')
|
|
121
|
+
expect(html).toContain('latest: 2')
|
|
122
|
+
expect(html).toContain('3 measured')
|
|
123
|
+
expect(html).toContain('3 inconclusive')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('flags a dry-run metric so its phase is not read as load-bearing', () => {
|
|
127
|
+
const html = render(
|
|
128
|
+
<AnalysisRunRenderer
|
|
129
|
+
data={analysisRun({ phase: 'Successful', metricResults: [{ name: 'canary-error-rate', phase: 'Failed', dryRun: true }] })}
|
|
130
|
+
/>,
|
|
131
|
+
)
|
|
132
|
+
expect(html).toContain('(dry-run)')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('renders the tally and metric sections only when populated', () => {
|
|
136
|
+
const bare = render(<AnalysisRunRenderer data={analysisRun({ phase: 'Pending' })} />)
|
|
137
|
+
expect(bare).not.toContain('Measurement Tally')
|
|
138
|
+
expect(bare).not.toContain('Metrics (')
|
|
139
|
+
|
|
140
|
+
const full = render(
|
|
141
|
+
<AnalysisRunRenderer
|
|
142
|
+
data={analysisRun({ phase: 'Successful', runSummary: { count: 4, successful: 4 }, metricResults: [{ name: 'm', phase: 'Successful' }] })}
|
|
143
|
+
/>,
|
|
144
|
+
)
|
|
145
|
+
expect(full).toContain('Measurement Tally')
|
|
146
|
+
expect(full).toContain('Metrics (1)')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('renders without a status, spec, or owner', () => {
|
|
150
|
+
expect(() => render(<AnalysisRunRenderer data={{ metadata: { name: 'ar' } }} />)).not.toThrow()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('getAnalysisRunStatus', () => {
|
|
155
|
+
// Inconclusive is the alert tier: worse than Running, not as bad as Failed.
|
|
156
|
+
it('maps each phase to the right health tier', () => {
|
|
157
|
+
const cases: Array<[string | undefined, string]> = [
|
|
158
|
+
['Successful', 'healthy'],
|
|
159
|
+
['Running', 'degraded'],
|
|
160
|
+
['Pending', 'degraded'],
|
|
161
|
+
['Inconclusive', 'alert'],
|
|
162
|
+
['Failed', 'unhealthy'],
|
|
163
|
+
['Error', 'unhealthy'],
|
|
164
|
+
[undefined, 'unknown'],
|
|
165
|
+
['Bogus', 'unknown'],
|
|
166
|
+
]
|
|
167
|
+
for (const [phase, level] of cases) {
|
|
168
|
+
expect(getAnalysisRunStatus({ status: { phase } }).level).toBe(level)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('labels an absent phase Unknown rather than blank', () => {
|
|
173
|
+
expect(getAnalysisRunStatus({}).text).toBe('Unknown')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('summarizeAnalysisMetrics', () => {
|
|
178
|
+
const run = (...phases: string[]) => ({
|
|
179
|
+
status: { metricResults: phases.map((phase, i) => ({ name: `m${i}`, phase })) },
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does not count in-flight metrics as passing', () => {
|
|
183
|
+
expect(summarizeAnalysisMetrics(run('Successful', 'Running', 'Pending'))).toEqual({
|
|
184
|
+
total: 3,
|
|
185
|
+
passing: 1,
|
|
186
|
+
notPassing: 0,
|
|
187
|
+
dryRun: 0,
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('counts every failure phase as not passing', () => {
|
|
192
|
+
expect(summarizeAnalysisMetrics(run('Failed', 'Error', 'Inconclusive', 'Successful'))).toEqual({
|
|
193
|
+
total: 4,
|
|
194
|
+
passing: 1,
|
|
195
|
+
notPassing: 3,
|
|
196
|
+
dryRun: 0,
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('reports all passing only once every metric is Successful', () => {
|
|
201
|
+
const { total, passing } = summarizeAnalysisMetrics(run('Successful', 'Successful'))
|
|
202
|
+
expect(passing).toBe(total)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('handles a run with no metric results', () => {
|
|
206
|
+
expect(summarizeAnalysisMetrics({})).toEqual({ total: 0, passing: 0, notPassing: 0, dryRun: 0 })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Argo excludes dryRun metrics from worstStatus, so a failed dry-run must not
|
|
210
|
+
// paint the row amber while the run itself reports Successful.
|
|
211
|
+
it('excludes dry-run metrics from the verdict counts', () => {
|
|
212
|
+
const run = {
|
|
213
|
+
status: {
|
|
214
|
+
phase: 'Successful',
|
|
215
|
+
metricResults: [
|
|
216
|
+
{ name: 'real', phase: 'Successful' },
|
|
217
|
+
{ name: 'canary-only', phase: 'Failed', dryRun: true },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
expect(summarizeAnalysisMetrics(run)).toEqual({
|
|
222
|
+
total: 1,
|
|
223
|
+
passing: 1,
|
|
224
|
+
notPassing: 0,
|
|
225
|
+
dryRun: 1,
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('still counts a non-dry-run failure alongside a dry-run one', () => {
|
|
230
|
+
const run = {
|
|
231
|
+
status: {
|
|
232
|
+
metricResults: [
|
|
233
|
+
{ name: 'real', phase: 'Failed' },
|
|
234
|
+
{ name: 'shadow', phase: 'Failed', dryRun: true },
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
const { total, notPassing, dryRun } = summarizeAnalysisMetrics(run)
|
|
239
|
+
expect({ total, notPassing, dryRun }).toEqual({ total: 1, notPassing: 1, dryRun: 1 })
|
|
240
|
+
})
|
|
241
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Activity, BarChart3, Server } from 'lucide-react'
|
|
2
|
+
import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
|
|
3
|
+
import { Badge, type BadgeSeverity } from '../../ui/Badge'
|
|
4
|
+
import { formatAge } from '../resource-utils'
|
|
5
|
+
|
|
6
|
+
interface AnalysisRunRendererProps {
|
|
7
|
+
data: any
|
|
8
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PHASE_SEVERITY: Record<string, BadgeSeverity> = {
|
|
12
|
+
Successful: 'success',
|
|
13
|
+
Running: 'info',
|
|
14
|
+
Pending: 'neutral',
|
|
15
|
+
Inconclusive: 'warning',
|
|
16
|
+
Failed: 'error',
|
|
17
|
+
Error: 'error',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function phaseSeverity(phase: string): BadgeSeverity {
|
|
21
|
+
return PHASE_SEVERITY[phase] ?? 'neutral'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The Rollout's own pause reason is only "InconclusiveAnalysisRun" — the metric
|
|
25
|
+
// that actually decided it lives here, in metricResults[].
|
|
26
|
+
export function AnalysisRunRenderer({ data, onNavigate }: AnalysisRunRendererProps) {
|
|
27
|
+
const spec = data.spec || {}
|
|
28
|
+
const status = data.status || {}
|
|
29
|
+
const phase = status.phase || 'Unknown'
|
|
30
|
+
const summary = status.runSummary || {}
|
|
31
|
+
const metricResults: any[] = status.metricResults || []
|
|
32
|
+
const labels = data.metadata?.labels || {}
|
|
33
|
+
const ownerRollout = (data.metadata?.ownerReferences || []).find((ref: any) => ref.kind === 'Rollout')
|
|
34
|
+
|
|
35
|
+
const failing = metricResults.filter((m) => m.phase === 'Failed' || m.phase === 'Error')
|
|
36
|
+
const inconclusive = metricResults.filter((m) => m.phase === 'Inconclusive')
|
|
37
|
+
|
|
38
|
+
const verdictMessage = status.message || failing[0]?.message || inconclusive[0]?.message
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
{(phase === 'Failed' || phase === 'Error') && (
|
|
43
|
+
<AlertBanner
|
|
44
|
+
variant="error"
|
|
45
|
+
title={phase === 'Error' ? 'Analysis could not run' : 'Analysis failed — the rollout will be aborted'}
|
|
46
|
+
message={verdictMessage || 'One or more metrics breached their failure condition.'}
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{phase === 'Inconclusive' && (
|
|
51
|
+
<AlertBanner
|
|
52
|
+
variant="warning"
|
|
53
|
+
title="Analysis was inconclusive — the rollout is paused for a human decision"
|
|
54
|
+
message={verdictMessage || 'No metric failed outright, but none met its success condition either.'}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
<Section title="Status" icon={Activity}>
|
|
59
|
+
<PropertyList>
|
|
60
|
+
<Property label="Phase" value={<Badge severity={phaseSeverity(phase)}>{phase}</Badge>} />
|
|
61
|
+
{labels['rollout-type'] && <Property label="Triggered as" value={labels['rollout-type']} />}
|
|
62
|
+
{labels['step-index'] && <Property label="Canary step" value={labels['step-index']} />}
|
|
63
|
+
<Property label="Started" value={status.startedAt ? formatAge(status.startedAt) : undefined} />
|
|
64
|
+
<Property label="Completed" value={status.completedAt ? formatAge(status.completedAt) : undefined} />
|
|
65
|
+
<Property label="Message" value={status.message} />
|
|
66
|
+
{ownerRollout && (
|
|
67
|
+
<Property
|
|
68
|
+
label="Rollout"
|
|
69
|
+
value={
|
|
70
|
+
onNavigate ? (
|
|
71
|
+
<button
|
|
72
|
+
onClick={() =>
|
|
73
|
+
onNavigate({ kind: 'Rollout', namespace: data.metadata?.namespace, name: ownerRollout.name })
|
|
74
|
+
}
|
|
75
|
+
className="text-brand hover:underline"
|
|
76
|
+
>
|
|
77
|
+
{ownerRollout.name}
|
|
78
|
+
</button>
|
|
79
|
+
) : (
|
|
80
|
+
ownerRollout.name
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
85
|
+
</PropertyList>
|
|
86
|
+
</Section>
|
|
87
|
+
|
|
88
|
+
{Object.keys(summary).length > 0 && (
|
|
89
|
+
<Section title="Measurement Tally" icon={BarChart3}>
|
|
90
|
+
<PropertyList>
|
|
91
|
+
<Property label="Measurements" value={summary.count} />
|
|
92
|
+
<Property label="Successful" value={summary.successful} />
|
|
93
|
+
<Property label="Failed" value={summary.failed} />
|
|
94
|
+
<Property label="Inconclusive" value={summary.inconclusive} />
|
|
95
|
+
<Property label="Errored" value={summary.error} />
|
|
96
|
+
</PropertyList>
|
|
97
|
+
</Section>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{metricResults.length > 0 && (
|
|
101
|
+
<Section title={`Metrics (${metricResults.length})`} defaultExpanded>
|
|
102
|
+
<div className="space-y-2">
|
|
103
|
+
{metricResults.map((metric: any) => {
|
|
104
|
+
const definition = (spec.metrics || []).find((m: any) => m.name === metric.name)
|
|
105
|
+
const measurements: any[] = metric.measurements || []
|
|
106
|
+
const latest = measurements[measurements.length - 1]
|
|
107
|
+
return (
|
|
108
|
+
<div key={metric.name} className="card-inner space-y-1.5">
|
|
109
|
+
<div className="flex items-center justify-between gap-2">
|
|
110
|
+
<span className="font-mono text-sm text-theme-text-primary">{metric.name}</span>
|
|
111
|
+
<Badge severity={phaseSeverity(metric.phase)} size="sm">
|
|
112
|
+
{metric.phase || 'Unknown'}
|
|
113
|
+
{metric.dryRun ? ' (dry-run)' : ''}
|
|
114
|
+
</Badge>
|
|
115
|
+
</div>
|
|
116
|
+
{latest?.value && (
|
|
117
|
+
<div className="font-mono text-xs text-theme-text-secondary">latest: {latest.value}</div>
|
|
118
|
+
)}
|
|
119
|
+
{definition?.successCondition && (
|
|
120
|
+
<div className="font-mono text-xs text-theme-text-tertiary">
|
|
121
|
+
success if: {definition.successCondition}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
{definition?.failureCondition && (
|
|
125
|
+
<div className="font-mono text-xs text-theme-text-tertiary">
|
|
126
|
+
fail if: {definition.failureCondition}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-secondary">
|
|
130
|
+
{metric.count != null && <span>{metric.count} measured</span>}
|
|
131
|
+
{metric.successful ? <span>{metric.successful} ok</span> : null}
|
|
132
|
+
{metric.failed ? <span>{metric.failed} failed</span> : null}
|
|
133
|
+
{metric.inconclusive ? <span>{metric.inconclusive} inconclusive</span> : null}
|
|
134
|
+
{metric.error ? <span>{metric.error} errored</span> : null}
|
|
135
|
+
{metric.consecutiveError ? <span>{metric.consecutiveError} consecutive errors</span> : null}
|
|
136
|
+
{definition?.failureLimit != null && <span>failure limit {definition.failureLimit}</span>}
|
|
137
|
+
</div>
|
|
138
|
+
{metric.message && (
|
|
139
|
+
<div className="text-xs text-theme-text-secondary">{metric.message}</div>
|
|
140
|
+
)}
|
|
141
|
+
{latest?.message && latest.message !== metric.message && (
|
|
142
|
+
<div className="text-xs text-theme-text-secondary">{latest.message}</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
</Section>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{(spec.args || []).length > 0 && (
|
|
152
|
+
<Section title={`Arguments (${spec.args.length})`} icon={Server} defaultExpanded={false}>
|
|
153
|
+
<PropertyList>
|
|
154
|
+
{spec.args.map((arg: any) => (
|
|
155
|
+
<Property
|
|
156
|
+
key={arg.name}
|
|
157
|
+
label={arg.name}
|
|
158
|
+
value={arg.value ?? (arg.valueFrom ? 'from reference' : undefined)}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</PropertyList>
|
|
162
|
+
</Section>
|
|
163
|
+
)}
|
|
164
|
+
</>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getCNPGBackupStatus,
|
|
5
5
|
getCNPGBackupCluster,
|
|
6
6
|
getCNPGBackupMethod,
|
|
7
|
+
getCNPGBackupPlugin,
|
|
7
8
|
getCNPGBackupPhase,
|
|
8
9
|
getCNPGBackupDuration,
|
|
9
10
|
getCNPGBackupName,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
getCNPGBackupServerName,
|
|
12
13
|
getCNPGBackupError,
|
|
13
14
|
getCNPGBackupTarget,
|
|
15
|
+
CNPG_BARMAN_OBJECTSTORE_GROUP,
|
|
14
16
|
} from '../resource-utils-cnpg'
|
|
15
17
|
|
|
16
18
|
interface CNPGBackupRendererProps {
|
|
@@ -24,6 +26,7 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
|
|
|
24
26
|
const phase = getCNPGBackupPhase(data)
|
|
25
27
|
const target = getCNPGBackupTarget(data)
|
|
26
28
|
const clusterName = getCNPGBackupCluster(data)
|
|
29
|
+
const backupPlugin = getCNPGBackupPlugin(data)
|
|
27
30
|
|
|
28
31
|
// WAL range fields
|
|
29
32
|
const beginWal = data.status?.beginWal
|
|
@@ -48,6 +51,24 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
|
|
|
48
51
|
<PropertyList>
|
|
49
52
|
<Property label="Phase" value={phase} />
|
|
50
53
|
<Property label="Method" value={getCNPGBackupMethod(data)} />
|
|
54
|
+
{/* A plugin-taken backup lands in an ObjectStore rather than in the
|
|
55
|
+
Cluster's own barmanObjectStore, so naming the plugin is what
|
|
56
|
+
tells an operator where to go looking. */}
|
|
57
|
+
{backupPlugin && <Property label="Plugin" value={backupPlugin.name} />}
|
|
58
|
+
{backupPlugin?.parameters?.barmanObjectName && (
|
|
59
|
+
<Property
|
|
60
|
+
label="Object Store"
|
|
61
|
+
value={
|
|
62
|
+
<ResourceLink
|
|
63
|
+
name={backupPlugin.parameters.barmanObjectName}
|
|
64
|
+
kind="objectstores"
|
|
65
|
+
group={CNPG_BARMAN_OBJECTSTORE_GROUP}
|
|
66
|
+
namespace={data.metadata?.namespace || ''}
|
|
67
|
+
onNavigate={onNavigate}
|
|
68
|
+
/>
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
51
72
|
<Property label="Duration" value={getCNPGBackupDuration(data)} />
|
|
52
73
|
<Property label="Backup Name" value={getCNPGBackupName(data)} />
|
|
53
74
|
{data.status?.instanceID?.podName && (
|
|
@@ -81,8 +102,16 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
|
|
|
81
102
|
}
|
|
82
103
|
return clusterName
|
|
83
104
|
})()} />
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
{/* Destination and server name are in-tree barmanObjectStore fields.
|
|
106
|
+
Under the plugin method they are never populated because both live
|
|
107
|
+
on the ObjectStore, and rendering them as "-" reads as "not
|
|
108
|
+
configured" rather than "recorded elsewhere". */}
|
|
109
|
+
{!backupPlugin && (
|
|
110
|
+
<>
|
|
111
|
+
<Property label="Destination" value={getCNPGBackupDestinationPath(data)} />
|
|
112
|
+
<Property label="Server Name" value={getCNPGBackupServerName(data)} />
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
86
115
|
</PropertyList>
|
|
87
116
|
</Section>
|
|
88
117
|
|
|
@@ -83,3 +83,36 @@ describe('the WAL banner must not claim nothing else looks wrong when something
|
|
|
83
83
|
expect(html(walFailing(phase, 2))).not.toContain(CLAIM)
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
describe('recovery points must be readable as ages, not raw machine timestamps', () => {
|
|
88
|
+
const withBackup = (lastSuccessful: string) => ({
|
|
89
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
90
|
+
kind: 'Cluster',
|
|
91
|
+
metadata: { name: 'pg', namespace: 'db' },
|
|
92
|
+
spec: { instances: 2, backup: { barmanObjectStore: { destinationPath: 's3://bucket' } } },
|
|
93
|
+
status: {
|
|
94
|
+
phase: 'Cluster in healthy state',
|
|
95
|
+
readyInstances: 2,
|
|
96
|
+
lastSuccessfulBackup: lastSuccessful,
|
|
97
|
+
firstRecoverabilityPoint: lastSuccessful,
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('states how old the last successful backup is', () => {
|
|
102
|
+
const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString()
|
|
103
|
+
const html = renderToString(<CNPGClusterRenderer data={withBackup(old)} />)
|
|
104
|
+
// The age is the whole point: a raw ISO string requires the reader to do
|
|
105
|
+
// date arithmetic to notice their newest backup is 200 days old.
|
|
106
|
+
expect(html).toContain('200d ago')
|
|
107
|
+
// And the ISO form with its milliseconds must not survive as the display value.
|
|
108
|
+
expect(html).not.toContain(old)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('keeps the absolute timestamp beside the age', () => {
|
|
112
|
+
// Age alone is the wrong unit here: "1d ago" next to "1d ago" is the same
|
|
113
|
+
// string for events a week apart in a failure history.
|
|
114
|
+
const at = '2026-01-30T09:40:37.000Z'
|
|
115
|
+
const html = renderToString(<CNPGClusterRenderer data={withBackup(at)} />)
|
|
116
|
+
expect(html).toContain('2026-01-30 09:40:37 UTC')
|
|
117
|
+
})
|
|
118
|
+
})
|