@skyhook-io/k8s-ui 1.11.0 → 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/resources/ResourcesView.tsx +2 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +24 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +11 -0
- package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- 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/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/WorkloadRenderer.tsx +15 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +63 -0
- package/src/components/resources/resource-utils-cnpg.ts +60 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -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/package.json
CHANGED
|
@@ -2,6 +2,8 @@ import { clsx } from 'clsx'
|
|
|
2
2
|
import { CheckCircle2, AlertCircle, Loader2, Pause, HelpCircle, XCircle } from 'lucide-react'
|
|
3
3
|
import type { GitOpsStatus, SyncStatus, GitOpsHealthStatus } from '../../types/gitops'
|
|
4
4
|
import { SEVERITY_BADGE_BORDERED, SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
5
|
+
import { formatCompactAge } from '../../utils/format'
|
|
6
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
5
7
|
|
|
6
8
|
interface GitOpsStatusBadgeProps {
|
|
7
9
|
status: GitOpsStatus
|
|
@@ -176,3 +178,41 @@ export function HealthStatusBadge({ health }: { health: GitOpsHealthStatus }) {
|
|
|
176
178
|
</span>
|
|
177
179
|
)
|
|
178
180
|
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ReconcilingIndicator rides alongside the sync badge rather than replacing it,
|
|
185
|
+
* the same primary-plus-secondary shape the Velero schedule cell uses for a
|
|
186
|
+
* paused-and-rejected schedule.
|
|
187
|
+
*
|
|
188
|
+
* A pass being in flight says nothing about whether the declared state is
|
|
189
|
+
* applied, so it must not touch the sync word — but a pass that never finishes
|
|
190
|
+
* is the one fault nothing else in the product notices, and on a healthy object
|
|
191
|
+
* a real pass completes in milliseconds. In practice this only becomes visible
|
|
192
|
+
* when a controller is wedged, which is exactly when it should be.
|
|
193
|
+
*/
|
|
194
|
+
export function ReconcilingIndicator({
|
|
195
|
+
reconciling,
|
|
196
|
+
since,
|
|
197
|
+
sync,
|
|
198
|
+
}: {
|
|
199
|
+
reconciling?: boolean
|
|
200
|
+
since?: string
|
|
201
|
+
/** The sync word shown beside this. Omitted when there is no badge alongside. */
|
|
202
|
+
sync?: SyncStatus
|
|
203
|
+
}) {
|
|
204
|
+
// Suppressed when the sync badge already says Reconciling — it carries the
|
|
205
|
+
// same fact and spins for it. Showing both puts two spinners on one row for
|
|
206
|
+
// one thing. The secondary exists for the case the primary CANNOT express:
|
|
207
|
+
// an applied object whose reconcile pass never finished.
|
|
208
|
+
if (!reconciling || sync === 'Reconciling') return null
|
|
209
|
+
const age = formatCompactAge(since)
|
|
210
|
+
const tip = age
|
|
211
|
+
? `A reconcile pass has been in flight for ${age}. On a healthy resource a pass finishes in milliseconds, so a long-running one usually means the controller is stuck.`
|
|
212
|
+
: 'A reconcile pass is in flight.'
|
|
213
|
+
return (
|
|
214
|
+
<Tooltip content={tip}>
|
|
215
|
+
<Loader2 className="w-3 h-3 shrink-0 animate-spin text-theme-text-tertiary" aria-label="Reconcile pass in flight" />
|
|
216
|
+
</Tooltip>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
Zap,
|
|
25
25
|
} from 'lucide-react'
|
|
26
26
|
|
|
27
|
-
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
27
|
+
import { HealthStatusBadge, ReconcilingIndicator, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
28
28
|
import { Tooltip } from '../ui/Tooltip'
|
|
29
29
|
import { Input } from '../ui/Input'
|
|
30
30
|
import { PageHeader } from '../ui/PageHeader'
|
|
@@ -150,6 +150,14 @@ export interface GitOpsRow {
|
|
|
150
150
|
sync: string
|
|
151
151
|
health: string
|
|
152
152
|
suspended: boolean
|
|
153
|
+
/**
|
|
154
|
+
* A reconcile pass is executing. Deliberately separate from sync: a pass
|
|
155
|
+
* being in flight says nothing about whether the declared state is applied,
|
|
156
|
+
* but a pass that never finishes is the one fault nothing else in the
|
|
157
|
+
* product notices.
|
|
158
|
+
*/
|
|
159
|
+
reconciling?: boolean
|
|
160
|
+
reconcilingSince?: string
|
|
153
161
|
repository: string
|
|
154
162
|
targetRevision: string
|
|
155
163
|
path: string
|
|
@@ -1300,7 +1308,12 @@ function GitOpsTable({
|
|
|
1300
1308
|
<TableCell>
|
|
1301
1309
|
{row.terminating
|
|
1302
1310
|
? <span className="text-[11px] text-theme-text-tertiary">—</span>
|
|
1303
|
-
:
|
|
1311
|
+
: (
|
|
1312
|
+
<span className="inline-flex items-center gap-1 min-w-0">
|
|
1313
|
+
<SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />
|
|
1314
|
+
<ReconcilingIndicator reconciling={row.reconciling} since={row.reconcilingSince} sync={row.sync as any} />
|
|
1315
|
+
</span>
|
|
1316
|
+
)}
|
|
1304
1317
|
</TableCell>
|
|
1305
1318
|
<TableCell>
|
|
1306
1319
|
{row.terminating
|
|
@@ -1529,6 +1542,7 @@ function GitOpsTile({
|
|
|
1529
1542
|
) : (
|
|
1530
1543
|
<>
|
|
1531
1544
|
<SyncStatusBadge sync={row.sync as any} suspended={row.suspended} />
|
|
1545
|
+
<ReconcilingIndicator reconciling={row.reconciling} since={row.reconcilingSince} sync={row.sync as any} />
|
|
1532
1546
|
<HealthStatusBadge health={row.health as any} />
|
|
1533
1547
|
</>
|
|
1534
1548
|
)}
|
|
@@ -1896,6 +1910,8 @@ export function normalizeArgoApplication(resource: any): GitOpsRow {
|
|
|
1896
1910
|
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1897
1911
|
sync: status?.sync ?? 'Unknown',
|
|
1898
1912
|
health: status?.health ?? 'Unknown',
|
|
1913
|
+
reconciling: status?.reconciling,
|
|
1914
|
+
reconcilingSince: status?.reconcilingSince,
|
|
1899
1915
|
suspended: (status?.suspended ?? false) || isArgoSuspendedByRadar(resource),
|
|
1900
1916
|
repository: resource.spec?.source?.repoURL ?? '',
|
|
1901
1917
|
targetRevision: resource.spec?.source?.targetRevision ?? '',
|
|
@@ -1932,6 +1948,8 @@ export function normalizeFluxKustomization(resource: any, fluxSourceUrls?: Map<s
|
|
|
1932
1948
|
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1933
1949
|
sync: status?.sync ?? 'Unknown',
|
|
1934
1950
|
health: status?.health ?? 'Unknown',
|
|
1951
|
+
reconciling: status?.reconciling,
|
|
1952
|
+
reconcilingSince: status?.reconcilingSince,
|
|
1935
1953
|
suspended: status?.suspended ?? resource.spec?.suspend === true,
|
|
1936
1954
|
repository,
|
|
1937
1955
|
targetRevision: resource.status?.lastAppliedRevision ?? resource.status?.lastAttemptedRevision ?? '',
|
|
@@ -1969,6 +1987,8 @@ export function normalizeFluxHelmRelease(resource: any, fluxSourceUrls?: Map<str
|
|
|
1969
1987
|
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
1970
1988
|
sync: status?.sync ?? 'Unknown',
|
|
1971
1989
|
health: status?.health ?? 'Unknown',
|
|
1990
|
+
reconciling: status?.reconciling,
|
|
1991
|
+
reconcilingSince: status?.reconcilingSince,
|
|
1972
1992
|
suspended: status?.suspended ?? resource.spec?.suspend === true,
|
|
1973
1993
|
repository,
|
|
1974
1994
|
targetRevision: chartSpec.version ?? resource.status?.lastAttemptedRevision ?? '',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { ReconcilingIndicator } from './GitOpsStatusBadge'
|
|
4
|
+
|
|
5
|
+
const LABEL = 'Reconcile pass in flight'
|
|
6
|
+
|
|
7
|
+
// A reconcile pass in flight is worth showing only where the sync word cannot
|
|
8
|
+
// already say it. Radar's own precedent for a secondary indicator (the Velero
|
|
9
|
+
// schedule cell's paused-and-rejected case) suppresses the icon when the badge
|
|
10
|
+
// carries the same fact, and without that rule one row shows two spinners.
|
|
11
|
+
describe('ReconcilingIndicator', () => {
|
|
12
|
+
it('shows on an applied object whose pass never finished', () => {
|
|
13
|
+
// The case the sync word cannot express, and the reason this exists: a
|
|
14
|
+
// wedged controller behind a correct "Synced".
|
|
15
|
+
const html = renderToString(
|
|
16
|
+
<ReconcilingIndicator reconciling since="2026-08-11T06:05:00Z" sync="Synced" />,
|
|
17
|
+
)
|
|
18
|
+
expect(html).toContain(LABEL)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('is suppressed when the sync badge already reports Reconciling', () => {
|
|
22
|
+
const html = renderToString(
|
|
23
|
+
<ReconcilingIndicator reconciling since="2026-08-11T06:05:00Z" sync="Reconciling" />,
|
|
24
|
+
)
|
|
25
|
+
expect(html).toBe('')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('is absent when nothing is in flight', () => {
|
|
29
|
+
expect(renderToString(<ReconcilingIndicator reconciling={false} sync="Synced" />)).toBe('')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -100,7 +100,10 @@ export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStat
|
|
|
100
100
|
return argoStatusToGitOpsStatus(resource?.status ?? {})
|
|
101
101
|
}
|
|
102
102
|
const conditions = (resource?.status?.conditions ?? []) as FluxCondition[]
|
|
103
|
-
return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true
|
|
103
|
+
return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true, {
|
|
104
|
+
generation: resource?.metadata?.generation,
|
|
105
|
+
observedGeneration: resource?.status?.observedGeneration,
|
|
106
|
+
})
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
// getGitOpsTool routes a kind+group to 'argo' or 'flux'. Same shape used
|
|
@@ -707,6 +707,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
707
707
|
policyreports: [
|
|
708
708
|
{ key: 'name', label: 'Name' },
|
|
709
709
|
{ key: 'namespace', label: 'Namespace', width: 'w-36' },
|
|
710
|
+
{ key: 'scope', label: 'Subject', width: 'w-56' },
|
|
710
711
|
{ key: 'status', label: 'Status', width: 'w-32' },
|
|
711
712
|
{ key: 'pass', label: 'Pass', width: 'w-16' },
|
|
712
713
|
{ key: 'fail', label: 'Fail', width: 'w-16' },
|
|
@@ -717,6 +718,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
|
|
|
717
718
|
],
|
|
718
719
|
clusterpolicyreports: [
|
|
719
720
|
{ key: 'name', label: 'Name' },
|
|
721
|
+
{ key: 'scope', label: 'Subject', width: 'w-56' },
|
|
720
722
|
{ key: 'status', label: 'Status', width: 'w-32' },
|
|
721
723
|
{ key: 'pass', label: 'Pass', width: 'w-16' },
|
|
722
724
|
{ key: 'fail', label: 'Fail', width: 'w-16' },
|
|
@@ -14,7 +14,10 @@ export function AlertRenderer({ data }: AlertRendererProps) {
|
|
|
14
14
|
const conditions = (status.conditions || []) as FluxCondition[]
|
|
15
15
|
|
|
16
16
|
// Convert to unified GitOps status
|
|
17
|
-
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true
|
|
17
|
+
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
|
|
18
|
+
generation: data.metadata?.generation,
|
|
19
|
+
observedGeneration: status.observedGeneration,
|
|
20
|
+
})
|
|
18
21
|
|
|
19
22
|
// Problem detection
|
|
20
23
|
const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getCNPGClusterImage,
|
|
8
8
|
getCNPGClusterStorage,
|
|
9
9
|
getCNPGClusterStorageClass,
|
|
10
|
+
getCNPGVolumeHealth,
|
|
10
11
|
getCNPGClusterWALStorage,
|
|
11
12
|
getCNPGClusterBootstrapMethod,
|
|
12
13
|
getCNPGClusterUpdateStrategy,
|
|
@@ -43,6 +44,7 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
43
44
|
const monitoring = getCNPGClusterMonitoring(data)
|
|
44
45
|
const isReplica = getCNPGClusterIsReplica(data)
|
|
45
46
|
const walStorage = getCNPGClusterWALStorage(data)
|
|
47
|
+
const volumeHealth = getCNPGVolumeHealth(data)
|
|
46
48
|
const postgresParams = getCNPGClusterPostgresParams(data)
|
|
47
49
|
const instanceNames = getCNPGClusterInstanceNames(data)
|
|
48
50
|
const bootstrapMethod = getCNPGClusterBootstrapMethod(data)
|
|
@@ -210,6 +212,14 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
210
212
|
/>
|
|
211
213
|
)}
|
|
212
214
|
|
|
215
|
+
{volumeHealth && volumeHealth.unusable.length > 0 && (
|
|
216
|
+
<AlertBanner
|
|
217
|
+
variant="error"
|
|
218
|
+
title="Unusable storage volume"
|
|
219
|
+
message={`${volumeHealth.unusable.join(', ')} cannot be used because a paired volume is missing. An instance backed by it will not start, and the cluster cannot return to full size until the volume is restored or removed.`}
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
222
|
+
|
|
213
223
|
{/* Cluster Overview */}
|
|
214
224
|
<Section title="Cluster Overview" icon={Database} defaultExpanded>
|
|
215
225
|
<PropertyList>
|
|
@@ -290,6 +300,20 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
|
|
|
290
300
|
<PropertyList>
|
|
291
301
|
<Property label="Data Size" value={getCNPGClusterStorage(data)} />
|
|
292
302
|
<Property label="Storage Class" value={getCNPGClusterStorageClass(data)} />
|
|
303
|
+
{/* Requested size and class are spec-side intentions. These are what the
|
|
304
|
+
operator reports about the volumes that actually exist. */}
|
|
305
|
+
{volumeHealth?.total !== undefined && (
|
|
306
|
+
<Property label="Volumes" value={`${volumeHealth.healthy.length}/${volumeHealth.total} healthy`} />
|
|
307
|
+
)}
|
|
308
|
+
{volumeHealth && volumeHealth.unusable.length > 0 && (
|
|
309
|
+
<Property label="Unusable" value={volumeHealth.unusable.join(', ')} />
|
|
310
|
+
)}
|
|
311
|
+
{volumeHealth && volumeHealth.resizing.length > 0 && (
|
|
312
|
+
<Property label="Resizing" value={volumeHealth.resizing.join(', ')} />
|
|
313
|
+
)}
|
|
314
|
+
{volumeHealth && volumeHealth.dangling.length > 0 && (
|
|
315
|
+
<Property label="Detached" value={volumeHealth.dangling.join(', ')} />
|
|
316
|
+
)}
|
|
293
317
|
</PropertyList>
|
|
294
318
|
{walStorage && (
|
|
295
319
|
<div className="mt-2 pt-2 border-t border-theme-border">
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getCNPGPoolerParameters,
|
|
9
9
|
getCNPGPoolerAuthQuery,
|
|
10
10
|
getCNPGPoolerAuthQuerySecret,
|
|
11
|
+
isCNPGPoolerPaused,
|
|
11
12
|
} from '../resource-utils-cnpg'
|
|
12
13
|
|
|
13
14
|
interface CNPGPoolerRendererProps {
|
|
@@ -23,6 +24,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
|
|
|
23
24
|
// CR never establishes.
|
|
24
25
|
const scheduled = data.status?.instances ?? 0
|
|
25
26
|
const isDegraded = desired > 0 && scheduled < desired
|
|
27
|
+
const isPaused = isCNPGPoolerPaused(data)
|
|
26
28
|
const clusterName = getCNPGPoolerCluster(data)
|
|
27
29
|
const parameters = getCNPGPoolerParameters(data)
|
|
28
30
|
const authQuery = getCNPGPoolerAuthQuery(data)
|
|
@@ -39,12 +41,21 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
|
|
|
39
41
|
/>
|
|
40
42
|
)}
|
|
41
43
|
|
|
44
|
+
{isPaused && (
|
|
45
|
+
<AlertBanner
|
|
46
|
+
variant="warning"
|
|
47
|
+
title="Pooler Paused"
|
|
48
|
+
message="PgBouncer is paused, so client connections are held rather than served. The pods stay scheduled and Ready throughout, so nothing else about this Pooler will look wrong."
|
|
49
|
+
/>
|
|
50
|
+
)}
|
|
51
|
+
|
|
42
52
|
{/* Pooler Configuration */}
|
|
43
53
|
<Section title="Pooler Configuration" icon={Users} defaultExpanded>
|
|
44
54
|
<PropertyList>
|
|
45
55
|
<Property label="Type" value={getCNPGPoolerType(data)} />
|
|
46
56
|
<Property label="Pool Mode" value={getCNPGPoolerMode(data)} />
|
|
47
57
|
<Property label="Instances Scheduled" value={getCNPGPoolerInstances(data)} />
|
|
58
|
+
{isPaused && <Property label="PgBouncer" value="Paused" />}
|
|
48
59
|
</PropertyList>
|
|
49
60
|
</Section>
|
|
50
61
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { CompositeRenderer } from './CompositeRenderer'
|
|
4
|
+
import type { CrossplaneResourceRef } from '../resource-utils-crossplane'
|
|
5
|
+
|
|
6
|
+
const claim = {
|
|
7
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
8
|
+
kind: 'DatabaseClaim',
|
|
9
|
+
metadata: { name: 'example-database', namespace: 'demo-app' },
|
|
10
|
+
spec: {
|
|
11
|
+
compositionRef: { name: 'database' },
|
|
12
|
+
resourceRef: {
|
|
13
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
14
|
+
kind: 'Database',
|
|
15
|
+
name: 'example-database-x7k2m',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const composed: CrossplaneResourceRef[] = [
|
|
21
|
+
{ apiVersion: 'database.example.org/v1beta1', kind: 'Instance', name: 'example-database-instance' },
|
|
22
|
+
{ apiVersion: 'identity.example.org/v1beta1', kind: 'Role', name: 'example-database-access' },
|
|
23
|
+
{ apiVersion: 'kubernetes.crossplane.io/v1alpha2', kind: 'Object', name: 'example-database-connection' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const html = (props: any) => renderToString(<CompositeRenderer {...props} />)
|
|
27
|
+
|
|
28
|
+
describe('CompositeRenderer — claim composed-resources panel (issue coverage)', () => {
|
|
29
|
+
it('lists composed resources resolved from the bound XR (the fix)', () => {
|
|
30
|
+
const out = html({ data: claim, composedRefs: composed })
|
|
31
|
+
expect(out).toContain('Composed Resources (3)')
|
|
32
|
+
for (const ref of composed) {
|
|
33
|
+
expect(out).toContain(ref.name)
|
|
34
|
+
}
|
|
35
|
+
expect(out).not.toContain('No composed resources yet')
|
|
36
|
+
// claim identity section + the bound-XR link
|
|
37
|
+
expect(out).toContain('Claim')
|
|
38
|
+
expect(out).toContain('Bound Composite')
|
|
39
|
+
expect(out).toContain('example-database-x7k2m')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('shows a loading state while the bound XR is being fetched — not "none"', () => {
|
|
43
|
+
const out = html({ data: claim, composedRefs: [], boundXRStatus: { loading: true } })
|
|
44
|
+
expect(out).toContain('Loading composed resources')
|
|
45
|
+
expect(out).not.toContain('No composed resources yet')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('distinguishes a not-found / unreadable bound XR from genuinely-none', () => {
|
|
49
|
+
const out = html({ data: claim, composedRefs: [], boundXRStatus: { missing: true } })
|
|
50
|
+
expect(out).toContain('was not found')
|
|
51
|
+
expect(out).not.toContain('No composed resources yet')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('surfaces a bound-XR read error (RBAC/500) distinctly, with the message', () => {
|
|
55
|
+
const out = html({
|
|
56
|
+
data: claim,
|
|
57
|
+
composedRefs: [],
|
|
58
|
+
boundXRStatus: { error: true, errorMessage: 'forbidden: cannot get databases' },
|
|
59
|
+
})
|
|
60
|
+
expect(out).toContain('read the bound composite') // apostrophe is HTML-escaped in SSR output
|
|
61
|
+
expect(out).toContain('forbidden: cannot get databases')
|
|
62
|
+
expect(out).not.toContain('No composed resources yet')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('falls back to the genuine empty state for a claim whose XR simply has none yet', () => {
|
|
66
|
+
const out = html({ data: claim, composedRefs: [] })
|
|
67
|
+
expect(out).toContain('No composed resources yet')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('for an XR viewed directly (no bound-XR status), reads its own resourceRefs', () => {
|
|
71
|
+
const xr = {
|
|
72
|
+
apiVersion: 'platform.example.org/v1alpha1',
|
|
73
|
+
kind: 'Database',
|
|
74
|
+
metadata: { name: 'example-database-x7k2m' },
|
|
75
|
+
spec: { resourceRefs: composed },
|
|
76
|
+
}
|
|
77
|
+
const out = html({ data: xr })
|
|
78
|
+
expect(out).toContain('Composed Resources (3)')
|
|
79
|
+
expect(out).toContain('Composite Resource') // not "Claim"
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -52,6 +52,28 @@ interface CompositeRendererProps {
|
|
|
52
52
|
* of an unrelated Bucket from another provider. Populated by the host
|
|
53
53
|
* wrapper. When absent, the renderer shows refs without status badges. */
|
|
54
54
|
composedRefStatuses?: Map<string, ComposedRefStatus>
|
|
55
|
+
/** Composed-resource refs resolved by the host. A v1 Claim carries only a
|
|
56
|
+
* singular spec.resourceRef to its bound XR — the composed refs live on that
|
|
57
|
+
* XR, not the claim — so the host follows the ref, fetches the XR, and passes
|
|
58
|
+
* its refs here. When omitted, refs are read off `data` directly (correct for
|
|
59
|
+
* an XR/Composite viewed on its own). */
|
|
60
|
+
composedRefs?: CrossplaneResourceRef[]
|
|
61
|
+
/** State of the host's fetch of the claim's bound XR. Set only for a v1 Claim.
|
|
62
|
+
* Without it, a claim whose XR is still loading or unreadable (404/RBAC) would
|
|
63
|
+
* fall into the empty branch and read "No composed resources yet" — which is
|
|
64
|
+
* indistinguishable from a claim that genuinely has none. */
|
|
65
|
+
boundXRStatus?: BoundXRStatus
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fetch state of a claim's bound XR, reported by the host wrapper. */
|
|
69
|
+
export interface BoundXRStatus {
|
|
70
|
+
/** true while the XR fetch is in flight */
|
|
71
|
+
loading?: boolean
|
|
72
|
+
/** 404 — the referenced XR does not exist (dangling resourceRef) */
|
|
73
|
+
missing?: boolean
|
|
74
|
+
/** any non-404 failure (auth, network, server error); message in errorMessage */
|
|
75
|
+
error?: boolean
|
|
76
|
+
errorMessage?: string
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
function makeRefKey(ref: CrossplaneResourceRef): string {
|
|
@@ -67,13 +89,13 @@ function groupFromApiVersion(apiVersion: string | undefined): string {
|
|
|
67
89
|
return slash < 0 ? '' : apiVersion.slice(0, slash)
|
|
68
90
|
}
|
|
69
91
|
|
|
70
|
-
export function CompositeRenderer({ data, onNavigate, composedRefStatuses }: CompositeRendererProps) {
|
|
92
|
+
export function CompositeRenderer({ data, onNavigate, composedRefStatuses, composedRefs, boundXRStatus }: CompositeRendererProps) {
|
|
71
93
|
const status = getCrossplaneStatus(data)
|
|
72
94
|
const reason = getCrossplaneStatusReason(data)
|
|
73
95
|
const compositionRef = getCompositionRef(data)
|
|
74
96
|
const compositionRevisionRef = getCompositionRevisionRef(data)
|
|
75
97
|
const compositionUpdatePolicy = getCompositionUpdatePolicy(data)
|
|
76
|
-
const resourceRefs = getCrossplaneResourceRefs(data)
|
|
98
|
+
const resourceRefs = composedRefs ?? getCrossplaneResourceRefs(data)
|
|
77
99
|
const claim = isClaim(data)
|
|
78
100
|
const boundXR = claim ? getBoundXRRef(data) : null
|
|
79
101
|
const paused = isCrossplanePaused(data)
|
|
@@ -153,9 +175,7 @@ export function CompositeRenderer({ data, onNavigate, composedRefStatuses }: Com
|
|
|
153
175
|
defaultExpanded
|
|
154
176
|
>
|
|
155
177
|
{resourceRefs.length === 0 ? (
|
|
156
|
-
<
|
|
157
|
-
No composed resources yet — the composition has not produced any managed resources.
|
|
158
|
-
</div>
|
|
178
|
+
<ComposedResourcesEmpty boundXRStatus={boundXRStatus} boundXRName={boundXR?.name} />
|
|
159
179
|
) : (
|
|
160
180
|
<div className="space-y-1">
|
|
161
181
|
{resourceRefs.map((ref) => (
|
|
@@ -175,6 +195,44 @@ export function CompositeRenderer({ data, onNavigate, composedRefStatuses }: Com
|
|
|
175
195
|
)
|
|
176
196
|
}
|
|
177
197
|
|
|
198
|
+
function ComposedResourcesEmpty({
|
|
199
|
+
boundXRStatus,
|
|
200
|
+
boundXRName,
|
|
201
|
+
}: {
|
|
202
|
+
boundXRStatus?: BoundXRStatus
|
|
203
|
+
boundXRName?: string
|
|
204
|
+
}) {
|
|
205
|
+
if (boundXRStatus?.loading) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="text-sm text-theme-text-tertiary py-2">
|
|
208
|
+
Loading composed resources from the bound composite…
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
if (boundXRStatus?.missing) {
|
|
213
|
+
const named = boundXRName ? ` (${boundXRName})` : ''
|
|
214
|
+
return (
|
|
215
|
+
<div className="text-sm text-theme-text-tertiary py-2">
|
|
216
|
+
The bound composite{named} referenced by this claim was not found — it may not have been
|
|
217
|
+
created yet, or you may not have permission to read it.
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (boundXRStatus?.error) {
|
|
222
|
+
const named = boundXRName ? ` (${boundXRName})` : ''
|
|
223
|
+
return (
|
|
224
|
+
<div className="text-sm status-unhealthy rounded px-2 py-2">
|
|
225
|
+
Couldn't read the bound composite{named}: {boundXRStatus.errorMessage || 'fetch failed'}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
return (
|
|
230
|
+
<div className="text-sm text-theme-text-tertiary py-2">
|
|
231
|
+
No composed resources yet — the composition has not produced any managed resources.
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
178
236
|
function ComposedRefRow({
|
|
179
237
|
ref_,
|
|
180
238
|
statusEntry,
|
|
@@ -189,7 +247,9 @@ function ComposedRefRow({
|
|
|
189
247
|
return (
|
|
190
248
|
<div className="flex items-center gap-2 py-1 px-2 rounded hover:bg-theme-hover text-sm">
|
|
191
249
|
<Box className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
|
|
192
|
-
|
|
250
|
+
{/* Kind is content-sized (capped) so the resource name — the field that
|
|
251
|
+
distinguishes rows — keeps the width instead of truncating. */}
|
|
252
|
+
<span className="text-theme-text-tertiary font-mono shrink-0 max-w-[8rem] truncate">{ref_.kind}</span>
|
|
193
253
|
<span className="flex-1 min-w-0 truncate">
|
|
194
254
|
<ResourceLink
|
|
195
255
|
name={ref_.name}
|
|
@@ -200,7 +260,7 @@ function ComposedRefRow({
|
|
|
200
260
|
/>
|
|
201
261
|
</span>
|
|
202
262
|
{ref_.namespace && (
|
|
203
|
-
<span className="text-xs text-theme-text-tertiary truncate w-
|
|
263
|
+
<span className="text-xs text-theme-text-tertiary truncate max-w-[8rem] shrink-0 text-right">{ref_.namespace}</span>
|
|
204
264
|
)}
|
|
205
265
|
<StatusCellInline statusEntry={statusEntry} />
|
|
206
266
|
<ChevronRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
|
|
@@ -17,7 +17,10 @@ export function FluxHelmReleaseRenderer({ data, onNavigate }: FluxHelmReleaseRen
|
|
|
17
17
|
const history = status.history || []
|
|
18
18
|
|
|
19
19
|
// Convert to unified GitOps status
|
|
20
|
-
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true
|
|
20
|
+
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
|
|
21
|
+
generation: data.metadata?.generation,
|
|
22
|
+
observedGeneration: status.observedGeneration,
|
|
23
|
+
})
|
|
21
24
|
|
|
22
25
|
// Problem detection
|
|
23
26
|
const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
|
|
@@ -16,7 +16,10 @@ export function GitRepositoryRenderer({ data }: GitRepositoryRendererProps) {
|
|
|
16
16
|
const artifact = status.artifact || {}
|
|
17
17
|
|
|
18
18
|
// Convert to unified GitOps status
|
|
19
|
-
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true
|
|
19
|
+
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
|
|
20
|
+
generation: data.metadata?.generation,
|
|
21
|
+
observedGeneration: status.observedGeneration,
|
|
22
|
+
})
|
|
20
23
|
|
|
21
24
|
// Problem detection
|
|
22
25
|
const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
|
|
@@ -17,7 +17,10 @@ export function HelmRepositoryRenderer({ data }: HelmRepositoryRendererProps) {
|
|
|
17
17
|
const artifact = status.artifact || {}
|
|
18
18
|
|
|
19
19
|
// Convert to unified GitOps status
|
|
20
|
-
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true
|
|
20
|
+
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
|
|
21
|
+
generation: data.metadata?.generation,
|
|
22
|
+
observedGeneration: status.observedGeneration,
|
|
23
|
+
})
|
|
21
24
|
|
|
22
25
|
// Determine repository type
|
|
23
26
|
const isOCI = spec.type === 'oci'
|
|
@@ -16,7 +16,10 @@ export function KustomizationRenderer({ data, onNavigate }: KustomizationRendere
|
|
|
16
16
|
const inventoryEntries = status.inventory?.entries || []
|
|
17
17
|
|
|
18
18
|
// Convert to unified GitOps status
|
|
19
|
-
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true
|
|
19
|
+
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
|
|
20
|
+
generation: data.metadata?.generation,
|
|
21
|
+
observedGeneration: status.observedGeneration,
|
|
22
|
+
})
|
|
20
23
|
|
|
21
24
|
// Parse inventory to managed resources
|
|
22
25
|
const managedResources = parseFluxInventory(inventoryEntries)
|
|
@@ -16,7 +16,10 @@ export function OCIRepositoryRenderer({ data }: OCIRepositoryRendererProps) {
|
|
|
16
16
|
const artifact = status.artifact || {}
|
|
17
17
|
|
|
18
18
|
// Convert to unified GitOps status
|
|
19
|
-
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true
|
|
19
|
+
const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
|
|
20
|
+
generation: data.metadata?.generation,
|
|
21
|
+
observedGeneration: status.observedGeneration,
|
|
22
|
+
})
|
|
20
23
|
|
|
21
24
|
// Problem detection
|
|
22
25
|
const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useState, type ReactNode, type JSX } from 'react'
|
|
2
2
|
import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff, Shield } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { PolicySection } from './PolicySection'
|
|
5
|
+
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
4
6
|
import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
5
7
|
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName } from '../resource-utils'
|
|
6
8
|
import { getResourceStatusColor, SEVERITY_BADGE_BORDERED } from '../../../utils/badge-colors'
|
|
@@ -108,6 +110,10 @@ interface PodRendererProps {
|
|
|
108
110
|
rbacData?: RBACSubjectResponse | null
|
|
109
111
|
rbacLoading?: boolean
|
|
110
112
|
rbacError?: Error | null
|
|
113
|
+
/** Undefined means the host didn't wire the fetch; the section is omitted. */
|
|
114
|
+
policyData?: PolicyResourceResponse | null
|
|
115
|
+
policyLoading?: boolean
|
|
116
|
+
policyError?: Error | null
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
// ── Env vars section — extracted to use hooks (useState for reveal) ──────────
|
|
@@ -287,6 +293,9 @@ export function PodRenderer({
|
|
|
287
293
|
rbacData,
|
|
288
294
|
rbacLoading,
|
|
289
295
|
rbacError,
|
|
296
|
+
policyData,
|
|
297
|
+
policyLoading,
|
|
298
|
+
policyError,
|
|
290
299
|
}: PodRendererProps) {
|
|
291
300
|
const containerStatuses = data.status?.containerStatuses || []
|
|
292
301
|
const containers = data.spec?.containers || []
|
|
@@ -582,6 +591,10 @@ export function PodRenderer({
|
|
|
582
591
|
)}
|
|
583
592
|
|
|
584
593
|
{/* Container Status */}
|
|
594
|
+
{policyData !== undefined && (
|
|
595
|
+
<PolicySection data={policyData} loading={policyLoading} error={policyError} />
|
|
596
|
+
)}
|
|
597
|
+
|
|
585
598
|
<Section title="Containers" icon={HardDrive} defaultExpanded>
|
|
586
599
|
<div className="space-y-3">
|
|
587
600
|
{containers.map((container: any) => {
|