@skyhook-io/k8s-ui 1.14.5 → 1.14.7
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/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +41 -10
- 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 +76 -28
- package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
- 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/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- 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/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- 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/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- 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/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- 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 +123 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +237 -14
- 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/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +8 -12
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +20 -1
- 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/animation.ts +93 -13
- 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
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
-
import { AlertTriangle, Check,
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from 'react'
|
|
2
|
+
import { AlertTriangle, Check, Loader2, Upload, X } from 'lucide-react'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
4
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
3
5
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
4
6
|
import { YamlEditor, type YamlSchemaLoader } from '../ui/YamlEditor'
|
|
5
7
|
import {
|
|
@@ -9,6 +11,17 @@ import {
|
|
|
9
11
|
} from '../ui/YamlReview'
|
|
10
12
|
import { Tooltip } from '../ui/Tooltip'
|
|
11
13
|
import { formatApplyError } from '../../utils/k8s-errors'
|
|
14
|
+
import {
|
|
15
|
+
describeFileDrag,
|
|
16
|
+
needsReplaceConfirmation,
|
|
17
|
+
readYamlFile,
|
|
18
|
+
YAML_FILE_ACCEPT,
|
|
19
|
+
type FileDragState,
|
|
20
|
+
} from '../../utils/yaml-file-import'
|
|
21
|
+
|
|
22
|
+
// A frame this close to the last one means the main thread is keeping up; a
|
|
23
|
+
// blocked editor produces gaps far larger.
|
|
24
|
+
const responsiveFrameMs = 100
|
|
12
25
|
|
|
13
26
|
export interface ApplyResult {
|
|
14
27
|
name: string
|
|
@@ -71,6 +84,41 @@ export function CreateResourceDialog({
|
|
|
71
84
|
nonAtomic: boolean
|
|
72
85
|
context?: string
|
|
73
86
|
} | null>(null)
|
|
87
|
+
const [drag, setDrag] = useState<FileDragState>('none')
|
|
88
|
+
const [pendingImport, setPendingImport] = useState<{ fileName: string; yaml: string } | null>(null)
|
|
89
|
+
const [importing, setImporting] = useState<string | null>(null)
|
|
90
|
+
// An import is several awaits and several frames long. Anything that changes
|
|
91
|
+
// what the result should land on — a newer import, a close, a reopen — bumps
|
|
92
|
+
// the generation, and every deferred step checks it before touching state.
|
|
93
|
+
const importGeneration = useRef(0)
|
|
94
|
+
const scheduledFrames = useRef(new Set<number>())
|
|
95
|
+
// The buffer as it stands now, not as it stood when a read began.
|
|
96
|
+
const yamlRef = useRef(yaml)
|
|
97
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
98
|
+
// dragenter/dragleave also fire for the editor's own descendants, so the
|
|
99
|
+
// overlay tracks depth rather than the first dragleave it sees.
|
|
100
|
+
const dragDepth = useRef(0)
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
yamlRef.current = yaml
|
|
104
|
+
}, [yaml])
|
|
105
|
+
|
|
106
|
+
const retireDeferredImports = useCallback(() => {
|
|
107
|
+
importGeneration.current += 1
|
|
108
|
+
for (const id of scheduledFrames.current) cancelAnimationFrame(id)
|
|
109
|
+
scheduledFrames.current.clear()
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
useEffect(() => retireDeferredImports, [retireDeferredImports])
|
|
113
|
+
|
|
114
|
+
const scheduleFrame = useCallback((generation: number, run: () => void) => {
|
|
115
|
+
const id = requestAnimationFrame(() => {
|
|
116
|
+
scheduledFrames.current.delete(id)
|
|
117
|
+
if (generation !== importGeneration.current) return
|
|
118
|
+
run()
|
|
119
|
+
})
|
|
120
|
+
scheduledFrames.current.add(id)
|
|
121
|
+
}, [])
|
|
74
122
|
|
|
75
123
|
useEffect(() => {
|
|
76
124
|
if (!open) return
|
|
@@ -82,16 +130,24 @@ export function CreateResourceDialog({
|
|
|
82
130
|
setError(null)
|
|
83
131
|
setSuccess(null)
|
|
84
132
|
setPreview(null)
|
|
85
|
-
|
|
133
|
+
setDrag('none')
|
|
134
|
+
setPendingImport(null)
|
|
135
|
+
setImporting(null)
|
|
136
|
+
dragDepth.current = 0
|
|
137
|
+
retireDeferredImports()
|
|
138
|
+
}, [open, initialYaml, retireDeferredImports])
|
|
86
139
|
|
|
87
140
|
const pending = isApplying || isPreviewing
|
|
88
141
|
const closeNow = useCallback(() => {
|
|
142
|
+
retireDeferredImports()
|
|
89
143
|
setError(null)
|
|
90
144
|
setSuccess(null)
|
|
91
145
|
setPreview(null)
|
|
146
|
+
setPendingImport(null)
|
|
147
|
+
setImporting(null)
|
|
92
148
|
setYaml('')
|
|
93
149
|
onClose()
|
|
94
|
-
}, [onClose])
|
|
150
|
+
}, [onClose, retireDeferredImports])
|
|
95
151
|
const handleClose = useCallback(() => {
|
|
96
152
|
if (!pending) closeNow()
|
|
97
153
|
}, [closeNow, pending])
|
|
@@ -123,6 +179,100 @@ export function CreateResourceDialog({
|
|
|
123
179
|
[closeNow, onCreated],
|
|
124
180
|
)
|
|
125
181
|
|
|
182
|
+
// Handing a document to the editor parses the whole buffer and blocks the
|
|
183
|
+
// main thread, so the status has to be on screen before that starts. Two
|
|
184
|
+
// frames: the first commits it, the second lets the browser paint it. A
|
|
185
|
+
// spinner would only freeze mid-turn, so this says what is happening instead
|
|
186
|
+
// of pretending to animate.
|
|
187
|
+
const loadIntoEditor = useCallback(
|
|
188
|
+
(fileName: string, content: string, generation: number) => {
|
|
189
|
+
setImporting(fileName)
|
|
190
|
+
scheduleFrame(generation, () => {
|
|
191
|
+
scheduleFrame(generation, () => {
|
|
192
|
+
setYaml(content)
|
|
193
|
+
// Clearing it in this same callback would batch it into the very
|
|
194
|
+
// render that blocks, retiring the status while the editor is still
|
|
195
|
+
// unusable. Wait for a frame to arrive on time instead: that is the
|
|
196
|
+
// editor answering again, which is what the status was promising.
|
|
197
|
+
let previous = performance.now()
|
|
198
|
+
const clearWhenResponsive = () => {
|
|
199
|
+
const now = performance.now()
|
|
200
|
+
if (now - previous < responsiveFrameMs) {
|
|
201
|
+
setImporting(null)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
previous = now
|
|
205
|
+
scheduleFrame(generation, clearWhenResponsive)
|
|
206
|
+
}
|
|
207
|
+
scheduleFrame(generation, clearWhenResponsive)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
},
|
|
211
|
+
[scheduleFrame],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const loadFiles = useCallback(
|
|
215
|
+
async (files: File[]) => {
|
|
216
|
+
const generation = ++importGeneration.current
|
|
217
|
+
const result = await readYamlFile(files)
|
|
218
|
+
// A newer import, a close or a reopen happened while this one was
|
|
219
|
+
// reading; its result describes an editor that has moved on.
|
|
220
|
+
if (generation !== importGeneration.current) return
|
|
221
|
+
// This import owns the status from here. Taking the generation retired
|
|
222
|
+
// the frames that would have cleared a previous import's overlay, so
|
|
223
|
+
// every path out of this function has to leave it in a sane state — the
|
|
224
|
+
// ones that return early below would otherwise strand it on screen.
|
|
225
|
+
setImporting(null)
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
setSuccess(null)
|
|
228
|
+
setError(result.message)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
setError(null)
|
|
232
|
+
setSuccess(null)
|
|
233
|
+
if (needsReplaceConfirmation(yamlRef.current, initialYaml)) {
|
|
234
|
+
setPendingImport({ fileName: result.fileName, yaml: result.yaml })
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
loadIntoEditor(result.fileName, result.yaml, generation)
|
|
238
|
+
},
|
|
239
|
+
[initialYaml, loadIntoEditor],
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const handleDragEnter = useCallback((event: ReactDragEvent) => {
|
|
243
|
+
const state = describeFileDrag(event.dataTransfer)
|
|
244
|
+
if (state === 'none') return
|
|
245
|
+
event.preventDefault()
|
|
246
|
+
dragDepth.current += 1
|
|
247
|
+
setDrag(state)
|
|
248
|
+
}, [])
|
|
249
|
+
|
|
250
|
+
const handleDragOver = useCallback((event: ReactDragEvent) => {
|
|
251
|
+
if (describeFileDrag(event.dataTransfer) === 'none') return
|
|
252
|
+
// Without this the browser navigates away to the dropped file.
|
|
253
|
+
event.preventDefault()
|
|
254
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
255
|
+
}, [])
|
|
256
|
+
|
|
257
|
+
const handleDragLeave = useCallback((event: ReactDragEvent) => {
|
|
258
|
+
if (describeFileDrag(event.dataTransfer) === 'none') return
|
|
259
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
260
|
+
if (dragDepth.current === 0) setDrag('none')
|
|
261
|
+
}, [])
|
|
262
|
+
|
|
263
|
+
const handleDrop = useCallback(
|
|
264
|
+
(event: ReactDragEvent) => {
|
|
265
|
+
if (describeFileDrag(event.dataTransfer) === 'none') return
|
|
266
|
+
event.preventDefault()
|
|
267
|
+
dragDepth.current = 0
|
|
268
|
+
setDrag('none')
|
|
269
|
+
// The DataTransfer is neutered once this handler returns, so take the
|
|
270
|
+
// files off it before anything awaits.
|
|
271
|
+
void loadFiles(Array.from(event.dataTransfer.files))
|
|
272
|
+
},
|
|
273
|
+
[loadFiles],
|
|
274
|
+
)
|
|
275
|
+
|
|
126
276
|
const handleSubmit = useCallback(async () => {
|
|
127
277
|
if (!yaml.trim()) {
|
|
128
278
|
setError('YAML content is required')
|
|
@@ -256,13 +406,64 @@ export function CreateResourceDialog({
|
|
|
256
406
|
</div>
|
|
257
407
|
|
|
258
408
|
<div className="min-h-0 flex-1 px-5 py-3">
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
409
|
+
<div className="mb-2 flex items-center gap-2">
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
onClick={() => fileInputRef.current?.click()}
|
|
413
|
+
className="flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 text-xs font-medium text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
|
|
414
|
+
>
|
|
415
|
+
<Upload className="h-3.5 w-3.5" />
|
|
416
|
+
Import YAML file
|
|
417
|
+
</button>
|
|
418
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
419
|
+
or drop a YAML file into the editor
|
|
420
|
+
</span>
|
|
421
|
+
<input
|
|
422
|
+
ref={fileInputRef}
|
|
423
|
+
type="file"
|
|
424
|
+
accept={YAML_FILE_ACCEPT}
|
|
425
|
+
className="hidden"
|
|
426
|
+
onChange={(event) => {
|
|
427
|
+
const picked = Array.from(event.target.files ?? [])
|
|
428
|
+
// Clearing the input empties its FileList, so read it first.
|
|
429
|
+
event.target.value = ''
|
|
430
|
+
void loadFiles(picked)
|
|
431
|
+
}}
|
|
432
|
+
/>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<div
|
|
436
|
+
className="relative h-[400px]"
|
|
437
|
+
onDragEnter={handleDragEnter}
|
|
438
|
+
onDragOver={handleDragOver}
|
|
439
|
+
onDragLeave={handleDragLeave}
|
|
440
|
+
onDrop={handleDrop}
|
|
441
|
+
>
|
|
442
|
+
<YamlEditor
|
|
443
|
+
value={yaml}
|
|
444
|
+
onChange={setYaml}
|
|
445
|
+
height="400px"
|
|
446
|
+
onValidate={handleValidate}
|
|
447
|
+
schemaLoader={schemaLoader}
|
|
448
|
+
/>
|
|
449
|
+
{importing && (
|
|
450
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border border-theme-border bg-theme-base/85">
|
|
451
|
+
<span className="text-sm font-medium text-theme-text-primary">
|
|
452
|
+
Loading {importing}…
|
|
453
|
+
</span>
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
{drag !== 'none' && (
|
|
457
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed border-accent bg-theme-base/85">
|
|
458
|
+
<span className="flex items-center gap-2 text-sm font-medium text-theme-text-primary">
|
|
459
|
+
<Upload className="h-4 w-4" />
|
|
460
|
+
{drag === 'multiple-files'
|
|
461
|
+
? 'Drop a single YAML file'
|
|
462
|
+
: 'Drop YAML file to load'}
|
|
463
|
+
</span>
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
266
467
|
</div>
|
|
267
468
|
|
|
268
469
|
{(error || previewError) && (
|
|
@@ -362,12 +563,33 @@ export function CreateResourceDialog({
|
|
|
362
563
|
</div>
|
|
363
564
|
</>
|
|
364
565
|
)}
|
|
566
|
+
|
|
567
|
+
<ConfirmDialog
|
|
568
|
+
open={pendingImport !== null}
|
|
569
|
+
onClose={() => setPendingImport(null)}
|
|
570
|
+
onConfirm={() => {
|
|
571
|
+
// Confirming is its own decision about the editor as it stands now,
|
|
572
|
+
// so it loads under a fresh generation rather than the one the read
|
|
573
|
+
// started under, which anything since may have retired.
|
|
574
|
+
if (pendingImport) {
|
|
575
|
+
const generation = ++importGeneration.current
|
|
576
|
+
loadIntoEditor(pendingImport.fileName, pendingImport.yaml, generation)
|
|
577
|
+
}
|
|
578
|
+
setPendingImport(null)
|
|
579
|
+
}}
|
|
580
|
+
variant="warning"
|
|
581
|
+
showWarning={false}
|
|
582
|
+
title="Replace editor contents?"
|
|
583
|
+
message={`Loading ${pendingImport?.fileName ?? 'this file'} discards the YAML currently in the editor.`}
|
|
584
|
+
confirmLabel="Replace"
|
|
585
|
+
/>
|
|
365
586
|
</DialogPortal>
|
|
366
587
|
)
|
|
367
588
|
}
|
|
368
589
|
|
|
369
590
|
function ApplyErrorBanner({ error }: { error: string }) {
|
|
370
591
|
const [expanded, setExpanded] = useState(false)
|
|
592
|
+
const { panelId, buttonProps } = useDisclosure(expanded)
|
|
371
593
|
const parsed = formatApplyError(error)
|
|
372
594
|
const hasFriendly = Boolean(parsed.suggestion)
|
|
373
595
|
return (
|
|
@@ -383,19 +605,20 @@ function ApplyErrorBanner({ error }: { error: string }) {
|
|
|
383
605
|
</div>
|
|
384
606
|
{hasFriendly && (
|
|
385
607
|
<button
|
|
608
|
+
{...buttonProps}
|
|
386
609
|
type="button"
|
|
387
610
|
onClick={() => setExpanded((value) => !value)}
|
|
388
611
|
className="flex items-center gap-1 px-3 pb-2 text-red-500/60 hover:text-red-500/80 dark:text-red-400/60 dark:hover:text-red-400/80"
|
|
389
612
|
>
|
|
390
|
-
|
|
613
|
+
<CollapseChevron open={expanded} inheritColor className="h-3 w-3" />
|
|
391
614
|
Details
|
|
392
615
|
</button>
|
|
393
616
|
)}
|
|
394
|
-
{expanded && hasFriendly
|
|
617
|
+
<Collapse open={expanded && hasFriendly} id={panelId}>
|
|
395
618
|
<div className="break-all px-3 pb-2 font-mono leading-relaxed text-red-500/60 dark:text-red-400/60">
|
|
396
619
|
{parsed.raw}
|
|
397
620
|
</div>
|
|
398
|
-
|
|
621
|
+
</Collapse>
|
|
399
622
|
</div>
|
|
400
623
|
)
|
|
401
624
|
}
|
|
@@ -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
|
+
})
|