@skyhook-io/k8s-ui 1.6.0 → 1.6.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/package.json +7 -2
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +621 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +216 -0
- package/src/components/gitops/GitOpsTableView.tsx +1441 -0
- package/src/components/gitops/RollbackDialog.tsx +117 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +160 -0
- package/src/components/gitops/detail-helpers.test.ts +97 -0
- package/src/components/gitops/detail-helpers.ts +112 -0
- package/src/components/gitops/index.ts +52 -1
- package/src/components/gitops/short-cluster-name.test.ts +36 -0
- package/src/components/gitops/tree/index.ts +2 -0
- package/src/components/gitops/tree/merge.test.ts +240 -0
- package/src/components/gitops/tree/merge.ts +160 -0
- package/src/components/resources/ResourcesSidebar.tsx +42 -15
- package/src/components/resources/ResourcesView.tsx +403 -69
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/ui/drawer-components.tsx +8 -20
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/index.ts +3 -0
- package/src/types/core.ts +31 -6
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/badge-colors.ts +25 -0
- package/src/utils/gitops-owner.test.ts +70 -111
- package/src/utils/gitops-owner.ts +37 -74
- package/src/utils/helm-status.test.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Crossplane CRD utility functions
|
|
2
|
+
//
|
|
3
|
+
// Crossplane v1 vs v2 (v2 GA in late 2025) moved several spec fields under
|
|
4
|
+
// `spec.crossplane.*`. Accessors here check v2 path first, fall back to v1 —
|
|
5
|
+
// never gate on a Crossplane-version check, since both can be present in the
|
|
6
|
+
// same cluster during migrations.
|
|
7
|
+
|
|
8
|
+
import type { StatusBadge } from './resource-utils'
|
|
9
|
+
import { healthColors } from './resource-utils'
|
|
10
|
+
|
|
11
|
+
interface CrossplaneCondition {
|
|
12
|
+
type: string
|
|
13
|
+
status: string
|
|
14
|
+
reason?: string
|
|
15
|
+
message?: string
|
|
16
|
+
lastTransitionTime?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CrossplaneResourceRef {
|
|
20
|
+
apiVersion: string
|
|
21
|
+
kind: string
|
|
22
|
+
name: string
|
|
23
|
+
namespace?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// DETECTION HEURISTICS
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A Managed Resource has a providerConfigRef (v1: spec.providerConfigRef,
|
|
32
|
+
* v2: spec.crossplane.providerConfigRef). The crossplane.io/external-name
|
|
33
|
+
* annotation is the secondary signal, but providerConfigRef is more reliable —
|
|
34
|
+
* MRs always have a ProviderConfig (default or explicit), the external-name
|
|
35
|
+
* may be empty until reconciliation succeeds.
|
|
36
|
+
*/
|
|
37
|
+
export function isManagedResource(data: any): boolean {
|
|
38
|
+
if (!data?.spec) return false
|
|
39
|
+
if (data.spec.providerConfigRef) return true
|
|
40
|
+
if (data.spec.crossplane?.providerConfigRef) return true
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A Composite Resource (XR) or v1 Claim. XRs expose spec.resourceRefs
|
|
46
|
+
* (v1) or spec.crossplane.resourceRefs (v2); v1 Claims also expose
|
|
47
|
+
* singular spec.resourceRef + spec.compositionRef pointing at their
|
|
48
|
+
* bound XR. The singular-ref arm matters during the period before
|
|
49
|
+
* reconciliation populates resourceRefs — without it, freshly-applied
|
|
50
|
+
* Claims fall through to GenericRenderer/GenericCell despite being
|
|
51
|
+
* fully-formed Crossplane resources.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors the Go isCrossplaneComposite in internal/audit/runner.go so
|
|
54
|
+
* the audit walker and UI dispatch agree on what counts as a composite.
|
|
55
|
+
* Distinct from an MR: an XR/Claim never has providerConfigRef.
|
|
56
|
+
*/
|
|
57
|
+
export function isComposite(data: any): boolean {
|
|
58
|
+
if (!data?.spec) return false
|
|
59
|
+
if (isManagedResource(data)) return false
|
|
60
|
+
if (Array.isArray(data.spec.resourceRefs)) return true
|
|
61
|
+
if (Array.isArray(data.spec.crossplane?.resourceRefs)) return true
|
|
62
|
+
// v1 Claim: singular resourceRef + compositionRef. Both required —
|
|
63
|
+
// singular resourceRef alone shows up in unrelated CRDs.
|
|
64
|
+
if (data.spec.resourceRef && data.spec.compositionRef) return true
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A Claim is a v1-only namespaced alias for an XR. Detection: has resourceRef
|
|
70
|
+
* (singular — points at the bound XR), plus compositionRef. v2 removed Claims.
|
|
71
|
+
*/
|
|
72
|
+
export function isClaim(data: any): boolean {
|
|
73
|
+
if (!data?.spec) return false
|
|
74
|
+
if (isManagedResource(data)) return false
|
|
75
|
+
return Boolean(data.spec.resourceRef && data.spec.compositionRef)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// FIELD ACCESSORS (v1+v2 path-aware)
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
export function getProviderConfigRef(data: any): { name: string; kind?: string; apiVersion?: string } | null {
|
|
83
|
+
const ref = data?.spec?.crossplane?.providerConfigRef ?? data?.spec?.providerConfigRef
|
|
84
|
+
if (!ref?.name) return null
|
|
85
|
+
// apiVersion is sometimes present on the ref (Crossplane v1 schema) and
|
|
86
|
+
// sometimes omitted (legacy specs). Pass it through when present so
|
|
87
|
+
// callers can route navigation by GVR; if absent, callers must omit
|
|
88
|
+
// the group rather than guess — ProviderConfig groups can differ from
|
|
89
|
+
// the MR's own group (e.g. an MR in s3.aws.upbound.io references a
|
|
90
|
+
// ProviderConfig in aws.upbound.io).
|
|
91
|
+
return { name: ref.name, kind: ref.kind, apiVersion: ref.apiVersion }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getCompositionRef(data: any): { name: string } | null {
|
|
95
|
+
const ref = data?.spec?.crossplane?.compositionRef ?? data?.spec?.compositionRef
|
|
96
|
+
if (!ref?.name) return null
|
|
97
|
+
return { name: ref.name }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getCompositionRevisionRef(data: any): { name: string } | null {
|
|
101
|
+
const ref = data?.spec?.crossplane?.compositionRevisionRef ?? data?.spec?.compositionRevisionRef
|
|
102
|
+
if (!ref?.name) return null
|
|
103
|
+
return { name: ref.name }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getCompositionUpdatePolicy(data: any): string | null {
|
|
107
|
+
return data?.spec?.crossplane?.compositionUpdatePolicy ?? data?.spec?.compositionUpdatePolicy ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getCrossplaneResourceRefs(data: any): CrossplaneResourceRef[] {
|
|
111
|
+
const refs = data?.spec?.crossplane?.resourceRefs ?? data?.spec?.resourceRefs
|
|
112
|
+
if (!Array.isArray(refs)) return []
|
|
113
|
+
return refs.filter((r: any) => r?.kind && r?.name)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Singular resourceRef points at the bound XR for a v1 Claim.
|
|
118
|
+
*/
|
|
119
|
+
export function getBoundXRRef(data: any): CrossplaneResourceRef | null {
|
|
120
|
+
const ref = data?.spec?.resourceRef
|
|
121
|
+
if (!ref?.kind || !ref?.name) return null
|
|
122
|
+
return ref
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getExternalName(data: any): string | null {
|
|
126
|
+
return data?.metadata?.annotations?.['crossplane.io/external-name'] ?? null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getManagementPolicies(data: any): string[] | null {
|
|
130
|
+
const v2 = data?.spec?.crossplane?.managementPolicies
|
|
131
|
+
if (Array.isArray(v2)) return v2
|
|
132
|
+
const v1 = data?.spec?.managementPolicies
|
|
133
|
+
if (Array.isArray(v1)) return v1
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getDeletionPolicy(data: any): string | null {
|
|
138
|
+
return data?.spec?.crossplane?.deletionPolicy ?? data?.spec?.deletionPolicy ?? null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Crossplane convention: setting `crossplane.io/paused: "true"` on any MR/XR/Claim
|
|
143
|
+
* stops the provider from reconciling it. Universal across every provider, so
|
|
144
|
+
* surfacing it as a chip on every Crossplane resource is high-leverage.
|
|
145
|
+
*/
|
|
146
|
+
export function isCrossplanePaused(data: any): boolean {
|
|
147
|
+
return data?.metadata?.annotations?.['crossplane.io/paused'] === 'true'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Walk ownerReferences to find the parent Composite for a Managed Resource.
|
|
152
|
+
* Crossplane sets exactly one ownerRef pointing at the composing XR. Returns
|
|
153
|
+
* null for standalone MRs (no composite parent).
|
|
154
|
+
*/
|
|
155
|
+
export function getComposingXRRef(data: any): { apiVersion: string; kind: string; name: string } | null {
|
|
156
|
+
const refs = data?.metadata?.ownerReferences
|
|
157
|
+
if (!Array.isArray(refs) || refs.length === 0) return null
|
|
158
|
+
// Crossplane composing-resource ownerRefs are typed with apiextensions.crossplane.io
|
|
159
|
+
// -generated CR groups. The Composite is always the controller; pick that one.
|
|
160
|
+
const owner = refs.find((r: any) => r?.controller === true) ?? refs[0]
|
|
161
|
+
if (!owner?.kind || !owner?.name) return null
|
|
162
|
+
return { apiVersion: owner.apiVersion ?? '', kind: owner.kind, name: owner.name }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// STATUS — Managed Resource / Composite / Claim
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
function findCondition(data: any, type: string): CrossplaneCondition | undefined {
|
|
170
|
+
const conds: CrossplaneCondition[] = data?.status?.conditions || []
|
|
171
|
+
return conds.find(c => c.type === type)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Crossplane resources expose two top-level conditions:
|
|
176
|
+
* - Synced: provider has accepted the spec (no rejected fields, no auth errors)
|
|
177
|
+
* - Ready: external resource exists in the desired state
|
|
178
|
+
*
|
|
179
|
+
* Both must be True for healthy; Synced=False usually means a configuration
|
|
180
|
+
* problem (bad ProviderConfig, malformed spec); Ready=False usually means the
|
|
181
|
+
* external API is still working or the resource hasn't converged yet.
|
|
182
|
+
*/
|
|
183
|
+
export function getCrossplaneStatus(data: any): StatusBadge {
|
|
184
|
+
if (data?.metadata?.deletionTimestamp) {
|
|
185
|
+
return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
|
|
186
|
+
}
|
|
187
|
+
const ready = findCondition(data, 'Ready')
|
|
188
|
+
const synced = findCondition(data, 'Synced')
|
|
189
|
+
|
|
190
|
+
if (!ready && !synced) {
|
|
191
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
192
|
+
}
|
|
193
|
+
if (synced?.status === 'False') {
|
|
194
|
+
return { text: 'Out of sync', color: healthColors.alert, level: 'alert' }
|
|
195
|
+
}
|
|
196
|
+
if (ready?.status === 'False') {
|
|
197
|
+
return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
198
|
+
}
|
|
199
|
+
if (ready?.status === 'True' && synced?.status === 'True') {
|
|
200
|
+
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
201
|
+
}
|
|
202
|
+
if (ready?.status === 'Unknown' || synced?.status === 'Unknown') {
|
|
203
|
+
return { text: 'Provisioning', color: healthColors.degraded, level: 'degraded' }
|
|
204
|
+
}
|
|
205
|
+
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* First non-empty message from a False/Unknown Ready or Synced condition —
|
|
210
|
+
* used as the tooltip below status badges and as the alert banner body in
|
|
211
|
+
* detail views.
|
|
212
|
+
*/
|
|
213
|
+
export function getCrossplaneStatusReason(data: any): string | null {
|
|
214
|
+
const ready = findCondition(data, 'Ready')
|
|
215
|
+
const synced = findCondition(data, 'Synced')
|
|
216
|
+
if (synced && synced.status !== 'True' && synced.message) return synced.message
|
|
217
|
+
if (ready && ready.status !== 'True' && ready.message) return ready.message
|
|
218
|
+
if (synced && synced.status !== 'True' && synced.reason) return synced.reason
|
|
219
|
+
if (ready && ready.status !== 'True' && ready.reason) return ready.reason
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// STATUS — Provider
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Providers expose Installed + Healthy conditions. A Provider in
|
|
229
|
+
* Installed=True but Healthy=False is mid-rollout (image pulled but pod
|
|
230
|
+
* crashlooping). Installed=False means the provider package failed
|
|
231
|
+
* verification or the image isn't pullable.
|
|
232
|
+
*/
|
|
233
|
+
export function getProviderStatus(data: any): StatusBadge {
|
|
234
|
+
if (data?.metadata?.deletionTimestamp) {
|
|
235
|
+
return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
|
|
236
|
+
}
|
|
237
|
+
const installed = findCondition(data, 'Installed')
|
|
238
|
+
const healthy = findCondition(data, 'Healthy')
|
|
239
|
+
|
|
240
|
+
if (installed?.status === 'False') {
|
|
241
|
+
return { text: installed.reason || 'Install failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
242
|
+
}
|
|
243
|
+
if (healthy?.status === 'False') {
|
|
244
|
+
return { text: healthy.reason || 'Unhealthy', color: healthColors.alert, level: 'alert' }
|
|
245
|
+
}
|
|
246
|
+
if (installed?.status === 'True' && healthy?.status === 'True') {
|
|
247
|
+
return { text: 'Healthy', color: healthColors.healthy, level: 'healthy' }
|
|
248
|
+
}
|
|
249
|
+
if (installed?.status === 'True') {
|
|
250
|
+
return { text: 'Installed', color: healthColors.degraded, level: 'degraded' }
|
|
251
|
+
}
|
|
252
|
+
return { text: 'Pending', color: healthColors.unknown, level: 'unknown' }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// STATUS — ProviderConfig
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* ProviderConfigs don't have rich status. Most expose `status.users` (the
|
|
261
|
+
* number of MRs referencing them) and may carry a Ready condition in some
|
|
262
|
+
* providers. We surface "Active" if anything references it, otherwise "Idle".
|
|
263
|
+
*/
|
|
264
|
+
export function getProviderConfigStatus(data: any): StatusBadge {
|
|
265
|
+
if (data?.metadata?.deletionTimestamp) {
|
|
266
|
+
return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
|
|
267
|
+
}
|
|
268
|
+
const users = typeof data?.status?.users === 'number' ? data.status.users : null
|
|
269
|
+
const ready = findCondition(data, 'Ready')
|
|
270
|
+
if (ready?.status === 'False') {
|
|
271
|
+
return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
272
|
+
}
|
|
273
|
+
if (users && users > 0) {
|
|
274
|
+
return { text: `${users} in use`, color: healthColors.healthy, level: 'healthy' }
|
|
275
|
+
}
|
|
276
|
+
return { text: 'Idle', color: healthColors.neutral, level: 'neutral' }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function getProviderConfigCredentialsSource(data: any): string {
|
|
280
|
+
return data?.spec?.credentials?.source ?? '-'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ============================================================================
|
|
284
|
+
// PROVIDER FIELD ACCESSORS
|
|
285
|
+
// ============================================================================
|
|
286
|
+
|
|
287
|
+
export function getProviderPackage(data: any): string {
|
|
288
|
+
return data?.spec?.package ?? '-'
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function getProviderPackagePullPolicy(data: any): string | null {
|
|
292
|
+
return data?.spec?.packagePullPolicy ?? null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function getProviderCurrentRevision(data: any): string | null {
|
|
296
|
+
return data?.status?.currentRevision ?? null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ============================================================================
|
|
300
|
+
// COMPOSITION / XRD FIELD ACCESSORS (for cell rendering only)
|
|
301
|
+
// ============================================================================
|
|
302
|
+
|
|
303
|
+
export function getXRDClaimNames(data: any): { kind?: string; plural?: string } {
|
|
304
|
+
const cn = data?.spec?.claimNames
|
|
305
|
+
return { kind: cn?.kind, plural: cn?.plural }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function getXRDKind(data: any): string {
|
|
309
|
+
return data?.spec?.names?.kind ?? '-'
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function getCompositionMode(data: any): string {
|
|
313
|
+
return data?.spec?.mode ?? 'Resources'
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function getCompositionFunctionCount(data: any): number {
|
|
317
|
+
const pipeline = data?.spec?.pipeline
|
|
318
|
+
return Array.isArray(pipeline) ? pipeline.length : 0
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function getCompositionCompositeTypeRef(data: any): string {
|
|
322
|
+
const ref = data?.spec?.compositeTypeRef
|
|
323
|
+
if (!ref) return '-'
|
|
324
|
+
return ref.kind ? `${ref.kind}` : '-'
|
|
325
|
+
}
|
|
@@ -86,6 +86,20 @@ export function getFluxHelmReleaseStatus(hr: any): StatusBadge {
|
|
|
86
86
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Last diagnostic message for a HelmRelease — surfaces dependency-wait vs install/upgrade failure
|
|
91
|
+
* vs test failure, which is otherwise hidden inside conditions. Returns '' for healthy releases
|
|
92
|
+
* (Ready=True) so the table column stays quiet on a green cluster.
|
|
93
|
+
*/
|
|
94
|
+
export function getFluxHelmReleaseMessage(hr: any): string {
|
|
95
|
+
const conditions: FluxCondition[] = hr.status?.conditions || []
|
|
96
|
+
const readyCondition = conditions.find((c) => c.type === 'Ready')
|
|
97
|
+
if (readyCondition?.status === 'True') return ''
|
|
98
|
+
if (readyCondition?.message) return readyCondition.message
|
|
99
|
+
const releasedCondition = conditions.find((c) => c.type === 'Released')
|
|
100
|
+
return releasedCondition?.message || ''
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
// ============================================================================
|
|
90
104
|
// FLUXCD TABLE CELL UTILITIES
|
|
91
105
|
// ============================================================================
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { isColumnFilterableByDistinctCount, SKIP_FILTER_COLUMNS } from './ResourcesView'
|
|
3
|
+
|
|
4
|
+
describe('isColumnFilterableByDistinctCount', () => {
|
|
5
|
+
// Caps were tuned after a customer-visible regression where the namespace
|
|
6
|
+
// filter silently vanished on clusters with >20 namespaces. Pin them so a
|
|
7
|
+
// future "tidy up" of the ternary doesn't re-introduce the bug.
|
|
8
|
+
|
|
9
|
+
describe('namespace (uncapped — scales with cluster, not kind enum)', () => {
|
|
10
|
+
it('is filterable at 2 distinct values', () => {
|
|
11
|
+
expect(isColumnFilterableByDistinctCount('namespace', 2)).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('is filterable above the old 20-value cap', () => {
|
|
15
|
+
expect(isColumnFilterableByDistinctCount('namespace', 25)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('is filterable at very large clusters (5000 namespaces)', () => {
|
|
19
|
+
expect(isColumnFilterableByDistinctCount('namespace', 5000)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('is not filterable with a single distinct value (nothing to filter)', () => {
|
|
23
|
+
expect(isColumnFilterableByDistinctCount('namespace', 1)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('node (cap = 200 — large but bounded)', () => {
|
|
28
|
+
it('is filterable below the cap', () => {
|
|
29
|
+
expect(isColumnFilterableByDistinctCount('node', 150)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is filterable exactly at the cap', () => {
|
|
33
|
+
expect(isColumnFilterableByDistinctCount('node', 200)).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('is not filterable just above the cap', () => {
|
|
37
|
+
expect(isColumnFilterableByDistinctCount('node', 201)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('is not filterable far above the cap', () => {
|
|
41
|
+
expect(isColumnFilterableByDistinctCount('node', 500)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('generic column (cap = 30 — enum-style)', () => {
|
|
46
|
+
it('is filterable at 2 distinct values', () => {
|
|
47
|
+
expect(isColumnFilterableByDistinctCount('status', 2)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('is filterable exactly at the cap', () => {
|
|
51
|
+
expect(isColumnFilterableByDistinctCount('status', 30)).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('is not filterable just above the cap', () => {
|
|
55
|
+
expect(isColumnFilterableByDistinctCount('status', 31)).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('SKIP_FILTER_COLUMNS denylist', () => {
|
|
60
|
+
it('skips columns in the denylist regardless of distinct count', () => {
|
|
61
|
+
expect(isColumnFilterableByDistinctCount('name', 5)).toBe(false)
|
|
62
|
+
expect(isColumnFilterableByDistinctCount('age', 5)).toBe(false)
|
|
63
|
+
expect(isColumnFilterableByDistinctCount('message', 5)).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Removed from the denylist deliberately — events benefit from filtering
|
|
67
|
+
// by Reason (FailedScheduling, BackOff, Killing, …). Re-adding would
|
|
68
|
+
// silently kill the Reason dropdown on the Events table.
|
|
69
|
+
it('does not skip `reason` (the Events Reason filter)', () => {
|
|
70
|
+
expect(SKIP_FILTER_COLUMNS.has('reason')).toBe(false)
|
|
71
|
+
expect(isColumnFilterableByDistinctCount('reason', 8)).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -37,6 +37,18 @@ interface ResourceActionsBarProps {
|
|
|
37
37
|
showYaml?: boolean
|
|
38
38
|
onToggleYaml?: () => void
|
|
39
39
|
|
|
40
|
+
/** When provided, renders a "Compare" button that opens the compare picker. */
|
|
41
|
+
onCompareTo?: () => void
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Host-supplied callback for cross-cluster compare. When set alongside
|
|
45
|
+
* onCompareTo, the Compare button becomes a small dropdown offering both
|
|
46
|
+
* scopes. When set alone, the button opens cross-cluster compare directly.
|
|
47
|
+
* Only embedded hosts (Radar Hub) wire this; standalone Radar leaves it
|
|
48
|
+
* undefined and the button stays single-cluster.
|
|
49
|
+
*/
|
|
50
|
+
onCompareAcrossClusters?: () => void
|
|
51
|
+
|
|
40
52
|
// Capabilities (injected by platform)
|
|
41
53
|
canExec?: boolean
|
|
42
54
|
canViewLogs?: boolean
|
|
@@ -111,6 +123,8 @@ interface ResourceActionsBarProps {
|
|
|
111
123
|
|
|
112
124
|
export function ResourceActionsBar({
|
|
113
125
|
resource, data, onClose, hideLogs, showYaml, onToggleYaml,
|
|
126
|
+
onCompareTo,
|
|
127
|
+
onCompareAcrossClusters,
|
|
114
128
|
canExec, canViewLogs, canPortForward,
|
|
115
129
|
onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
|
|
116
130
|
renderPortForward,
|
|
@@ -189,6 +203,16 @@ export function ResourceActionsBar({
|
|
|
189
203
|
const [showLogsMenu, setShowLogsMenu] = useState(false)
|
|
190
204
|
const logsMenuTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
191
205
|
|
|
206
|
+
const [showCompareMenu, setShowCompareMenu] = useState(false)
|
|
207
|
+
const compareMenuTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
208
|
+
const handleCompareMouseEnter = () => {
|
|
209
|
+
if (compareMenuTimeout.current) clearTimeout(compareMenuTimeout.current)
|
|
210
|
+
if (onCompareTo && onCompareAcrossClusters) setShowCompareMenu(true)
|
|
211
|
+
}
|
|
212
|
+
const handleCompareMouseLeave = () => {
|
|
213
|
+
compareMenuTimeout.current = setTimeout(() => setShowCompareMenu(false), 150)
|
|
214
|
+
}
|
|
215
|
+
|
|
192
216
|
const handleLogsMouseEnter = () => {
|
|
193
217
|
if (logsMenuTimeout.current) clearTimeout(logsMenuTimeout.current)
|
|
194
218
|
if (containers.length > 1) setShowLogsMenu(true)
|
|
@@ -483,6 +507,59 @@ export function ResourceActionsBar({
|
|
|
483
507
|
</button>
|
|
484
508
|
)}
|
|
485
509
|
|
|
510
|
+
{(onCompareTo || onCompareAcrossClusters) && (
|
|
511
|
+
<div
|
|
512
|
+
className="relative"
|
|
513
|
+
onMouseEnter={handleCompareMouseEnter}
|
|
514
|
+
onMouseLeave={handleCompareMouseLeave}
|
|
515
|
+
>
|
|
516
|
+
<Tooltip
|
|
517
|
+
content={
|
|
518
|
+
onCompareTo && onCompareAcrossClusters
|
|
519
|
+
? `Compare ${formatKindName(resource.kind).toLowerCase()}`
|
|
520
|
+
: onCompareAcrossClusters
|
|
521
|
+
? `Compare across clusters`
|
|
522
|
+
: `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
|
|
523
|
+
}
|
|
524
|
+
>
|
|
525
|
+
<button
|
|
526
|
+
onClick={onCompareTo ?? onCompareAcrossClusters}
|
|
527
|
+
aria-label={
|
|
528
|
+
onCompareTo && onCompareAcrossClusters
|
|
529
|
+
? `Compare ${formatKindName(resource.kind).toLowerCase()}`
|
|
530
|
+
: onCompareAcrossClusters
|
|
531
|
+
? `Compare across clusters`
|
|
532
|
+
: `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
|
|
533
|
+
}
|
|
534
|
+
className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
|
|
535
|
+
>
|
|
536
|
+
<GitCompare className="w-3.5 h-3.5" />
|
|
537
|
+
{onCompareTo && onCompareAcrossClusters && (
|
|
538
|
+
<ChevronDown className="w-3 h-3 ml-0.5" />
|
|
539
|
+
)}
|
|
540
|
+
</button>
|
|
541
|
+
</Tooltip>
|
|
542
|
+
{showCompareMenu && onCompareTo && onCompareAcrossClusters && (
|
|
543
|
+
<div className="absolute top-full right-0 mt-1 min-w-[220px] py-1 bg-theme-surface border border-theme-border rounded-lg shadow-theme-lg z-50">
|
|
544
|
+
<button
|
|
545
|
+
onClick={() => { onCompareTo(); setShowCompareMenu(false) }}
|
|
546
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover transition-colors text-left"
|
|
547
|
+
>
|
|
548
|
+
<GitCompare className="w-3 h-3 text-theme-text-tertiary shrink-0" />
|
|
549
|
+
<span>Compare in this cluster</span>
|
|
550
|
+
</button>
|
|
551
|
+
<button
|
|
552
|
+
onClick={() => { onCompareAcrossClusters(); setShowCompareMenu(false) }}
|
|
553
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-theme-text-primary hover:bg-theme-hover transition-colors text-left"
|
|
554
|
+
>
|
|
555
|
+
<GitCompare className="w-3 h-3 text-theme-text-tertiary shrink-0" />
|
|
556
|
+
<span>Compare across clusters</span>
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
)}
|
|
562
|
+
|
|
486
563
|
{onDelete && (
|
|
487
564
|
<Tooltip content="Delete resource">
|
|
488
565
|
<button
|