@skyhook-io/k8s-ui 1.14.1 → 1.14.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 +1 -1
- package/src/components/issues/IssuesView.tsx +1 -1
- package/src/components/issues/types.ts +2 -0
- package/src/components/resources/ResourcesView.tsx +129 -67
- package/src/components/resources/column-settings-merge.test.ts +104 -0
- package/src/components/resources/istio-gateway-filter.test.ts +5 -3
- package/src/components/resources/istio-policy-accessors.test.ts +193 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
- package/src/components/resources/renderers/eso-cells.tsx +0 -5
- package/src/components/resources/renderers/istio-cells.tsx +36 -26
- package/src/components/resources/renderers/kueue-cells.tsx +0 -9
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
- package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
- package/src/components/resources/renderers/ray-cells.tsx +12 -4
- package/src/components/resources/renderers/volcano-cells.tsx +0 -9
- package/src/components/resources/resource-utils-cnpg.ts +5 -0
- package/src/components/resources/resource-utils-eso.test.ts +56 -0
- package/src/components/resources/resource-utils-eso.ts +37 -5
- package/src/components/resources/resource-utils-istio.ts +152 -44
- package/src/components/resources/resource-utils-kyverno.ts +3 -6
- package/src/components/resources/resource-utils-prometheus.ts +51 -23
- package/src/components/resources/resource-utils-ray.test.ts +36 -0
- package/src/components/resources/resource-utils-ray.ts +6 -0
- package/src/components/resources/resource-utils.ts +166 -18
- package/src/components/resources/status-accessors.test.ts +363 -0
- package/src/components/resources/status-overclaim.test.ts +158 -0
- package/src/components/timeline/DiffViewer.test.tsx +26 -0
- package/src/components/timeline/DiffViewer.tsx +16 -12
- package/src/components/topology/TopologyGraph.tsx +7 -2
- package/src/components/ui/Collapse.tsx +9 -2
- package/src/components/ui/ConfirmDialog.test.tsx +37 -0
- package/src/components/ui/ConfirmDialog.tsx +3 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +2 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getServiceMonitorStatus, getPrometheusRuleStatus, getPodMonitorStatus } from './resource-utils-prometheus'
|
|
3
|
+
import { getKyvernoPolicyStatus } from './resource-utils-kyverno'
|
|
4
|
+
import { getServiceEntryStatus, getVirtualServiceStatus } from './resource-utils-istio'
|
|
5
|
+
import { getCNPGObjectStoreStatus } from './resource-utils-cnpg'
|
|
6
|
+
|
|
7
|
+
// A green badge asserts something a controller checked. These pin the line
|
|
8
|
+
// between what an object declares and what something actually confirmed.
|
|
9
|
+
|
|
10
|
+
describe('Prometheus configuration resources', () => {
|
|
11
|
+
// Status lives at status.bindings[].conditions, only condition type
|
|
12
|
+
// "Accepted" exists, and the operator writes it only under the
|
|
13
|
+
// StatusForConfigurationResources feature gate.
|
|
14
|
+
const accepted = (status: string, extra: Record<string, unknown> = {}) => ({
|
|
15
|
+
metadata: { generation: 3 },
|
|
16
|
+
status: { bindings: [{ conditions: [{ type: 'Accepted', status, observedGeneration: 3, ...extra }] }] },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
for (const [name, fn] of [
|
|
20
|
+
['ServiceMonitor', getServiceMonitorStatus],
|
|
21
|
+
['PrometheusRule', getPrometheusRuleStatus],
|
|
22
|
+
['PodMonitor', getPodMonitorStatus],
|
|
23
|
+
] as const) {
|
|
24
|
+
it(`${name} does not report health when the gate wrote nothing`, () => {
|
|
25
|
+
// The overwhelmingly common shape: no status at all.
|
|
26
|
+
expect(fn({ spec: {} })).toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it(`${name} ignores a status.conditions that these kinds never have`, () => {
|
|
30
|
+
// This path is not part of these resources' schema.
|
|
31
|
+
expect(fn({ spec: {}, status: { conditions: [{ type: 'Reconciled', status: 'True' }] } }))
|
|
32
|
+
.toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it(`${name} surfaces an explicit rejection`, () => {
|
|
36
|
+
expect(fn(accepted('False', { reason: 'InvalidConfig' })))
|
|
37
|
+
.toMatchObject({ text: 'InvalidConfig', level: 'unhealthy' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it(`${name} reports acceptance when a workload confirmed it`, () => {
|
|
41
|
+
expect(fn(accepted('True'))).toMatchObject({ text: 'Accepted', level: 'healthy' })
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('does not read a condition observed against an older spec', () => {
|
|
46
|
+
const stale = {
|
|
47
|
+
metadata: { generation: 7 },
|
|
48
|
+
status: { bindings: [{ conditions: [{ type: 'Accepted', status: 'True', observedGeneration: 4 }] }] },
|
|
49
|
+
}
|
|
50
|
+
expect(getServiceMonitorStatus(stale)).toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not read one workload as all of them when another has not decided', () => {
|
|
54
|
+
const undecided = {
|
|
55
|
+
metadata: { generation: 1 },
|
|
56
|
+
status: {
|
|
57
|
+
bindings: [
|
|
58
|
+
{ name: 'a', conditions: [{ type: 'Accepted', status: 'True', observedGeneration: 1 }] },
|
|
59
|
+
{ name: 'b', conditions: [{ type: 'Accepted', status: 'Unknown', observedGeneration: 1 }] },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
expect(getServiceMonitorStatus(undecided)).toMatchObject({ text: 'Accepted by 1/2', level: 'unknown' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reports partial acceptance when workloads disagree', () => {
|
|
67
|
+
// One config resource can be selected by several Prometheus workloads, and
|
|
68
|
+
// each reports separately.
|
|
69
|
+
const mixed = {
|
|
70
|
+
metadata: { generation: 1 },
|
|
71
|
+
status: {
|
|
72
|
+
bindings: [
|
|
73
|
+
{ name: 'a', conditions: [{ type: 'Accepted', status: 'True', observedGeneration: 1 }] },
|
|
74
|
+
{ name: 'b', conditions: [{ type: 'Accepted', status: 'False', observedGeneration: 1 }] },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
expect(getServiceMonitorStatus(mixed)).toMatchObject({ text: 'Accepted by 1/2', level: 'degraded' })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('getKyvernoPolicyStatus', () => {
|
|
83
|
+
it('reads the Ready condition the controller writes', () => {
|
|
84
|
+
expect(getKyvernoPolicyStatus({ status: { conditions: [{ type: 'Ready', status: 'True' }] } }))
|
|
85
|
+
.toMatchObject({ text: 'Ready', level: 'healthy' })
|
|
86
|
+
expect(getKyvernoPolicyStatus({ status: { conditions: [{ type: 'Ready', status: 'False', reason: 'Failed' }] } }))
|
|
87
|
+
.toMatchObject({ text: 'Failed', level: 'unhealthy' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not read declared rules as the controller accepting them', () => {
|
|
91
|
+
// Fires before the controller reports, and for Ready=Unknown.
|
|
92
|
+
expect(getKyvernoPolicyStatus({ spec: { rules: [{}, {}] } }))
|
|
93
|
+
.toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
94
|
+
expect(getKyvernoPolicyStatus({ spec: { rules: [{}] }, status: { conditions: [{ type: 'Ready', status: 'Unknown' }] } }))
|
|
95
|
+
.toMatchObject({ text: 'Not assessed', level: 'unknown' })
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('getServiceEntryStatus', () => {
|
|
100
|
+
it('treats location as a classification, not reachability', () => {
|
|
101
|
+
// MESH_EXTERNAL/MESH_INTERNAL say where the host sits relative to the mesh.
|
|
102
|
+
// Nothing here establishes it resolves or answers.
|
|
103
|
+
expect(getServiceEntryStatus({ spec: { hosts: ['api.example.com'] } }))
|
|
104
|
+
.toMatchObject({ text: 'External', level: 'neutral' })
|
|
105
|
+
expect(getServiceEntryStatus({ spec: { hosts: ['a'], location: 'MESH_INTERNAL' } }))
|
|
106
|
+
.toMatchObject({ text: 'Internal', level: 'neutral' })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('still flags an entry that selects nothing', () => {
|
|
110
|
+
expect(getServiceEntryStatus({ spec: {} })).toMatchObject({ text: 'No Hosts', level: 'unhealthy' })
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('getVirtualServiceStatus', () => {
|
|
115
|
+
it('does not call a hostless VirtualService broken', () => {
|
|
116
|
+
// Istio requires hosts to be empty on a delegate VirtualService, and this
|
|
117
|
+
// object cannot see whether a parent delegates to it.
|
|
118
|
+
expect(getVirtualServiceStatus({ spec: { http: [{ route: [{}] }] } }))
|
|
119
|
+
.toMatchObject({ text: 'No Hosts', level: 'unknown' })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not read a declaration as working routing', () => {
|
|
123
|
+
expect(getVirtualServiceStatus({ spec: { hosts: ['a'], http: [{ route: [{}] }] } }))
|
|
124
|
+
.toMatchObject({ text: 'Defined', level: 'neutral' })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('still flags hosts with nowhere to send them', () => {
|
|
128
|
+
expect(getVirtualServiceStatus({ spec: { hosts: ['a'] } }))
|
|
129
|
+
.toMatchObject({ text: 'No Routes', level: 'unhealthy' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('flags a routeless object even when it declares no hosts', () => {
|
|
133
|
+
// A delegate omits hosts, but it exists to supply routes.
|
|
134
|
+
expect(getVirtualServiceStatus({ spec: {} }))
|
|
135
|
+
.toMatchObject({ text: 'No Routes', level: 'unhealthy' })
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('getCNPGObjectStoreStatus', () => {
|
|
140
|
+
it('does not call a listed server recoverable with no recovery point', () => {
|
|
141
|
+
// Every timestamp on a RecoveryWindow is optional, so the server can be
|
|
142
|
+
// listed while holding nothing restorable.
|
|
143
|
+
expect(getCNPGObjectStoreStatus({ status: { serverRecoveryWindow: { pg: {} } } }))
|
|
144
|
+
.toMatchObject({ text: 'No recovery point', level: 'unknown' })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('reports recoverable once a real point exists', () => {
|
|
148
|
+
expect(getCNPGObjectStoreStatus({
|
|
149
|
+
status: { serverRecoveryWindow: { pg: { firstRecoverabilityPoint: '2026-01-01T00:00:00Z' } } },
|
|
150
|
+
})).toMatchObject({ text: 'Recoverable', level: 'healthy' })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('still reports failing backups ahead of everything else', () => {
|
|
154
|
+
expect(getCNPGObjectStoreStatus({
|
|
155
|
+
status: { serverRecoveryWindow: { pg: { lastFailedBackupTime: '2026-02-01T00:00:00Z' } } },
|
|
156
|
+
})).toMatchObject({ text: 'Backups Failing', level: 'unhealthy' })
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { DiffViewer } from "./DiffViewer";
|
|
5
|
+
|
|
6
|
+
describe("DiffViewer", () => {
|
|
7
|
+
it("labels added, removed, and changed values without relying on color", () => {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
<DiffViewer
|
|
10
|
+
diff={{
|
|
11
|
+
summary: "Three fields changed",
|
|
12
|
+
fields: [
|
|
13
|
+
{ path: "spec.added", oldValue: null, newValue: "on" },
|
|
14
|
+
{ path: "spec.removed", oldValue: "on", newValue: null },
|
|
15
|
+
{ path: "spec.changed", oldValue: "v1", newValue: "v2" },
|
|
16
|
+
],
|
|
17
|
+
}}
|
|
18
|
+
/>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(html).toContain("Added value:");
|
|
22
|
+
expect(html).toContain("Removed value:");
|
|
23
|
+
expect(html).toContain("Old value:");
|
|
24
|
+
expect(html).toContain("New value:");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -16,7 +16,7 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
|
|
|
16
16
|
if (compact) {
|
|
17
17
|
return (
|
|
18
18
|
<div className="text-xs text-theme-text-secondary flex items-center gap-1">
|
|
19
|
-
<RefreshCw className="w-3 h-3" />
|
|
19
|
+
<RefreshCw className="w-3 h-3" aria-hidden />
|
|
20
20
|
<span>{diff.summary || `${diff.fields.length} field(s) changed`}</span>
|
|
21
21
|
</div>
|
|
22
22
|
)
|
|
@@ -27,7 +27,7 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
|
|
|
27
27
|
{/* Summary */}
|
|
28
28
|
{diff.summary && (
|
|
29
29
|
<div className="text-sm font-medium text-theme-text-secondary flex items-center gap-2">
|
|
30
|
-
<RefreshCw className="w-4 h-4 text-blue-400" />
|
|
30
|
+
<RefreshCw className="w-4 h-4 text-blue-400" aria-hidden />
|
|
31
31
|
{diff.summary}
|
|
32
32
|
</div>
|
|
33
33
|
)}
|
|
@@ -54,31 +54,35 @@ function FieldChangeRow({ field }: FieldChangeRowProps) {
|
|
|
54
54
|
return (
|
|
55
55
|
<div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
|
|
56
56
|
{/* Field path */}
|
|
57
|
-
<div className="text-xs font-mono text-theme-text-tertiary mb-1">{field.path}</div>
|
|
57
|
+
<div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{field.path}</div>
|
|
58
58
|
|
|
59
59
|
{/* Values */}
|
|
60
|
-
<div className="flex items-center gap-2 text-sm">
|
|
60
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
|
61
61
|
{isAdded ? (
|
|
62
62
|
<>
|
|
63
|
-
<Plus className="w-3.5 h-3.5 text-green-400 shrink-0" />
|
|
64
|
-
<span className="
|
|
63
|
+
<Plus className="w-3.5 h-3.5 text-green-400 shrink-0" aria-hidden />
|
|
64
|
+
<span className="sr-only">Added value: </span>
|
|
65
|
+
<span className="min-w-0 break-words text-green-400 [overflow-wrap:anywhere]">
|
|
65
66
|
{formatValue(field.newValue)}
|
|
66
67
|
</span>
|
|
67
68
|
</>
|
|
68
69
|
) : isRemoved ? (
|
|
69
70
|
<>
|
|
70
|
-
<Minus className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
|
71
|
-
<span className="
|
|
71
|
+
<Minus className="w-3.5 h-3.5 text-red-400 shrink-0" aria-hidden />
|
|
72
|
+
<span className="sr-only">Removed value: </span>
|
|
73
|
+
<span className="min-w-0 break-words text-red-400 line-through [overflow-wrap:anywhere]">
|
|
72
74
|
{formatValue(field.oldValue)}
|
|
73
75
|
</span>
|
|
74
76
|
</>
|
|
75
77
|
) : isModified ? (
|
|
76
78
|
<>
|
|
77
|
-
<span className="
|
|
79
|
+
<span className="sr-only">Old value: </span>
|
|
80
|
+
<span className="min-w-0 break-words text-red-400 line-through [overflow-wrap:anywhere]">
|
|
78
81
|
{formatValue(field.oldValue)}
|
|
79
82
|
</span>
|
|
80
|
-
<ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
|
|
81
|
-
<span className="
|
|
83
|
+
<ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" aria-hidden />
|
|
84
|
+
<span className="sr-only">New value: </span>
|
|
85
|
+
<span className="min-w-0 break-words text-green-400 [overflow-wrap:anywhere]">
|
|
82
86
|
{formatValue(field.newValue)}
|
|
83
87
|
</span>
|
|
84
88
|
</>
|
|
@@ -124,7 +128,7 @@ export const DiffBadge = memo(function DiffBadge({ diff }: DiffBadgeProps) {
|
|
|
124
128
|
'bg-skyhook-500/10 text-skyhook-400 border border-skyhook-500/20'
|
|
125
129
|
)}
|
|
126
130
|
>
|
|
127
|
-
<RefreshCw className="w-3 h-3" />
|
|
131
|
+
<RefreshCw className="w-3 h-3" aria-hidden />
|
|
128
132
|
{diff.summary}
|
|
129
133
|
</span>
|
|
130
134
|
)
|
|
@@ -34,7 +34,7 @@ import { GroupNode } from './GroupNode'
|
|
|
34
34
|
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
35
35
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
36
36
|
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
|
|
37
|
-
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
|
|
37
|
+
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode, HealthStatus } from '../../types'
|
|
38
38
|
import { pluralize } from '../../utils/pluralize'
|
|
39
39
|
import { foldHash } from '../../utils/structure-hash'
|
|
40
40
|
import { recordLayoutDuration, recordLayoutSkipped, recordStructureKeyDuration } from '../../perf'
|
|
@@ -408,6 +408,7 @@ export function TopologyGraph({
|
|
|
408
408
|
phase: string
|
|
409
409
|
restarts: number
|
|
410
410
|
containers: number
|
|
411
|
+
status?: HealthStatus
|
|
411
412
|
}>
|
|
412
413
|
|
|
413
414
|
// Find edges pointing to this pod group
|
|
@@ -425,7 +426,11 @@ export function TopologyGraph({
|
|
|
425
426
|
id: podId,
|
|
426
427
|
kind: 'Pod',
|
|
427
428
|
name: pod.name,
|
|
428
|
-
|
|
429
|
+
// The server computes pod health; pkg/health tracks a crash loop across
|
|
430
|
+
// the kubelet's Waiting->Running oscillation, which the phase alone
|
|
431
|
+
// hides — a crash-looping pod sits at Phase=Running. Deriving it here
|
|
432
|
+
// again would rebuild that logic in a second place and get it wrong.
|
|
433
|
+
status: pod.status ?? 'unknown',
|
|
429
434
|
data: {
|
|
430
435
|
...podGroupNode.data,
|
|
431
436
|
namespace: pod.namespace,
|
|
@@ -37,7 +37,10 @@ export function Collapse({
|
|
|
37
37
|
const render = !mountLazily || hasOpened
|
|
38
38
|
return (
|
|
39
39
|
<div
|
|
40
|
-
className={clsx(
|
|
40
|
+
className={clsx(
|
|
41
|
+
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
41
44
|
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
|
42
45
|
>
|
|
43
46
|
{/* `relative` is load-bearing. Collapsed content is still laid out at full
|
|
@@ -61,7 +64,11 @@ export function CollapseChevron({ open, className }: { open: boolean; className?
|
|
|
61
64
|
return (
|
|
62
65
|
<ChevronRight
|
|
63
66
|
aria-hidden="true"
|
|
64
|
-
className={clsx(
|
|
67
|
+
className={clsx(
|
|
68
|
+
'shrink-0 text-theme-text-tertiary transition-transform duration-200 motion-reduce:transition-none',
|
|
69
|
+
open && 'rotate-90',
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
65
72
|
/>
|
|
66
73
|
)
|
|
67
74
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { ConfirmDialog } from './ConfirmDialog'
|
|
5
|
+
|
|
6
|
+
vi.mock('./DialogPortal', () => ({
|
|
7
|
+
DialogPortal: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
const props = {
|
|
11
|
+
open: true,
|
|
12
|
+
onClose: () => {},
|
|
13
|
+
onConfirm: () => {},
|
|
14
|
+
title: 'Share this investigation?',
|
|
15
|
+
message: 'Organization members can read and continue this investigation.',
|
|
16
|
+
confirmLabel: 'Share with organization',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ConfirmDialog warning disclosure', () => {
|
|
20
|
+
it('can omit redundant boilerplate without removing the actual disclosure or actions', () => {
|
|
21
|
+
const html = renderToStaticMarkup(<ConfirmDialog {...props} variant="warning" showWarning={false} />)
|
|
22
|
+
expect(html).toContain(props.message)
|
|
23
|
+
expect(html).toContain(props.confirmLabel)
|
|
24
|
+
expect(html).toContain('Cancel')
|
|
25
|
+
expect(html).not.toContain('Please confirm you want to proceed')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves warning boilerplate by default for existing callers', () => {
|
|
29
|
+
expect(renderToStaticMarkup(<ConfirmDialog {...props} variant="warning" />))
|
|
30
|
+
.toContain('Please confirm you want to proceed with this action.')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('preserves the irreversible-action warning for destructive callers', () => {
|
|
34
|
+
expect(renderToStaticMarkup(<ConfirmDialog {...props} />))
|
|
35
|
+
.toContain('This action cannot be undone.')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
|
|
|
18
18
|
isLoading?: boolean
|
|
19
19
|
isClosable?: boolean // Allow closing even when isLoading (e.g., for long-running ops the user can dismiss)
|
|
20
20
|
confirmDisabled?: boolean // Block the confirm action while custom content is invalid (e.g., bad YAML)
|
|
21
|
+
showWarning?: boolean
|
|
21
22
|
className?: string
|
|
22
23
|
children?: ReactNode // Optional custom content (e.g., checkboxes)
|
|
23
24
|
}
|
|
@@ -35,6 +36,7 @@ export function ConfirmDialog({
|
|
|
35
36
|
isLoading = false,
|
|
36
37
|
isClosable = false,
|
|
37
38
|
confirmDisabled = false,
|
|
39
|
+
showWarning = true,
|
|
38
40
|
className,
|
|
39
41
|
children,
|
|
40
42
|
}: ConfirmDialogProps) {
|
|
@@ -84,7 +86,7 @@ export function ConfirmDialog({
|
|
|
84
86
|
)}
|
|
85
87
|
|
|
86
88
|
{/* Warning message — hidden once the action is in progress */}
|
|
87
|
-
{!isLoading && !children && (
|
|
89
|
+
{showWarning && !isLoading && !children && (
|
|
88
90
|
<div className="p-4">
|
|
89
91
|
<div
|
|
90
92
|
className={clsx(
|
|
@@ -291,7 +291,7 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
|
|
|
291
291
|
|
|
292
292
|
export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
|
|
293
293
|
|
|
294
|
-
function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
|
|
294
|
+
export function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
|
|
295
295
|
if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
|
|
296
296
|
return isConditionHealthy(cond) ? 'ok' : 'fail'
|
|
297
297
|
}
|
|
@@ -728,10 +728,11 @@ export function WorkloadView({
|
|
|
728
728
|
// prominent "Diagnose" on a problem, a quiet icon when fine. Rendered via the host
|
|
729
729
|
// slot (DiagnoseCustomization); standalone Radar injects it, Hub overrides it.
|
|
730
730
|
const renderDiagnose = actionsBarProps?.renderDiagnose as
|
|
731
|
-
| ((ctx: { kind: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
|
|
731
|
+
| ((ctx: { kind: string; group?: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
|
|
732
732
|
| undefined
|
|
733
733
|
const diagnoseAction = renderDiagnose?.({
|
|
734
734
|
kind: apiKind,
|
|
735
|
+
group,
|
|
735
736
|
namespace,
|
|
736
737
|
name,
|
|
737
738
|
health: diagnoseHealthHint(apiKind, resource),
|