@skyhook-io/k8s-ui 1.8.14 → 1.9.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 (36) hide show
  1. package/README.md +9 -0
  2. package/package.json +4 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +28 -0
  4. package/src/components/applications/ApplicationDetail.tsx +17 -0
  5. package/src/components/audit/AuditAlerts.tsx +13 -11
  6. package/src/components/audit/AuditBadgeTooltip.test.tsx +6 -0
  7. package/src/components/audit/AuditBadgeTooltip.tsx +9 -6
  8. package/src/components/audit/AuditCard.tsx +15 -13
  9. package/src/components/audit/AuditFindingsTable.tsx +33 -31
  10. package/src/components/audit/AuditSeverityPresentation.test.tsx +64 -0
  11. package/src/components/checks/types.ts +4 -4
  12. package/src/components/issues/types.ts +4 -0
  13. package/src/components/resources/ResourcesView.tsx +9 -7
  14. package/src/components/resources/get-pod-phase-display.test.ts +204 -0
  15. package/src/components/resources/resource-utils.ts +101 -0
  16. package/src/components/shared/CreateResourceDialog.tsx +307 -172
  17. package/src/components/shared/EditableYamlView.tsx +197 -26
  18. package/src/components/timeline/TimelineSwimlanes.tsx +28 -15
  19. package/src/components/timeline/index.ts +0 -2
  20. package/src/components/timeline/timeline-live.test.ts +0 -37
  21. package/src/components/timeline/timeline-live.ts +2 -24
  22. package/src/components/topology/K8sResourceNode.tsx +11 -9
  23. package/src/components/ui/FilterPill.tsx +3 -1
  24. package/src/components/ui/YamlEditor.test.ts +148 -0
  25. package/src/components/ui/YamlEditor.tsx +759 -186
  26. package/src/components/ui/YamlReview.test.ts +45 -0
  27. package/src/components/ui/YamlReview.tsx +426 -0
  28. package/src/components/ui/index.ts +9 -0
  29. package/src/components/ui/monacoRuntime.ts +41 -0
  30. package/src/components/ui/monacoYaml.worker.ts +1 -0
  31. package/src/components/ui/yamlMonacoRuntime.ts +110 -0
  32. package/src/components/workload/WorkloadView.tsx +27 -2
  33. package/src/monaco-deep.d.ts +5 -0
  34. package/src/types/core.ts +8 -0
  35. package/src/utils/yaml.test.ts +100 -2
  36. package/src/utils/yaml.ts +86 -1
@@ -133,13 +133,14 @@ import {
133
133
  podMatchesProblemCategory,
134
134
  SEVERITY_DOT_COLOR,
135
135
  } from './resource-utils'
136
- import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
136
+ import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
137
137
  import { pluralize } from '../../utils/pluralize'
138
138
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
139
139
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
140
140
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
141
141
  import { Tooltip } from '../ui/Tooltip'
142
142
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
143
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
143
144
  // CRD-specific cell components (extracted)
144
145
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
145
146
  import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
