@skyhook-io/k8s-ui 1.1.1 → 1.2.0

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 (41) hide show
  1. package/package.json +6 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +136 -24
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  16. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  17. package/src/components/resources/renderers/index.ts +2 -0
  18. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  19. package/src/components/resources/resource-utils-contour.ts +34 -0
  20. package/src/components/resources/resource-utils.ts +91 -0
  21. package/src/components/shared/ResourceActionsBar.tsx +135 -2
  22. package/src/components/shared/ResourceRendererDispatch.tsx +23 -5
  23. package/src/components/timeline/shared.tsx +8 -10
  24. package/src/components/topology/K8sResourceNode.tsx +1 -0
  25. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  26. package/src/components/topology/layout.ts +1 -0
  27. package/src/components/topology/topology.css +1 -0
  28. package/src/components/ui/CodeViewer.tsx +1 -2
  29. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  30. package/src/components/ui/Toast.tsx +5 -5
  31. package/src/components/ui/drawer-components.tsx +28 -22
  32. package/src/components/workload/WorkloadView.tsx +11 -3
  33. package/src/theme/index.ts +3 -0
  34. package/src/theme/tailwind-theme.css +61 -0
  35. package/src/theme/variables.css +121 -0
  36. package/src/types/core.ts +12 -0
  37. package/src/utils/animation.ts +1 -1
  38. package/src/utils/badge-colors.ts +158 -123
  39. package/src/utils/log-format.ts +127 -35
  40. package/src/utils/resource-hierarchy.ts +5 -0
  41. package/src/utils/resource-icons.ts +3 -0
