@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.
Files changed (104) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditCard.tsx +18 -7
  6. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  8. package/src/components/charts/AreaChart.test.tsx +13 -0
  9. package/src/components/charts/AreaChart.tsx +28 -25
  10. package/src/components/charts/PrometheusChartsView.tsx +41 -10
  11. package/src/components/charts/axis.test.ts +16 -0
  12. package/src/components/charts/axis.ts +3 -1
  13. package/src/components/charts/format.ts +4 -0
  14. package/src/components/charts/nearestSample.test.ts +31 -0
  15. package/src/components/charts/nearestSample.ts +15 -0
  16. package/src/components/checks/ChecksView.tsx +76 -28
  17. package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
  18. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  19. package/src/components/gitops/health-provenance.test.ts +54 -0
  20. package/src/components/gitops/health-provenance.ts +24 -0
  21. package/src/components/gitops/index.ts +2 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  24. package/src/components/gitops/tree/merge.test.ts +34 -0
  25. package/src/components/gitops/tree/merge.ts +7 -0
  26. package/src/components/issues/IssuesView.tsx +35 -54
  27. package/src/components/logs/LogCore.tsx +40 -9
  28. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  29. package/src/components/resources/ResourcesView.tsx +67 -2
  30. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  31. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  32. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  33. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  34. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  35. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  36. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  37. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  38. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  39. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  40. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  41. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  42. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  43. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  45. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  46. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  47. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  48. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  49. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  50. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  51. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  52. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  53. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  54. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  55. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  56. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  57. package/src/components/resources/renderers/index.ts +3 -0
  58. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  59. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
  60. package/src/components/resources/resource-utils.ts +18 -9
  61. package/src/components/shared/CreateResourceDialog.tsx +237 -14
  62. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  63. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  64. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  65. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  66. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  67. package/src/components/shared/index.ts +4 -0
  68. package/src/components/timeline/TimelineList.tsx +14 -11
  69. package/src/components/timeline/TimelineStrip.tsx +26 -4
  70. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  71. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  72. package/src/components/topology/TopologyControls.tsx +27 -8
  73. package/src/components/topology/TopologyGraph.tsx +14 -5
  74. package/src/components/trace/ReachabilityView.tsx +17 -16
  75. package/src/components/ui/CardSection.tsx +79 -35
  76. package/src/components/ui/Collapse.test.tsx +74 -0
  77. package/src/components/ui/Collapse.tsx +115 -29
  78. package/src/components/ui/DialogPortal.tsx +7 -2
  79. package/src/components/ui/FetchResult.test.tsx +20 -2
  80. package/src/components/ui/FetchResult.tsx +33 -3
  81. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  82. package/src/components/ui/FreshnessControl.tsx +8 -2
  83. package/src/components/ui/RestrictedState.tsx +7 -4
  84. package/src/components/ui/RowActionMenu.tsx +20 -8
  85. package/src/components/ui/SelectMenu.tsx +18 -6
  86. package/src/components/ui/YamlEditor.tsx +7 -9
  87. package/src/components/ui/drawer-components.tsx +14 -21
  88. package/src/components/ui/index.ts +30 -22
  89. package/src/components/workload/WorkloadView.tsx +8 -12
  90. package/src/hooks/useAnimatedUnmount.ts +21 -3
  91. package/src/theme/components.css +20 -1
  92. package/src/types/core.ts +15 -1
  93. package/src/types/gitops-insights.ts +23 -0
  94. package/src/types/gitops-tree.ts +29 -0
  95. package/src/utils/animation.ts +93 -13
  96. package/src/utils/index.ts +1 -0
  97. package/src/utils/navigation.test.ts +20 -1
  98. package/src/utils/navigation.ts +25 -0
  99. package/src/utils/network-policy.test.ts +42 -0
  100. package/src/utils/network-policy.ts +59 -0
  101. package/src/utils/reflector.ts +139 -0
  102. package/src/utils/yaml-file-import.test.ts +189 -0
  103. package/src/utils/yaml-file-import.ts +95 -0
  104. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -1,5 +1,7 @@
1
- import { useCallback, useEffect, useState } from 'react'
2
- import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'
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
- }, [open, initialYaml])
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
- <YamlEditor
260
- value={yaml}
261
- onChange={setYaml}
262
- height="400px"
263
- onValidate={handleValidate}
264
- schemaLoader={schemaLoader}
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
- {expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
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
+ })