@skyhook-io/k8s-ui 1.4.0 → 1.4.2

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 (55) hide show
  1. package/package.json +2 -2
  2. package/src/components/resources/ResourcesSidebar.tsx +152 -57
  3. package/src/components/resources/ResourcesView.tsx +141 -49
  4. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  5. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  6. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  7. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  8. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  9. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  10. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
  11. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  12. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  13. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  14. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
  15. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  16. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  17. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  18. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  19. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  20. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  21. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  22. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  23. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  24. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  25. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  26. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
  27. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  28. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  29. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  30. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  31. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  32. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  33. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  34. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  35. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  36. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  38. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  39. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  40. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  41. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  42. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  43. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  44. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  45. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  46. package/src/components/resources/resource-utils.ts +131 -33
  47. package/src/components/shared/EditableYamlView.tsx +38 -3
  48. package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
  49. package/src/components/timeline/TimelineList.tsx +11 -4
  50. package/src/components/ui/Tooltip.tsx +16 -0
  51. package/src/components/ui/drawer-components.tsx +48 -1
  52. package/src/theme/variables.css +1 -1
  53. package/src/types/gitops.ts +8 -0
  54. package/src/utils/navigation.test.ts +29 -2
  55. package/src/utils/navigation.ts +45 -24
@@ -1,4 +1,4 @@
1
- import { Play, Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react'
1
+ import { Play, Clock, CheckCircle, XCircle, Loader2, SkipForward, PauseCircle } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
4
4
  import { formatAge, formatDuration } from '../resource-utils'
@@ -13,6 +13,8 @@ interface WorkflowStep {
13
13
  phase: string
14
14
  startedAt: string | null
15
15
  finishedAt: string | null
16
+ message: string | null
17
+ nodeType: string
16
18
  }
17
19
 
18
20
  function getStepDuration(step: WorkflowStep): string | null {
@@ -22,7 +24,13 @@ function getStepDuration(step: WorkflowStep): string | null {
22
24
  return formatDuration(end.getTime() - start.getTime(), true)
23
25
  }
24
26
 
25
- function StepStatusIcon({ phase }: { phase: string }) {
27
+ function StepStatusIcon({ phase, nodeType }: { phase: string; nodeType?: string }) {
28
+ if (nodeType === 'Skipped' || phase === 'Skipped') {
29
+ return <SkipForward className="w-4 h-4 text-theme-text-tertiary shrink-0" />
30
+ }
31
+ if (nodeType === 'Suspend' && phase !== 'Succeeded') {
32
+ return <PauseCircle className="w-4 h-4 text-yellow-400 shrink-0" />
33
+ }
26
34
  switch (phase) {
27
35
  case 'Succeeded':
28
36
  return <CheckCircle className="w-4 h-4 text-green-400 shrink-0" />
@@ -46,6 +54,9 @@ function getPhaseBadgeClass(phase: string): string {
46
54
  return 'status-unhealthy'
47
55
  case 'Pending':
48
56
  return 'status-degraded'
57
+ case 'Skipped':
58
+ case 'Omitted':
59
+ return 'status-unknown'
49
60
  default:
50
61
  return 'status-unknown'
51
62
  }
@@ -74,29 +85,39 @@ function getWorkflowProblems(data: any): string[] {
74
85
  problems.push(status.message || 'Workflow error')
75
86
  }
76
87
 
77
- // Check for failed nodes
88
+ // Check for failed nodes — include error messages when available
78
89
  const nodes = status.nodes || {}
79
- const failedSteps = Object.values(nodes)
80
- .filter((node: any) => node.type === 'Pod' && node.phase === 'Failed')
81
- .map((node: any) => node.displayName)
90
+ const failedNodes = Object.values(nodes)
91
+ .filter((node: any) => (node.type === 'Pod' || node.type === 'Suspend' || node.type === 'Skipped') && (node.phase === 'Failed' || node.phase === 'Error'))
82
92
 
83
- if (failedSteps.length > 0) {
84
- problems.push(`Failed steps: ${failedSteps.join(', ')}`)
93
+ if (failedNodes.length > 0) {
94
+ const withMessages = failedNodes.filter((node: any) => node.message)
95
+ if (withMessages.length > 0) {
96
+ for (const n of withMessages as any[]) {
97
+ problems.push(`${n.displayName}: ${n.message}`)
98
+ }
99
+ } else {
100
+ problems.push(`Failed steps: ${failedNodes.map((n: any) => n.displayName).join(', ')}`)
101
+ }
85
102
  }
86
103
 
87
104
  return problems
88
105
  }
89
106
 
107
+ const VISIBLE_NODE_TYPES = new Set(['Pod', 'Skipped', 'Suspend'])
108
+
90
109
  function extractSteps(data: any): WorkflowStep[] {
91
110
  const nodes = data.status?.nodes || {}
92
111
  const steps: WorkflowStep[] = Object.entries(nodes)
93
- .filter(([, node]: [string, any]) => node.type === 'Pod')
112
+ .filter(([, node]: [string, any]) => VISIBLE_NODE_TYPES.has(node.type))
94
113
  .map(([id, node]: [string, any]) => ({
95
114
  id,
96
115
  displayName: node.displayName || id,
97
- phase: node.phase || 'Pending',
116
+ phase: node.phase || (node.type === 'Skipped' ? 'Skipped' : 'Pending'),
98
117
  startedAt: node.startedAt || null,
99
118
  finishedAt: node.finishedAt || null,
119
+ message: node.message || null,
120
+ nodeType: node.type,
100
121
  }))
101
122
 
102
123
  steps.sort((a, b) => {
@@ -163,9 +184,22 @@ export function WorkflowRenderer({ data }: WorkflowRendererProps) {
163
184
  {status.startedAt && <Property label="Started" value={formatAge(status.startedAt)} />}
164
185
  <Property label="Finished" value={status.finishedAt ? formatAge(status.finishedAt) : 'Running...'} />
165
186
  {templateName && <Property label="Template" value={templateName} />}
187
+ {status.progress && (
188
+ <Property label="Progress" value={status.progress} />
189
+ )}
166
190
  {estimatedDuration != null && (
167
191
  <Property label="Estimated Duration" value={formatEstimatedDuration(estimatedDuration)} />
168
192
  )}
193
+ {status.resourcesDuration && (
194
+ <Property
195
+ label="Resource Usage"
196
+ value={
197
+ Object.entries(status.resourcesDuration as Record<string, number>)
198
+ .map(([resource, seconds]) => `${resource}: ${seconds}s`)
199
+ .join(', ')
200
+ }
201
+ />
202
+ )}
169
203
  </PropertyList>
170
204
  </Section>
171
205
 
@@ -173,13 +207,33 @@ export function WorkflowRenderer({ data }: WorkflowRendererProps) {
173
207
  {steps.length > 0 && (
174
208
  <Section title={`Steps (${steps.length})`} defaultExpanded>
175
209
  <div className="space-y-1.5">
176
- {steps.map(step => (
177
- <div key={step.id} className="flex items-center gap-2 text-sm card-inner px-3 py-2">
178
- <StepStatusIcon phase={step.phase} />
179
- <span className="flex-1 text-theme-text-primary">{step.displayName}</span>
180
- <span className="text-xs text-theme-text-secondary">{getStepDuration(step) || '-'}</span>
181
- </div>
182
- ))}
210
+ {steps.map(step => {
211
+ const isFailed = step.phase === 'Failed' || step.phase === 'Error'
212
+ const isSkipped = step.nodeType === 'Skipped' || step.phase === 'Skipped'
213
+ const isSuspend = step.nodeType === 'Suspend'
214
+ return (
215
+ <div key={step.id} className={clsx(
216
+ 'text-sm card-inner px-3 py-2',
217
+ isFailed && 'border-l-2 border-red-500'
218
+ )}>
219
+ <div className="flex items-center gap-2">
220
+ <StepStatusIcon phase={step.phase} nodeType={step.nodeType} />
221
+ <span className={clsx(
222
+ 'flex-1',
223
+ isSkipped ? 'text-theme-text-tertiary' : isSuspend ? 'text-yellow-400' : 'text-theme-text-primary'
224
+ )}>
225
+ {step.displayName}
226
+ {isSkipped && <span className="ml-1 text-xs text-theme-text-tertiary">(skipped)</span>}
227
+ {isSuspend && <span className="ml-1 text-xs text-yellow-400/70">(suspend)</span>}
228
+ </span>
229
+ <span className="text-xs text-theme-text-secondary">{getStepDuration(step) || '-'}</span>
230
+ </div>
231
+ {isFailed && step.message && (
232
+ <div className="text-xs text-red-400 mt-1 ml-6 break-all">{step.message}</div>
233
+ )}
234
+ </div>
235
+ )
236
+ })}
183
237
  </div>
184
238
  </Section>
185
239
  )}
@@ -1,4 +1,4 @@
1
- import { Play, FileText, Key, Lock } from 'lucide-react'
1
+ import { Play, FileText, Key, Lock, File, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
2
2
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
3
3
 
4
4
  interface WorkflowTemplateRendererProps {
@@ -44,6 +44,11 @@ export function WorkflowTemplateRenderer({ data }: WorkflowTemplateRendererProps
44
44
  {templates.map((template: any) => {
45
45
  const type = getTemplateType(template)
46
46
  const image = getTemplateImage(template)
47
+ const inputParams: Array<{ name: string }> = template.inputs?.parameters || []
48
+ const outputParams: Array<{ name: string }> = template.outputs?.parameters || []
49
+ const outputArtifacts: Array<{ name: string }> = template.outputs?.artifacts || []
50
+ const inputArtifacts: Array<{ name: string }> = template.inputs?.artifacts || []
51
+ const hasIO = inputParams.length > 0 || outputParams.length > 0 || outputArtifacts.length > 0 || inputArtifacts.length > 0
47
52
  return (
48
53
  <div key={template.name} className="card-inner px-3 py-2 text-sm">
49
54
  <div className="font-medium text-theme-text-primary">{template.name}</div>
@@ -53,6 +58,46 @@ export function WorkflowTemplateRenderer({ data }: WorkflowTemplateRendererProps
53
58
  {image}
54
59
  </div>
55
60
  )}
61
+ {hasIO && (
62
+ <div className="mt-2 space-y-1.5">
63
+ {(inputParams.length > 0 || inputArtifacts.length > 0) && (
64
+ <div className="flex items-start gap-1.5">
65
+ <ArrowDownToLine className="w-3 h-3 text-theme-text-tertiary mt-0.5 shrink-0" />
66
+ <div className="flex flex-wrap gap-1">
67
+ {inputParams.map((p) => (
68
+ <span key={p.name} className="badge-sm bg-theme-elevated text-theme-text-secondary">
69
+ {p.name}
70
+ </span>
71
+ ))}
72
+ {inputArtifacts.map((a) => (
73
+ <span key={a.name} className="badge-sm bg-theme-elevated text-theme-text-secondary flex items-center gap-0.5">
74
+ <File className="w-2.5 h-2.5" />
75
+ {a.name}
76
+ </span>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ )}
81
+ {(outputParams.length > 0 || outputArtifacts.length > 0) && (
82
+ <div className="flex items-start gap-1.5">
83
+ <ArrowUpFromLine className="w-3 h-3 text-theme-text-tertiary mt-0.5 shrink-0" />
84
+ <div className="flex flex-wrap gap-1">
85
+ {outputParams.map((p) => (
86
+ <span key={p.name} className="badge-sm bg-theme-elevated text-theme-text-secondary">
87
+ {p.name}
88
+ </span>
89
+ ))}
90
+ {outputArtifacts.map((a) => (
91
+ <span key={a.name} className="badge-sm bg-theme-elevated text-theme-text-secondary flex items-center gap-0.5">
92
+ <File className="w-2.5 h-2.5" />
93
+ {a.name}
94
+ </span>
95
+ ))}
96
+ </div>
97
+ </div>
98
+ )}
99
+ </div>
100
+ )}
56
101
  </div>
57
102
  )
58
103
  })}
@@ -3,6 +3,7 @@
3
3
  import { clsx } from 'clsx'
4
4
  import { Tooltip } from '../../ui/Tooltip'
5
5
  import {
6
+ CAPACITY_TYPE_BADGE,
6
7
  getNodePoolStatus,
7
8
  getNodePoolNodeClassRef,
8
9
  getNodePoolLimits,
@@ -22,9 +23,11 @@ export function NodePoolCell({ resource, column }: { resource: any; column: stri
22
23
  case 'status': {
23
24
  const status = getNodePoolStatus(resource)
24
25
  return (
25
- <span className={clsx('badge', status.color)}>
26
- {status.text}
27
- </span>
26
+ <Tooltip content={status.text}>
27
+ <span className={clsx('badge truncate max-w-[140px]', status.color)}>
28
+ {status.text}
29
+ </span>
30
+ </Tooltip>
28
31
  )
29
32
  }
30
33
  case 'nodeClass': {
@@ -70,6 +73,18 @@ export function NodeClaimCell({ resource, column }: { resource: any; column: str
70
73
  const pool = getNodeClaimNodePoolRef(resource)
71
74
  return <span className="text-sm text-theme-text-secondary">{pool}</span>
72
75
  }
76
+ case 'capacityType': {
77
+ const ct = resource.metadata?.labels?.['karpenter.sh/capacity-type'] || '-'
78
+ return (
79
+ <span className={clsx('badge-sm', CAPACITY_TYPE_BADGE[ct] || '')}>
80
+ {ct}
81
+ </span>
82
+ )
83
+ }
84
+ case 'zone': {
85
+ const zone = resource.metadata?.labels?.['topology.kubernetes.io/zone'] || '-'
86
+ return <span className="text-sm text-theme-text-secondary">{zone}</span>
87
+ }
73
88
  default:
74
89
  return <span className="text-sm text-theme-text-tertiary">-</span>
75
90
  }
@@ -166,6 +166,43 @@ export function getCNPGClusterPostgresParams(resource: any): Record<string, stri
166
166
  return resource.spec?.postgresql?.parameters || {}
167
167
  }
168
168
 
169
+ /** Per-instance replication state from status.instancesReportedState */
170
+ export interface CNPGInstanceReportedState {
171
+ podName: string
172
+ isPrimary: boolean
173
+ timelineID?: number
174
+ }
175
+
176
+ export function getCNPGClusterInstancesReportedState(resource: any): CNPGInstanceReportedState[] {
177
+ const reported = resource.status?.instancesReportedState
178
+ if (!reported || typeof reported !== 'object') return []
179
+ return Object.entries(reported).map(([podName, state]: [string, any]) => ({
180
+ podName,
181
+ isPrimary: state?.isPrimary === true,
182
+ timelineID: state?.timelineID,
183
+ }))
184
+ }
185
+
186
+ /** Certificate expirations from status.certificates.expirations */
187
+ export interface CNPGCertificateExpiry {
188
+ secretName: string
189
+ expiryDate: string
190
+ daysUntilExpiry: number
191
+ }
192
+
193
+ export function getCNPGClusterCertificateExpirations(resource: any): CNPGCertificateExpiry[] {
194
+ const expirations = resource.status?.certificates?.expirations
195
+ if (!expirations || typeof expirations !== 'object') return []
196
+ const now = new Date()
197
+ return Object.entries(expirations).map(([secretName, expiryDate]: [string, any]) => {
198
+ const expiry = new Date(expiryDate)
199
+ const daysUntilExpiry = isNaN(expiry.getTime())
200
+ ? -1 // treat unparseable dates as expired/critical
201
+ : Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
202
+ return { secretName, expiryDate: String(expiryDate), daysUntilExpiry }
203
+ })
204
+ }
205
+
169
206
  // ============================================================================
170
207
  // CNPG BACKUP UTILITIES
171
208
  // ============================================================================
@@ -342,3 +379,13 @@ export function getCNPGPoolerInstances(resource: any): string {
342
379
  export function getCNPGPoolerParameters(resource: any): Record<string, string> {
343
380
  return resource.spec?.pgbouncer?.parameters || {}
344
381
  }
382
+
383
+ export function getCNPGPoolerAuthQuery(resource: any): string | undefined {
384
+ return resource.spec?.pgbouncer?.authQuery
385
+ }
386
+
387
+ export function getCNPGPoolerAuthQuerySecret(resource: any): { name: string } | undefined {
388
+ const secret = resource.spec?.pgbouncer?.authQuerySecret
389
+ if (!secret?.name) return undefined
390
+ return { name: secret.name }
391
+ }
@@ -3,6 +3,12 @@
3
3
  import type { StatusBadge } from './resource-utils'
4
4
  import { healthColors } from './resource-utils'
5
5
 
6
+ // Shared capacity type badge colors for NodeClaim renderer and table cells
7
+ export const CAPACITY_TYPE_BADGE: Record<string, string> = {
8
+ spot: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
9
+ 'on-demand': 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
10
+ }
11
+
6
12
  // ============================================================================
7
13
  // KARPENTER NODEPOOL UTILITIES
8
14
  // ============================================================================
@@ -99,23 +99,67 @@ export function getPrometheusRuleTotalRules(resource: any): number {
99
99
  return groups.reduce((sum: number, g: any) => sum + (g.rules?.length || 0), 0)
100
100
  }
101
101
 
102
- export function getPrometheusRuleGroups(resource: any): Array<{
102
+ export interface PrometheusAlertRule {
103
+ type: 'alert'
104
+ alert: string
105
+ expr: string
106
+ for?: string
107
+ severity?: string
108
+ summary?: string
109
+ description?: string
110
+ labels?: Record<string, string>
111
+ }
112
+
113
+ export interface PrometheusRecordingRule {
114
+ type: 'recording'
115
+ record: string
116
+ expr: string
117
+ labels?: Record<string, string>
118
+ }
119
+
120
+ export type PrometheusRule = PrometheusAlertRule | PrometheusRecordingRule
121
+
122
+ export interface PrometheusRuleGroup {
103
123
  name: string
104
124
  interval?: string
105
125
  ruleCount: number
106
126
  alertCount: number
107
127
  recordCount: number
108
- }> {
128
+ rules: PrometheusRule[]
129
+ }
130
+
131
+ export function getPrometheusRuleGroups(resource: any): PrometheusRuleGroup[] {
109
132
  return (resource.spec?.groups || []).map((g: any) => {
110
- const rules = g.rules || []
111
- const alertCount = rules.filter((r: any) => r.alert).length
112
- const recordCount = rules.filter((r: any) => r.record).length
133
+ const rawRules = g.rules || []
134
+ const alertCount = rawRules.filter((r: any) => r.alert).length
135
+ const recordCount = rawRules.filter((r: any) => r.record).length
136
+ const rules: PrometheusRule[] = rawRules.map((r: any) => {
137
+ if (r.alert) {
138
+ return {
139
+ type: 'alert' as const,
140
+ alert: r.alert,
141
+ expr: r.expr || '',
142
+ for: r.for,
143
+ severity: r.labels?.severity,
144
+ summary: r.annotations?.summary,
145
+ description: r.annotations?.description,
146
+ labels: r.labels,
147
+ }
148
+ }
149
+ return {
150
+ type: 'recording' as const,
151
+ record: r.record || '',
152
+ expr: r.expr || '',
153
+ labels: r.labels,
154
+ }
155
+ })
113
156
  return {
114
157
  name: g.name,
115
158
  interval: g.interval,
116
- ruleCount: rules.length,
159
+ ruleCount: rawRules.length,
117
160
  alertCount,
118
161
  recordCount,
162
+ rules,
119
163
  }
120
164
  })
121
165
  }
@@ -42,6 +42,44 @@ export interface PodProblem {
42
42
  message: string
43
43
  }
44
44
 
45
+ /** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
46
+ export const SEVERITY_DOT_COLOR: Record<PodProblem['severity'], string> = {
47
+ critical: 'bg-red-400',
48
+ high: 'bg-orange-400',
49
+ medium: 'bg-yellow-400',
50
+ }
51
+
52
+ /** Check whether a pod's problems match a given problem category (filter chip label) */
53
+ export function podMatchesProblemCategory(problems: PodProblem[], restarts: number, category: string): boolean {
54
+ const msgs = problems.map(p => p.message)
55
+ switch (category) {
56
+ case 'CrashLoopBackOff':
57
+ return msgs.includes('CrashLoopBackOff')
58
+ case 'ImagePullBackOff':
59
+ return msgs.some(m => m.includes('ImagePull') && !m.startsWith('Init:'))
60
+ case 'OOMKilled':
61
+ return msgs.includes('OOMKilled')
62
+ case 'Unschedulable':
63
+ return msgs.includes('Unschedulable')
64
+ case 'Not Ready':
65
+ return msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe'))
66
+ case 'High Restarts':
67
+ return restarts > 5
68
+ case 'Init Failed':
69
+ return msgs.some(m => m.startsWith('Init:'))
70
+ case 'Exit Code Error':
71
+ return msgs.some(m => m.startsWith('Exit Code'))
72
+ case 'Failed':
73
+ return msgs.includes('Failed') || msgs.includes('Unknown')
74
+ case 'Other': {
75
+ const knownPatterns = ['CrashLoopBackOff', 'ImagePull', 'ErrImagePull', 'OOMKilled', 'Unschedulable', 'Not Ready', 'Probe', 'Init:', 'Exit Code', 'Failed', 'Unknown']
76
+ return problems.some(p => !knownPatterns.some(pat => p.message.includes(pat)) && p.message !== `${restarts} restarts`)
77
+ }
78
+ default:
79
+ return false
80
+ }
81
+ }
82
+
45
83
  export function getPodStatus(pod: any): StatusBadge {
46
84
  const phase = pod.status?.phase || 'Unknown'
47
85
  const containerStatuses = pod.status?.containerStatuses || []
@@ -87,7 +125,31 @@ export function getPodStatus(pod: any): StatusBadge {
87
125
  export function getPodProblems(pod: any): PodProblem[] {
88
126
  const problems: PodProblem[] = []
89
127
  const containerStatuses = pod.status?.containerStatuses || []
128
+ const initContainerStatuses = pod.status?.initContainerStatuses || []
90
129
  const conditions = pod.status?.conditions || []
130
+ const phase = pod.status?.phase
131
+
132
+ // Failed or Unknown phase
133
+ if (phase === 'Failed' && pod.status?.reason !== 'Evicted') {
134
+ problems.push({ severity: 'critical', message: 'Failed' })
135
+ } else if (phase === 'Unknown') {
136
+ problems.push({ severity: 'high', message: 'Unknown' })
137
+ }
138
+
139
+ // Init container failures
140
+ for (const cs of initContainerStatuses) {
141
+ if (cs.state?.waiting?.reason && cs.state.waiting.reason !== 'PodInitializing') {
142
+ const reason = cs.state.waiting.reason
143
+ if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
144
+ problems.push({ severity: 'critical', message: `Init: ${reason}` })
145
+ } else {
146
+ problems.push({ severity: 'high', message: `Init: ${reason}` })
147
+ }
148
+ }
149
+ if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
150
+ problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}` })
151
+ }
152
+ }
91
153
 
92
154
  for (const cs of containerStatuses) {
93
155
  // Check waiting state
@@ -99,11 +161,15 @@ export function getPodProblems(pod: any): PodProblem[] {
99
161
  problems.push({ severity: 'critical', message: 'Config Error' })
100
162
  } else if (reason === 'ContainerCannotRun') {
101
163
  problems.push({ severity: 'critical', message: 'Cannot Run' })
164
+ } else if (reason !== 'ContainerCreating' && reason !== 'PodInitializing') {
165
+ problems.push({ severity: 'high', message: reason })
102
166
  }
103
167
  }
104
168
  // Check terminated state
105
169
  if (cs.state?.terminated?.reason === 'OOMKilled') {
106
170
  problems.push({ severity: 'critical', message: 'OOMKilled' })
171
+ } else if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
172
+ problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}` })
107
173
  }
108
174
  // High restart count
109
175
  if (cs.restartCount > 5) {
@@ -142,7 +208,7 @@ export function getPodProblems(pod: any): PodProblem[] {
142
208
  }
143
209
 
144
210
  // Evicted pods
145
- if (pod.status?.phase === 'Failed' && pod.status?.reason === 'Evicted') {
211
+ if (phase === 'Failed' && pod.status?.reason === 'Evicted') {
146
212
  problems.push({ severity: 'high', message: 'Evicted' })
147
213
  }
148
214
 
@@ -156,7 +222,6 @@ export function getPodProblems(pod: any): PodProblem[] {
156
222
  }
157
223
 
158
224
  // Not ready (Running but containers not ready)
159
- const phase = pod.status?.phase
160
225
  if (phase === 'Running') {
161
226
  const readyContainers = containerStatuses.filter((c: any) => c.ready).length
162
227
  const totalContainers = containerStatuses.length
@@ -335,6 +400,70 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
335
400
  return { text: `${ready}/${desired}`, color: healthColors.unhealthy, level: 'unhealthy' }
336
401
  }
337
402
 
403
+ /** Detect problems for Deployments, StatefulSets, DaemonSets. Parallel to getPodProblems. */
404
+ export function getWorkloadProblems(resource: any, kind: string): PodProblem[] {
405
+ const problems: PodProblem[] = []
406
+ const status = resource.status || {}
407
+ const spec = resource.spec || {}
408
+ const k = kind.toLowerCase()
409
+
410
+ if (k === 'daemonsets') {
411
+ const desired = status.desiredNumberScheduled || 0
412
+ const ready = status.numberReady || 0
413
+ if (desired > 0 && ready === 0) {
414
+ problems.push({ severity: 'critical', message: 'No pods ready' })
415
+ } else if (desired > 0 && ready < desired) {
416
+ problems.push({ severity: 'high', message: `${desired - ready} pods unavailable` })
417
+ }
418
+ return problems
419
+ }
420
+
421
+ // Deployment, StatefulSet
422
+ const desired = spec.replicas ?? status.replicas ?? 0
423
+ const ready = status.readyReplicas || 0
424
+ const available = status.availableReplicas ?? ready
425
+ const updated = status.updatedReplicas || 0
426
+
427
+ if (desired === 0) return problems
428
+
429
+ if (ready === 0 && desired > 0) {
430
+ problems.push({ severity: 'critical', message: 'No pods ready' })
431
+ } else if (available < desired) {
432
+ problems.push({ severity: 'high', message: `${desired - available} pods unavailable` })
433
+ }
434
+
435
+ if (updated > 0 && updated < desired) {
436
+ problems.push({ severity: 'medium', message: 'Rollout in progress' })
437
+ }
438
+
439
+ // Check conditions for stuck rollouts (Deployment only)
440
+ if (k === 'deployments') {
441
+ const conditions = status.conditions || []
442
+ for (const cond of conditions) {
443
+ if (cond.type === 'Progressing' && cond.status === 'False' && cond.reason === 'ProgressDeadlineExceeded') {
444
+ problems.push({ severity: 'critical', message: 'Rollout stuck' })
445
+ }
446
+ }
447
+ }
448
+
449
+ return problems
450
+ }
451
+
452
+ /** Check whether a workload's problems match a given problem category */
453
+ export function workloadMatchesProblemCategory(problems: PodProblem[], category: string): boolean {
454
+ const msgs = problems.map(p => p.message)
455
+ switch (category) {
456
+ case 'Unavailable':
457
+ return msgs.some(m => m.includes('unavailable') || m.includes('No pods ready'))
458
+ case 'Rollout Stuck':
459
+ return msgs.includes('Rollout stuck')
460
+ case 'Rollout In Progress':
461
+ return msgs.includes('Rollout in progress')
462
+ default:
463
+ return false
464
+ }
465
+ }
466
+
338
467
  export function getWorkloadImages(resource: any): string[] {
339
468
  const containers = resource.spec?.template?.spec?.containers || []
340
469
  return containers.map((c: any) => {
@@ -1325,37 +1454,6 @@ export function getRoleBindingSubjectCount(rb: any): number {
1325
1454
  // WORKLOAD PROBLEM DETECTION (for table row indicators)
1326
1455
  // ============================================================================
1327
1456
 
1328
- export function getWorkloadProblems(resource: any, kind: string): string[] {
1329
- const problems: string[] = []
1330
- const status = resource.status || {}
1331
- const spec = resource.spec || {}
1332
-
1333
- if (kind === 'daemonsets') {
1334
- const ready = status.numberReady || 0
1335
- const desired = status.desiredNumberScheduled || 0
1336
- if (desired > 0 && ready < desired) {
1337
- problems.push(`${desired - ready} pods not ready`)
1338
- }
1339
- } else {
1340
- const ready = status.readyReplicas || 0
1341
- const desired = spec.replicas ?? 0
1342
- if (desired > 0 && ready < desired) {
1343
- problems.push(`${desired - ready} replicas not ready`)
1344
- }
1345
- }
1346
-
1347
- const conditions = status.conditions || []
1348
- for (const cond of conditions) {
1349
- if (cond.status === 'True' && cond.type === 'ReplicaFailure') {
1350
- problems.push('ReplicaFailure')
1351
- }
1352
- if (cond.status === 'False' && cond.type === 'Available') {
1353
- problems.push('Unavailable')
1354
- }
1355
- }
1356
-
1357
- return problems
1358
- }
1359
1457
 
1360
1458
  // ============================================================================
1361
1459
  // FORMATTING UTILITIES