@skyhook-io/k8s-ui 1.10.5 → 1.12.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 +1 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
- package/src/components/gitops/detail-helpers.ts +4 -1
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +261 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +133 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +19 -4
- package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
- package/src/components/resources/renderers/PolicySection.tsx +170 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +5 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +590 -1
- package/src/components/resources/resource-utils-cnpg.ts +433 -56
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +6 -0
- package/src/components/trace/ReachabilityGraph.tsx +20 -6
- package/src/components/trace/ReachabilityView.tsx +253 -52
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachGraphModel.test.ts +16 -0
- package/src/components/trace/reachInspector.test.ts +233 -10
- package/src/components/trace/reachInspector.ts +166 -36
- package/src/components/trace/reachMarks.test.ts +32 -1
- package/src/components/trace/reachMarks.ts +44 -0
- package/src/components/trace/reachOrigins.ts +12 -0
- package/src/components/trace/types.ts +25 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/theme/components.css +26 -0
- package/src/types/gitops-flux-status.test.ts +163 -0
- package/src/types/gitops.ts +80 -24
- package/src/types/index.ts +1 -0
- package/src/types/policy.ts +42 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/resource-icons.test.ts +33 -0
- package/src/utils/resource-icons.ts +65 -2
package/src/theme/components.css
CHANGED
|
@@ -273,3 +273,29 @@
|
|
|
273
273
|
@media (prefers-reduced-motion: reduce) {
|
|
274
274
|
.reach-edge-testing { animation: none; }
|
|
275
275
|
}
|
|
276
|
+
|
|
277
|
+
/* reach-node-testing: the vantage a test is running FROM, while it runs.
|
|
278
|
+
The edge already shows the request travelling; the capsule it leaves from
|
|
279
|
+
stayed inert, so the source of the work looked as static as everything else.
|
|
280
|
+
A slow border breathe + a lifted surface says "this one is working" without
|
|
281
|
+
competing with the flowing edge. Semantic: only the running origin gets it. */
|
|
282
|
+
@keyframes reach-node-breathe {
|
|
283
|
+
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); }
|
|
284
|
+
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
285
|
+
}
|
|
286
|
+
.reach-node-testing {
|
|
287
|
+
animation: reach-node-breathe 1.6s ease-in-out infinite;
|
|
288
|
+
}
|
|
289
|
+
/* The label itself pulses gently - "testing now" that never changes reads as
|
|
290
|
+
a stuck state on a long run. */
|
|
291
|
+
@keyframes reach-label-fade {
|
|
292
|
+
0%, 100% { opacity: 1; }
|
|
293
|
+
50% { opacity: 0.55; }
|
|
294
|
+
}
|
|
295
|
+
.reach-label-testing {
|
|
296
|
+
animation: reach-label-fade 1.6s ease-in-out infinite;
|
|
297
|
+
}
|
|
298
|
+
@media (prefers-reduced-motion: reduce) {
|
|
299
|
+
.reach-node-testing,
|
|
300
|
+
.reach-label-testing { animation: none; }
|
|
301
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { fluxConditionsToGitOpsStatus, type FluxCondition } from './gitops'
|
|
3
|
+
|
|
4
|
+
const c = (type: string, status: string, extra: Partial<FluxCondition> = {}): FluxCondition =>
|
|
5
|
+
({ type, status, ...extra } as FluxCondition)
|
|
6
|
+
|
|
7
|
+
// Mirrors pkg/gitops/fluxstatus_test.go. The two implementations must not drift:
|
|
8
|
+
// they feed the same columns from different sides of the wire.
|
|
9
|
+
describe('fluxConditionsToGitOpsStatus truth table', () => {
|
|
10
|
+
it('a stale Reconciling does not outvote an applied Ready', () => {
|
|
11
|
+
// The reported bug: kubectl and k9s read the printer columns and show
|
|
12
|
+
// READY=True while Radar showed the row as syncing.
|
|
13
|
+
const got = fluxConditionsToGitOpsStatus(
|
|
14
|
+
[c('Ready', 'True'), c('Healthy', 'True'), c('Reconciling', 'True')],
|
|
15
|
+
false,
|
|
16
|
+
{ generation: 1, observedGeneration: 1 },
|
|
17
|
+
)
|
|
18
|
+
expect(got.sync).toBe('Synced')
|
|
19
|
+
expect(got.health).toBe('Healthy')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('generation drift outranks a stale Ready', () => {
|
|
23
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'True')], false, {
|
|
24
|
+
generation: 2,
|
|
25
|
+
observedGeneration: 1,
|
|
26
|
+
})
|
|
27
|
+
expect(got.sync).toBe('Reconciling')
|
|
28
|
+
expect(got.health).toBe('Progressing')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('treats the -1 sentinel as drift', () => {
|
|
32
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'False'), c('Reconciling', 'True')], false, {
|
|
33
|
+
generation: 1,
|
|
34
|
+
observedGeneration: -1,
|
|
35
|
+
})
|
|
36
|
+
expect(got.sync).toBe('Reconciling')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not treat an absent observedGeneration as drift', () => {
|
|
40
|
+
// Kinds and controller versions that never set it must not all read stale.
|
|
41
|
+
expect(fluxConditionsToGitOpsStatus([c('Ready', 'True')], false, { generation: 3 }).sync).toBe('Synced')
|
|
42
|
+
expect(fluxConditionsToGitOpsStatus([c('Ready', 'True')], false, {}).sync).toBe('Synced')
|
|
43
|
+
expect(fluxConditionsToGitOpsStatus([c('Ready', 'True')], false).sync).toBe('Synced')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('lets Stalled outrank Ready', () => {
|
|
47
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'True'), c('Stalled', 'True')], false, {
|
|
48
|
+
generation: 1,
|
|
49
|
+
observedGeneration: 1,
|
|
50
|
+
})
|
|
51
|
+
expect(got.sync).toBe('OutOfSync')
|
|
52
|
+
expect(got.health).toBe('Degraded')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('separates applied from healthy', () => {
|
|
56
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'True'), c('Healthy', 'False')], false, {
|
|
57
|
+
generation: 1,
|
|
58
|
+
observedGeneration: 1,
|
|
59
|
+
})
|
|
60
|
+
expect(got.sync).toBe('Synced')
|
|
61
|
+
expect(got.health).toBe('Degraded')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reports Reconciling when the pass has not reached Ready', () => {
|
|
65
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'Unknown'), c('Reconciling', 'True')], false, {
|
|
66
|
+
generation: 1,
|
|
67
|
+
observedGeneration: 1,
|
|
68
|
+
})
|
|
69
|
+
expect(got.sync).toBe('Reconciling')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps a suspended-but-applied object Synced, and drops it on drift', () => {
|
|
73
|
+
const gen = { generation: 1, observedGeneration: 1 }
|
|
74
|
+
expect(fluxConditionsToGitOpsStatus([c('Ready', 'True')], true, gen).sync).toBe('Synced')
|
|
75
|
+
expect(
|
|
76
|
+
fluxConditionsToGitOpsStatus([c('Ready', 'True')], true, { generation: 2, observedGeneration: 1 }).sync,
|
|
77
|
+
).toBe('Unknown')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('treats a retrying Ready=False as progressing, not degraded', () => {
|
|
81
|
+
// Mirrors pkg/gitops/fluxstatus_test.go. Flux composes reasons like
|
|
82
|
+
// ProgressingWithRetry; a retry in flight is out of sync but not settled.
|
|
83
|
+
const got = fluxConditionsToGitOpsStatus(
|
|
84
|
+
[c('Ready', 'False', { reason: 'ProgressingWithRetry' })],
|
|
85
|
+
false,
|
|
86
|
+
{ generation: 1, observedGeneration: 1 },
|
|
87
|
+
)
|
|
88
|
+
expect(got.sync).toBe('OutOfSync')
|
|
89
|
+
expect(got.health).toBe('Progressing')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('treats a settled Ready=False as degraded', () => {
|
|
93
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'False', { reason: 'BuildFailed' })], false, {
|
|
94
|
+
generation: 1,
|
|
95
|
+
observedGeneration: 1,
|
|
96
|
+
})
|
|
97
|
+
expect(got.sync).toBe('OutOfSync')
|
|
98
|
+
expect(got.health).toBe('Degraded')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
describe('reconciling activity', () => {
|
|
104
|
+
it('survives even when it no longer drives the status', () => {
|
|
105
|
+
const got = fluxConditionsToGitOpsStatus(
|
|
106
|
+
[c('Ready', 'True'), c('Reconciling', 'True', { lastTransitionTime: '2026-08-11T06:05:00Z' })],
|
|
107
|
+
false,
|
|
108
|
+
{ generation: 1, observedGeneration: 1 },
|
|
109
|
+
)
|
|
110
|
+
expect(got.sync).toBe('Synced')
|
|
111
|
+
expect(got.reconciling).toBe(true)
|
|
112
|
+
expect(got.reconcilingSince).toBe('2026-08-11T06:05:00Z')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('is absent on a quiet object', () => {
|
|
116
|
+
const got = fluxConditionsToGitOpsStatus([c('Ready', 'True')], false, {
|
|
117
|
+
generation: 1,
|
|
118
|
+
observedGeneration: 1,
|
|
119
|
+
})
|
|
120
|
+
expect(got.reconciling).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Source kinds carry no Healthy condition; HelmRelease uses Released.
|
|
125
|
+
describe('non-Kustomization Flux kinds', () => {
|
|
126
|
+
it.each([
|
|
127
|
+
['GitRepository', [c('Ready', 'True'), c('ArtifactInStorage', 'True')]],
|
|
128
|
+
['HelmRepository', [c('Ready', 'True'), c('ArtifactInStorage', 'True')]],
|
|
129
|
+
['HelmRelease', [c('Ready', 'True'), c('Released', 'True')]],
|
|
130
|
+
])('%s reads as Synced/Healthy', (_kind, conds) => {
|
|
131
|
+
const got = fluxConditionsToGitOpsStatus(conds as FluxCondition[], false, {
|
|
132
|
+
generation: 1,
|
|
133
|
+
observedGeneration: 1,
|
|
134
|
+
})
|
|
135
|
+
expect(got.sync).toBe('Synced')
|
|
136
|
+
expect(got.health).toBe('Healthy')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// The wedged-controller case end to end: a fully-applied object whose reconcile
|
|
141
|
+
// pass never finished must read Synced (it IS applied) AND still carry the
|
|
142
|
+
// in-flight fact, or a stuck controller becomes invisible. Before the sync fix
|
|
143
|
+
// this state showed a permanent "Syncing", which was the wrong word but the
|
|
144
|
+
// only thing in the product that noticed.
|
|
145
|
+
describe('a wedged controller stays visible', () => {
|
|
146
|
+
it('reports Synced and keeps the in-flight fact with its start time', () => {
|
|
147
|
+
const got = fluxConditionsToGitOpsStatus(
|
|
148
|
+
[
|
|
149
|
+
c('Ready', 'True', { lastTransitionTime: '2026-08-11T06:00:00Z' }),
|
|
150
|
+
c('Healthy', 'True'),
|
|
151
|
+
c('Reconciling', 'True', { lastTransitionTime: '2026-08-11T06:05:00Z' }),
|
|
152
|
+
],
|
|
153
|
+
false,
|
|
154
|
+
{ generation: 1, observedGeneration: 1 },
|
|
155
|
+
)
|
|
156
|
+
expect(got.sync).toBe('Synced')
|
|
157
|
+
expect(got.health).toBe('Healthy')
|
|
158
|
+
// Both are required: without these the indicator has nothing to render and
|
|
159
|
+
// the operator gets a confident all-clear over a stuck controller.
|
|
160
|
+
expect(got.reconciling).toBe(true)
|
|
161
|
+
expect(got.reconcilingSince).toBe('2026-08-11T06:05:00Z')
|
|
162
|
+
})
|
|
163
|
+
})
|
package/src/types/gitops.ts
CHANGED
|
@@ -63,6 +63,15 @@ export interface GitOpsStatus {
|
|
|
63
63
|
message?: string
|
|
64
64
|
lastSyncTime?: string
|
|
65
65
|
suspended: boolean
|
|
66
|
+
/**
|
|
67
|
+
* A reconcile pass is executing right now. Activity, not sync and not health —
|
|
68
|
+
* it never influences either, because a controller being mid-pass says nothing
|
|
69
|
+
* about whether the declared state is applied. Renderers may show a subtle
|
|
70
|
+
* indicator; nothing that drives a status word.
|
|
71
|
+
*/
|
|
72
|
+
reconciling?: boolean
|
|
73
|
+
/** When the in-flight pass started, so a wedged controller can be told from a routine one. */
|
|
74
|
+
reconcilingSince?: string
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/**
|
|
@@ -97,50 +106,92 @@ export interface FluxCondition {
|
|
|
97
106
|
// ============================================================================
|
|
98
107
|
|
|
99
108
|
/**
|
|
100
|
-
*
|
|
109
|
+
* Generation tracking for a Flux object. `observedGeneration` has three states,
|
|
110
|
+
* and the difference matters: absent means the controller never reports it and
|
|
111
|
+
* we have no signal, which must not be read as drift.
|
|
112
|
+
*/
|
|
113
|
+
export interface FluxGeneration {
|
|
114
|
+
generation?: number
|
|
115
|
+
observedGeneration?: number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* True when the controller has demonstrably not yet acted on the current spec.
|
|
120
|
+
* Absent observedGeneration is no signal, not drift — some Flux kinds and older
|
|
121
|
+
* controllers never set it, and treating absence as drift would mark every
|
|
122
|
+
* healthy object on them as out of date. A present-but-different value counts,
|
|
123
|
+
* including the -1 sentinel Flux writes on an object it has never reconciled.
|
|
124
|
+
*/
|
|
125
|
+
function hasGenerationDrift(gen?: FluxGeneration): boolean {
|
|
126
|
+
if (!gen) return false
|
|
127
|
+
const { generation, observedGeneration } = gen
|
|
128
|
+
if (typeof generation !== 'number' || typeof observedGeneration !== 'number') return false
|
|
129
|
+
return generation !== observedGeneration
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Maps FluxCD conditions to unified GitOpsStatus.
|
|
134
|
+
*
|
|
135
|
+
* Sync answers "is the declared state applied", which is `Ready` plus
|
|
136
|
+
* `observedGeneration`. `Reconciling` answers "is the controller mid-pass",
|
|
137
|
+
* which is neither — the two are independent conditions upstream, and letting
|
|
138
|
+
* activity outvote readiness reported a healthy object as not synced.
|
|
101
139
|
*
|
|
102
|
-
*
|
|
103
|
-
* - Ready
|
|
104
|
-
* -
|
|
105
|
-
* - Ready=
|
|
106
|
-
* - Ready
|
|
107
|
-
* -
|
|
108
|
-
* - spec.suspend=true: Suspended
|
|
140
|
+
* Order matters:
|
|
141
|
+
* - suspend → Suspended (sync from Ready, unless drifted)
|
|
142
|
+
* - Stalled=True → OutOfSync / Degraded
|
|
143
|
+
* - Ready=True and no generation drift → Synced, health from Healthy
|
|
144
|
+
* - drift, or Reconciling with Ready not True → Reconciling / Progressing
|
|
145
|
+
* - Ready=False → OutOfSync
|
|
109
146
|
*/
|
|
110
147
|
export function fluxConditionsToGitOpsStatus(
|
|
111
148
|
conditions: FluxCondition[],
|
|
112
|
-
suspended: boolean
|
|
149
|
+
suspended: boolean,
|
|
150
|
+
generation?: FluxGeneration
|
|
113
151
|
): GitOpsStatus {
|
|
114
152
|
const readyCondition = conditions.find(c => c.type === 'Ready')
|
|
115
153
|
const reconcilingCondition = conditions.find(c => c.type === 'Reconciling')
|
|
116
154
|
const stalledCondition = conditions.find(c => c.type === 'Stalled')
|
|
117
155
|
const healthyCondition = conditions.find(c => c.type === 'Healthy')
|
|
118
156
|
|
|
119
|
-
|
|
157
|
+
const drifted = hasGenerationDrift(generation)
|
|
158
|
+
const isReconciling = reconcilingCondition?.status === 'True'
|
|
159
|
+
// Carried on every result so a wedged controller is visible next to an
|
|
160
|
+
// otherwise-correct Synced, without any status word claiming a fault.
|
|
161
|
+
const activity = isReconciling
|
|
162
|
+
? { reconciling: true, reconcilingSince: reconcilingCondition?.lastTransitionTime }
|
|
163
|
+
: {}
|
|
164
|
+
|
|
165
|
+
// Handle suspended state. Suspending does not un-apply what was applied, so a
|
|
166
|
+
// suspended object that was Ready stays Synced — but a spec change made while
|
|
167
|
+
// suspended will never be picked up, so drift downgrades it to Unknown.
|
|
120
168
|
if (suspended) {
|
|
121
169
|
return {
|
|
122
|
-
sync: readyCondition?.status === 'True' ? 'Synced' : 'Unknown',
|
|
170
|
+
sync: readyCondition?.status === 'True' && !drifted ? 'Synced' : 'Unknown',
|
|
123
171
|
health: 'Suspended',
|
|
124
172
|
message: 'Reconciliation is suspended',
|
|
125
173
|
lastSyncTime: readyCondition?.lastTransitionTime,
|
|
126
174
|
suspended: true,
|
|
175
|
+
...activity,
|
|
127
176
|
}
|
|
128
177
|
}
|
|
129
178
|
|
|
130
|
-
//
|
|
131
|
-
|
|
179
|
+
// Stalled is a terminal failure and outranks readiness: it means the
|
|
180
|
+
// controller has given up, whatever the last successful apply left behind.
|
|
181
|
+
if (stalledCondition?.status === 'True') {
|
|
132
182
|
return {
|
|
133
|
-
sync: '
|
|
134
|
-
health: '
|
|
135
|
-
message:
|
|
183
|
+
sync: 'OutOfSync',
|
|
184
|
+
health: 'Degraded',
|
|
185
|
+
message: stalledCondition.message || 'Reconciliation stalled',
|
|
136
186
|
lastSyncTime: readyCondition?.lastTransitionTime,
|
|
137
187
|
suspended: false,
|
|
188
|
+
...activity,
|
|
138
189
|
}
|
|
139
190
|
}
|
|
140
191
|
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
192
|
+
// The applied state. Ahead of the reconciling branch on purpose — this is the
|
|
193
|
+
// whole point of the ordering.
|
|
194
|
+
if (readyCondition?.status === 'True' && !drifted) {
|
|
144
195
|
const isHealthy = healthyCondition?.status !== 'False'
|
|
145
196
|
return {
|
|
146
197
|
sync: 'Synced',
|
|
@@ -148,17 +199,20 @@ export function fluxConditionsToGitOpsStatus(
|
|
|
148
199
|
message: isHealthy ? undefined : healthyCondition?.message,
|
|
149
200
|
lastSyncTime: readyCondition.lastTransitionTime,
|
|
150
201
|
suspended: false,
|
|
202
|
+
...activity,
|
|
151
203
|
}
|
|
152
204
|
}
|
|
153
205
|
|
|
154
|
-
//
|
|
155
|
-
|
|
206
|
+
// Genuinely not applied: the controller has not observed the current spec, or
|
|
207
|
+
// it is working and has not reached Ready.
|
|
208
|
+
if (drifted || isReconciling) {
|
|
156
209
|
return {
|
|
157
|
-
sync: '
|
|
158
|
-
health: '
|
|
159
|
-
message:
|
|
210
|
+
sync: 'Reconciling',
|
|
211
|
+
health: 'Progressing',
|
|
212
|
+
message: reconcilingCondition?.message || 'Reconciliation in progress',
|
|
160
213
|
lastSyncTime: readyCondition?.lastTransitionTime,
|
|
161
214
|
suspended: false,
|
|
215
|
+
...activity,
|
|
162
216
|
}
|
|
163
217
|
}
|
|
164
218
|
|
|
@@ -173,6 +227,7 @@ export function fluxConditionsToGitOpsStatus(
|
|
|
173
227
|
message: readyCondition.message,
|
|
174
228
|
lastSyncTime: readyCondition.lastTransitionTime,
|
|
175
229
|
suspended: false,
|
|
230
|
+
...activity,
|
|
176
231
|
}
|
|
177
232
|
}
|
|
178
233
|
|
|
@@ -182,6 +237,7 @@ export function fluxConditionsToGitOpsStatus(
|
|
|
182
237
|
health: 'Unknown',
|
|
183
238
|
message: 'Status cannot be determined',
|
|
184
239
|
suspended: false,
|
|
240
|
+
...activity,
|
|
185
241
|
}
|
|
186
242
|
}
|
|
187
243
|
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Per-resource policy findings, from GET /api/policy/resource/{kind}/{ns}/{name}.
|
|
2
|
+
//
|
|
3
|
+
// The coverage fields are not decoration. An empty `findings` array means one of
|
|
4
|
+
// several very different things — nothing violated, no engine installed, the
|
|
5
|
+
// caller may not read the reports, the index is still warming — and a section
|
|
6
|
+
// that renders all of them as blank space tells an operator they are compliant
|
|
7
|
+
// in the cases where we simply do not know.
|
|
8
|
+
|
|
9
|
+
export type PolicyResult = 'pass' | 'fail' | 'warn' | 'error' | 'skip' | string
|
|
10
|
+
|
|
11
|
+
export interface PolicyResourceFinding {
|
|
12
|
+
policy: string
|
|
13
|
+
rule?: string
|
|
14
|
+
result: PolicyResult
|
|
15
|
+
severity?: string
|
|
16
|
+
category?: string
|
|
17
|
+
message?: string
|
|
18
|
+
engine?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PolicyResourceCounts {
|
|
22
|
+
pass: number
|
|
23
|
+
fail: number
|
|
24
|
+
warn: number
|
|
25
|
+
error: number
|
|
26
|
+
skip: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PolicyResourceResponse {
|
|
30
|
+
/** False whenever the answer is "we could not check". Never treat an empty
|
|
31
|
+
* findings array as "clean" without consulting this first. */
|
|
32
|
+
evaluated: boolean
|
|
33
|
+
status: string
|
|
34
|
+
reasonCode?: string
|
|
35
|
+
/** Report families the caller could not read. Present even when evaluated:
|
|
36
|
+
* the answer is real but incomplete. */
|
|
37
|
+
deniedGroups?: string[]
|
|
38
|
+
/** False when the index is frozen at its initial contents. */
|
|
39
|
+
liveUpdates: boolean
|
|
40
|
+
counts: PolicyResourceCounts
|
|
41
|
+
findings: PolicyResourceFinding[]
|
|
42
|
+
}
|
|
@@ -207,6 +207,8 @@ export function formatGroupName(group: string): string {
|
|
|
207
207
|
'eks.amazonaws.com': 'EKS',
|
|
208
208
|
'networking.k8s.aws': 'AWS Networking',
|
|
209
209
|
'acid.zalan.do': 'Zalando Postgres',
|
|
210
|
+
'postgresql.cnpg.io': 'CloudNativePG',
|
|
211
|
+
'barmancloud.cnpg.io': 'CloudNativePG',
|
|
210
212
|
'serving.kserve.io': 'KServe',
|
|
211
213
|
'ray.io': 'KubeRay',
|
|
212
214
|
'leaderworkerset.x-k8s.io': 'LeaderWorkerSet',
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { Database, Server, Puzzle } from 'lucide-react'
|
|
3
|
+
import { getResourceIcon, DEFAULT_RESOURCE_ICON } from './resource-icons'
|
|
4
|
+
|
|
5
|
+
describe('getResourceIcon', () => {
|
|
6
|
+
it('disambiguates colliding kinds by API group', () => {
|
|
7
|
+
// `Cluster` is shipped by CNPG, Cluster API, KubeBlocks and others. Without
|
|
8
|
+
// the group there is nothing to pick on, so the browser passes it.
|
|
9
|
+
expect(getResourceIcon('Cluster', 'postgresql.cnpg.io')).toBe(Database)
|
|
10
|
+
expect(getResourceIcon('Cluster', 'cluster.x-k8s.io')).toBe(Server)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('falls back to the default for an unmapped group', () => {
|
|
14
|
+
// A third operator's `clusters` CRD must not inherit a Postgres icon.
|
|
15
|
+
expect(getResourceIcon('Cluster', 'apps.kubeblocks.io')).toBe(DEFAULT_RESOURCE_ICON)
|
|
16
|
+
expect(getResourceIcon('Backup', 'kubevirt.io')).toBe(DEFAULT_RESOURCE_ICON)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('ignores the group for kinds that do not collide', () => {
|
|
20
|
+
expect(getResourceIcon('Pod')).not.toBe(Puzzle)
|
|
21
|
+
expect(getResourceIcon('Pod', 'postgresql.cnpg.io')).toBe(getResourceIcon('Pod'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('resolves unambiguous CNPG kinds without a group', () => {
|
|
25
|
+
// Pooler is CNPG-only, so it keys directly and works everywhere.
|
|
26
|
+
expect(getResourceIcon('Pooler')).not.toBe(DEFAULT_RESOURCE_ICON)
|
|
27
|
+
expect(getResourceIcon('Pooler')).toBe(getResourceIcon('Pooler', 'postgresql.cnpg.io'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns the default for an entirely unknown kind', () => {
|
|
31
|
+
expect(getResourceIcon('Widget')).toBe(DEFAULT_RESOURCE_ICON)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
HardDrive,
|
|
30
30
|
Cylinder,
|
|
31
31
|
Database,
|
|
32
|
+
DatabaseBackup,
|
|
33
|
+
Waypoints,
|
|
32
34
|
FileSearch,
|
|
35
|
+
Fingerprint,
|
|
36
|
+
Wand2,
|
|
37
|
+
Sparkles,
|
|
38
|
+
Trash2,
|
|
39
|
+
ShieldOff,
|
|
33
40
|
|
|
34
41
|
// Cluster
|
|
35
42
|
Cpu,
|
|
@@ -65,6 +72,11 @@ import {
|
|
|
65
72
|
HeartPulse,
|
|
66
73
|
BookOpen,
|
|
67
74
|
|
|
75
|
+
// Velero
|
|
76
|
+
Cloud,
|
|
77
|
+
Camera,
|
|
78
|
+
Package,
|
|
79
|
+
|
|
68
80
|
// Fallback
|
|
69
81
|
Puzzle,
|
|
70
82
|
} from 'lucide-react'
|
|
@@ -196,6 +208,12 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
196
208
|
// Contour
|
|
197
209
|
httpproxy: Globe,
|
|
198
210
|
|
|
211
|
+
// CloudNativePG. Pooler is unambiguous so it keys directly; CNPG's colliding
|
|
212
|
+
// kinds resolve through GROUP_QUALIFIED_KIND_ICONS below. Topology
|
|
213
|
+
// pseudo-kinds (cnpgcluster/…) belong here only once pkg/topology's
|
|
214
|
+
// KindForGVK emits them — it has no CNPG case today.
|
|
215
|
+
pooler: Waypoints,
|
|
216
|
+
|
|
199
217
|
// Cluster API
|
|
200
218
|
capicluster: Server,
|
|
201
219
|
machinedeployment: Layers,
|
|
@@ -227,16 +245,61 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
227
245
|
azuremachinetemplate: Cpu,
|
|
228
246
|
azuremanagedcluster: Server,
|
|
229
247
|
|
|
248
|
+
// Kyverno — legacy kyverno.io family, modern policies.kyverno.io CEL
|
|
249
|
+
// family, and the wgpolicyk8s.io reports every engine writes into.
|
|
250
|
+
policy: Shield,
|
|
251
|
+
clusterpolicy: Shield,
|
|
252
|
+
policyreport: FileSearch,
|
|
253
|
+
clusterpolicyreport: FileSearch,
|
|
254
|
+
validatingpolicy: ShieldCheck,
|
|
255
|
+
namespacedvalidatingpolicy: ShieldCheck,
|
|
256
|
+
imagevalidatingpolicy: Fingerprint,
|
|
257
|
+
namespacedimagevalidatingpolicy: Fingerprint,
|
|
258
|
+
mutatingpolicy: Wand2,
|
|
259
|
+
namespacedmutatingpolicy: Wand2,
|
|
260
|
+
generatingpolicy: Sparkles,
|
|
261
|
+
namespacedgeneratingpolicy: Sparkles,
|
|
262
|
+
deletingpolicy: Trash2,
|
|
263
|
+
namespaceddeletingpolicy: Trash2,
|
|
264
|
+
policyexception: ShieldOff,
|
|
265
|
+
cleanuppolicy: Trash2,
|
|
266
|
+
clustercleanuppolicy: Trash2,
|
|
267
|
+
|
|
230
268
|
// Trivy Operator
|
|
231
269
|
vulnerabilityreport: Shield,
|
|
232
270
|
configauditreport: ShieldCheck,
|
|
233
271
|
exposedsecretreport: ShieldAlert,
|
|
234
272
|
sbomreport: FileSearch,
|
|
273
|
+
|
|
274
|
+
// Velero. Only kinds whose names no other operator claims: this map is keyed
|
|
275
|
+
// on kind alone with no group awareness, so a shared name would give a foreign
|
|
276
|
+
// resource a Velero icon. That rules out `backup` (CNPG), `restore`
|
|
277
|
+
// (rancher/backup-restore-operator) and `schedule` (several operators).
|
|
278
|
+
backupstoragelocation: Cloud,
|
|
279
|
+
volumesnapshotlocation: Camera,
|
|
280
|
+
backuprepository: Package,
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
/** Get the icon for a Kubernetes resource kind (case-insensitive). */
|
|
238
|
-
|
|
239
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Kinds shared by more than one operator, where the icon can only be chosen
|
|
286
|
+
* once the API group is known. The resource browser passes the group; callers
|
|
287
|
+
* that don't have one fall through to the ungrouped map.
|
|
288
|
+
*/
|
|
289
|
+
const GROUP_QUALIFIED_KIND_ICONS: Record<string, Record<string, LucideIcon>> = {
|
|
290
|
+
cluster: { 'postgresql.cnpg.io': Database, 'cluster.x-k8s.io': Server },
|
|
291
|
+
backup: { 'postgresql.cnpg.io': DatabaseBackup },
|
|
292
|
+
scheduledbackup: { 'postgresql.cnpg.io': DatabaseBackup },
|
|
293
|
+
pooler: { 'postgresql.cnpg.io': Waypoints },
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function getResourceIcon(kind: string, group?: string): LucideIcon {
|
|
297
|
+
const k = kind.toLowerCase()
|
|
298
|
+
if (group) {
|
|
299
|
+
const byGroup = GROUP_QUALIFIED_KIND_ICONS[k]?.[group]
|
|
300
|
+
if (byGroup) return byGroup
|
|
301
|
+
}
|
|
302
|
+
return KIND_ICON_MAP[k] ?? Puzzle
|
|
240
303
|
}
|
|
241
304
|
|
|
242
305
|
/** Get the icon for a topology node kind, including virtual kinds (Internet, PodGroup). */
|