@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 +9 -0
- package/package.json +6 -3
- package/src/components/resources/get-pod-phase-display.test.ts +204 -0
- package/src/components/resources/resource-utils.ts +101 -0
- package/src/components/shared/CreateResourceDialog.tsx +307 -172
- package/src/components/shared/EditableYamlView.tsx +197 -26
- package/src/components/ui/YamlEditor.test.ts +148 -0
- package/src/components/ui/YamlEditor.tsx +759 -186
- package/src/components/ui/YamlReview.test.ts +45 -0
- package/src/components/ui/YamlReview.tsx +426 -0
- package/src/components/ui/index.ts +9 -0
- package/src/components/ui/monacoRuntime.ts +41 -0
- package/src/components/ui/monacoYaml.worker.ts +1 -0
- package/src/components/ui/yamlMonacoRuntime.ts +110 -0
- package/src/components/workload/WorkloadView.tsx +27 -2
- package/src/monaco-deep.d.ts +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/yaml.test.ts +100 -2
- package/src/utils/yaml.ts +86 -1
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.
|
|
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
|
-
"
|
|
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.
|
|
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" —
|