@skyhook-io/k8s-ui 1.8.13 → 1.8.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.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @skyhook-io/k8s-ui
2
+
3
+ Shared, source-distributed Kubernetes UI components used by Radar.
4
+
5
+ ## YAML editor bundling
6
+
7
+ `YamlEditor` and `YamlDiffEditor` bundle Monaco, its editor worker, and the YAML language worker into the consuming application. They make no runtime CDN or internet requests, so they work in air-gapped environments.
8
+
9
+ Consumers must use a bundler that supports module workers created with `new Worker(new URL(..., import.meta.url), { type: 'module' })`. The source package is compatible with Vite and webpack 5. Monaco and `monaco-yaml` are intentionally pinned as a compatible pair because their worker-factory APIs must move together. The package declares that exact Monaco version as a peer so the host and YAML runtime share one Monaco instance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.13",
3
+ "version": "1.8.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -69,7 +69,8 @@
69
69
  "dependencies": {
70
70
  "@monaco-editor/react": "^4.7.0",
71
71
  "html-to-image": "^1.11.0",
72
- "react-virtuoso": "^4.18.10",
72
+ "monaco-yaml": "5.5.1",
73
+ "react-virtuoso": "^4.18.11",
73
74
  "shiki": "^4.2.0"
74
75
  },
75
76
  "peerDependencies": {
@@ -81,12 +82,13 @@
81
82
  "diff": ">=5.0.0",
82
83
  "elkjs": ">=0.9.0",
83
84
  "lucide-react": ">=0.400.0",
85
+ "monaco-editor": "0.55.1",
84
86
  "react": ">=18.0.0",
85
87
  "react-dom": ">=18.0.0",
86
88
  "yaml": ">=2.0.0"
87
89
  },
88
90
  "devDependencies": {
89
- "@types/node": "^26.0.1",
91
+ "@types/node": "^26.1.1",
90
92
  "@types/react": "^19.2.17",
91
93
  "@types/react-dom": "^19.2.3",
92
94
  "@xterm/addon-fit": "^0.11.0",
@@ -97,6 +99,7 @@
97
99
  "diff": "^9.0.0",
98
100
  "elkjs": "^0.11.1",
99
101
  "lucide-react": "^1.24.0",
102
+ "monaco-editor": "0.55.1",
100
103
  "react": "^19.2.7",
101
104
  "react-dom": "^19.2.7",
102
105
  "typescript": "^6.0.2",
@@ -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" —