@skyhook-io/k8s-ui 1.14.5 → 1.14.6

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.
Files changed (67) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +13 -0
  5. package/src/components/charts/AreaChart.tsx +28 -25
  6. package/src/components/charts/PrometheusChartsView.tsx +12 -0
  7. package/src/components/charts/axis.test.ts +16 -0
  8. package/src/components/charts/axis.ts +3 -1
  9. package/src/components/charts/format.ts +4 -0
  10. package/src/components/charts/nearestSample.test.ts +31 -0
  11. package/src/components/charts/nearestSample.ts +15 -0
  12. package/src/components/checks/ChecksView.tsx +20 -2
  13. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  14. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  15. package/src/components/gitops/health-provenance.test.ts +54 -0
  16. package/src/components/gitops/health-provenance.ts +24 -0
  17. package/src/components/gitops/index.ts +2 -0
  18. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  19. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  20. package/src/components/gitops/tree/merge.test.ts +34 -0
  21. package/src/components/gitops/tree/merge.ts +7 -0
  22. package/src/components/resources/ResourcesView.tsx +67 -2
  23. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  24. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  25. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  26. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  27. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  28. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  29. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  30. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  31. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  32. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  33. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  34. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  35. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  36. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  37. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  38. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  40. package/src/components/resources/renderers/index.ts +3 -0
  41. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  42. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  43. package/src/components/resources/resource-utils.ts +18 -9
  44. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  45. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  46. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  47. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  50. package/src/components/shared/index.ts +4 -0
  51. package/src/components/ui/FetchResult.test.tsx +20 -2
  52. package/src/components/ui/FetchResult.tsx +33 -3
  53. package/src/components/ui/FreshnessControl.tsx +8 -2
  54. package/src/components/workload/WorkloadView.tsx +2 -2
  55. package/src/theme/components.css +16 -0
  56. package/src/types/core.ts +15 -1
  57. package/src/types/gitops-insights.ts +23 -0
  58. package/src/types/gitops-tree.ts +29 -0
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/navigation.test.ts +20 -1
  61. package/src/utils/navigation.ts +25 -0
  62. package/src/utils/network-policy.test.ts +42 -0
  63. package/src/utils/network-policy.ts +59 -0
  64. package/src/utils/reflector.ts +139 -0
  65. package/src/utils/yaml-file-import.test.ts +189 -0
  66. package/src/utils/yaml-file-import.ts +95 -0
  67. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import {
4
+ DrainPlanContent,
5
+ canConfirmDrain,
6
+ emptyDirPodsAtRisk,
7
+ planMatches,
8
+ type DrainPlan,
9
+ type DrainPlanPod,
10
+ } from './DrainPlanDialog'
11
+
12
+ function pod(name: string, outcome: DrainPlanPod['outcome'], extra: Partial<DrainPlanPod> = {}): DrainPlanPod {
13
+ return { namespace: 'shop', name, outcome, reason: `${name} reason`, emptyDir: false, pdbChecked: true, ...extra }
14
+ }
15
+
16
+ function plan(pods: DrainPlanPod[], options: Partial<DrainPlan['options']> = {}): DrainPlan {
17
+ return {
18
+ node: 'worker-1',
19
+ generatedAt: '2026-09-10T10:00:00Z',
20
+ estimate: true,
21
+ options: { ignoreDaemonSets: true, deleteEmptyDirData: false, force: false, ...options },
22
+ summary: {
23
+ evict: pods.filter((p) => p.outcome === 'evict').length,
24
+ skip: pods.filter((p) => p.outcome === 'skip').length,
25
+ mayBlock: pods.filter((p) => p.outcome === 'may-block').length,
26
+ },
27
+ pods,
28
+ pdbsEvaluated: true,
29
+ }
30
+ }
31
+
32
+ const off = { force: false, deleteEmptyDirData: false }
33
+ const emptyDirOn = { force: false, deleteEmptyDirData: true }
34
+
35
+ describe('planMatches', () => {
36
+ it('rejects a plan computed for another node or other options', () => {
37
+ const p = plan([])
38
+ expect(planMatches(p, 'worker-1', off)).toBe(true)
39
+ expect(planMatches(p, 'worker-2', off)).toBe(false)
40
+ expect(planMatches(p, 'worker-1', emptyDirOn)).toBe(false)
41
+ expect(planMatches(null, 'worker-1', off)).toBe(false)
42
+ })
43
+ })
44
+
45
+ describe('canConfirmDrain', () => {
46
+ it('never enables the drain before a current plan exists', () => {
47
+ expect(canConfirmDrain({ plan: null, nodeName: 'worker-1', options: off, loading: false, planSupported: true })).toBe(false)
48
+ expect(canConfirmDrain({ plan: plan([]), nodeName: 'worker-1', options: off, loading: true, planSupported: true })).toBe(false)
49
+ expect(canConfirmDrain({ plan: plan([]), nodeName: 'other', options: off, loading: false, planSupported: true })).toBe(false)
50
+ })
51
+
52
+ it('keeps the drain disabled after a plan request error, even if an older plan is still around', () => {
53
+ const p = plan([pod('web', 'evict')])
54
+ expect(canConfirmDrain({ plan: p, nodeName: 'worker-1', options: off, loading: false, error: 'boom', planSupported: true })).toBe(false)
55
+ expect(canConfirmDrain({ plan: null, nodeName: 'worker-1', options: off, loading: false, error: 'boom', planSupported: false })).toBe(true)
56
+ })
57
+
58
+ it('enables the drain once a matching plan is shown', () => {
59
+ expect(canConfirmDrain({ plan: plan([pod('web', 'evict')]), nodeName: 'worker-1', options: off, loading: false, planSupported: true })).toBe(true)
60
+ })
61
+
62
+ it('does not gate the drain a second time when emptyDir pods would be evicted', () => {
63
+ const p = plan([pod('cache', 'evict', { emptyDir: true })], { deleteEmptyDirData: true })
64
+ expect(emptyDirPodsAtRisk(p).map((x) => x.name)).toEqual(['cache'])
65
+ expect(canConfirmDrain({ plan: p, nodeName: 'worker-1', options: emptyDirOn, loading: false, planSupported: true })).toBe(true)
66
+ })
67
+
68
+ it('confirms without a plan when the host cannot compute one', () => {
69
+ expect(canConfirmDrain({ plan: null, nodeName: 'worker-1', options: off, loading: false, planSupported: false })).toBe(true)
70
+ expect(canConfirmDrain({ plan: null, nodeName: 'worker-1', options: emptyDirOn, loading: false, planSupported: false })).toBe(true)
71
+ })
72
+ })
73
+
74
+ describe('DrainPlanContent', () => {
75
+ const noop = () => {}
76
+
77
+ function render(props: Partial<Parameters<typeof DrainPlanContent>[0]>) {
78
+ return renderToString(
79
+ <DrainPlanContent
80
+ nodeName="worker-1"
81
+ plan={null}
82
+ loading={false}
83
+ options={off}
84
+ onOptionsChange={noop}
85
+ planSupported
86
+ {...props}
87
+ />,
88
+ )
89
+ }
90
+
91
+ it('shows per-pod outcomes with reasons and calls the result an estimate', () => {
92
+ const html = render({ plan: plan([pod('web', 'may-block', { pdb: 'shop/web', reason: 'PodDisruptionBudget shop/web currently allows no disruptions' }), pod('agent', 'skip')]) })
93
+ expect(html).toContain('may block')
94
+ expect(html).toContain('shop/web')
95
+ expect(html).toContain('currently allows no disruptions')
96
+ expect(html).toContain('agent reason')
97
+ expect(html).toContain('Estimated at')
98
+ expect(html).toContain('About this estimate')
99
+ })
100
+
101
+ it('offers a refresh only when the host can recompute the plan', () => {
102
+ const p = plan([pod('web', 'evict')])
103
+ expect(render({ plan: p, onRefreshPlan: noop })).toContain('Recompute the plan')
104
+ expect(render({ plan: p })).not.toContain('Recompute the plan')
105
+ })
106
+
107
+ it('exposes the estimate caveat on a focusable trigger', () => {
108
+ const html = render({ plan: plan([pod('web', 'evict')]) })
109
+ expect(html).toMatch(/<button[^>]*aria-label="About this estimate"/)
110
+ })
111
+
112
+ it('offers a retry on a failed plan only when the host can recompute it', () => {
113
+ expect(render({ error: 'boom', onRefreshPlan: noop })).toContain('Try again')
114
+ expect(render({ error: 'boom' })).not.toContain('Try again')
115
+ })
116
+
117
+ it('shows a loading state instead of a stale plan', () => {
118
+ const html = render({ loading: true, plan: plan([pod('web', 'evict')]) })
119
+ expect(html).toContain('Computing the plan')
120
+ expect(html).not.toContain('web reason')
121
+ })
122
+
123
+ it('ignores a plan computed for another node', () => {
124
+ const html = render({ plan: { ...plan([pod('web', 'evict')]), node: 'worker-2' } })
125
+ expect(html).not.toContain('web reason')
126
+ })
127
+
128
+ it('names the pods whose emptyDir data would go, and asks for no second tick', () => {
129
+ const p = plan([pod('cache', 'evict', { emptyDir: true }), pod('web', 'evict')], { deleteEmptyDirData: true })
130
+ const html = render({ plan: p, options: emptyDirOn })
131
+ expect(html).toContain('emptyDir data will be discarded')
132
+ expect(html).toContain('shop/cache')
133
+ expect(html).not.toMatch(/<input[^>]*type="checkbox"[^>]*class="mt-0\.5/)
134
+ })
135
+
136
+ it('says nothing about discarding emptyDir data while the option is off', () => {
137
+ const html = render({ plan: plan([pod('cache', 'skip', { emptyDir: true })]) })
138
+ expect(html).not.toContain('emptyDir data will be discarded')
139
+ })
140
+
141
+ it('warns when PodDisruptionBudgets could not be evaluated and does not print a zero for them', () => {
142
+ const p = { ...plan([pod('web', 'evict')]), pdbsEvaluated: false, pdbError: 'list poddisruptionbudgets: forbidden' }
143
+ const html = render({ plan: p })
144
+ expect(html).toContain('PodDisruptionBudgets were not evaluated')
145
+ expect(html).toContain('forbidden')
146
+ expect(html).toContain('PodDisruptionBudgets not evaluated')
147
+ expect(html).not.toContain('0 may block')
148
+ })
149
+
150
+ it('warns honestly when the estimate shows no emptyDir pod', () => {
151
+ const html = render({ plan: plan([pod('web', 'evict')], { deleteEmptyDirData: true }), options: emptyDirOn })
152
+ expect(html).toContain('No pod that would be evicted uses emptyDir right now')
153
+ })
154
+ })
@@ -0,0 +1,297 @@
1
+ import { Info, Loader2, RefreshCw } from 'lucide-react'
2
+ import { ConfirmDialog } from '../ui/ConfirmDialog'
3
+ import { Badge, type BadgeSeverity } from '../ui/Badge'
4
+ import { AlertBanner } from '../ui/drawer-components'
5
+ import { Tooltip } from '../ui/Tooltip'
6
+ import { pluralize } from '../../utils/pluralize'
7
+
8
+ // Shapes returned by POST /api/nodes/{name}/drain-plan.
9
+ export type DrainOutcome = 'evict' | 'skip' | 'may-block'
10
+
11
+ export interface DrainPlanPod {
12
+ namespace: string
13
+ name: string
14
+ outcome: DrainOutcome
15
+ reason: string
16
+ emptyDir: boolean
17
+ pdb?: string
18
+ pdbChecked: boolean
19
+ }
20
+
21
+ export interface DrainPlan {
22
+ node: string
23
+ generatedAt: string
24
+ estimate: boolean
25
+ options: { ignoreDaemonSets: boolean; deleteEmptyDirData: boolean; force: boolean; gracePeriodSeconds?: number }
26
+ summary: { evict: number; skip: number; mayBlock: number }
27
+ pods: DrainPlanPod[]
28
+ pdbsEvaluated: boolean
29
+ pdbError?: string
30
+ }
31
+
32
+ // The two options the operator chooses in the dialog. Both are always sent
33
+ // explicitly to the drain endpoint; nothing relies on server-side defaults.
34
+ export interface DrainDialogOptions {
35
+ force: boolean
36
+ deleteEmptyDirData: boolean
37
+ }
38
+
39
+ export const DEFAULT_DRAIN_DIALOG_OPTIONS: DrainDialogOptions = { force: false, deleteEmptyDirData: false }
40
+
41
+ /** A plan is only usable when it was computed for this node with these options. */
42
+ export function planMatches(plan: DrainPlan | null | undefined, nodeName: string, options: DrainDialogOptions): plan is DrainPlan {
43
+ return Boolean(
44
+ plan &&
45
+ plan.node === nodeName &&
46
+ plan.options.force === options.force &&
47
+ plan.options.deleteEmptyDirData === options.deleteEmptyDirData,
48
+ )
49
+ }
50
+
51
+ /** Pods whose emptyDir data would be discarded by this drain (evicted, not skipped). */
52
+ export function emptyDirPodsAtRisk(plan: DrainPlan): DrainPlanPod[] {
53
+ return plan.pods.filter((p) => p.emptyDir && p.outcome !== 'skip')
54
+ }
55
+
56
+ export interface CanConfirmDrainInput {
57
+ plan: DrainPlan | null | undefined
58
+ nodeName: string
59
+ options: DrainDialogOptions
60
+ loading: boolean
61
+ /** the last plan request failed; with plan support the drain stays disabled until a plan is shown again */
62
+ error?: string | null
63
+ /** false when the host cannot compute plans (no onPlanDrain); the dialog then confirms without one */
64
+ planSupported: boolean
65
+ }
66
+
67
+ /**
68
+ * Whether the destructive Drain button may be enabled. With plan support the
69
+ * operator must see a current plan first. Deleting emptyDir data is its own
70
+ * deliberate choice, off by default and warned about by name, so it does not
71
+ * gate the button a second time.
72
+ */
73
+ export function canConfirmDrain({ plan, nodeName, options, loading, error, planSupported }: CanConfirmDrainInput): boolean {
74
+ if (loading) return false
75
+ if (planSupported && error) return false
76
+ const current = planMatches(plan, nodeName, options) ? plan : null
77
+ if (planSupported && !current) return false
78
+ return true
79
+ }
80
+
81
+ const OUTCOME_SEVERITY: Record<DrainOutcome, BadgeSeverity> = {
82
+ evict: 'warning',
83
+ 'may-block': 'error',
84
+ skip: 'neutral',
85
+ }
86
+
87
+ const OUTCOME_LABEL: Record<DrainOutcome, string> = {
88
+ evict: 'evict',
89
+ 'may-block': 'may block',
90
+ skip: 'skip',
91
+ }
92
+
93
+ const ESTIMATE_CAVEAT =
94
+ 'An estimate, not a guarantee: the drain re-lists live state when it runs, and pods, budgets and permissions can change until then. ' +
95
+ 'Evictions a budget refuses are retried until the drain deadline (60 seconds, shared by all evictions); a pod covered by more than one budget is refused outright.'
96
+
97
+ interface DrainPlanContentProps {
98
+ nodeName: string
99
+ plan?: DrainPlan | null
100
+ loading: boolean
101
+ error?: string | null
102
+ options: DrainDialogOptions
103
+ onOptionsChange: (options: DrainDialogOptions) => void
104
+ planSupported: boolean
105
+ /** Recompute the plan with the current options; also the retry after a failed plan. */
106
+ onRefreshPlan?: () => void
107
+ }
108
+
109
+ /**
110
+ * The body of the drain dialog: options, the plan table and the emptyDir
111
+ * warning. Presentational, so it renders without a DOM (tests use
112
+ * react-dom/server); the enclosing dialog owns the confirm gating.
113
+ */
114
+ export function DrainPlanContent({
115
+ nodeName, plan, loading, error, options, onOptionsChange, planSupported, onRefreshPlan,
116
+ }: DrainPlanContentProps) {
117
+ const current = planMatches(plan, nodeName, options) ? plan : null
118
+ const atRisk = current ? emptyDirPodsAtRisk(current) : []
119
+
120
+ return (
121
+ <div className="flex flex-col gap-3 text-sm text-theme-text-secondary">
122
+ <div className="flex flex-wrap gap-x-6 gap-y-2">
123
+ <label className="flex items-center gap-2 cursor-pointer">
124
+ <input
125
+ type="checkbox"
126
+ checked={options.force}
127
+ onChange={(e) => onOptionsChange({ ...options, force: e.target.checked })}
128
+ className="rounded border-theme-border"
129
+ />
130
+ Force: evict pods not managed by a controller (they are not recreated)
131
+ </label>
132
+ <label className="flex items-center gap-2 cursor-pointer">
133
+ <input
134
+ type="checkbox"
135
+ checked={options.deleteEmptyDirData}
136
+ onChange={(e) => onOptionsChange({ ...options, deleteEmptyDirData: e.target.checked })}
137
+ className="rounded border-theme-border"
138
+ />
139
+ Delete emptyDir data: evict pods that use emptyDir volumes (their data is lost)
140
+ </label>
141
+ </div>
142
+
143
+ {planSupported && loading && (
144
+ <div className="flex items-center gap-2 text-theme-text-secondary">
145
+ <Loader2 className="w-4 h-4 animate-spin" /> Computing the plan…
146
+ </div>
147
+ )}
148
+
149
+ {planSupported && !loading && error && (
150
+ <AlertBanner variant="error" title="Could not compute the drain plan" message={error}>
151
+ {onRefreshPlan && (
152
+ <button
153
+ type="button"
154
+ onClick={onRefreshPlan}
155
+ className="mt-2 rounded border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
156
+ >
157
+ Try again
158
+ </button>
159
+ )}
160
+ </AlertBanner>
161
+ )}
162
+
163
+ {planSupported && !loading && !error && current && (
164
+ <>
165
+ <div className="flex items-start justify-between gap-2">
166
+ <div className="text-theme-text-primary">
167
+ {pluralize(current.summary.evict, 'pod')} to evict,{' '}
168
+ {current.pdbsEvaluated
169
+ ? `${current.summary.mayBlock} may block on a PodDisruptionBudget`
170
+ : 'PodDisruptionBudgets not evaluated'}
171
+ , {current.summary.skip} skipped.
172
+ </div>
173
+ <div className="flex items-center gap-1 shrink-0 text-xs text-theme-text-tertiary">
174
+ <span>Estimated at {new Date(current.generatedAt).toLocaleTimeString()}</span>
175
+ <Tooltip content={ESTIMATE_CAVEAT} className="max-w-xs">
176
+ <button
177
+ type="button"
178
+ aria-label="About this estimate"
179
+ className="p-0.5 rounded text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
180
+ >
181
+ <Info className="w-3.5 h-3.5" />
182
+ </button>
183
+ </Tooltip>
184
+ {onRefreshPlan && (
185
+ <Tooltip content="Recompute the plan">
186
+ <button
187
+ type="button"
188
+ aria-label="Recompute the plan"
189
+ onClick={onRefreshPlan}
190
+ className="p-1 rounded text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
191
+ >
192
+ <RefreshCw className="w-3.5 h-3.5" />
193
+ </button>
194
+ </Tooltip>
195
+ )}
196
+ </div>
197
+ </div>
198
+
199
+ {!current.pdbsEvaluated && (
200
+ <AlertBanner
201
+ variant="warning"
202
+ title="PodDisruptionBudgets were not evaluated"
203
+ message={current.pdbError ?? 'Budgets could not be listed; evictions may still be refused.'}
204
+ />
205
+ )}
206
+
207
+ <div className="max-h-72 overflow-auto rounded border border-theme-border">
208
+ <table className="w-full text-xs">
209
+ <tbody>
210
+ {current.pods.map((p) => (
211
+ <tr key={`${p.namespace}/${p.name}`} className="border-b border-theme-border last:border-b-0 align-top">
212
+ <td className="px-2 py-1.5 whitespace-nowrap">
213
+ <Badge severity={OUTCOME_SEVERITY[p.outcome]} size="sm">{OUTCOME_LABEL[p.outcome]}</Badge>
214
+ </td>
215
+ <td className="px-2 py-1.5 font-mono whitespace-nowrap text-theme-text-primary">
216
+ {p.namespace}/{p.name}
217
+ </td>
218
+ <td className="px-2 py-1.5 text-theme-text-secondary">{p.reason}</td>
219
+ </tr>
220
+ ))}
221
+ {current.pods.length === 0 && (
222
+ <tr>
223
+ <td className="px-2 py-3 text-theme-text-secondary" colSpan={3}>No pods are scheduled on this node.</td>
224
+ </tr>
225
+ )}
226
+ </tbody>
227
+ </table>
228
+ </div>
229
+ </>
230
+ )}
231
+
232
+ {options.deleteEmptyDirData && (
233
+ <AlertBanner
234
+ variant="warning"
235
+ title="emptyDir data will be discarded"
236
+ message={
237
+ current && atRisk.length > 0
238
+ ? `${pluralize(atRisk.length, 'pod')} that would be evicted ${atRisk.length === 1 ? 'uses' : 'use'} emptyDir: ${atRisk.map((p) => `${p.namespace}/${p.name}`).join(', ')}. That data cannot be recovered.`
239
+ : current
240
+ ? 'No pod that would be evicted uses emptyDir right now. Any that does when the drain runs loses that data, which cannot be recovered.'
241
+ : 'Every evicted pod that uses emptyDir volumes loses that data, which cannot be recovered.'
242
+ }
243
+ />
244
+ )}
245
+ </div>
246
+ )
247
+ }
248
+
249
+ interface DrainPlanDialogProps {
250
+ open: boolean
251
+ nodeName: string
252
+ plan?: DrainPlan | null
253
+ loading: boolean
254
+ error?: string | null
255
+ options: DrainDialogOptions
256
+ onOptionsChange: (options: DrainDialogOptions) => void
257
+ onConfirm: (options: DrainDialogOptions) => void
258
+ onClose: () => void
259
+ isDraining: boolean
260
+ /** Whether the host can compute plans. Without it the dialog confirms without one. */
261
+ planSupported: boolean
262
+ /** Recompute the plan with the current options; also the retry after a failed plan. */
263
+ onRefreshPlan?: () => void
264
+ }
265
+
266
+ export function DrainPlanDialog({
267
+ open, nodeName, plan, loading, error, options, onOptionsChange, onConfirm, onClose, isDraining, planSupported, onRefreshPlan,
268
+ }: DrainPlanDialogProps) {
269
+ const confirmEnabled = canConfirmDrain({ plan, nodeName, options, loading, error, planSupported })
270
+
271
+ return (
272
+ <ConfirmDialog
273
+ open={open}
274
+ onClose={onClose}
275
+ onConfirm={() => onConfirm(options)}
276
+ title="Drain Node"
277
+ message={`Cordon "${nodeName}" and evict the pods listed below. The list is an estimate: the drain re-lists live state when it runs.`}
278
+ confirmLabel={isDraining ? 'Draining...' : 'Drain'}
279
+ variant="danger"
280
+ isLoading={isDraining}
281
+ isClosable
282
+ confirmDisabled={!confirmEnabled}
283
+ className="max-w-3xl"
284
+ >
285
+ <DrainPlanContent
286
+ nodeName={nodeName}
287
+ plan={plan}
288
+ loading={loading}
289
+ error={error}
290
+ options={options}
291
+ onOptionsChange={onOptionsChange}
292
+ planSupported={planSupported}
293
+ onRefreshPlan={onRefreshPlan}
294
+ />
295
+ </ConfirmDialog>
296
+ )
297
+ }
@@ -28,6 +28,7 @@ import type { SelectedResource, WorkloadRevision } from '../../types'
28
28
  import { displayKindName } from '../ui/drawer-components'
29
29
  import { getDefaultContainerName } from '../resources/resource-utils'
30
30
  import { SetImageDialog, type ManagedImageSource } from './SetImageDialog'
31
+ import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
31
32
  import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
32
33
  import { isArgoRolloutResource } from '../../utils/workload-rollout'
33
34
  import { isCoreBatchJob } from '../../utils/api-resources'
@@ -135,6 +136,17 @@ interface ResourceActionsBarProps {
135
136
  isUncordoningNode?: boolean
136
137
  onDrainNode?: (params: { name: string; options?: { deleteEmptyDirData?: boolean; force?: boolean } }) => void
137
138
  isDrainingNode?: boolean
139
+ // Read-only drain plan (POST /nodes/{name}/drain-plan). When the host provides
140
+ // onPlanDrain, the drain dialog shows the plan before enabling the destructive action.
141
+ onPlanDrain?: (params: { name: string; options: DrainDialogOptions }) => void
142
+ onPlanDrainReset?: () => void // forget the last plan when the dialog closes, so a re-open never shows a stale one
143
+ drainPlan?: DrainPlan | null
144
+ isPlanningDrain?: boolean
145
+ drainPlanError?: string | null
146
+ // The connected backend has no drain-plan endpoint (version skew, e.g. a newer
147
+ // frontend against an older radar). The dialog falls back to the plan-less
148
+ // acknowledgement-only mode instead of keeping Drain disabled on a dead request.
149
+ drainPlanUnsupported?: boolean
138
150
  }
139
151
 
140
152
  export function ResourceActionsBar({
@@ -165,6 +177,7 @@ export function ResourceActionsBar({
165
177
  onCordonNode, isCordoningNode,
166
178
  onUncordonNode, isUncordoningNode,
167
179
  onDrainNode, isDrainingNode,
180
+ onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUnsupported,
168
181
  }: ResourceActionsBarProps) {
169
182
  const kind = resource.kind.toLowerCase()
170
183
  const coreBatchJob = isCoreBatchJob(kind, resource.group)
@@ -183,7 +196,28 @@ export function ResourceActionsBar({
183
196
  // Node operation confirmation state
184
197
  const [showCordonConfirm, setShowCordonConfirm] = useState(false)
185
198
  const [showDrainConfirm, setShowDrainConfirm] = useState(false)
186
- const [drainForce, setDrainForce] = useState(false)
199
+ const [drainOptions, setDrainOptions] = useState<DrainDialogOptions>(DEFAULT_DRAIN_DIALOG_OPTIONS)
200
+
201
+ // A backend without the plan endpoint stays unsupported for as long as the dialog is
202
+ // open: the host reports it through a mutation error, which the next request clears,
203
+ // so without latching every option change would refire a request known to 404 and
204
+ // bounce the dialog out of its fallback mode.
205
+ const [planUnsupported, setPlanUnsupported] = useState(false)
206
+ useEffect(() => {
207
+ if (drainPlanUnsupported) setPlanUnsupported(true)
208
+ }, [drainPlanUnsupported])
209
+ useEffect(() => {
210
+ if (!showDrainConfirm) setPlanUnsupported(false)
211
+ }, [showDrainConfirm])
212
+ const planSupported = Boolean(onPlanDrain) && !planUnsupported
213
+
214
+ // Fetch (and refetch on option changes) the read-only plan while the drain dialog is open.
215
+ useEffect(() => {
216
+ if (showDrainConfirm && onPlanDrain && !planUnsupported) {
217
+ onPlanDrain({ name: resource.name, options: drainOptions })
218
+ }
219
+ // eslint-disable-next-line react-hooks/exhaustive-deps
220
+ }, [showDrainConfirm, drainOptions.force, drainOptions.deleteEmptyDirData, resource.name, planUnsupported])
187
221
 
188
222
  // Rollback dialog state
189
223
  const [showRevisions, setShowRevisions] = useState(false)
@@ -675,40 +709,33 @@ export function ResourceActionsBar({
675
709
  isLoading={isCordoningNode}
676
710
  />
677
711
 
678
- {/* Node drain confirmation */}
679
- <ConfirmDialog
712
+ {/* Node drain: read-only plan first, then the destructive action with explicit options */}
713
+ <DrainPlanDialog
680
714
  open={showDrainConfirm}
715
+ nodeName={resource.name}
716
+ plan={drainPlan}
717
+ loading={Boolean(isPlanningDrain)}
718
+ error={planSupported ? drainPlanError : null}
719
+ options={drainOptions}
720
+ onOptionsChange={setDrainOptions}
721
+ planSupported={planSupported}
722
+ onRefreshPlan={planSupported ? () => onPlanDrain?.({ name: resource.name, options: drainOptions }) : undefined}
723
+ isDraining={Boolean(isDrainingNode)}
681
724
  onClose={() => {
682
725
  setShowDrainConfirm(false)
683
- setDrainForce(false)
726
+ setDrainOptions(DEFAULT_DRAIN_DIALOG_OPTIONS)
727
+ onPlanDrainReset?.()
684
728
  }}
685
- onConfirm={() => {
729
+ onConfirm={(opts) => {
686
730
  onDrainNode?.({
687
731
  name: resource.name,
688
- options: { deleteEmptyDirData: true, force: drainForce || undefined },
732
+ options: { deleteEmptyDirData: opts.deleteEmptyDirData, force: opts.force },
689
733
  })
690
734
  setShowDrainConfirm(false)
691
- setDrainForce(false)
735
+ setDrainOptions(DEFAULT_DRAIN_DIALOG_OPTIONS)
736
+ onPlanDrainReset?.()
692
737
  }}
693
- title="Drain Node"
694
- message={`Cordon and evict all pods from node "${resource.name}"? DaemonSet pods will be skipped.`}
695
- confirmLabel={isDrainingNode ? 'Draining...' : 'Drain'}
696
- variant="danger"
697
- isLoading={isDrainingNode}
698
- isClosable
699
- >
700
- <div className="flex flex-col gap-2 text-sm text-theme-text-secondary">
701
- <label className="flex items-center gap-2 cursor-pointer">
702
- <input
703
- type="checkbox"
704
- checked={drainForce}
705
- onChange={(e) => setDrainForce(e.target.checked)}
706
- className="rounded border-theme-border"
707
- />
708
- Force (evict pods not managed by a controller)
709
- </label>
710
- </div>
711
- </ConfirmDialog>
738
+ />
712
739
 
713
740
  {showRevisions && isRollbackKind && (
714
741
  <RevisionHistoryDialog
@@ -180,6 +180,27 @@ function renderCollidingKind(kind: string, apiVersion: string): string {
180
180
  )
181
181
  }
182
182
 
183
+ describe('LimitRange dispatch', () => {
184
+ const limitRange = {
185
+ apiVersion: 'v1',
186
+ kind: 'LimitRange',
187
+ metadata: { name: 'team-limits', namespace: 'dev' },
188
+ spec: { limits: [{ type: 'Container', defaultRequest: { cpu: '100m' }, default: { cpu: '500m' } }] },
189
+ }
190
+
191
+ it('renders the declared rules instead of the generic fallback', () => {
192
+ const html = renderKind('limitranges', limitRange, 'dev')
193
+ expect(html).toContain('Defaults &amp; Constraints')
194
+ expect(html).toContain('100m')
195
+ })
196
+
197
+ // A LimitRange has no status. Reporting one would be an invention, and the
198
+ // drawer header would disagree with the object.
199
+ it('reports no health for a kind that has none', () => {
200
+ expect(getResourceStatus('limitranges', limitRange)).toBeNull()
201
+ })
202
+ })
203
+
183
204
  describe('getResourceStatus — workload rollout activity', () => {
184
205
  const steadyDegraded = {
185
206
  metadata: { generation: 4 },
@@ -277,6 +298,30 @@ describe('getResourceStatus — colliding plurals', () => {
277
298
  })).not.toBeNull()
278
299
  })
279
300
 
301
+ it('reads an Argo Rollouts Experiment with the AnalysisPhase vocabulary', () => {
302
+ expect(getResourceStatus('experiments', {
303
+ apiVersion: 'argoproj.io/v1alpha1',
304
+ status: { phase: 'Successful' },
305
+ })).toMatchObject({ text: 'Successful', level: 'healthy' })
306
+
307
+ expect(getResourceStatus('experiments', {
308
+ apiVersion: 'argoproj.io/v1alpha1',
309
+ status: { phase: 'Running' },
310
+ })).toMatchObject({ level: 'degraded' })
311
+ })
312
+
313
+ // Katib ships its own Experiment at kubeflow.org sharing this plural, and it
314
+ // does not report AnalysisPhase. Falling through to the generic reader is
315
+ // fine; being scored with Argo's vocabulary is not, because that attaches a
316
+ // HealthLevel derived from a phase Katib never reports.
317
+ it('does not score a Katib Experiment with the Argo vocabulary', () => {
318
+ const katib = getResourceStatus('experiments', {
319
+ apiVersion: 'kubeflow.org/v1beta1',
320
+ status: { conditions: [{ type: 'Running', status: 'True' }] },
321
+ })
322
+ expect(katib?.level).toBeUndefined()
323
+ })
324
+
280
325
  it('fabricates no engine status for a third-party backups CRD', () => {
281
326
  expect(getResourceStatus('backups', { apiVersion: 'kubevirt.io/v1', status: {} })).toBeNull()
282
327
  expect(getResourceStatus('backups', { apiVersion: 'kubevirt.io/v1', status: { phase: 'Running' } }))