@@ -1829,8 +1830,8 @@ interface ResourcesViewData {
1829
1830
  onNavigate?: (path: string, options?: { replace?: boolean }) => void
1830
1831
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1831
1832
  certExpiryError?: boolean
1832
- // Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
1833
- // list shows one kind at a time, so ns/name is unambiguous). Host-injected.
1833
+ /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
1834
+ * counts render danger as High and warning as Medium. */
1834
1835
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1835
1836
  onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
1836
1837
  onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
@@ -1889,7 +1890,8 @@ interface ResourcesViewProps {
1889
1890
  topNodeMetrics?: TopNodeMetrics[]
1890
1891
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1891
1892
  certExpiryError?: boolean
1892
- // Cluster Audit findings for the selected kind, keyed by "namespace/name".
1893
+ /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
1894
+ * counts render danger as High and warning as Medium. */
1893
1895
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1894
1896
  // Pinned kinds
1895
1897
  pinned?: Array<{ name: string; kind: string; group: string }>
@@ -5381,8 +5383,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5381
5383
  {auditTotal > 0 && audit && (
5382
5384
  <Tooltip content={audit.messages && audit.messages.length > 0
5383
5385
  ? <AuditBadgeTooltip messages={audit.messages} />
5384
- : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
5385
- <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>
5386
+ : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} high` : ''}${audit.warning > 0 ? ` · ${audit.warning} medium` : ''}`}>
5387
+ <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT_CLASS.high : SEVERITY_TEXT_CLASS.medium)}>
5386
5388
  <AlertTriangle className="w-3 h-3" />
5387
5389
  {auditTotal}
5388
5390
  </span>
@@ -6265,7 +6267,7 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
6265
6267
  const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
6266
6268
  if (flagged && flagged.danger + flagged.warning > 0) {
6267
6269
  return (
6268
- <span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
6270
+ <span className={clsx('badge', flagged.danger > 0 ? 'status-alert' : 'status-degraded')}>
6269
6271
  No endpoints
6270
6272
  </span>
6271
6273
  )
@@ -26,6 +26,210 @@ describe('getPodStatus', () => {
26
26
  }
27
27
  expect(getPodStatus(crashing).level).toBe('unhealthy')
28
28
  })
29
+
30
+ it('deranks a serving container with unsettled crash history', () => {
31
+ const recovered = {
32
+ status: {
33
+ phase: 'Running',
34
+ containerStatuses: [{
35
+ name: 'app',
36
+ ready: true,
37
+ restartCount: 2,
38
+ state: { running: {} },
39
+ lastState: { terminated: { reason: 'Error', exitCode: 1 } },
40
+ }],
41
+ },
42
+ }
43
+ expect(getPodStatus(recovered)).toMatchObject({ text: 'Restarted (2)', level: 'degraded' })
44
+ expect(getPodPhaseDisplay(recovered)).toMatchObject({
45
+ text: 'Running — Recently restarted (2 restarts)',
46
+ level: 'degraded',
47
+ })
48
+ })
49
+
50
+ it('keeps a non-serving container with crash history unhealthy', () => {
51
+ const down = {
52
+ status: {
53
+ phase: 'Running',
54
+ containerStatuses: [{
55
+ name: 'app',
56
+ ready: false,
57
+ restartCount: 2,
58
+ state: { running: {} },
59
+ lastState: { terminated: { reason: 'Error', exitCode: 1 } },
60
+ }],
61
+ },
62
+ }
63
+ expect(getPodStatus(down).level).toBe('unhealthy')
64
+ expect(getPodPhaseDisplay(down).level).toBe('unhealthy')
65
+ })
66
+
67
+ it('keeps a readiness-probed flapper visible until the settle window', () => {
68
+ const flapper = {
69
+ spec: { containers: [{ name: 'app', readinessProbe: { tcpSocket: { port: 8080 } } }] },
70
+ status: {
71
+ phase: 'Running',
72
+ containerStatuses: [{
73
+ name: 'app',
74
+ ready: true,
75
+ restartCount: 2,
76
+ state: { running: { startedAt: new Date(Date.now() - 60 * 1000).toISOString() } },
77
+ lastState: {
78
+ terminated: {
79
+ reason: 'Error',
80
+ exitCode: 1,
81
+ finishedAt: new Date(Date.now() - 60 * 1000).toISOString(),
82
+ },
83
+ },
84
+ }],
85
+ },
86
+ }
87
+ expect(getPodStatus(flapper).level).toBe('degraded')
88
+
89
+ const settled = structuredClone(flapper)
90
+ settled.status.containerStatuses[0].state.running.startedAt = new Date(Date.now() - 6 * 60 * 1000).toISOString()
91
+ expect(getPodStatus(settled).level).toBe('healthy')
92
+ })
93
+
94
+ it('lets an active sibling failure outrank recovered crash history', () => {
95
+ const pod = {
96
+ status: {
97
+ phase: 'Running',
98
+ containerStatuses: [
99
+ {
100
+ name: 'app',
101
+ ready: true,
102
+ restartCount: 2,
103
+ state: { running: {} },
104
+ lastState: { terminated: { reason: 'Error', exitCode: 1 } },
105
+ },
106
+ {
107
+ name: 'image',
108
+ ready: false,
109
+ state: { waiting: { reason: 'ImagePullBackOff' } },
110
+ },
111
+ ],
112
+ },
113
+ }
114
+ expect(getPodStatus(pod)).toMatchObject({ text: 'ImagePullBackOff', level: 'unhealthy' })
115
+ expect(getPodPhaseDisplay(pod)).toMatchObject({
116
+ text: 'Running — ImagePullBackOff',
117
+ level: 'unhealthy',
118
+ })
119
+ })
120
+
121
+ it('keeps a not-ready sibling visible ahead of recovered crash history', () => {
122
+ const pod = {
123
+ status: {
124
+ phase: 'Running',
125
+ containerStatuses: [
126
+ {
127
+ name: 'app',
128
+ ready: true,
129
+ restartCount: 1,
130
+ state: { running: {} },
131
+ lastState: { terminated: { reason: 'Error', exitCode: 1 } },
132
+ },
133
+ {
134
+ name: 'sidecar',
135
+ ready: false,
136
+ restartCount: 0,
137
+ state: { running: {} },
138
+ },
139
+ ],
140
+ },
141
+ }
142
+ expect(getPodStatus(pod)).toMatchObject({ text: 'Running (1/2)', level: 'degraded' })
143
+ expect(getPodPhaseDisplay(pod)).toMatchObject({
144
+ text: 'Running — Not Ready (1/2)',
145
+ level: 'degraded',
146
+ })
147
+ })
148
+
149
+ it('counts only crash history still inside the settle window', () => {
150
+ const pod = {
151
+ status: {
152
+ phase: 'Running',
153
+ containerStatuses: [
154
+ {
155
+ name: 'app',
156
+ ready: true,
157
+ restartCount: 1,
158
+ state: { running: {} },
159
+ lastState: { terminated: { reason: 'Error', exitCode: 1 } },
160
+ },
161
+ {
162
+ name: 'settled-sidecar',
163
+ ready: true,
164
+ restartCount: 40,
165
+ state: { running: { startedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString() } },
166
+ lastState: {
167
+ terminated: {
168
+ reason: 'Error',
169
+ exitCode: 1,
170
+ finishedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
171
+ },
172
+ },
173
+ },
174
+ ],
175
+ },
176
+ }
177
+ expect(getPodStatus(pod)).toMatchObject({ text: 'Restarted (1)', level: 'degraded' })
178
+ expect(getPodPhaseDisplay(pod)).toMatchObject({
179
+ text: 'Running — Recently restarted (1 restart)',
180
+ level: 'degraded',
181
+ })
182
+ })
183
+
184
+ it('does not revive old crash history after a long node outage', () => {
185
+ const pod = {
186
+ status: {
187
+ phase: 'Running',
188
+ containerStatuses: [{
189
+ name: 'app',
190
+ ready: true,
191
+ restartCount: 5,
192
+ state: { running: { startedAt: new Date(Date.now() - 30 * 1000).toISOString() } },
193
+ lastState: {
194
+ terminated: {
195
+ reason: 'Error',
196
+ exitCode: 1,
197
+ finishedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
198
+ },
199
+ },
200
+ }],
201
+ },
202
+ }
203
+ expect(getPodStatus(pod)).toMatchObject({ text: 'Running', level: 'healthy' })
204
+
205
+ const maxBackoffContinuation = structuredClone(pod)
206
+ maxBackoffContinuation.status.containerStatuses[0].state.running.startedAt =
207
+ new Date(Date.now() - 60 * 1000).toISOString()
208
+ maxBackoffContinuation.status.containerStatuses[0].lastState.terminated.finishedAt =
209
+ new Date(Date.now() - 6 * 60 * 1000).toISOString()
210
+ expect(getPodStatus(maxBackoffContinuation).level).toBe('degraded')
211
+ })
212
+
213
+ it('shows Terminating instead of a recovered-crash warning during graceful deletion', () => {
214
+ const terminating = {
215
+ metadata: { deletionTimestamp: new Date().toISOString() },
216
+ status: {
217
+ phase: 'Running',
218
+ containerStatuses: [{
219
+ name: 'app',
220
+ ready: true,
221
+ restartCount: 2,
222
+ state: { running: {} },
223
+ lastState: { terminated: { reason: 'Error', exitCode: 1 } },
224
+ }],
225
+ },
226
+ }
227
+ expect(getPodStatus(terminating)).toMatchObject({ text: 'Terminating', level: 'neutral' })
228
+ expect(getPodPhaseDisplay(terminating)).toMatchObject({
229
+ text: 'Running — Terminating',
230
+ level: 'neutral',
231
+ })
232
+ })
29
233
  })