@@ -6,6 +6,8 @@ import {
6
6
  Trash2,
7
7
  Play,
8
8
  Pause,
9
+ Ban,
10
+ AlertTriangle,
9
11
  Box,
10
12
  ChevronDown,
11
13
  History,
@@ -16,7 +18,8 @@ import {
16
18
  } from 'lucide-react'
17
19
  import { createTwoFilesPatch } from 'diff'
18
20
  import { clsx } from 'clsx'
19
- import { ForceDeleteConfirmDialog } from '../ui/ForceDeleteConfirmDialog'
21
+ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDeleteConfirmDialog'
22
+ import { ConfirmDialog } from '../ui/ConfirmDialog'
20
23
  import { DialogPortal } from '../ui/DialogPortal'
21
24
  import type { SelectedResource, WorkloadRevision } from '../../types'
22
25
  import { formatKindName } from '../ui/drawer-components'
@@ -50,6 +53,8 @@ interface ResourceActionsBarProps {
50
53
  // Delete
51
54
  onDelete?: (params: { kind: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
52
55
  isDeleting?: boolean
56
+ cascadeDependents?: CascadeDependent[]
57
+ cascadeLoading?: boolean
53
58
 
54
59
  // Workload restart
55
60
  onRestart?: (params: { kind: string; namespace: string; name: string }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
@@ -89,6 +94,18 @@ interface ResourceActionsBarProps {
89
94
  isArgoSuspending?: boolean
90
95
  onArgoResume?: (params: { namespace: string; name: string }) => void
91
96
  isArgoResuming?: boolean
97
+
98
+ // Node debug shell
99
+ onOpenNodeTerminal?: (params: { nodeName: string }) => void
100
+
101
+ // Node operations (cordon/uncordon/drain)
102
+ canNodeWrite?: boolean
103
+ onCordonNode?: (params: { name: string }) => void
104
+ isCordoningNode?: boolean
105
+ onUncordonNode?: (params: { name: string }) => void
106
+ isUncordoningNode?: boolean
107
+ onDrainNode?: (params: { name: string; options?: { deleteEmptyDirData?: boolean; force?: boolean } }) => void
108
+ isDrainingNode?: boolean
92
109
  }
93
110
 
94
111
  export function ResourceActionsBar({
@@ -96,7 +113,7 @@ export function ResourceActionsBar({
96
113
  canExec, canViewLogs, canPortForward,
97
114
  onOpenTerminal, onOpenLogs: openLogs, onOpenWorkloadLogs: openWorkloadLogs, onCopyCommand,
98
115
  renderPortForward,
99
- onDelete, isDeleting,
116
+ onDelete, isDeleting, cascadeDependents, cascadeLoading,
100
117
  onRestart, isRestarting,
101
118
  revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
102
119
  onTriggerCronJob, isTriggeringCronJob,
@@ -110,12 +127,22 @@ export function ResourceActionsBar({
110
127
  onArgoRefresh, isArgoRefreshing,
111
128
  onArgoSuspend, isArgoSuspending,
112
129
  onArgoResume, isArgoResuming,
130
+ onOpenNodeTerminal,
131
+ canNodeWrite,
132
+ onCordonNode, isCordoningNode,
133
+ onUncordonNode, isUncordoningNode,
134
+ onDrainNode, isDrainingNode,
113
135
  }: ResourceActionsBarProps) {
114
136
  const kind = resource.kind.toLowerCase()
115
137
 
116
138
  // Delete confirmation state
117
139
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
118
140
 
141
+ // Node operation confirmation state
142
+ const [showCordonConfirm, setShowCordonConfirm] = useState(false)
143
+ const [showDrainConfirm, setShowDrainConfirm] = useState(false)
144
+ const [drainForce, setDrainForce] = useState(false)
145
+
119
146
  // Rollback dialog state
120
147
  const [showRevisions, setShowRevisions] = useState(false)
121
148
  const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind)
@@ -227,6 +254,60 @@ export function ResourceActionsBar({
227
254
  </>
228
255
  )}
229
256
 
257
+ {/* Node actions */}
258
+ {kind === 'nodes' && (
259
+ <>
260
+ {canExec && onOpenNodeTerminal && (
261
+ <button
262
+ onClick={() => onOpenNodeTerminal({ nodeName: resource.name })}
263
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
264
+ >
265
+ <Terminal className="w-3.5 h-3.5" />
266
+ Debug Shell
267
+ </button>
268
+ )}
269
+
270
+ {canNodeWrite && (
271
+ <>
272
+ {data?.spec?.unschedulable ? (
273
+ onUncordonNode && (
274
+ <button
275
+ onClick={() => onUncordonNode({ name: resource.name })}
276
+ disabled={isUncordoningNode}
277
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50"
278
+ >
279
+ <Play className={`w-3.5 h-3.5 ${isUncordoningNode ? 'animate-pulse' : ''}`} />
280
+ {isUncordoningNode ? 'Uncordoning...' : 'Uncordon'}
281
+ </button>
282
+ )
283
+ ) : (
284
+ onCordonNode && (
285
+ <button
286
+ onClick={() => setShowCordonConfirm(true)}
287
+ disabled={isCordoningNode}
288
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-lg transition-colors disabled:opacity-50"
289
+ >
290
+ <Ban className={`w-3.5 h-3.5 ${isCordoningNode ? 'animate-pulse' : ''}`} />
291
+ {isCordoningNode ? 'Cordoning...' : 'Cordon'}
292
+ </button>
293
+ )
294
+ )}
295
+
296
+ {onDrainNode && (
297
+ <button
298
+ onClick={() => setShowDrainConfirm(true)}
299
+ disabled={isDrainingNode}
300
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
301
+ >
302
+ <AlertTriangle className={`w-3.5 h-3.5 ${isDrainingNode ? 'animate-pulse' : ''}`} />
303
+ {isDrainingNode ? 'Draining...' : 'Drain'}
304
+ </button>
305
+ )}
306
+ </>
307
+ )}
308
+ </>
309
+ )}
310
+
230
311
  {/* Service actions */}
231
312
  {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && canPortForward && resource.namespace && resource.name && renderPortForward && (
232
313
  renderPortForward({
@@ -419,8 +500,60 @@ export function ResourceActionsBar({
419
500
  resourceKind={formatKindName(resource.kind)}
420
501
  namespaceName={resource.namespace}
421
502
  isLoading={isDeleting ?? false}
503
+ cascadeDependents={cascadeDependents}
504
+ cascadeLoading={cascadeLoading}
505
+ />
506
+
507
+ {/* Node cordon confirmation */}
508
+ <ConfirmDialog
509
+ open={showCordonConfirm}
510
+ onClose={() => setShowCordonConfirm(false)}
511
+ onConfirm={() => {
512
+ onCordonNode?.({ name: resource.name })
513
+ setShowCordonConfirm(false)
514
+ }}
515
+ title="Cordon Node"
516
+ message={`Mark node "${resource.name}" as unschedulable? No new pods will be scheduled on this node.`}
517
+ confirmLabel="Cordon"
518
+ variant="warning"
519
+ isLoading={isCordoningNode}
422
520
  />
423
521
 
522
+ {/* Node drain confirmation */}
523
+ <ConfirmDialog
524
+ open={showDrainConfirm}
525
+ onClose={() => {
526
+ setShowDrainConfirm(false)
527
+ setDrainForce(false)
528
+ }}
529
+ onConfirm={() => {
530
+ onDrainNode?.({
531
+ name: resource.name,
532
+ options: { deleteEmptyDirData: true, force: drainForce || undefined },
533
+ })
534
+ setShowDrainConfirm(false)
535
+ setDrainForce(false)
536
+ }}
537
+ title="Drain Node"
538
+ message={`Cordon and evict all pods from node "${resource.name}"? DaemonSet pods will be skipped.`}
539
+ confirmLabel={isDrainingNode ? 'Draining...' : 'Drain'}
540
+ variant="danger"
541
+ isLoading={isDrainingNode}
542
+ isClosable
543
+ >
544
+ <div className="flex flex-col gap-2 text-sm text-theme-text-secondary">
545
+ <label className="flex items-center gap-2 cursor-pointer">
546
+ <input
547
+ type="checkbox"
548
+ checked={drainForce}
549
+ onChange={(e) => setDrainForce(e.target.checked)}
550
+ className="rounded border-theme-border"
551
+ />
552
+ Force (evict pods not managed by a controller)
553
+ </label>
554
+ </div>
555
+ </ConfirmDialog>
556
+
424
557
  {showRevisions && ['deployments', 'statefulsets', 'daemonsets'].includes(kind) && (
425
558
  <RevisionHistoryDialog
426
559
  kind={resource.kind}
@@ -64,6 +64,7 @@ import {
64
64
  getKnativeConditionStatus,
65
65
  getRevisionStatus,
66
66
  } from '../resources/resource-utils-knative'
67
+ import { getHTTPProxyStatus } from '../resources/resource-utils-contour'
67
68
  import {
68
69
  PodRenderer,
69
70
  WorkloadRenderer,
@@ -169,8 +170,9 @@ import {
169
170
  RuntimeClassRenderer,
170
171
  LeaseRenderer,
171
172
  TraefikIngressRouteRenderer,
173
+ ContourHTTPProxyRenderer,
172
174
  } from '../resources/renderers'
173
- import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo } from '../../types'
175
+ import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom } from '../../types'
174
176
  import type { CopyHandler } from '../ui/drawer-components'
175
177
 
176
178
  /**
@@ -186,6 +188,7 @@ export interface RendererOverrides {
186
188
  data: any; onCopy: CopyHandler; copied: string | null
187
189
  onNavigate?: (ref: ResourceRef) => void
188
190
  onOpenLogs?: (podName: string, containerName: string) => void
191
+ resolvedEnvFrom?: ResolvedEnvFrom
189
192
  }>
190
193
  NodeRenderer?: React.ComponentType<{
191
194
  data: any; relationships?: Relationships
@@ -233,6 +236,7 @@ const KNOWN_KINDS = new Set([
233
236
  'channels', 'inmemorychannels', 'subscriptions', 'sequences', 'parallels',
234
237
  'knativeingresses', 'knativecertificates', 'serverlessservices', 'domainmappings',
235
238
  'ingressroutes', 'ingressroutetcps', 'ingressrouteudps',
239
+ 'httpproxies',
236
240
  ])
237
241
 
238
242
  // ============================================================================
@@ -255,6 +259,10 @@ interface ResourceRendererDispatchProps {
255
259
  showMetrics?: boolean
256
260
  /** When provided, container-level Logs buttons call this instead of opening the dock */
257
261
  onOpenLogs?: (podName: string, containerName: string) => void
262
+ /** Resolved ConfigMap/Secret data for envFrom expansion in PodRenderer */
263
+ resolvedEnvFrom?: ResolvedEnvFrom
264
+ /** Platform-specific renderer overrides (e.g. with hooks for metrics, exec, port-forward) */
265
+ rendererOverrides?: RendererOverrides
258
266
  /** Optional hint shown in the Events section (e.g. link to Timeline tab) */
259
267
  eventsHint?: React.ReactNode
260
268
  /** When provided, sidebar sections (related resources, events, labels, annotations, metadata) are passed to this render prop instead of being rendered inline */
@@ -265,8 +273,6 @@ interface ResourceRendererDispatchProps {
265
273
  eventsLoading?: boolean
266
274
  /** Render prop for Prometheus metrics charts — injected by the platform wrapper */
267
275
  renderMetrics?: (props: { kind: string; namespace: string; name: string }) => React.ReactNode
268
- /** Platform-specific renderer overrides (e.g. with hooks for metrics, exec, port-forward) */
269
- rendererOverrides?: RendererOverrides
270
276
  }
271
277
 
272
278
  export function ResourceRendererDispatch({
@@ -287,13 +293,13 @@ export function ResourceRendererDispatch({
287
293
  events,
288
294
  eventsLoading,
289
295
  renderMetrics,
296
+ resolvedEnvFrom,
290
297
  rendererOverrides,
291
298
  }: ResourceRendererDispatchProps) {
292
299
  const kind = resource.kind.toLowerCase()
293
300
 
294
301
  const isKnownKind = KNOWN_KINDS.has(kind)
295
302
 
296
- // Resolve renderer components — use platform override when provided, otherwise base
297
303
  const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
298
304
  const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
299
305
  const NodeComp = rendererOverrides?.NodeRenderer ?? NodeRenderer
@@ -313,7 +319,7 @@ export function ResourceRendererDispatch({
313
319
  <div className={renderSidebar ? 'lg:flex' : ''}>
314
320
  <div className={clsx('p-4 space-y-4', renderSidebar && 'lg:flex-1 lg:min-w-0')}>
315
321
  {/* Kind-specific content - delegates to modular renderers */}
316
- {kind === 'pods' && <PodComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} onOpenLogs={onOpenLogs} />}
322
+ {kind === 'pods' && <PodComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} onOpenLogs={onOpenLogs} resolvedEnvFrom={resolvedEnvFrom} />}
317
323
  {['deployments', 'statefulsets', 'daemonsets'].includes(kind) && <WorkloadComp kind={kind} data={data} onNavigate={onNavigate} />}
318
324
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
319
325
  {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} />}
@@ -426,6 +432,9 @@ export function ResourceRendererDispatch({
426
432
  {/* Traefik */}
427
433
  {(kind === 'ingressroutes' || kind === 'ingressroutetcps' || kind === 'ingressrouteudps') && <TraefikIngressRouteRenderer data={data} onNavigate={onNavigate} />}
428
434
 
435
+ {/* Contour */}
436
+ {kind === 'httpproxies' && <ContourHTTPProxyRenderer data={data} onNavigate={onNavigate} />}
437
+
429
438
  {/* Generic renderer for CRDs and unknown resource types */}
430
439
  {!isKnownKind && <GenericRenderer data={data} />}
431
440
 
@@ -538,6 +547,15 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
538
547
  if (k === 'peerauthentications') return getPeerAuthenticationStatus(data)
539
548
  if (k === 'authorizationpolicies') return getAuthorizationPolicyStatus(data)
540
549
 
550
+ // Contour HTTPProxy
551
+ if (k === 'httpproxies') {
552
+ const s = getHTTPProxyStatus(data)
553
+ if (s.status === 'healthy') return { text: s.label, color: 'bg-green-500/20 text-green-400' }
554
+ if (s.status === 'unhealthy') return { text: s.label, color: 'bg-red-500/20 text-red-400' }
555
+ if (s.status === 'degraded') return { text: s.label, color: 'bg-yellow-500/20 text-yellow-400' }
556
+ return null
557
+ }
558
+
541
559
  // Knative Revisions have custom status logic (scaled-to-zero, activating)
542
560
  if (k === 'knativerevisions') {
543
561
  const status = getRevisionStatus(data)
@@ -123,19 +123,19 @@ export function HealthSpanLegend() {
123
123
  return (
124
124
  <div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
125
125
  <span className="flex items-center gap-1">
126
- <span className="w-4 h-2 rounded-sm bg-green-500/60" />
126
+ <span className="w-4 h-2 rounded-sm bg-green-500/60 dark:bg-green-600/60" />
127
127
  <span>Healthy</span>
128
128
  </span>
129
129
  <span className="flex items-center gap-1">
130
- <span className="w-4 h-2 rounded-sm bg-blue-500/60" />
130
+ <span className="w-4 h-2 rounded-sm bg-blue-500/60 dark:bg-blue-500/60" />
131
131
  <span>Rolling</span>
132
132
  </span>
133
133
  <span className="flex items-center gap-1">
134
- <span className="w-4 h-2 rounded-sm bg-amber-500/60" />
134
+ <span className="w-4 h-2 rounded-sm bg-amber-500/60 dark:bg-[#b8861e]" />
135
135
  <span>Degraded</span>
136
136
  </span>
137
137
  <span className="flex items-center gap-1">
138
- <span className="w-4 h-2 rounded-sm bg-red-500/60" />
138
+ <span className="w-4 h-2 rounded-sm bg-red-500/60 dark:bg-red-500/60" />
139
139
  <span>Unhealthy</span>
140
140
  </span>
141
141
  </div>
@@ -402,15 +402,13 @@ export function HealthSpan({ health, left, width, title, createdBefore }: Health
402
402
  const getHealthColor = () => {
403
403
  switch (health) {
404
404
  case 'healthy':
405
- return 'bg-green-500/60'
405
+ return 'bg-green-500/60 dark:bg-green-600/60'
406
406
  case 'rolling':
407
- // Blue/purple for expected degradation during rollout
408
- return 'bg-blue-500/60'
407
+ return 'bg-blue-500/60 dark:bg-blue-500/60'
409
408
  case 'degraded':
410
- // Amber for unexpected degradation
411
- return 'bg-amber-500/60'
409
+ return 'bg-amber-500/60 dark:bg-[#b8861e]'
412
410
  case 'unhealthy':
413
- return 'bg-red-500/60'
411
+ return 'bg-red-500/60 dark:bg-red-500/60'
414
412
  default:
415
413
  // Unknown or other states
416
414
  return 'bg-gray-400/40'
@@ -134,6 +134,7 @@ export const NODE_DIMENSIONS: Record<NodeKind, { width: number; height: number }
134
134
  ServersTransportTCP: { width: 300, height: 56 },
135
135
  TLSOption: { width: 280, height: 56 },
136
136
  TLSStore: { width: 280, height: 56 },
137
+ HTTPProxy: { width: 280, height: 56 }, // Contour
137
138
  }
138
139
 
139
140
 
@@ -77,6 +77,9 @@ const RESOURCE_KINDS: {
77
77
  { kind: 'ServersTransportTCP', label: 'Transport TCP', icon: getTopologyIcon('ServersTransportTCP'), color: 'text-cyan-400', category: 'custom' },
78
78
  { kind: 'TLSOption', label: 'TLS Option', icon: getTopologyIcon('TLSOption'), color: 'text-cyan-400', category: 'custom' },
79
79
  { kind: 'TLSStore', label: 'TLS Store', icon: getTopologyIcon('TLSStore'), color: 'text-cyan-400', category: 'custom' },
80
+
81
+ // Contour
82
+ { kind: 'HTTPProxy', label: 'HTTPProxy', icon: getTopologyIcon('HTTPProxy'), color: 'text-violet-400', category: 'networking' },
80
83
  ]
81
84
 
82
85
  const CATEGORIES = [
@@ -444,6 +444,7 @@ function pickGroupName(nodes: TopologyNode[]): string {
444
444
  'ServersTransportTCP': 4,
445
445
  'TLSOption': 4,
446
446
  'TLSStore': 4,
447
+ 'HTTPProxy': 1, // Contour
447
448
  }
448
449
 
449
450
  // Sort by priority and pick the first
@@ -96,6 +96,7 @@
96
96
  .topology-icon-nodepool { background: #6366f1; }
97
97
  .topology-icon-nodeclaim { background: #6366f1; }
98
98
  .topology-icon-nodeclass { background: #6366f1; }
99
+ .topology-icon-httpproxy { background: #7c3aed; }
99
100
 
100
101
  /* Background dots - uses CSS variable for theme awareness */
101
102
  .react-flow__background pattern circle {
@@ -401,8 +401,7 @@ export function CodeViewer({
401
401
  </div>
402
402
 
403
403
  <style>{`
404
- :root[data-theme="light"] { --code-line-number: #8b949e; }
405
- :root[data-theme="dark"] { --code-line-number: #484f58; }
404
+ :root { --code-line-number: light-dark(#8b949e, #484f58); }
406
405
  .shiki-viewer pre {
407
406
  margin: 0;
408
407
  padding: 12px;
@@ -1,5 +1,14 @@
1
- import { useState } from 'react'
1
+ import { useState, useMemo } from 'react'
2
+ import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
2
3
  import { ConfirmDialog } from './ConfirmDialog'
4
+ import { formatKindName } from './drawer-components'
5
+
6
+ export interface CascadeDependent {
7
+ kind: string
8
+ namespace: string
9
+ name: string
10
+ group?: string
11
+ }
3
12
 
4
13
  interface ForceDeleteConfirmDialogProps {
5
14
  open: boolean
@@ -9,6 +18,8 @@ interface ForceDeleteConfirmDialogProps {
9
18
  resourceKind: string
10
19
  namespaceName: string
11
20
  isLoading: boolean
21
+ cascadeDependents?: CascadeDependent[]
22
+ cascadeLoading?: boolean
12
23
  }
13
24
 
14
25
  export function ForceDeleteConfirmDialog({
@@ -19,6 +30,8 @@ export function ForceDeleteConfirmDialog({
19
30
  resourceKind,
20
31
  namespaceName,
21
32
  isLoading,
33
+ cascadeDependents,
34
+ cascadeLoading,
22
35
  }: ForceDeleteConfirmDialogProps) {
23
36
  const [forceDelete, setForceDelete] = useState(false)
24
37
 
@@ -43,15 +56,76 @@ export function ForceDeleteConfirmDialog({
43
56
  variant="danger"
44
57
  isLoading={isLoading}
45
58
  >
46
- <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
47
- <input
48
- type="checkbox"
49
- checked={forceDelete}
50
- onChange={(e) => setForceDelete(e.target.checked)}
51
- className="w-4 h-4 rounded border-theme-border bg-theme-base text-red-600 focus:ring-red-500 focus:ring-offset-0"
52
- />
53
- <span>Force delete (strips finalizers and bypasses grace period)</span>
54
- </label>
59
+ <div className="flex flex-col gap-3 pb-1">
60
+ {cascadeLoading && (
61
+ <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
62
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
63
+ Checking for dependent resources...
64
+ </div>
65
+ )}
66
+
67
+ {!cascadeLoading && cascadeDependents && cascadeDependents.length > 0 && (
68
+ <CascadeDependentsList dependents={cascadeDependents} />
69
+ )}
70
+
71
+ <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
72
+ <input
73
+ type="checkbox"
74
+ checked={forceDelete}
75
+ onChange={(e) => setForceDelete(e.target.checked)}
76
+ className="w-4 h-4 rounded border-theme-border bg-theme-base text-red-600 focus:ring-red-500 focus:ring-offset-0"
77
+ />
78
+ <span>Force delete (strips finalizers and bypasses grace period)</span>
79
+ </label>
80
+ </div>
55
81
  </ConfirmDialog>
56
82
  )
57
83
  }
84
+
85
+ const MAX_NAMES_PER_KIND = 8
86
+
87
+ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[] }) {
88
+ const [expanded, setExpanded] = useState(false)
89
+
90
+ const grouped = useMemo(() => {
91
+ const map = new Map<string, string[]>()
92
+ for (const dep of dependents) {
93
+ const kind = dep.kind
94
+ if (!map.has(kind)) map.set(kind, [])
95
+ map.get(kind)!.push(dep.name)
96
+ }
97
+ return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0]))
98
+ }, [dependents])
99
+
100
+ return (
101
+ <div className="rounded border border-amber-500/30 bg-amber-500/5">
102
+ <button
103
+ type="button"
104
+ onClick={() => setExpanded(!expanded)}
105
+ className="flex items-center gap-2 w-full px-3 py-2 text-left text-xs font-medium text-amber-400 hover:bg-amber-500/10 transition-colors"
106
+ >
107
+ {expanded ? <ChevronDown className="w-3.5 h-3.5 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 shrink-0" />}
108
+ <span>
109
+ Will also delete {dependents.length} dependent {dependents.length === 1 ? 'resource' : 'resources'}
110
+ </span>
111
+ </button>
112
+
113
+ {expanded && (
114
+ <div className="px-3 pb-2.5 space-y-1.5">
115
+ {grouped.map(([kind, names]) => (
116
+ <div key={kind} className="text-xs">
117
+ <span className="font-medium text-theme-text-primary">{formatKindName(kind)}</span>
118
+ <span className="text-theme-text-tertiary ml-1">({names.length})</span>
119
+ <div className="ml-3 mt-0.5 text-theme-text-secondary font-mono break-all">
120
+ {names.slice(0, MAX_NAMES_PER_KIND).join(', ')}
121
+ {names.length > MAX_NAMES_PER_KIND && (
122
+ <span className="text-theme-text-tertiary"> +{names.length - MAX_NAMES_PER_KIND} more</span>
123
+ )}
124
+ </div>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ )}
129
+ </div>
130
+ )
131
+ }
@@ -166,7 +166,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
166
166
  isError
167
167
  ? 'bg-red-950/90 border-red-800/50'
168
168
  : isSuccess
169
- ? 'bg-emerald-950/90 border-emerald-700/50 dark:bg-emerald-950/90 dark:border-emerald-700/50'
169
+ ? 'bg-green-950/90 border-green-700/50'
170
170
  : 'bg-theme-surface border-theme-border'
171
171
  )}
172
172
  style={style}
@@ -189,13 +189,13 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
189
189
  {/* Content */}
190
190
  <div className="flex-1 min-w-0">
191
191
  <div className="flex items-center gap-2">
192
- <span className={clsx('text-sm font-medium', isError ? 'text-red-200' : isSuccess ? 'text-emerald-100' : 'text-theme-text-primary')}>
192
+ <span className={clsx('text-sm font-medium', isError ? 'text-red-200' : isSuccess ? 'text-green-100' : 'text-theme-text-primary')}>
193
193
  {toast.message}
194
194
  </span>
195
- {!isError && <Check className={clsx('w-3.5 h-3.5 shrink-0', isSuccess ? 'text-emerald-300' : 'text-green-400')} />}
195
+ {!isError && <Check className={clsx('w-3.5 h-3.5 shrink-0', isSuccess ? 'text-green-300' : 'text-green-400')} />}
196
196
  </div>
197
197
  {toast.detail && (
198
- <p className={clsx('mt-1 text-xs', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
198
+ <p className={clsx('mt-1 text-xs', isError ? 'text-red-300/80' : isSuccess ? 'text-green-300/80' : 'text-theme-text-secondary')}>
199
199
  {toast.detail}
200
200
  </p>
201
201
  )}
@@ -214,7 +214,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
214
214
  isError
215
215
  ? 'text-red-400 hover:text-red-300 hover:bg-red-900/50'
216
216
  : isSuccess
217
- ? 'text-emerald-400 hover:text-emerald-300 hover:bg-emerald-900/50'
217
+ ? 'text-green-400 hover:text-green-300 hover:bg-green-900/50'
218
218
  : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated'
219
219
  )}
220
220
  >
@@ -164,10 +164,10 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
164
164
  // ============================================================================
165
165
 
166
166
  const ALERT_COLORS = {
167
- error: { bg: 'bg-red-500/10', border: 'border-red-500/30', title: 'text-red-400', message: 'text-red-300/80', list: 'text-red-300', bullet: 'text-red-400/60' },
168
- warning: { bg: 'bg-yellow-500/10', border: 'border-yellow-500/30', title: 'text-yellow-400', message: 'text-yellow-300/80', list: 'text-yellow-300', bullet: 'text-yellow-400/60' },
169
- info: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', title: 'text-blue-400', message: 'text-blue-300/80', list: 'text-blue-300', bullet: 'text-blue-400/60' },
170
- success: { bg: 'bg-green-500/10', border: 'border-green-500/30', title: 'text-green-400', message: 'text-green-300/80', list: 'text-green-300', bullet: 'text-green-400/60' },
167
+ error: { bg: 'bg-red-500/10', border: 'border-red-500/30', title: 'text-red-700 dark:text-red-400', message: 'text-red-600/80 dark:text-red-400/80', list: 'text-red-600 dark:text-red-400', bullet: 'text-red-500/60 dark:text-red-400/60' },
168
+ warning: { bg: 'bg-yellow-500/10', border: 'border-yellow-500/30', title: 'text-yellow-700 dark:text-yellow-400', message: 'text-yellow-600/80 dark:text-yellow-400/80', list: 'text-yellow-600 dark:text-yellow-400', bullet: 'text-yellow-500/60 dark:text-yellow-400/60' },
169
+ info: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', title: 'text-blue-700 dark:text-blue-400', message: 'text-blue-600/80 dark:text-blue-400/80', list: 'text-blue-600 dark:text-blue-400', bullet: 'text-blue-500/60 dark:text-blue-400/60' },
170
+ success: { bg: 'bg-green-500/10', border: 'border-green-500/30', title: 'text-green-700 dark:text-green-400', message: 'text-green-600/80 dark:text-green-400/80', list: 'text-green-600 dark:text-green-400', bullet: 'text-green-500/60 dark:text-green-400/60' },
171
171
  } as const
172
172
 
173
173
  const DEFAULT_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -453,24 +453,24 @@ export function AppInfoSection({ data }: { data: any }) {
453
453
  export function getKindColor(kind: string): string {
454
454
  const k = kind.toLowerCase()
455
455
  // Use darker text for light mode contrast, brighter for dark mode
456
- if (k.includes('pod')) return 'bg-lime-500/20 text-lime-700 dark:text-lime-300 border-lime-500/30'
457
- if (k.includes('deployment')) return 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border-emerald-500/30'
458
- if (k.includes('service')) return 'bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30'
459
- if (k.includes('gateway')) return 'bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30'
460
- if (k.includes('route')) return 'bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30'
461
- if (k.includes('ingress')) return 'bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30'
462
- if (k.includes('configmap')) return 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30'
463
- if (k.endsWith('report')) return 'bg-rose-500/20 text-rose-700 dark:text-rose-300 border-rose-500/30'
464
- if (k.includes('secret')) return 'bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30'
465
- if (k.includes('daemonset')) return 'bg-teal-500/20 text-teal-700 dark:text-teal-300 border-teal-500/30'
466
- if (k.includes('statefulset')) return 'bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30'
467
- if (k.includes('replicaset')) return 'bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30'
468
- if (k.includes('hpa') || k.includes('horizontalpodautoscaler')) return 'bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30'
469
- if (k.includes('cronjob')) return 'bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30'
470
- if (k.includes('job')) return 'bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30'
471
- if (k.includes('node')) return 'bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30'
472
- if (k.includes('namespace')) return 'bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30'
473
- if (k.includes('persistentvolume')) return 'bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30'
456
+ if (k.includes('pod')) return 'bg-lime-500/20 text-lime-700 dark:text-lime-400 border-lime-500/30'
457
+ if (k.includes('deployment')) return 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border-emerald-500/30'
458
+ if (k.includes('service')) return 'bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500/30'
459
+ if (k.includes('gateway')) return 'bg-violet-500/20 text-violet-700 dark:text-violet-400 border-violet-500/30'
460
+ if (k.includes('route')) return 'bg-purple-500/20 text-purple-700 dark:text-purple-400 border-purple-500/30'
461
+ if (k.includes('ingress')) return 'bg-violet-500/20 text-violet-700 dark:text-violet-400 border-violet-500/30'
462
+ if (k.includes('configmap')) return 'bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/30'
463
+ if (k.endsWith('report')) return 'bg-rose-500/20 text-rose-700 dark:text-rose-400 border-rose-500/30'
464
+ if (k.includes('secret')) return 'bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/30'
465
+ if (k.includes('daemonset')) return 'bg-teal-500/20 text-teal-700 dark:text-teal-400 border-teal-500/30'
466
+ if (k.includes('statefulset')) return 'bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 border-cyan-500/30'
467
+ if (k.includes('replicaset')) return 'bg-green-500/20 text-green-700 dark:text-green-400 border-green-500/30'
468
+ if (k.includes('hpa') || k.includes('horizontalpodautoscaler')) return 'bg-pink-500/20 text-pink-700 dark:text-pink-400 border-pink-500/30'
469
+ if (k.includes('cronjob')) return 'bg-orange-500/20 text-orange-700 dark:text-orange-400 border-orange-500/30'
470
+ if (k.includes('job')) return 'bg-orange-500/20 text-orange-700 dark:text-orange-400 border-orange-500/30'
471
+ if (k.includes('node')) return 'bg-gray-500/20 text-gray-700 dark:text-gray-400 border-gray-500/30'
472
+ if (k.includes('namespace')) return 'bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500/30'
473
+ if (k.includes('persistentvolume')) return 'bg-purple-500/20 text-purple-700 dark:text-purple-400 border-purple-500/30'
474
474
  // Default color for CRDs
475
475
  return 'bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/30'
476
476
  }
@@ -485,11 +485,17 @@ export function formatKindName(kind: string): string {
485
485
  secrets: 'Secret', jobs: 'Job', cronjobs: 'CronJob', hpas: 'HPA',
486
486
  horizontalpodautoscalers: 'HPA', nodes: 'Node', namespaces: 'Namespace',
487
487
  persistentvolumeclaims: 'PVC', persistentvolumes: 'PV',
488
+ httpproxies: 'HTTPProxy',
488
489
  }
489
490
  if (names[k]) return names[k]
490
491
 
491
492
  // For unknown kinds (CRDs), use the original kind name
492
493
  // or format it nicely if it's a plural name
494
+ if (k.endsWith('ies')) {
495
+ // Handle -ies → -y (e.g., httpproxies → Httpproxy)
496
+ const singular = kind.slice(0, -3) + 'y'
497
+ return singular.charAt(0).toUpperCase() + singular.slice(1)
498
+ }
493
499
  if (kind.endsWith('s') && !kind.endsWith('ss')) {
494
500
  // Try to singularize simple plurals
495
501
  const singular = kind.slice(0, -1)