@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.
- package/package.json +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +12 -0
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- 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 [
|
|
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
|
|
679
|
-
<
|
|
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
|
-
|
|
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:
|
|
732
|
+
options: { deleteEmptyDirData: opts.deleteEmptyDirData, force: opts.force },
|
|
689
733
|
})
|
|
690
734
|
setShowDrainConfirm(false)
|
|
691
|
-
|
|
735
|
+
setDrainOptions(DEFAULT_DRAIN_DIALOG_OPTIONS)
|
|
736
|
+
onPlanDrainReset?.()
|
|
692
737
|
}}
|
|
693
|
-
|
|
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 & 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' } }))
|