30
234
 
31
235
  const podRunningHealthy = {
@@ -171,6 +171,63 @@ function firstFatalContainer(pod: any): { name: string; reason: string } | null
171
171
  return null
172
172
  }
173
173
 
174
+ const CRASH_SETTLE_MS = 5 * 60 * 1000
175
+ const STALE_CRASH_STATE_GAP_MS = 2 * CRASH_SETTLE_MS
176
+
177
+ function stableCrashHistory(cs: any): boolean {
178
+ if (!cs?.restartCount) return false
179
+
180
+ const startedAt = cs?.state?.running?.startedAt
181
+ const finishedAt = cs?.lastState?.terminated?.finishedAt
182
+ if (startedAt) {
183
+ const startedAtMs = new Date(startedAt).getTime()
184
+ if (Number.isFinite(startedAtMs) && Date.now() - startedAtMs >= CRASH_SETTLE_MS) return false
185
+ if (finishedAt) {
186
+ const finishedAtMs = new Date(finishedAt).getTime()
187
+ if (
188
+ Number.isFinite(startedAtMs)
189
+ && Number.isFinite(finishedAtMs)
190
+ && startedAtMs - finishedAtMs > STALE_CRASH_STATE_GAP_MS
191
+ ) return false
192
+ }
193
+ } else if (cs?.state?.running && finishedAt) {
194
+ const finishedAtMs = new Date(finishedAt).getTime()
195
+ if (Number.isFinite(finishedAtMs) && Date.now() - finishedAtMs >= CRASH_SETTLE_MS) return false
196
+ }
197
+ if (cs?.state?.terminated?.exitCode === 0) return false
198
+
199
+ const term = cs?.lastState?.terminated
200
+ if (!term || term.reason === 'OOMKilled') return false
201
+ return term.reason === 'CrashLoopBackOff' || term.reason === 'Error' || Number(term.exitCode ?? 0) !== 0
202
+ }
203
+
204
+ function podCrashHistoryLevel(pod: any): 'degraded' | 'unhealthy' | null {
205
+ let degraded = false
206
+ for (const cs of pod?.status?.containerStatuses || []) {
207
+ if (!stableCrashHistory(cs)) continue
208
+ if (!(cs?.state?.running && cs?.ready)) return 'unhealthy'
209
+ degraded = true
210
+ }
211
+
212
+ const initContainers = pod?.spec?.initContainers || []
213
+ for (const cs of pod?.status?.initContainerStatuses || []) {
214
+ const spec = initContainers.find((c: any) => c?.name === cs?.name)
215
+ const restartable = spec?.restartPolicy === 'Always'
216
+ if (!stableCrashHistory(cs)) continue
217
+ if (!(restartable && cs?.state?.running && cs?.ready)) return 'unhealthy'
218
+ degraded = true
219
+ }
220
+ return degraded ? 'degraded' : null
221
+ }
222
+
223
+ function podCrashRestartTotal(pod: any): number {
224
+ const main = pod?.status?.containerStatuses || []
225
+ const init = pod?.status?.initContainerStatuses || []
226
+ return [...main, ...init]
227
+ .filter(stableCrashHistory)
228
+ .reduce((sum, cs) => sum + (cs?.restartCount || 0), 0)
229
+ }
230
+
174
231
  export function getPodStatus(pod: any): StatusBadge {
175
232
  const phase = pod.status?.phase || 'Unknown'
176
233
  const containerStatuses = pod.status?.containerStatuses || []
@@ -194,6 +251,17 @@ export function getPodStatus(pod: any): StatusBadge {
194
251
  return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
195
252
  }
196
253
 
254
+ const crashHistoryLevel = phase !== 'Succeeded' && phase !== 'Unknown'
255
+ ? podCrashHistoryLevel(pod)
256
+ : null
257
+ if (crashHistoryLevel === 'unhealthy') {
258
+ return {
259
+ text: 'CrashLoopBackOff',
260
+ color: healthColors.unhealthy,
261
+ level: 'unhealthy',
262
+ }
263
+ }
264
+
197
265
  // Terminating: neutral while gracefully shutting down, degraded once stuck
198
266
  // (a wedged finalizer / preStop hook holding the pod open).
199
267
  if (pod.metadata?.deletionTimestamp) {
@@ -203,6 +271,16 @@ export function getPodStatus(pod: any): StatusBadge {
203
271
  return { text: 'Terminating', color: healthColors.neutral, level: 'neutral' }
204
272
  }
205
273
 
274
+ const hasUnsettledMainContainer = containerStatuses.some((c: any) => !containerSettledOk(c))
275
+ if (crashHistoryLevel === 'degraded' && !hasUnsettledMainContainer) {
276
+ const restarts = podCrashRestartTotal(pod)
277
+ return {
278
+ text: `Restarted (${restarts})`,
279
+ color: healthColors.degraded,
280
+ level: 'degraded',
281
+ }
282
+ }
283
+
206
284
  switch (phase) {
207
285
  case 'Running': {
208
286
  // Degrade only on containers that are neither ready nor successfully done —
@@ -288,6 +366,18 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
288
366
  return { phase, text: 'Failed', level: 'unhealthy' }
289
367
  }
290
368
 
369
+ const crashHistoryLevel = phase !== 'Succeeded' && phase !== 'Unknown'
370
+ ? podCrashHistoryLevel(pod)
371
+ : null
372
+ if (crashHistoryLevel === 'unhealthy') {
373
+ return {
374
+ phase,
375
+ text: `${phase} — CrashLoopBackOff`,
376
+ level: 'unhealthy',
377
+ hint: 'At least one container has crash history and is not serving now.',
378
+ }
379
+ }
380
+
291
381
  // Terminating: neutral while gracefully shutting down, degraded once stuck.
292
382
  if (pod?.metadata?.deletionTimestamp) {
293
383
  const stuck = minutesSince(pod.metadata.deletionTimestamp) >= TERMINATING_STUCK_MINUTES
@@ -301,6 +391,17 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
301
391
  }
302
392
  }
303
393
 
394
+ const hasUnsettledMainContainer = containerStatuses.some((c) => !containerSettledOk(c))
395
+ if (crashHistoryLevel === 'degraded' && !hasUnsettledMainContainer) {
396
+ const crashRestarts = podCrashRestartTotal(pod)
397
+ return {
398
+ phase,
399
+ text: `${phase} — Recently restarted (${crashRestarts} restart${crashRestarts === 1 ? '' : 's'})`,
400
+ level: 'degraded',
401
+ hint: 'Containers are ready now but remain in the crash settle window.',
402
+ }
403
+ }
404
+
304
405
  switch (phase) {
305
406
  case 'Running': {
306
407
  // A successfully-completed container (exit 0) is settled, not "not ready" —