@skyhook-io/k8s-ui 1.8.5 → 1.8.7
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/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +2 -0
- package/src/components/audit/AuditAlerts.tsx +4 -0
- package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
- package/src/components/audit/AuditFindingsTable.tsx +4 -0
- package/src/components/audit/index.ts +1 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
- package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
- package/src/components/gitops/GitOpsTableView.tsx +3 -1
- package/src/components/issues/IssuesView.tsx +9 -36
- package/src/components/issues/ResourceIssuesSection.tsx +142 -0
- package/src/components/issues/diagnostic.ts +64 -0
- package/src/components/issues/index.ts +2 -1
- package/src/components/issues/severity.ts +10 -9
- package/src/components/issues/types.ts +5 -0
- package/src/components/resources/ResourcesView.tsx +38 -2
- package/src/components/resources/cron-to-human.test.ts +41 -0
- package/src/components/resources/get-pod-phase-display.test.ts +115 -3
- package/src/components/resources/get-pod-problems.test.ts +18 -0
- package/src/components/resources/health-golden.test.ts +66 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
- package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
- package/src/components/resources/renderers/JobRenderer.tsx +12 -7
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
- package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
- package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
- package/src/components/resources/renderers/PodRenderer.tsx +28 -9
- package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
- package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
- package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
- package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
- package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
- package/src/components/resources/renderers/contour-cells.tsx +6 -5
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/istio-cells.tsx +16 -25
- package/src/components/resources/resource-utils-argo.test.ts +23 -0
- package/src/components/resources/resource-utils-argo.ts +5 -1
- package/src/components/resources/resource-utils-cnpg.ts +1 -1
- package/src/components/resources/resource-utils-keda.ts +17 -7
- package/src/components/resources/resource-utils.ts +206 -71
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
- package/src/components/timeline/shared.tsx +15 -4
- package/src/components/topology/K8sResourceNode.tsx +28 -1
- package/src/components/topology/TopologyControls.tsx +41 -9
- package/src/components/topology/layout.ts +11 -5
- package/src/components/ui/Badge.tsx +66 -3
- package/src/components/ui/ClusterName.tsx +12 -4
- package/src/components/ui/PaneLoader.tsx +30 -7
- package/src/components/ui/drawer-components.test.tsx +35 -0
- package/src/components/ui/drawer-components.tsx +13 -1
- package/src/components/workload/WorkloadView.tsx +35 -5
- package/src/types/core.ts +136 -2
- package/src/utils/applications.test.ts +55 -1
- package/src/utils/applications.ts +28 -7
- package/src/utils/asset-url.ts +13 -0
- package/src/utils/badge-colors.ts +7 -0
|
@@ -112,41 +112,122 @@ export function podMatchesProblemCategory(problems: PodProblem[], restarts: numb
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// A transient lifecycle state (plain Pending, Terminating, awaiting an address)
|
|
116
|
+
// is not a fault while it's young — it becomes one only once it's stuck past
|
|
117
|
+
// these windows, mirroring the backend health thresholds (ClassifyPodHealth:
|
|
118
|
+
// pending 5m; detect.go: terminating 10m, LB/PVC pending 5m). Two things escalate
|
|
119
|
+
// immediately, regardless of age, because they're definitive failures the backend
|
|
120
|
+
// also flags at once: fatal *container* states (CrashLoopBackOff, ImagePull,
|
|
121
|
+
// InvalidImageName, OOMKilled, …) and Unschedulable (the scheduler tried and
|
|
122
|
+
// could not place the pod).
|
|
123
|
+
const PENDING_STUCK_MINUTES = 5
|
|
124
|
+
const TERMINATING_STUCK_MINUTES = 10
|
|
125
|
+
|
|
126
|
+
// minutesSince returns minutes elapsed since an ISO timestamp, or 0 when it's
|
|
127
|
+
// missing/invalid — so "unknown age" is treated as not-yet-stuck (benign).
|
|
128
|
+
function minutesSince(timestamp?: string): number {
|
|
129
|
+
if (!timestamp) return 0
|
|
130
|
+
const t = new Date(timestamp).getTime()
|
|
131
|
+
if (Number.isNaN(t)) return 0
|
|
132
|
+
return (Date.now() - t) / 60000
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function podUnschedulable(pod: any): boolean {
|
|
136
|
+
const conds = pod?.status?.conditions || []
|
|
137
|
+
return conds.some(
|
|
138
|
+
(c: any) => c.type === 'PodScheduled' && c.status === 'False' && c.reason === 'Unschedulable'
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A container that has terminated successfully (exit 0) is done, not "not ready".
|
|
143
|
+
// A completing Job pod (Running phase, container Completed, Ready=false) must not
|
|
144
|
+
// read as degraded just because ready<total — this mirrors ClassifyPodHealth, so
|
|
145
|
+
// the badge agrees with the timeline/Problems verdict.
|
|
146
|
+
function containerSettledOk(cs: any): boolean {
|
|
147
|
+
return cs?.ready === true || cs?.state?.terminated?.exitCode === 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Hard-failure waiting reasons that won't self-resolve — mirrors the backend's
|
|
151
|
+
// isFatalWaitingReason. These escalate immediately regardless of pod age or a
|
|
152
|
+
// Terminating overlay (a bad image / config error is a real failure now, not a
|
|
153
|
+
// benign young-Pending pod).
|
|
154
|
+
const FATAL_WAITING_REASONS = new Set([
|
|
155
|
+
'CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull', 'InvalidImageName',
|
|
156
|
+
'ImageInspectError', 'CreateContainerConfigError', 'CreateContainerError', 'RunContainerError',
|
|
157
|
+
])
|
|
158
|
+
|
|
159
|
+
// firstFatalContainer returns the first init- or main-container in a hard-failure
|
|
160
|
+
// state (or null). Init containers are walked first: when init is failing the pod
|
|
161
|
+
// stays Pending and main ContainerStatuses haven't populated yet, so checking main
|
|
162
|
+
// alone would miss it and fall through to a benign "Pending".
|
|
163
|
+
function firstFatalContainer(pod: any): { name: string; reason: string } | null {
|
|
164
|
+
const init = pod?.status?.initContainerStatuses || []
|
|
165
|
+
const main = pod?.status?.containerStatuses || []
|
|
166
|
+
for (const cs of [...init, ...main]) {
|
|
167
|
+
const w = cs?.state?.waiting?.reason
|
|
168
|
+
if (w && FATAL_WAITING_REASONS.has(w)) return { name: cs?.name, reason: w }
|
|
169
|
+
if (cs?.state?.terminated?.reason === 'OOMKilled') return { name: cs?.name, reason: 'OOMKilled' }
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
115
174
|
export function getPodStatus(pod: any): StatusBadge {
|
|
116
175
|
const phase = pod.status?.phase || 'Unknown'
|
|
117
176
|
const containerStatuses = pod.status?.containerStatuses || []
|
|
118
177
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
178
|
+
// Fatal init/main container states win over the Pending grace AND a Terminating
|
|
179
|
+
// overlay — a crash-loop, bad image, or config error is a real failure now,
|
|
180
|
+
// whatever the pod's age or deletion state. Skip for Succeeded: a terminal
|
|
181
|
+
// success must not flip unhealthy on a sidecar that OOMed before the main
|
|
182
|
+
// container finished (matches getPodPhaseDisplay + ClassifyPodHealth).
|
|
183
|
+
if (phase !== 'Succeeded') {
|
|
184
|
+
const fatal = firstFatalContainer(pod)
|
|
185
|
+
if (fatal) {
|
|
186
|
+
return { text: fatal.reason, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
187
|
+
}
|
|
122
188
|
}
|
|
123
189
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
190
|
+
// A terminal failure stays unhealthy even while the pod is being deleted — the
|
|
191
|
+
// Terminating overlay below must not mask a Failed pod (the Problems panel and
|
|
192
|
+
// dashboard report it as an error regardless).
|
|
193
|
+
if (phase === 'Failed') {
|
|
194
|
+
return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Terminating: neutral while gracefully shutting down, degraded once stuck
|
|
198
|
+
// (a wedged finalizer / preStop hook holding the pod open).
|
|
199
|
+
if (pod.metadata?.deletionTimestamp) {
|
|
200
|
+
if (minutesSince(pod.metadata.deletionTimestamp) >= TERMINATING_STUCK_MINUTES) {
|
|
201
|
+
return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
|
|
134
202
|
}
|
|
203
|
+
return { text: 'Terminating', color: healthColors.neutral, level: 'neutral' }
|
|
135
204
|
}
|
|
136
205
|
|
|
137
206
|
switch (phase) {
|
|
138
|
-
case 'Running':
|
|
139
|
-
//
|
|
207
|
+
case 'Running': {
|
|
208
|
+
// Degrade only on containers that are neither ready nor successfully done —
|
|
209
|
+
// a completed container (exit 0) on a Running pod is finishing, not a fault.
|
|
140
210
|
const ready = containerStatuses.filter((c: any) => c.ready).length
|
|
141
211
|
const total = containerStatuses.length
|
|
142
|
-
|
|
212
|
+
const unsettled = containerStatuses.filter((c: any) => !containerSettledOk(c)).length
|
|
213
|
+
if (unsettled > 0) {
|
|
143
214
|
return { text: `Running (${ready}/${total})`, color: healthColors.degraded, level: 'degraded' }
|
|
144
215
|
}
|
|
145
216
|
return { text: 'Running', color: healthColors.healthy, level: 'healthy' }
|
|
217
|
+
}
|
|
146
218
|
case 'Succeeded':
|
|
147
219
|
return { text: 'Completed', color: healthColors.neutral, level: 'neutral' }
|
|
148
220
|
case 'Pending':
|
|
149
|
-
|
|
221
|
+
// Unschedulable = the scheduler tried and failed to place this pod; the
|
|
222
|
+
// backend flags it immediately (severity ramps with duration), so the badge
|
|
223
|
+
// does too. Plain Pending (not yet placed) keeps the young-grace window.
|
|
224
|
+
if (podUnschedulable(pod)) {
|
|
225
|
+
return { text: 'Unschedulable', color: healthColors.degraded, level: 'degraded' }
|
|
226
|
+
}
|
|
227
|
+
if (minutesSince(pod.metadata?.creationTimestamp) >= PENDING_STUCK_MINUTES) {
|
|
228
|
+
return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
|
|
229
|
+
}
|
|
230
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
150
231
|
case 'Failed':
|
|
151
232
|
return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
152
233
|
default:
|
|
@@ -181,49 +262,51 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
|
|
|
181
262
|
0
|
|
182
263
|
)
|
|
183
264
|
|
|
265
|
+
// Container-state failures take precedence over phase AND over a Terminating
|
|
266
|
+
// overlay: a CrashLoopBackOff pod can still report phase: Running, an init
|
|
267
|
+
// failure keeps the pod Pending, and a pod crashing while being deleted is
|
|
268
|
+
// still a failure worth surfacing. Skip for Succeeded — a Job pod whose sidecar
|
|
269
|
+
// was OOMKilled before the main container completed should not read unhealthy
|
|
270
|
+
// after terminal success.
|
|
271
|
+
if (phase !== 'Succeeded') {
|
|
272
|
+
const fatal = firstFatalContainer(pod)
|
|
273
|
+
if (fatal) {
|
|
274
|
+
return {
|
|
275
|
+
phase,
|
|
276
|
+
text: `${phase} — ${fatal.reason}`,
|
|
277
|
+
level: 'unhealthy',
|
|
278
|
+
hint: fatal.reason === 'OOMKilled'
|
|
279
|
+
? `Container "${fatal.name}" was OOMKilled.`
|
|
280
|
+
: `Container "${fatal.name}" is stuck in ${fatal.reason}.`,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A terminal failure stays unhealthy even while the pod is being deleted — don't
|
|
286
|
+
// let the Terminating overlay mask a Failed pod.
|
|
287
|
+
if (phase === 'Failed') {
|
|
288
|
+
return { phase, text: 'Failed', level: 'unhealthy' }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Terminating: neutral while gracefully shutting down, degraded once stuck.
|
|
184
292
|
if (pod?.metadata?.deletionTimestamp) {
|
|
293
|
+
const stuck = minutesSince(pod.metadata.deletionTimestamp) >= TERMINATING_STUCK_MINUTES
|
|
185
294
|
return {
|
|
186
295
|
phase,
|
|
187
296
|
text: `${phase} — Terminating`,
|
|
188
|
-
level: 'degraded',
|
|
189
|
-
hint:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// Container-state failures take precedence over phase: a CrashLoopBackOff
|
|
194
|
-
// pod can still report phase: Running. Skip for Succeeded — a Job pod whose
|
|
195
|
-
// sidecar was OOMKilled before the main container completed should not be
|
|
196
|
-
// shown as unhealthy after the pod has reached terminal success.
|
|
197
|
-
if (phase !== 'Succeeded') {
|
|
198
|
-
for (const cs of containerStatuses) {
|
|
199
|
-
const waitingReason = cs?.state?.waiting?.reason
|
|
200
|
-
if (
|
|
201
|
-
waitingReason === 'CrashLoopBackOff' ||
|
|
202
|
-
waitingReason === 'ImagePullBackOff' ||
|
|
203
|
-
waitingReason === 'ErrImagePull' ||
|
|
204
|
-
waitingReason === 'CreateContainerConfigError'
|
|
205
|
-
) {
|
|
206
|
-
return {
|
|
207
|
-
phase,
|
|
208
|
-
text: `${phase} — ${waitingReason}`,
|
|
209
|
-
level: 'unhealthy',
|
|
210
|
-
hint: `Container "${cs.name}" is stuck in ${waitingReason}.`,
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (cs?.state?.terminated?.reason === 'OOMKilled') {
|
|
214
|
-
return {
|
|
215
|
-
phase,
|
|
216
|
-
text: `${phase} — OOMKilled`,
|
|
217
|
-
level: 'unhealthy',
|
|
218
|
-
hint: `Container "${cs.name}" was OOMKilled.`,
|
|
219
|
-
}
|
|
220
|
-
}
|
|
297
|
+
level: stuck ? 'degraded' : 'neutral',
|
|
298
|
+
hint: stuck
|
|
299
|
+
? 'Pod has been terminating for a while — a finalizer or preStop hook may be wedged.'
|
|
300
|
+
: 'Pod has a deletionTimestamp set; awaiting graceful termination.',
|
|
221
301
|
}
|
|
222
302
|
}
|
|
223
303
|
|
|
224
304
|
switch (phase) {
|
|
225
305
|
case 'Running': {
|
|
226
|
-
|
|
306
|
+
// A successfully-completed container (exit 0) is settled, not "not ready" —
|
|
307
|
+
// don't degrade a completing pod over it (matches getPodStatus / timeline).
|
|
308
|
+
const unsettled = containerStatuses.filter((c) => !containerSettledOk(c)).length
|
|
309
|
+
const notReady = totalContainers > 0 && unsettled > 0
|
|
227
310
|
const cycling = restartTotal > RESTART_CYCLING_THRESHOLD
|
|
228
311
|
if (notReady && cycling) {
|
|
229
312
|
return {
|
|
@@ -254,7 +337,26 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
|
|
|
254
337
|
case 'Succeeded':
|
|
255
338
|
return { phase, text: 'Completed', level: 'neutral' }
|
|
256
339
|
case 'Pending':
|
|
257
|
-
|
|
340
|
+
// Unschedulable = the scheduler tried and failed; surface it immediately
|
|
341
|
+
// (the backend does, with severity ramping by duration). Plain Pending
|
|
342
|
+
// (not yet placed) keeps the young-grace window.
|
|
343
|
+
if (podUnschedulable(pod)) {
|
|
344
|
+
return {
|
|
345
|
+
phase,
|
|
346
|
+
text: `${phase} — Unschedulable`,
|
|
347
|
+
level: 'degraded',
|
|
348
|
+
hint: 'No node can accept this pod (insufficient resources, taints, affinity, or quota).',
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (minutesSince(pod?.metadata?.creationTimestamp) >= PENDING_STUCK_MINUTES) {
|
|
352
|
+
return {
|
|
353
|
+
phase,
|
|
354
|
+
text: 'Pending',
|
|
355
|
+
level: 'degraded',
|
|
356
|
+
hint: 'Pod has been Pending for several minutes — check scheduling and image pulls.',
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { phase, text: 'Pending', level: 'neutral' }
|
|
258
360
|
case 'Failed':
|
|
259
361
|
return { phase, text: 'Failed', level: 'unhealthy' }
|
|
260
362
|
default:
|
|
@@ -373,20 +475,23 @@ export function getPodProblems(pod: any): PodProblem[] {
|
|
|
373
475
|
problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
|
|
374
476
|
}
|
|
375
477
|
|
|
376
|
-
// Stuck terminating (zombie pod)
|
|
478
|
+
// Stuck terminating (zombie pod). Use the same threshold as the badge
|
|
479
|
+
// (TERMINATING_STUCK_MINUTES) so the drawer problem and the table badge flip
|
|
480
|
+
// together — firing at 60s while the badge stayed calm to 10m was a mismatch.
|
|
377
481
|
if (pod.metadata?.deletionTimestamp) {
|
|
378
|
-
|
|
379
|
-
const ageSeconds = (Date.now() - deleteTime) / 1000
|
|
380
|
-
if (ageSeconds > 60) {
|
|
482
|
+
if (minutesSince(pod.metadata.deletionTimestamp) >= TERMINATING_STUCK_MINUTES) {
|
|
381
483
|
problems.push({ severity: 'medium', message: 'Stuck Terminating' })
|
|
382
484
|
}
|
|
383
485
|
}
|
|
384
486
|
|
|
385
|
-
// Not ready (Running but containers not ready)
|
|
487
|
+
// Not ready (Running but containers not ready). Use the same containerSettledOk
|
|
488
|
+
// gate as getPodStatus so a completing Job pod (Running, container terminated
|
|
489
|
+
// exit 0, Ready=false) doesn't raise a drawer problem while the table badge
|
|
490
|
+
// stays calm — a settled/completed container is not "Not Ready".
|
|
386
491
|
if (phase === 'Running') {
|
|
387
|
-
const
|
|
492
|
+
const unsettled = containerStatuses.filter((c: any) => !containerSettledOk(c)).length
|
|
388
493
|
const totalContainers = containerStatuses.length
|
|
389
|
-
if (totalContainers > 0 &&
|
|
494
|
+
if (totalContainers > 0 && unsettled > 0) {
|
|
390
495
|
// Only add if we haven't already flagged a more specific issue
|
|
391
496
|
const hasSpecificIssue = problems.some(p =>
|
|
392
497
|
p.message.includes('Probe') || p.message.includes('CrashLoop') || p.message.includes('OOM')
|
|
@@ -544,7 +649,9 @@ export function getWorkloadStatus(resource: any, kind: string): StatusBadge {
|
|
|
544
649
|
const ready = status.numberReady || 0
|
|
545
650
|
const updated = status.updatedNumberScheduled || 0
|
|
546
651
|
|
|
547
|
-
|
|
652
|
+
// 0 desired = the node selector matches no nodes — intentional/idle, not a
|
|
653
|
+
// fault and not "unknown" (matches pkg/health.Workload). Sky.
|
|
654
|
+
if (desired === 0) return { text: '0 nodes', color: healthColors.neutral, level: 'neutral' }
|
|
548
655
|
if (ready === desired && updated === desired) {
|
|
549
656
|
return { text: `${ready}/${desired}`, color: healthColors.healthy, level: 'healthy' }
|
|
550
657
|
}
|
|
@@ -778,7 +885,12 @@ export function getIngressStatus(ingress: any): StatusBadge {
|
|
|
778
885
|
if (lbIngress.length > 0) {
|
|
779
886
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
780
887
|
}
|
|
781
|
-
|
|
888
|
+
// Awaiting an external address is normal right after creation; only flag it
|
|
889
|
+
// once the ingress/LB controller has had time and still hasn't assigned one.
|
|
890
|
+
if (minutesSince(ingress.metadata?.creationTimestamp) >= PENDING_STUCK_MINUTES) {
|
|
891
|
+
return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
|
|
892
|
+
}
|
|
893
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
782
894
|
}
|
|
783
895
|
|
|
784
896
|
export function getIngressHosts(ingress: any): string {
|
|
@@ -901,11 +1013,13 @@ export function getJobStatus(job: any): StatusBadge {
|
|
|
901
1013
|
|
|
902
1014
|
const completeCond = conditions.find((c: any) => c.type === 'Complete' && c.status === 'True')
|
|
903
1015
|
if (completeCond) {
|
|
904
|
-
|
|
1016
|
+
// A completed Job is done by design — neutral/idle (sky), not the green of a
|
|
1017
|
+
// serving workload (matches pkg/health.Workload).
|
|
1018
|
+
return { text: 'Complete', color: healthColors.neutral, level: 'neutral' }
|
|
905
1019
|
}
|
|
906
1020
|
|
|
907
1021
|
if (job.spec?.suspend) {
|
|
908
|
-
return { text: 'Suspended', color: healthColors.
|
|
1022
|
+
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
909
1023
|
}
|
|
910
1024
|
|
|
911
1025
|
if (status.active > 0) {
|
|
@@ -936,7 +1050,7 @@ export function getJobDuration(job: any): string | null {
|
|
|
936
1050
|
|
|
937
1051
|
export function getCronJobStatus(cj: any): StatusBadge {
|
|
938
1052
|
if (cj.spec?.suspend) {
|
|
939
|
-
return { text: 'Suspended', color: healthColors.
|
|
1053
|
+
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
940
1054
|
}
|
|
941
1055
|
const activeJobs = cj.status?.active?.length || 0
|
|
942
1056
|
if (activeJobs > 0) {
|
|
@@ -1010,6 +1124,9 @@ export function getNodeStatus(node: any): StatusBadge {
|
|
|
1010
1124
|
const isUnschedulable = node.spec?.unschedulable === true
|
|
1011
1125
|
|
|
1012
1126
|
if (isReady && isUnschedulable) {
|
|
1127
|
+
// Cordon is intentional but consequential — it's lost scheduling capacity, and
|
|
1128
|
+
// a forgotten cordon strands nodes — so it stays on the warning axis (matching
|
|
1129
|
+
// the backend Cordoned issue), unlike no-op intentional states (suspended/idle).
|
|
1013
1130
|
return { text: 'Ready,SchedulingDisabled', color: healthColors.degraded, level: 'degraded' }
|
|
1014
1131
|
}
|
|
1015
1132
|
if (isReady) {
|
|
@@ -1134,17 +1251,30 @@ export function cronToHuman(cron: string): string {
|
|
|
1134
1251
|
if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1135
1252
|
return `Daily at ${hour}:00`
|
|
1136
1253
|
}
|
|
1137
|
-
|
|
1254
|
+
// Exclude step-minute ("*/N") here so it falls through to the interval branch
|
|
1255
|
+
// below — otherwise "*/5 * * * *" rendered as "Every hour at :*/5" instead of
|
|
1256
|
+
// "Every 5 minutes". A literal minute like "30" still reads "Every hour at :30".
|
|
1257
|
+
if (minute !== '*' && !minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1138
1258
|
return `Every hour at :${minute.padStart(2, '0')}`
|
|
1139
1259
|
}
|
|
1140
1260
|
if (minute === '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1141
1261
|
return 'Every minute'
|
|
1142
1262
|
}
|
|
1143
|
-
|
|
1263
|
+
// Only claim a plain "Every N minutes" when nothing else constrains the window;
|
|
1264
|
+
// otherwise "*/5 9 * * *" (only at 09:xx) or "*/5 * * * 1-5" (weekdays only)
|
|
1265
|
+
// would read as an unconstrained interval. Constrained shapes fall through to
|
|
1266
|
+
// the raw cron rather than assert something misleading.
|
|
1267
|
+
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
1144
1268
|
const interval = minute.slice(2)
|
|
1145
|
-
return `Every ${interval} minutes`
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1269
|
+
return interval === '1' ? 'Every minute' : `Every ${interval} minutes`
|
|
1270
|
+
}
|
|
1271
|
+
// Weekday phrasing needs a literal hour:minute — a wildcard/step in either field
|
|
1272
|
+
// (e.g. "*/5 * * * 1-5") would render "Weekdays at *:*/5", so let it fall to raw.
|
|
1273
|
+
if (
|
|
1274
|
+
(dayOfWeek === '1-5' || dayOfWeek === 'MON-FRI') &&
|
|
1275
|
+
hour !== '*' && !hour.startsWith('*/') &&
|
|
1276
|
+
minute !== '*' && !minute.startsWith('*/')
|
|
1277
|
+
) {
|
|
1148
1278
|
return `Weekdays at ${hour}:${minute.padStart(2, '0')}`
|
|
1149
1279
|
}
|
|
1150
1280
|
|
|
@@ -1161,7 +1291,12 @@ export function getPVCStatus(pvc: any): StatusBadge {
|
|
|
1161
1291
|
case 'Bound':
|
|
1162
1292
|
return { text: 'Bound', color: healthColors.healthy, level: 'healthy' }
|
|
1163
1293
|
case 'Pending':
|
|
1164
|
-
|
|
1294
|
+
// Pending is benign here: a WaitForFirstConsumer claim stays Pending by
|
|
1295
|
+
// design until a pod needs it (could be forever for a scaled-to-zero
|
|
1296
|
+
// workload). We can't see the StorageClass binding mode from the PVC alone,
|
|
1297
|
+
// so age can't distinguish that from genuinely-stuck — the Problems panel,
|
|
1298
|
+
// which has that context, owns the stuck-PVC alarm.
|
|
1299
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
1165
1300
|
case 'Lost':
|
|
1166
1301
|
return { text: 'Lost', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
1167
1302
|
default:
|
|
@@ -1236,7 +1371,7 @@ export function getWorkflowStatus(workflow: any): StatusBadge {
|
|
|
1236
1371
|
case 'Succeeded':
|
|
1237
1372
|
return { text: 'Succeeded', color: healthColors.healthy, level: 'healthy' }
|
|
1238
1373
|
case 'Running':
|
|
1239
|
-
return { text: 'Running', color: healthColors.
|
|
1374
|
+
return { text: 'Running', color: healthColors.neutral, level: 'neutral' }
|
|
1240
1375
|
case 'Failed':
|
|
1241
1376
|
return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
1242
1377
|
case 'Error':
|
|
@@ -181,6 +181,10 @@ import {
|
|
|
181
181
|
RuntimeClassRenderer,
|
|
182
182
|
LeaseRenderer,
|
|
183
183
|
TraefikIngressRouteRenderer,
|
|
184
|
+
TraefikMiddlewareRenderer,
|
|
185
|
+
TraefikServiceRenderer,
|
|
186
|
+
TraefikTLSOptionRenderer,
|
|
187
|
+
TraefikServersTransportRenderer,
|
|
184
188
|
ContourHTTPProxyRenderer,
|
|
185
189
|
CAPIClusterRenderer,
|
|
186
190
|
CAPIMachineRenderer,
|
|
@@ -348,6 +352,8 @@ const KNOWN_KINDS = new Set([
|
|
|
348
352
|
'channels', 'inmemorychannels', 'subscriptions', 'sequences', 'parallels',
|
|
349
353
|
'knativeingresses', 'knativecertificates', 'serverlessservices', 'domainmappings',
|
|
350
354
|
'ingressroutes', 'ingressroutetcps', 'ingressrouteudps',
|
|
355
|
+
'middlewares', 'middlewaretcps', 'traefikservices',
|
|
356
|
+
'serverstransports', 'serverstransporttcps', 'tlsoptions',
|
|
351
357
|
'httpproxies',
|
|
352
358
|
'machinedeployments', 'machines', 'machinesets', 'machinepools',
|
|
353
359
|
'kubeadmcontrolplanes', 'clusterclasses', 'machinehealthchecks',
|
|
@@ -665,6 +671,10 @@ export function ResourceRendererDispatch({
|
|
|
665
671
|
|
|
666
672
|
{/* Traefik */}
|
|
667
673
|
{(kind === 'ingressroutes' || kind === 'ingressroutetcps' || kind === 'ingressrouteudps') && <TraefikIngressRouteRenderer data={data} onNavigate={onNavigate} />}
|
|
674
|
+
{(kind === 'middlewares' || kind === 'middlewaretcps') && <TraefikMiddlewareRenderer data={data} onNavigate={onNavigate} />}
|
|
675
|
+
{kind === 'traefikservices' && <TraefikServiceRenderer data={data} onNavigate={onNavigate} />}
|
|
676
|
+
{(kind === 'serverstransports' || kind === 'serverstransporttcps') && <TraefikServersTransportRenderer data={data} onNavigate={onNavigate} />}
|
|
677
|
+
{kind === 'tlsoptions' && <TraefikTLSOptionRenderer data={data} />}
|
|
668
678
|
|
|
669
679
|
{/* Contour */}
|
|
670
680
|
{kind === 'httpproxies' && <ContourHTTPProxyRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -573,6 +573,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
573
573
|
<HealthBarLegendItem color="bg-blue-500/60 dark:bg-blue-500/60" label="rolling" description="Expected degradation during deployment rollout" />
|
|
574
574
|
<HealthBarLegendItem color="bg-amber-500/60 dark:bg-[#b8861e]" label="degraded" description="Unexpected partial availability" />
|
|
575
575
|
<HealthBarLegendItem color="bg-red-500/60 dark:bg-red-500/60" label="unhealthy" description="Resource is failing or not ready" />
|
|
576
|
+
<HealthBarLegendItem color="bg-sky-500/60 dark:bg-sky-500/60" label="idle" description="Intentionally off/resting (completed, suspended, scaled to zero)" />
|
|
576
577
|
</div>
|
|
577
578
|
</div>
|
|
578
579
|
|
|
@@ -138,6 +138,10 @@ export function HealthSpanLegend() {
|
|
|
138
138
|
<span className="w-4 h-2 rounded-sm bg-red-500/60 dark:bg-red-500/60" />
|
|
139
139
|
<span>Unhealthy</span>
|
|
140
140
|
</span>
|
|
141
|
+
<span className="flex items-center gap-1">
|
|
142
|
+
<span className="w-4 h-2 rounded-sm bg-sky-500/60 dark:bg-sky-500/60" />
|
|
143
|
+
<span>Idle</span>
|
|
144
|
+
</span>
|
|
141
145
|
</div>
|
|
142
146
|
)
|
|
143
147
|
}
|
|
@@ -387,7 +391,7 @@ export function TimeAxis({ startTime, endTime, tickCount = 8, labelColumnClass =
|
|
|
387
391
|
* Health span bar showing a health state over a time range.
|
|
388
392
|
*/
|
|
389
393
|
interface HealthSpanProps {
|
|
390
|
-
health: 'healthy' | 'degraded' | 'unhealthy' | string
|
|
394
|
+
health: 'healthy' | 'rolling' | 'degraded' | 'unhealthy' | 'neutral' | 'unknown' | string
|
|
391
395
|
left: number // percentage
|
|
392
396
|
width: number // percentage
|
|
393
397
|
title?: string
|
|
@@ -408,6 +412,9 @@ export function HealthSpan({ health, left, width, title, createdBefore }: Health
|
|
|
408
412
|
return 'bg-amber-500/60 dark:bg-[#b8861e]'
|
|
409
413
|
case 'unhealthy':
|
|
410
414
|
return 'bg-red-500/60 dark:bg-red-500/60'
|
|
415
|
+
case 'neutral':
|
|
416
|
+
// intentional/idle (suspended, scaled-to-0) — sky, calm
|
|
417
|
+
return 'bg-sky-500/60 dark:bg-sky-500/60'
|
|
411
418
|
default:
|
|
412
419
|
// Unknown or other states
|
|
413
420
|
return 'bg-gray-400/40'
|
|
@@ -569,7 +576,11 @@ export function buildHealthSpans(
|
|
|
569
576
|
const existsUntil = deleteEvent ? new Date(deleteEvent.timestamp).getTime() : now
|
|
570
577
|
|
|
571
578
|
const spans: { start: number; end: number; health: string }[] = []
|
|
572
|
-
|
|
579
|
+
// `null` is the "no health observed yet" sentinel — kept distinct from the
|
|
580
|
+
// real 'unknown' health value (emitted for node-lost pods). Overloading
|
|
581
|
+
// 'unknown' as both swallowed genuine-unknown spans and then false-greened
|
|
582
|
+
// them via the empty-spans fallback below.
|
|
583
|
+
let currentHealth: string | null = null
|
|
573
584
|
let spanStart = Math.max(existsFrom, startTime)
|
|
574
585
|
|
|
575
586
|
for (const evt of sorted) {
|
|
@@ -586,7 +597,7 @@ export function buildHealthSpans(
|
|
|
586
597
|
continue
|
|
587
598
|
}
|
|
588
599
|
|
|
589
|
-
if (newHealth !== currentHealth && currentHealth !==
|
|
600
|
+
if (newHealth !== currentHealth && currentHealth !== null) {
|
|
590
601
|
spans.push({ start: spanStart, end: ts, health: currentHealth })
|
|
591
602
|
spanStart = ts
|
|
592
603
|
}
|
|
@@ -594,7 +605,7 @@ export function buildHealthSpans(
|
|
|
594
605
|
}
|
|
595
606
|
|
|
596
607
|
// Close final span (only up to when resource existed)
|
|
597
|
-
if (currentHealth !==
|
|
608
|
+
if (currentHealth !== null) {
|
|
598
609
|
spans.push({ start: spanStart, end: Math.min(existsUntil, now), health: currentHealth })
|
|
599
610
|
}
|
|
600
611
|
|
|
@@ -3,15 +3,17 @@ import { Handle, Position } from '@xyflow/react'
|
|
|
3
3
|
import {
|
|
4
4
|
ChevronDown,
|
|
5
5
|
ChevronUp,
|
|
6
|
+
TriangleAlert,
|
|
6
7
|
} from 'lucide-react'
|
|
7
8
|
import { clsx } from 'clsx'
|
|
8
9
|
import type { NodeKind, HealthStatus, PodSummary } from '../../types'
|
|
9
10
|
import { displayKind } from '../../types'
|
|
10
|
-
import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
|
|
11
|
+
import { healthToSeverity, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
11
12
|
import { workloadHue } from '../../utils/workload-colors'
|
|
12
13
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
13
14
|
import { midTruncate } from '../../utils/format'
|
|
14
15
|
import { Tooltip } from '../ui/Tooltip'
|
|
16
|
+
import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
|
|
15
17
|
|
|
16
18
|
// Get actionable tooltip content for health issues
|
|
17
19
|
function getIssueTooltip(issue: string | undefined): React.ReactNode {
|
|
@@ -215,6 +217,12 @@ const STATUS_STYLES: Record<HealthStatus, React.CSSProperties> = {
|
|
|
215
217
|
border: '2px solid rgb(239 68 68 / 0.7)',
|
|
216
218
|
backgroundColor: 'rgb(248 113 113 / 0.15)',
|
|
217
219
|
},
|
|
220
|
+
// neutral = intentional/idle (suspended, scaled-to-0) — sky outline, calm.
|
|
221
|
+
// Distinct from `unknown`/`healthy` which carry no outline.
|
|
222
|
+
neutral: {
|
|
223
|
+
border: '2px solid rgb(56 189 248 / 0.55)',
|
|
224
|
+
backgroundColor: 'rgb(56 189 248 / 0.1)',
|
|
225
|
+
},
|
|
218
226
|
healthy: {},
|
|
219
227
|
unknown: {},
|
|
220
228
|
}
|
|
@@ -376,6 +384,15 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
376
384
|
id,
|
|
377
385
|
}: K8sResourceNodeProps) {
|
|
378
386
|
const { kind, name, status, nodeData, selected, dimmed, onExpand, onCollapse, isExpanded } = data
|
|
387
|
+
// Cluster Audit findings joined onto this node by the host (web/ enriches each
|
|
388
|
+
// node's data by auditKey). The host only counts "badge-worthy" findings —
|
|
389
|
+
// reference-integrity / lifecycle, "this resource is actually broken" — not the
|
|
390
|
+
// posture/best-practice nags that fire near-universally, so the indicator stays
|
|
391
|
+
// a signal. Colored by worst severity (danger red, else warning amber).
|
|
392
|
+
const auditDanger = typeof nodeData.auditDanger === 'number' ? nodeData.auditDanger : 0
|
|
393
|
+
const auditWarning = typeof nodeData.auditWarning === 'number' ? nodeData.auditWarning : 0
|
|
394
|
+
const auditTotal = auditDanger + auditWarning
|
|
395
|
+
const auditMessages = Array.isArray(nodeData.auditMessages) ? (nodeData.auditMessages as AuditBadgeMessage[]) : []
|
|
379
396
|
// Workload tint (application graph): a node owned by exactly one workload
|
|
380
397
|
// carries that workload's hue. Only on healthy/unknown cards — degraded/
|
|
381
398
|
// unhealthy already own the card background for health, which must win.
|
|
@@ -506,6 +523,16 @@ export const K8sResourceNode = memo(function K8sResourceNode({
|
|
|
506
523
|
<ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
|
|
507
524
|
</button>
|
|
508
525
|
)}
|
|
526
|
+
{auditTotal > 0 && (
|
|
527
|
+
<Tooltip
|
|
528
|
+
content={auditMessages.length > 0
|
|
529
|
+
? <AuditBadgeTooltip messages={auditMessages} clickHint={false} />
|
|
530
|
+
: `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${auditDanger > 0 ? ` · ${auditDanger} danger` : ''}`}
|
|
531
|
+
position="right"
|
|
532
|
+
>
|
|
533
|
+
<TriangleAlert className={clsx('w-3 h-3 cursor-help', auditDanger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
|
|
534
|
+
</Tooltip>
|
|
535
|
+
)}
|
|
509
536
|
{issueTooltip ? (
|
|
510
537
|
<Tooltip content={issueTooltip} position="right">
|
|
511
538
|
<span className={clsx('w-1.5 h-1.5 rounded-full cursor-help', getStatusDotColor(status))} />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FolderTree, ShieldCheck } from 'lucide-react'
|
|
2
2
|
import type { TopologyMode, GroupingMode } from '../../types/core'
|
|
3
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
3
4
|
|
|
4
5
|
interface TopologyControlsProps {
|
|
5
6
|
viewMode: TopologyMode
|
|
@@ -11,6 +12,12 @@ interface TopologyControlsProps {
|
|
|
11
12
|
onShowPolicyEffectChange?: (show: boolean) => void
|
|
12
13
|
/** Show the "Fleet" button (CAPI cluster management view) */
|
|
13
14
|
showFleetMode?: boolean
|
|
15
|
+
/**
|
|
16
|
+
* Navigate to the observed-traffic view. When provided, the "Network Flow"
|
|
17
|
+
* tooltip offers a link to it — disambiguating the config-derived flow graph
|
|
18
|
+
* here from the live, observed Traffic view. Omitted by hosts without one.
|
|
19
|
+
*/
|
|
20
|
+
onNavigateToTraffic?: () => void
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export function TopologyControls({
|
|
@@ -22,6 +29,7 @@ export function TopologyControls({
|
|
|
22
29
|
showPolicyEffect = false,
|
|
23
30
|
onShowPolicyEffectChange,
|
|
24
31
|
showFleetMode = false,
|
|
32
|
+
onNavigateToTraffic,
|
|
25
33
|
}: TopologyControlsProps) {
|
|
26
34
|
return (
|
|
27
35
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
|
@@ -69,16 +77,40 @@ export function TopologyControls({
|
|
|
69
77
|
>
|
|
70
78
|
Resources
|
|
71
79
|
</button>
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
<Tooltip
|
|
81
|
+
position="bottom"
|
|
82
|
+
content={
|
|
83
|
+
<div className="space-y-1.5 text-left">
|
|
84
|
+
<p className="text-theme-text-secondary">
|
|
85
|
+
How requests <em>should</em> route, derived from Ingress, Services and
|
|
86
|
+
routing CRDs (Traefik, Gateway API, Istio…) — not observed packets.
|
|
87
|
+
</p>
|
|
88
|
+
{onNavigateToTraffic && (
|
|
89
|
+
<p className="text-theme-text-tertiary">
|
|
90
|
+
Looking for observed, measured traffic?{' '}
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={onNavigateToTraffic}
|
|
94
|
+
className="text-skyhook-400 hover:text-skyhook-300 underline underline-offset-2"
|
|
95
|
+
>
|
|
96
|
+
Open Live Traffic →
|
|
97
|
+
</button>
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
}
|
|
79
102
|
>
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
<button
|
|
104
|
+
onClick={() => onViewModeChange('traffic')}
|
|
105
|
+
className={`px-2.5 py-1 text-xs rounded-md transition-colors whitespace-nowrap ${
|
|
106
|
+
viewMode === 'traffic'
|
|
107
|
+
? 'bg-skyhook-600 text-white'
|
|
108
|
+
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
|
|
109
|
+
}`}
|
|
110
|
+
>
|
|
111
|
+
Network Flow <span className="opacity-70">(config)</span>
|
|
112
|
+
</button>
|
|
113
|
+
</Tooltip>
|
|
82
114
|
{showFleetMode && (
|
|
83
115
|
<button
|
|
84
116
|
onClick={() => onViewModeChange('fleet')}
|