@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
@@ -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' } }))
@@ -1,3 +1,4 @@
1
+ import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
1
2
  import { clsx } from 'clsx'
2
3
  import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
3
4
  import { isArgoRolloutResource } from '../../utils/workload-rollout'
@@ -120,6 +121,8 @@ import {
120
121
  PVCRenderer,
121
122
  RolloutRenderer,
122
123
  AnalysisRunRenderer,
124
+ AnalysisTemplateRenderer,
125
+ ExperimentRenderer,
123
126
  CertificateRenderer,
124
127
  WorkflowRenderer,
125
128
  PersistentVolumeRenderer,
@@ -231,6 +234,7 @@ import {
231
234
  PriorityClassRenderer,
232
235
  RuntimeClassRenderer,
233
236
  LeaseRenderer,
237
+ LimitRangeRenderer,
234
238
  TraefikIngressRouteRenderer,
235
239
  TraefikMiddlewareRenderer,
236
240
  TraefikServiceRenderer,
@@ -426,7 +430,7 @@ const KNOWN_KINDS = new Set([
426
430
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
427
431
  'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
428
432
  'hpas', 'horizontalpodautoscalers', 'nodes', 'persistentvolumeclaims',
429
- 'rollouts', 'analysisruns', 'certificates', 'workflows', 'persistentvolumes',
433
+ 'rollouts', 'analysisruns', 'analysistemplates', 'clusteranalysistemplates', 'experiments', 'certificates', 'workflows', 'persistentvolumes',
430
434
  'storageclasses', 'certificaterequests', 'clusterissuers', 'issuers',
431
435
  'orders', 'challenges',
432
436
  'gateways', 'gatewayclasses', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'sealedsecrets', 'workflowtemplates', 'clusterworkflowtemplates',
@@ -473,7 +477,7 @@ const KNOWN_KINDS = new Set([
473
477
  'virtualservices', 'destinationrules', 'serviceentries',
474
478
  'peerauthentications', 'authorizationpolicies',
475
479
  'mutatingwebhookconfigurations', 'validatingwebhookconfigurations',
476
- 'ingressclasses', 'priorityclasses', 'runtimeclasses', 'leases',
480
+ 'ingressclasses', 'priorityclasses', 'runtimeclasses', 'leases', 'limitranges',
477
481
  'knativeservices', 'knativeconfigurations', 'knativerevisions', 'knativeroutes',
478
482
  'brokers', 'triggers', 'eventtypes', 'pingsources', 'apiserversources', 'containersources', 'sinkbindings',
479
483
  'channels', 'inmemorychannels', 'subscriptions', 'sequences', 'parallels',
@@ -678,7 +682,8 @@ export function ResourceRendererDispatch({
678
682
  kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
679
683
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
680
684
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs'
681
- || kind === 'policies' || kind === 'rollouts' || kind === 'machines' || kind === 'machinesets'
685
+ || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
686
+ || kind === 'machines' || kind === 'machinesets'
682
687
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
683
688
  const groupGatedMatched =
684
689
  (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
@@ -691,6 +696,11 @@ export function ResourceRendererDispatch({
691
696
  && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'messaging.knative.dev')))
692
697
  || (kind === 'policies' && isApiGroup(data?.apiVersion, 'kyverno.io'))
693
698
  || (kind === 'rollouts' && isArgoRolloutResource(data))
699
+ // Katib (kubeflow.org) ships its own, unrelated Experiment CRD sharing
700
+ // this plural — without this gate it got the Argo Rollouts Experiment
701
+ // renderer's mostly-empty status view instead of its actual resource
702
+ // details, the same collision shape as every other check in this block.
703
+ || (kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io'))
694
704
  || ((kind === 'machines' || kind === 'machinesets')
695
705
  && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io'))
696
706
  const groupGatedFallthrough = isGroupGatedKind && !groupGatedMatched
@@ -741,11 +751,15 @@ export function ResourceRendererDispatch({
741
751
  const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
742
752
  const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
743
753
  const RolloutComp = rendererOverrides?.RolloutRenderer ?? RolloutRenderer
754
+ const showsReflection = (kind === 'configmaps' || kind === 'secrets') && hasReflectorDetails(data, relationships?.reflection)
755
+ const reflectionRefs = showsReflection ? [relationships?.reflection?.source, ...(relationships?.reflection?.mirrors ?? [])].filter((ref): ref is ResourceRef => !!ref) : []
756
+ const withoutReflection = (refs: ResourceRef[] | undefined) => refs?.filter(ref => !reflectionRefs.some(mirror => mirror.kind === ref.kind && mirror.namespace === ref.namespace && mirror.name === ref.name && (mirror.group ?? '') === (ref.group ?? '')))
757
+ const sidebarRelationships = showsReflection && relationships ? { ...relationships, configRefs: withoutReflection(relationships.configRefs), consumers: withoutReflection(relationships.consumers) } : relationships
744
758
  const scaleBlockedBy = replicaScalers(relationships?.scalers)
745
759
 
746
760
  const sidebarContent = showCommonSections && (
747
761
  <>
748
- <RelatedResourcesSection relationships={relationships} onNavigate={onNavigate} />
762
+ <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
749
763
  {kind !== 'events' && <EventsSection events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
750
764
  <LabelsSection data={data} />
751
765
  <AnnotationsSection data={data} />
@@ -773,8 +787,8 @@ export function ResourceRendererDispatch({
773
787
  {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
774
788
  {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
775
789
  {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
776
- {kind === 'configmaps' && <ConfigMapRenderer data={data} />}
777
- {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
790
+ {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
791
+ {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
778
792
  {kind === 'jobs' && !nonCoreJobFallthrough && <JobRenderer data={data} />}
779
793
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
780
794
  {kind === 'cronworkflows' && <CronWorkflowRenderer data={data} onNavigate={onNavigate} />}
@@ -783,6 +797,8 @@ export function ResourceRendererDispatch({
783
797
  {kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
784
798
  {kind === 'rollouts' && isArgoRolloutResource(data) && <RolloutComp data={data} onNavigate={onNavigate} />}
785
799
  {kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
800
+ {(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
801
+ {kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
786
802
  {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
787
803
  {kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
788
804
  {kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
@@ -920,6 +936,7 @@ export function ResourceRendererDispatch({
920
936
  {kind === 'priorityclasses' && <PriorityClassRenderer data={data} />}
921
937
  {kind === 'runtimeclasses' && <RuntimeClassRenderer data={data} />}
922
938
  {kind === 'leases' && <LeaseRenderer data={data} />}
939
+ {kind === 'limitranges' && <LimitRangeRenderer data={data} />}
923
940
  {/* Knative Serving */}
924
941
  {(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
925
942
  {kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
@@ -1103,6 +1120,10 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1103
1120
  if (k === 'nodes') return getNodeStatus(data)
1104
1121
  if (k === 'persistentvolumeclaims') return getPVCStatus(data)
1105
1122
  if (k === 'analysisruns') return getAnalysisRunStatus(data)
1123
+ // Same collision guard as the render branch above — Katib's unrelated
1124
+ // Experiment CRD (kubeflow.org) shares this plural and doesn't report the
1125
+ // AnalysisPhase vocabulary getAnalysisRunStatus expects.
1126
+ if (k === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io')) return getAnalysisRunStatus(data)
1106
1127
  if (k === 'workflows') return getWorkflowStatus(data)
1107
1128
  if (k === 'cronworkflows') return getCronWorkflowStatus(data)
1108
1129
  if (k === 'certificates') {
@@ -1,6 +1,10 @@
1
1
  export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
2
2
  export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
3
3
  export { ResourceActionsBar, RevisionHistoryDialog, isRolloutKind } from './ResourceActionsBar'
4
+ export {
5
+ DrainPlanDialog, DrainPlanContent, canConfirmDrain, planMatches, emptyDirPodsAtRisk, DEFAULT_DRAIN_DIALOG_OPTIONS,
6
+ type DrainPlan, type DrainPlanPod, type DrainOutcome, type DrainDialogOptions,
7
+ } from './DrainPlanDialog'
4
8
  export { SetImageDialog, type ManagedImageSource, type SetImageDialogProps } from './SetImageDialog'
5
9
  export { CreateResourceDialog, type CreateResourceDialogProps, type ApplyResult } from './CreateResourceDialog'
6
10
  export { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from './ManagedByChip'