@skyhook-io/k8s-ui 1.7.13 → 1.7.15

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.
@@ -25,6 +25,8 @@ import {
25
25
  GitCompare,
26
26
  Regex,
27
27
  ListChecks,
28
+ Minus,
29
+ Scale,
28
30
  } from 'lucide-react'
29
31
  import { clsx } from 'clsx'
30
32
  import { ResourceBar } from '../ui/ResourceBar'
@@ -165,6 +167,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
165
167
  const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
166
168
  const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'] as const
167
169
  const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
170
+ const BULK_RESTART_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts'])
171
+ const BULK_SCALE_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets'])
168
172
 
169
173
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
170
174
  export const SKIP_FILTER_COLUMNS = new Set([
@@ -203,6 +207,8 @@ interface Column {
203
207
  minWidth?: number // minimum width in px
204
208
  }
205
209
 
210
+ type BulkResourceItem = { kind: string; group?: string; namespace: string; name: string }
211
+
206
212
  /**
207
213
  * Extra column injected by the parent — for example, a leading "Cluster"
208
214
  * column when the table is rendered inside a multi-cluster host.
@@ -1906,8 +1912,12 @@ interface ResourcesViewProps {
1906
1912
  */
1907
1913
  onClearNamespaces?: () => void
1908
1914
  // Bulk operations
1909
- onBulkDelete?: (items: Array<{ kind: string; group?: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
1915
+ onBulkDelete?: (items: BulkResourceItem[], options?: { force?: boolean; onSuccess?: () => void }) => void
1910
1916
  isBulkDeleting?: boolean
1917
+ onBulkRestart?: (items: BulkResourceItem[], options?: { onSuccess?: () => void }) => void
1918
+ isBulkRestarting?: boolean
1919
+ onBulkScale?: (items: BulkResourceItem[], replicas: number, options?: { onSuccess?: () => void }) => void
1920
+ isBulkScaling?: boolean
1911
1921
  }
1912
1922
 
1913
1923
  // Default selected kind
@@ -2058,6 +2068,10 @@ export function ResourcesView({
2058
2068
  onClearNamespaces,
2059
2069
  onBulkDelete,
2060
2070
  isBulkDeleting = false,
2071
+ onBulkRestart,
2072
+ isBulkRestarting = false,
2073
+ onBulkScale,
2074
+ isBulkScaling = false,
2061
2075
  }: ResourcesViewProps) {
2062
2076
  const initialFilters = getInitialFiltersFromURL()
2063
2077
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
@@ -2077,6 +2091,10 @@ export function ResourcesView({
2077
2091
  onSelectedKindChange?.(selectedKind)
2078
2092
  setBulkMode(false)
2079
2093
  setCheckedResources(new Set())
2094
+ setShowBulkDeleteConfirm(false)
2095
+ setShowBulkRestartConfirm(false)
2096
+ setShowBulkScaleDialog(false)
2097
+ setBulkForceDelete(false)
2080
2098
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
2081
2099
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
2082
2100
  const [regexMode, setRegexMode] = useState(false)
@@ -2112,12 +2130,15 @@ export function ResourcesView({
2112
2130
  const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)
2113
2131
 
2114
2132
  // Multi-select state for bulk operations. Checkboxes only render while
2115
- // bulk mode is active — entered via the toolbar toggle — so the risky
2116
- // bulk-delete surface stays out of the way during normal browsing.
2133
+ // bulk mode is active — entered via the toolbar toggle — so mutating
2134
+ // actions stay out of the way during normal browsing.
2117
2135
  const [bulkMode, setBulkMode] = useState(false)
2118
2136
  const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
2119
2137
  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
2138
+ const [showBulkRestartConfirm, setShowBulkRestartConfirm] = useState(false)
2139
+ const [showBulkScaleDialog, setShowBulkScaleDialog] = useState(false)
2120
2140
  const [bulkForceDelete, setBulkForceDelete] = useState(false)
2141
+ const [bulkScaleReplicas, setBulkScaleReplicas] = useState(0)
2121
2142
 
2122
2143
  const exitBulkMode = useCallback(() => {
2123
2144
  setBulkMode(false)
@@ -3652,13 +3673,44 @@ export function ResourcesView({
3652
3673
  return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
3653
3674
  }, [filteredResources, checkedResources, getResourceKey])
3654
3675
 
3676
+ const checkedBulkItems = useMemo(() => {
3677
+ return checkedItems.map(r => ({
3678
+ kind: selectedKind.name,
3679
+ group: selectedKind.group,
3680
+ namespace: r.metadata?.namespace || '',
3681
+ name: r.metadata?.name || '',
3682
+ }))
3683
+ }, [checkedItems, selectedKind.name, selectedKind.group])
3684
+
3685
+ const checkedItemDetails = useMemo(() => {
3686
+ return checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')
3687
+ }, [checkedItems])
3688
+
3689
+ const selectedKindName = selectedKind.name.toLowerCase()
3690
+ const canBulkRestartSelectedKind = onBulkRestart != null && BULK_RESTART_WORKLOAD_KINDS.has(selectedKindName)
3691
+ const canBulkScaleSelectedKind = onBulkScale != null && BULK_SCALE_WORKLOAD_KINDS.has(selectedKindName)
3692
+ const canBulkSelect = onBulkDelete != null || canBulkRestartSelectedKind || canBulkScaleSelectedKind
3693
+ const isBulkMutating = isBulkDeleting || isBulkRestarting || isBulkScaling
3694
+
3695
+ const openBulkScaleDialog = useCallback(() => {
3696
+ const replicas = checkedItems[0]?.spec?.replicas
3697
+ setBulkScaleReplicas(typeof replicas === 'number' ? replicas : 0)
3698
+ setShowBulkScaleDialog(true)
3699
+ }, [checkedItems])
3700
+
3701
+ const commonBulkScaleReplicas = useMemo(() => {
3702
+ if (checkedItems.length === 0) return null
3703
+ const first = checkedItems[0]?.spec?.replicas ?? 0
3704
+ return checkedItems.every(r => (r.spec?.replicas ?? 0) === first) ? first : null
3705
+ }, [checkedItems])
3706
+
3655
3707
  const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length
3656
3708
 
3657
3709
  const toggleCheckAll = useCallback(() => {
3658
3710
  setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
3659
3711
  }, [allVisibleChecked, filteredResources, getResourceKey])
3660
3712
 
3661
- const isCheckboxMode = onBulkDelete != null && bulkMode
3713
+ const isCheckboxMode = canBulkSelect && bulkMode
3662
3714
 
3663
3715
  // Filter columns by visibility
3664
3716
  const columns = useMemo(() => {
@@ -4330,7 +4382,7 @@ export function ResourcesView({
4330
4382
  </button>
4331
4383
  </Tooltip>
4332
4384
  )}
4333
- {onBulkDelete && (
4385
+ {canBulkSelect && (
4334
4386
  <Tooltip content={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
4335
4387
  <button
4336
4388
  onClick={() => {
@@ -4358,15 +4410,41 @@ export function ResourcesView({
4358
4410
  <span className="text-sm font-medium text-theme-text-primary">
4359
4411
  {checkedItems.length} selected
4360
4412
  </span>
4413
+ {canBulkRestartSelectedKind && (
4414
+ <button
4415
+ type="button"
4416
+ onClick={() => setShowBulkRestartConfirm(true)}
4417
+ disabled={checkedItems.length === 0 || isBulkMutating}
4418
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand-muted disabled:opacity-50 disabled:pointer-events-none rounded-lg transition-colors"
4419
+ >
4420
+ <RefreshCw className={clsx('w-3.5 h-3.5', isBulkRestarting && 'animate-spin')} />
4421
+ {isBulkRestarting ? 'Restarting...' : 'Restart'}
4422
+ </button>
4423
+ )}
4424
+ {canBulkScaleSelectedKind && (
4425
+ <button
4426
+ type="button"
4427
+ onClick={openBulkScaleDialog}
4428
+ disabled={checkedItems.length === 0 || isBulkMutating}
4429
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-theme-elevated hover:bg-theme-hover disabled:opacity-50 disabled:pointer-events-none text-theme-text-primary border border-theme-border rounded-lg transition-colors"
4430
+ >
4431
+ <Scale className="w-3.5 h-3.5" />
4432
+ {isBulkScaling ? 'Scaling...' : 'Scale'}
4433
+ </button>
4434
+ )}
4435
+ {onBulkDelete && (
4436
+ <button
4437
+ type="button"
4438
+ onClick={() => setShowBulkDeleteConfirm(true)}
4439
+ disabled={checkedItems.length === 0 || isBulkMutating}
4440
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
4441
+ >
4442
+ <Trash2 className="w-3.5 h-3.5" />
4443
+ Delete
4444
+ </button>
4445
+ )}
4361
4446
  <button
4362
- onClick={() => setShowBulkDeleteConfirm(true)}
4363
- disabled={checkedItems.length === 0}
4364
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
4365
- >
4366
- <Trash2 className="w-3.5 h-3.5" />
4367
- Delete
4368
- </button>
4369
- <button
4447
+ type="button"
4370
4448
  onClick={exitBulkMode}
4371
4449
  className="px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
4372
4450
  >
@@ -4724,13 +4802,7 @@ export function ResourcesView({
4724
4802
  open={showBulkDeleteConfirm}
4725
4803
  onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
4726
4804
  onConfirm={() => {
4727
- const items = checkedItems.map(r => ({
4728
- kind: selectedKind.name,
4729
- group: selectedKind.group,
4730
- namespace: r.metadata?.namespace || '',
4731
- name: r.metadata?.name || '',
4732
- }))
4733
- onBulkDelete?.(items, {
4805
+ onBulkDelete?.(checkedBulkItems, {
4734
4806
  force: bulkForceDelete,
4735
4807
  onSuccess: () => {
4736
4808
  exitBulkMode()
@@ -4741,7 +4813,7 @@ export function ResourcesView({
4741
4813
  }}
4742
4814
  title={`Delete ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4743
4815
  message={`You are about to delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}. This action cannot be undone.`}
4744
- details={checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')}
4816
+ details={checkedItemDetails}
4745
4817
  confirmLabel={bulkForceDelete ? `Force Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}` : `Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}`}
4746
4818
  variant="danger"
4747
4819
  isLoading={isBulkDeleting}
@@ -4757,6 +4829,80 @@ export function ResourcesView({
4757
4829
  <span>Force delete (strips finalizers and bypasses grace period)</span>
4758
4830
  </label>
4759
4831
  </ConfirmDialog>
4832
+ <ConfirmDialog
4833
+ open={showBulkRestartConfirm}
4834
+ onClose={() => setShowBulkRestartConfirm(false)}
4835
+ onConfirm={() => {
4836
+ onBulkRestart?.(checkedBulkItems, {
4837
+ onSuccess: () => {
4838
+ exitBulkMode()
4839
+ setShowBulkRestartConfirm(false)
4840
+ },
4841
+ })
4842
+ }}
4843
+ title={`Restart ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4844
+ message={`This will trigger a rolling restart for ${checkedItems.length} selected workload${checkedItems.length > 1 ? 's' : ''}.`}
4845
+ details={checkedItemDetails}
4846
+ confirmLabel={`Restart ${checkedItems.length} workload${checkedItems.length > 1 ? 's' : ''}`}
4847
+ variant="warning"
4848
+ isLoading={isBulkRestarting}
4849
+ isClosable
4850
+ />
4851
+ <ConfirmDialog
4852
+ open={showBulkScaleDialog}
4853
+ onClose={() => setShowBulkScaleDialog(false)}
4854
+ onConfirm={() => {
4855
+ onBulkScale?.(checkedBulkItems, bulkScaleReplicas, {
4856
+ onSuccess: () => {
4857
+ exitBulkMode()
4858
+ setShowBulkScaleDialog(false)
4859
+ },
4860
+ })
4861
+ }}
4862
+ title={`Scale ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4863
+ message={`Set every selected workload to exactly ${bulkScaleReplicas} replica${bulkScaleReplicas === 1 ? '' : 's'}.`}
4864
+ details={checkedItemDetails}
4865
+ confirmLabel={`Scale to ${bulkScaleReplicas}`}
4866
+ variant={bulkScaleReplicas === 0 ? 'danger' : 'warning'}
4867
+ isLoading={isBulkScaling}
4868
+ isClosable
4869
+ >
4870
+ <div className="space-y-3">
4871
+ <div className="flex items-center justify-center gap-3">
4872
+ <button
4873
+ type="button"
4874
+ onClick={() => setBulkScaleReplicas(Math.max(0, bulkScaleReplicas - 1))}
4875
+ disabled={bulkScaleReplicas <= 0}
4876
+ className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
4877
+ >
4878
+ <Minus className="w-5 h-5" />
4879
+ </button>
4880
+ <input
4881
+ type="number"
4882
+ min="0"
4883
+ max="10000"
4884
+ value={bulkScaleReplicas}
4885
+ onChange={(e) => setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))}
4886
+ className="w-24 text-center text-2xl font-semibold bg-theme-elevated border border-theme-border rounded-lg py-2 text-theme-text-primary focus:outline-none focus:border-skyhook-500"
4887
+ autoFocus
4888
+ />
4889
+ <button
4890
+ type="button"
4891
+ onClick={() => setBulkScaleReplicas(Math.min(10000, bulkScaleReplicas + 1))}
4892
+ disabled={bulkScaleReplicas >= 10000}
4893
+ className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
4894
+ >
4895
+ <Plus className="w-5 h-5" />
4896
+ </button>
4897
+ </div>
4898
+ <div className="text-xs text-theme-text-tertiary text-center">
4899
+ {commonBulkScaleReplicas === null ? 'Current replicas vary across the selected workloads.' : `Current: ${commonBulkScaleReplicas} replicas`}
4900
+ </div>
4901
+ <p className="text-xs text-theme-text-secondary text-center">
4902
+ All selected workloads will be set to the same replica count. Autoscalers may override it.
4903
+ </p>
4904
+ </div>
4905
+ </ConfirmDialog>
4760
4906
  </ResourcesViewDataContext.Provider>
4761
4907
  )
4762
4908
  }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getPodProblems } from './resource-utils'
3
+
4
+ describe('getPodProblems', () => {
5
+ it('includes the pod status message for evicted pods', () => {
6
+ const detail = 'Usage of EmptyDir volume "logs-nginx" exceeds the limit "2Gi".'
7
+
8
+ expect(
9
+ getPodProblems({
10
+ status: {
11
+ phase: 'Failed',
12
+ reason: 'Evicted',
13
+ message: detail,
14
+ },
15
+ }),
16
+ ).toContainEqual({ severity: 'high', message: 'Evicted', detail })
17
+ })
18
+
19
+ it('keeps exit-code labels stable while surfacing terminated messages', () => {
20
+ const detail = 'Container process exited after receiving SIGKILL.'
21
+
22
+ expect(
23
+ getPodProblems({
24
+ status: {
25
+ phase: 'Running',
26
+ containerStatuses: [
27
+ {
28
+ name: 'api',
29
+ restartCount: 0,
30
+ state: {
31
+ terminated: {
32
+ exitCode: 137,
33
+ reason: 'Error',
34
+ message: detail,
35
+ },
36
+ },
37
+ },
38
+ ],
39
+ },
40
+ }),
41
+ ).toContainEqual({ severity: 'high', message: 'Exit Code 137', detail })
42
+ })
43
+
44
+ it('keeps waiting-state labels stable while surfacing kubelet messages', () => {
45
+ const detail = 'Back-off pulling image "registry.example.com/api:missing".'
46
+
47
+ expect(
48
+ getPodProblems({
49
+ status: {
50
+ phase: 'Pending',
51
+ containerStatuses: [
52
+ {
53
+ name: 'api',
54
+ restartCount: 0,
55
+ state: {
56
+ waiting: {
57
+ reason: 'ImagePullBackOff',
58
+ message: detail,
59
+ },
60
+ },
61
+ },
62
+ ],
63
+ },
64
+ }),
65
+ ).toContainEqual({ severity: 'critical', message: 'ImagePullBackOff', detail })
66
+ })
67
+
68
+ it('infers sandbox startup stalls only for scheduled pods and follows backend severity gates', () => {
69
+ expect(
70
+ getPodProblems({
71
+ metadata: { creationTimestamp: new Date(Date.now() - 20 * 60 * 1000).toISOString() },
72
+ spec: { nodeName: 'worker-1' },
73
+ status: {
74
+ phase: 'Pending',
75
+ containerStatuses: [
76
+ {
77
+ name: 'api',
78
+ restartCount: 0,
79
+ state: { waiting: { reason: 'ContainerCreating' } },
80
+ },
81
+ ],
82
+ },
83
+ }),
84
+ ).toContainEqual({ severity: 'high', message: 'Sandbox Startup Stalled' })
85
+
86
+ expect(
87
+ getPodProblems({
88
+ metadata: { creationTimestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString() },
89
+ spec: { nodeName: 'worker-1' },
90
+ status: {
91
+ phase: 'Pending',
92
+ containerStatuses: [
93
+ {
94
+ name: 'api',
95
+ restartCount: 0,
96
+ state: { waiting: { reason: 'ContainerCreating' } },
97
+ },
98
+ ],
99
+ },
100
+ }),
101
+ ).toContainEqual({ severity: 'critical', message: 'Sandbox Startup Stalled' })
102
+
103
+ expect(
104
+ getPodProblems({
105
+ metadata: { creationTimestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString() },
106
+ spec: { nodeName: 'worker-1' },
107
+ status: {
108
+ phase: 'Pending',
109
+ conditions: [
110
+ {
111
+ type: 'PodScheduled',
112
+ status: 'False',
113
+ reason: 'Unschedulable',
114
+ },
115
+ ],
116
+ containerStatuses: [
117
+ {
118
+ name: 'api',
119
+ restartCount: 0,
120
+ state: { waiting: { reason: 'ContainerCreating' } },
121
+ },
122
+ ],
123
+ },
124
+ }),
125
+ ).not.toContainEqual(expect.objectContaining({ message: 'Sandbox Startup Stalled' }))
126
+ })
127
+ })
@@ -51,3 +51,59 @@ describe('PodRenderer envFrom expansion', () => {
51
51
  expect(html).not.toContain('PUBLIC_URL<!-- -->=')
52
52
  })
53
53
  })
54
+
55
+ describe('PodRenderer issues banner', () => {
56
+ it('renders pod status messages for evicted pods', () => {
57
+ const html = renderToString(
58
+ <PodRenderer
59
+ data={{
60
+ metadata: { name: 'nginx', namespace: 'default' },
61
+ spec: { containers: [{ name: 'nginx', image: 'nginx:latest' }] },
62
+ status: {
63
+ phase: 'Failed',
64
+ reason: 'Evicted',
65
+ message: 'Usage of EmptyDir volume "logs-nginx" exceeds the limit "2Gi".',
66
+ },
67
+ }}
68
+ onCopy={() => undefined}
69
+ copied={null}
70
+ />,
71
+ )
72
+
73
+ expect(html).toContain('Issues Detected')
74
+ expect(html).toContain('Evicted')
75
+ expect(html).toContain('Usage of EmptyDir volume')
76
+ expect(html).toContain('exceeds the limit')
77
+ })
78
+
79
+ it('wraps long issue detail text inside the banner', () => {
80
+ const html = renderToString(
81
+ <PodRenderer
82
+ data={{
83
+ metadata: { name: 'api', namespace: 'default' },
84
+ spec: { containers: [{ name: 'api', image: 'registry.example.com/api:missing' }] },
85
+ status: {
86
+ phase: 'Pending',
87
+ containerStatuses: [
88
+ {
89
+ name: 'api',
90
+ restartCount: 0,
91
+ state: {
92
+ waiting: {
93
+ reason: 'ImagePullBackOff',
94
+ message: `Back-off pulling image "${'a'.repeat(240)}"`,
95
+ },
96
+ },
97
+ },
98
+ ],
99
+ },
100
+ }}
101
+ onCopy={() => undefined}
102
+ copied={null}
103
+ />,
104
+ )
105
+
106
+ expect(html).toContain('ImagePullBackOff')
107
+ expect(html).toContain('min-w-0 break-words')
108
+ })
109
+ })
@@ -347,7 +347,7 @@ export function PodRenderer({
347
347
  {podProblems.map((p, i) => (
348
348
  <li key={i} className="flex items-start gap-1.5">
349
349
  <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0 mt-1', SEVERITY_DOT_COLOR[p.severity])} />
350
- <span className="text-red-600 dark:text-red-400">
350
+ <span className="min-w-0 break-words text-red-600 dark:text-red-400">
351
351
  {p.message}
352
352
  {p.detail && <span className="text-theme-text-secondary">: {p.detail}</span>}
353
353
  </span>
@@ -267,26 +267,39 @@ export function getPodProblems(pod: any): PodProblem[] {
267
267
  const initContainerStatuses = pod.status?.initContainerStatuses || []
268
268
  const conditions = pod.status?.conditions || []
269
269
  const phase = pod.status?.phase
270
+ const podStatusMessage = pod.status?.message || undefined
271
+ const hasPodIP = Boolean(pod.status?.podIP || pod.status?.podIPs?.some((ip: any) => ip?.ip))
272
+ const hasScheduledNode = Boolean(pod.spec?.nodeName)
273
+ const hasUnschedulableCondition = conditions.some((cond: any) => cond.type === 'PodScheduled' && cond.status === 'False')
274
+ const hasContainerCreating = containerStatuses.some((cs: any) => cs.state?.waiting?.reason === 'ContainerCreating')
275
+ const createdAtMs = pod.metadata?.creationTimestamp ? new Date(pod.metadata.creationTimestamp).getTime() : NaN
276
+ const podAgeMs = Number.isFinite(createdAtMs) ? Date.now() - createdAtMs : 0
277
+ const sandboxStartupStallAgeMs = 10 * 60 * 1000
278
+ const sandboxStartupStallCriticalAgeMs = 30 * 60 * 1000
279
+ const inferredSandboxStartupStall = phase === 'Pending' && hasScheduledNode && !hasUnschedulableCondition && hasContainerCreating && !hasPodIP && podAgeMs > sandboxStartupStallAgeMs
280
+ const inferredSandboxStartupStallSeverity: PodProblem['severity'] = podAgeMs >= sandboxStartupStallCriticalAgeMs ? 'critical' : 'high'
281
+ let hasSandboxStartupStallProblem = false
270
282
 
271
283
  // Failed or Unknown phase
272
284
  if (phase === 'Failed' && pod.status?.reason !== 'Evicted') {
273
- problems.push({ severity: 'critical', message: 'Failed' })
285
+ problems.push({ severity: 'critical', message: 'Failed', detail: podStatusMessage })
274
286
  } else if (phase === 'Unknown') {
275
- problems.push({ severity: 'high', message: 'Unknown' })
287
+ problems.push({ severity: 'high', message: 'Unknown', detail: podStatusMessage })
276
288
  }
277
289
 
278
290
  // Init container failures
279
291
  for (const cs of initContainerStatuses) {
280
292
  if (cs.state?.waiting?.reason && cs.state.waiting.reason !== 'PodInitializing') {
281
293
  const reason = cs.state.waiting.reason
294
+ const detail = cs.state.waiting.message || undefined
282
295
  if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
283
- problems.push({ severity: 'critical', message: `Init: ${reason}` })
296
+ problems.push({ severity: 'critical', message: `Init: ${reason}`, detail })
284
297
  } else {
285
- problems.push({ severity: 'high', message: `Init: ${reason}` })
298
+ problems.push({ severity: 'high', message: `Init: ${reason}`, detail })
286
299
  }
287
300
  }
288
301
  if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
289
- problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}` })
302
+ problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
290
303
  }
291
304
  }
292
305
 
@@ -294,30 +307,32 @@ export function getPodProblems(pod: any): PodProblem[] {
294
307
  // Check waiting state
295
308
  if (cs.state?.waiting?.reason) {
296
309
  const reason = cs.state.waiting.reason
310
+ const detail = cs.state.waiting.message || undefined
297
311
  if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
298
- problems.push({ severity: 'critical', message: reason })
312
+ problems.push({ severity: 'critical', message: reason, detail })
299
313
  } else if (reason === 'CreateContainerConfigError') {
300
- problems.push({ severity: 'critical', message: 'Config Error' })
314
+ problems.push({ severity: 'critical', message: 'Config Error', detail })
301
315
  } else if (reason === 'ContainerCannotRun') {
302
- problems.push({ severity: 'critical', message: 'Cannot Run' })
316
+ problems.push({ severity: 'critical', message: 'Cannot Run', detail })
303
317
  } else if (reason !== 'ContainerCreating' && reason !== 'PodInitializing') {
304
- problems.push({ severity: 'high', message: reason })
318
+ problems.push({ severity: 'high', message: reason, detail })
305
319
  }
306
320
  }
307
321
  // Check terminated state
308
322
  if (cs.state?.terminated?.reason === 'OOMKilled') {
309
- problems.push({ severity: 'critical', message: 'OOMKilled' })
323
+ problems.push({ severity: 'critical', message: 'OOMKilled', detail: cs.state.terminated.message || undefined })
310
324
  } else if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
311
- problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}` })
325
+ problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
312
326
  }
313
327
  // High restart count
314
328
  if (cs.restartCount > 5) {
315
329
  problems.push({ severity: 'medium', message: `${cs.restartCount} restarts` })
316
330
  }
317
331
  // Volume mount issues from last state
318
- const lastMsg = cs.lastState?.terminated?.message?.toLowerCase() || ''
332
+ const lastMsgRaw = cs.lastState?.terminated?.message || ''
333
+ const lastMsg = lastMsgRaw.toLowerCase()
319
334
  if (lastMsg.includes('failed to mount') || lastMsg.includes('failedattachvolume')) {
320
- problems.push({ severity: 'high', message: 'Volume Mount Failed' })
335
+ problems.push({ severity: 'high', message: 'Volume Mount Failed', detail: lastMsgRaw || undefined })
321
336
  }
322
337
  }
323
338
 
@@ -332,23 +347,29 @@ export function getPodProblems(pod: any): PodProblem[] {
332
347
  if (cond.type === 'ContainersReady' && cond.status === 'False') {
333
348
  const msg = (cond.message || '').toLowerCase()
334
349
  if (msg.includes('readiness')) {
335
- problems.push({ severity: 'medium', message: 'Readiness Probe Failing' })
350
+ problems.push({ severity: 'medium', message: 'Readiness Probe Failing', detail: cond.message || undefined })
336
351
  } else if (msg.includes('liveness')) {
337
- problems.push({ severity: 'high', message: 'Liveness Probe Failing' })
352
+ problems.push({ severity: 'high', message: 'Liveness Probe Failing', detail: cond.message || undefined })
338
353
  }
339
354
  }
340
355
  // IP allocation failures (subnet exhaustion)
341
356
  if (cond.type === 'PodReadyToStartContainers' && cond.status === 'False') {
342
357
  const msg = (cond.message || '').toLowerCase()
343
- if (msg.includes('failed to assign an ip') || msg.includes('pod sandbox')) {
344
- problems.push({ severity: 'critical', message: 'IP Allocation Failed' })
358
+ if (msg.includes('failed to assign an ip')) {
359
+ problems.push({ severity: 'critical', message: 'IP Allocation Failed', detail: cond.message || undefined })
360
+ } else if (msg.includes('pod sandbox')) {
361
+ problems.push({ severity: 'critical', message: 'Sandbox Startup Stalled', detail: cond.message || undefined })
362
+ hasSandboxStartupStallProblem = true
345
363
  }
346
364
  }
347
365
  }
366
+ if (inferredSandboxStartupStall && !hasSandboxStartupStallProblem) {
367
+ problems.push({ severity: inferredSandboxStartupStallSeverity, message: 'Sandbox Startup Stalled' })
368
+ }
348
369
 
349
370
  // Evicted pods
350
371
  if (phase === 'Failed' && pod.status?.reason === 'Evicted') {
351
- problems.push({ severity: 'high', message: 'Evicted' })
372
+ problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
352
373
  }
353
374
 
354
375
  // Stuck terminating (zombie pod)
@@ -23,7 +23,7 @@ import { ForceDeleteConfirmDialog, type CascadeDependent } from '../ui/ForceDele
23
23
  import { ConfirmDialog } from '../ui/ConfirmDialog'
24
24
  import { DialogPortal } from '../ui/DialogPortal'
25
25
  import type { SelectedResource, WorkloadRevision } from '../../types'
26
- import { formatKindName } from '../ui/drawer-components'
26
+ import { displayKindName } from '../ui/drawer-components'
27
27
  import { getDefaultContainerName } from '../resources/resource-utils'
28
28
 
29
29
  // ============================================================================
@@ -517,20 +517,20 @@ export function ResourceActionsBar({
517
517
  <Tooltip
518
518
  content={
519
519
  onCompareTo && onCompareAcrossClusters
520
- ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
520
+ ? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
521
521
  : onCompareAcrossClusters
522
522
  ? `Compare across clusters`
523
- : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
523
+ : `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
524
524
  }
525
525
  >
526
526
  <button
527
527
  onClick={onCompareTo ?? onCompareAcrossClusters}
528
528
  aria-label={
529
529
  onCompareTo && onCompareAcrossClusters
530
- ? `Compare ${formatKindName(resource.kind).toLowerCase()}`
530
+ ? `Compare ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
531
531
  : onCompareAcrossClusters
532
532
  ? `Compare across clusters`
533
- : `Compare to another ${formatKindName(resource.kind).toLowerCase()}`
533
+ : `Compare to another ${displayKindName(resource.kind, data?.kind).toLowerCase()}`
534
534
  }
535
535
  className="p-1.5 text-theme-text-secondary border border-theme-border-light rounded-lg hover:text-theme-text-primary hover:bg-theme-elevated transition-colors flex items-center"
536
536
  >
@@ -577,7 +577,7 @@ export function ResourceActionsBar({
577
577
  onClose={() => setShowDeleteConfirm(false)}
578
578
  onConfirm={handleDeleteConfirm}
579
579
  resourceName={resource.name}
580
- resourceKind={formatKindName(resource.kind)}
580
+ resourceKind={displayKindName(resource.kind, data?.kind)}
581
581
  namespaceName={resource.namespace}
582
582
  isLoading={isDeleting ?? false}
583
583
  cascadeDependents={cascadeDependents}
@@ -1,7 +1,6 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
3
3
  import { ConfirmDialog } from './ConfirmDialog'
4
- import { formatKindName } from './drawer-components'
5
4
  import { pluralize } from '../../utils/pluralize'
6
5
 
7
6
  export interface CascadeDependent {
@@ -115,7 +114,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
115
114
  <div className="px-3 pb-2.5 space-y-1.5">
116
115
  {grouped.map(([kind, names]) => (
117
116
  <div key={kind} className="text-xs">
118
- <span className="font-medium text-theme-text-primary">{formatKindName(kind)}</span>
117
+ <span className="font-medium text-theme-text-primary">{kind}</span>
119
118
  <span className="text-theme-text-tertiary ml-1">({names.length})</span>
120
119
  <div className="ml-3 mt-0.5 text-theme-text-secondary font-mono break-all">
121
120
  {names.slice(0, MAX_NAMES_PER_KIND).join(', ')}