@skyhook-io/k8s-ui 1.8.4 → 1.8.6
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 +2 -2
- package/src/components/charts/PrometheusChartsView.tsx +3 -8
- package/src/components/gitops/GitOpsDetailLayout.tsx +2 -3
- package/src/components/gitops/GitOpsTableView.tsx +2 -4
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -6
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +3 -7
- package/src/components/logs/LogCore.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +34 -5
- package/src/components/resources/get-pod-phase-display.test.ts +115 -3
- 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 +6 -5
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
- package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
- 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 +1 -1
- 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-cnpg.ts +1 -1
- package/src/components/resources/resource-utils-keda.ts +12 -6
- package/src/components/resources/resource-utils.ts +172 -57
- package/src/components/shared/ResourceActionsBar.tsx +1 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
- package/src/components/topology/TopologyControls.tsx +41 -9
- package/src/components/topology/TopologyGraph.tsx +20 -7
- package/src/components/topology/layout-elk-graph.test.ts +222 -0
- package/src/components/topology/layout.ts +96 -33
- package/src/components/topology/layout.worker.ts +18 -34
- package/src/components/ui/Badge.tsx +66 -3
- package/src/components/ui/ClusterName.tsx +12 -4
- package/src/components/ui/CodeViewer.tsx +1 -1
- package/src/components/ui/PaneLoader.tsx +6 -1
- package/src/components/ui/RestrictedState.tsx +152 -0
- package/src/components/ui/YamlEditor.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/types/core.ts +37 -0
- package/src/utils/asset-url.ts +13 -0
|
@@ -50,7 +50,9 @@ export function getScaledObjectStatus(resource: any): StatusBadge {
|
|
|
50
50
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
51
51
|
}
|
|
52
52
|
if (activeCond?.status === 'False') {
|
|
53
|
-
|
|
53
|
+
// Idle is the normal resting state of a scaler with no triggers firing
|
|
54
|
+
// (like a CronJob waiting for its next run), not a fault.
|
|
55
|
+
return { text: 'Idle', color: healthColors.neutral, level: 'neutral' }
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
if (readyCond?.status === 'True') {
|
|
@@ -129,17 +131,21 @@ export function getScaledJobStatus(resource: any): StatusBadge {
|
|
|
129
131
|
if (readyCond?.status === 'True') {
|
|
130
132
|
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
131
133
|
}
|
|
134
|
+
// A non-operational scaler (Ready=False) is unhealthy and must take precedence
|
|
135
|
+
// over the Idle (Active=False) branch below — otherwise a broken-and-idle
|
|
136
|
+
// ScaledJob hides as benign "Idle". Mirrors getScaledObjectStatus.
|
|
137
|
+
if (readyCond?.status === 'False') {
|
|
138
|
+
return { text: readyCond.reason || 'NotReady', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
139
|
+
}
|
|
132
140
|
|
|
133
141
|
const activeCond = conditions.find((c: any) => c.type === 'Active')
|
|
134
142
|
if (activeCond?.status === 'True') {
|
|
135
143
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
136
144
|
}
|
|
137
145
|
if (activeCond?.status === 'False') {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (readyCond?.status === 'False') {
|
|
142
|
-
return { text: readyCond.reason || 'NotReady', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
146
|
+
// Idle is the normal resting state of a scaler with no triggers firing
|
|
147
|
+
// (like a CronJob waiting for its next run), not a fault.
|
|
148
|
+
return { text: 'Idle', color: healthColors.neutral, level: 'neutral' }
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
@@ -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:
|
|
@@ -778,7 +880,12 @@ export function getIngressStatus(ingress: any): StatusBadge {
|
|
|
778
880
|
if (lbIngress.length > 0) {
|
|
779
881
|
return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
|
|
780
882
|
}
|
|
781
|
-
|
|
883
|
+
// Awaiting an external address is normal right after creation; only flag it
|
|
884
|
+
// once the ingress/LB controller has had time and still hasn't assigned one.
|
|
885
|
+
if (minutesSince(ingress.metadata?.creationTimestamp) >= PENDING_STUCK_MINUTES) {
|
|
886
|
+
return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
|
|
887
|
+
}
|
|
888
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
782
889
|
}
|
|
783
890
|
|
|
784
891
|
export function getIngressHosts(ingress: any): string {
|
|
@@ -905,7 +1012,7 @@ export function getJobStatus(job: any): StatusBadge {
|
|
|
905
1012
|
}
|
|
906
1013
|
|
|
907
1014
|
if (job.spec?.suspend) {
|
|
908
|
-
return { text: 'Suspended', color: healthColors.
|
|
1015
|
+
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
909
1016
|
}
|
|
910
1017
|
|
|
911
1018
|
if (status.active > 0) {
|
|
@@ -936,7 +1043,7 @@ export function getJobDuration(job: any): string | null {
|
|
|
936
1043
|
|
|
937
1044
|
export function getCronJobStatus(cj: any): StatusBadge {
|
|
938
1045
|
if (cj.spec?.suspend) {
|
|
939
|
-
return { text: 'Suspended', color: healthColors.
|
|
1046
|
+
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
940
1047
|
}
|
|
941
1048
|
const activeJobs = cj.status?.active?.length || 0
|
|
942
1049
|
if (activeJobs > 0) {
|
|
@@ -1010,6 +1117,9 @@ export function getNodeStatus(node: any): StatusBadge {
|
|
|
1010
1117
|
const isUnschedulable = node.spec?.unschedulable === true
|
|
1011
1118
|
|
|
1012
1119
|
if (isReady && isUnschedulable) {
|
|
1120
|
+
// Cordon is intentional but consequential — it's lost scheduling capacity, and
|
|
1121
|
+
// a forgotten cordon strands nodes — so it stays on the warning axis (matching
|
|
1122
|
+
// the backend Cordoned issue), unlike no-op intentional states (suspended/idle).
|
|
1013
1123
|
return { text: 'Ready,SchedulingDisabled', color: healthColors.degraded, level: 'degraded' }
|
|
1014
1124
|
}
|
|
1015
1125
|
if (isReady) {
|
|
@@ -1161,7 +1271,12 @@ export function getPVCStatus(pvc: any): StatusBadge {
|
|
|
1161
1271
|
case 'Bound':
|
|
1162
1272
|
return { text: 'Bound', color: healthColors.healthy, level: 'healthy' }
|
|
1163
1273
|
case 'Pending':
|
|
1164
|
-
|
|
1274
|
+
// Pending is benign here: a WaitForFirstConsumer claim stays Pending by
|
|
1275
|
+
// design until a pod needs it (could be forever for a scaled-to-zero
|
|
1276
|
+
// workload). We can't see the StorageClass binding mode from the PVC alone,
|
|
1277
|
+
// so age can't distinguish that from genuinely-stuck — the Problems panel,
|
|
1278
|
+
// which has that context, owns the stuck-PVC alarm.
|
|
1279
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
1165
1280
|
case 'Lost':
|
|
1166
1281
|
return { text: 'Lost', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
1167
1282
|
default:
|
|
@@ -1236,7 +1351,7 @@ export function getWorkflowStatus(workflow: any): StatusBadge {
|
|
|
1236
1351
|
case 'Succeeded':
|
|
1237
1352
|
return { text: 'Succeeded', color: healthColors.healthy, level: 'healthy' }
|
|
1238
1353
|
case 'Running':
|
|
1239
|
-
return { text: 'Running', color: healthColors.
|
|
1354
|
+
return { text: 'Running', color: healthColors.neutral, level: 'neutral' }
|
|
1240
1355
|
case 'Failed':
|
|
1241
1356
|
return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
1242
1357
|
case 'Error':
|
|
@@ -907,7 +907,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
|
|
|
907
907
|
<div className={clsx("p-4 overflow-y-auto", diffRevision ? "max-h-48 shrink-0" : "max-h-80")}>
|
|
908
908
|
{isLoading && (
|
|
909
909
|
<div className="flex items-center justify-center py-8 text-theme-text-secondary text-sm">
|
|
910
|
-
Loading revisions
|
|
910
|
+
Loading revisions…
|
|
911
911
|
</div>
|
|
912
912
|
)}
|
|
913
913
|
|
|
@@ -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} />}
|
|
@@ -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')}
|
|
@@ -30,7 +30,7 @@ import { K8sResourceNode } from './K8sResourceNode'
|
|
|
30
30
|
import { GroupNode } from './GroupNode'
|
|
31
31
|
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
|
|
32
32
|
import { ownershipOf } from '../../utils/topology-neighborhood'
|
|
33
|
-
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
|
|
33
|
+
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
|
|
34
34
|
import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
|
|
35
35
|
import { pluralize } from '../../utils/pluralize'
|
|
36
36
|
import { foldHash } from '../../utils/structure-hash'
|
|
@@ -94,7 +94,9 @@ function buildEdges(
|
|
|
94
94
|
groupingMode: GroupingMode,
|
|
95
95
|
isTrafficView: boolean,
|
|
96
96
|
nodeToGroup?: Map<string, string>,
|
|
97
|
-
nodeCount?: number
|
|
97
|
+
nodeCount?: number,
|
|
98
|
+
groupLevels?: Map<string, GroupDisplayLevel>,
|
|
99
|
+
smartDefaultActive = false,
|
|
98
100
|
): Edge[] {
|
|
99
101
|
const edges: Edge[] = []
|
|
100
102
|
const seenEdgeIds = new Set<string>() // O(1) duplicate detection
|
|
@@ -117,15 +119,17 @@ function buildEdges(
|
|
|
117
119
|
let source = edge.source
|
|
118
120
|
let target = edge.target
|
|
119
121
|
|
|
120
|
-
// If source is in a collapsed group, point to the group instead
|
|
122
|
+
// If source is in a collapsed group, point to the group instead. Same
|
|
123
|
+
// predicate as ELK node placement (isGroupEffectivelyCollapsed) so a
|
|
124
|
+
// rendered edge never references a member hidden inside a chip.
|
|
121
125
|
const sourceGroup = nodeGroupMap.get(source)
|
|
122
|
-
if (sourceGroup &&
|
|
126
|
+
if (sourceGroup && isGroupEffectivelyCollapsed(sourceGroup, collapsedGroups, groupLevels, smartDefaultActive)) {
|
|
123
127
|
source = sourceGroup
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
// If target is in a collapsed group, point to the group instead
|
|
127
131
|
const targetGroup = nodeGroupMap.get(target)
|
|
128
|
-
if (targetGroup &&
|
|
132
|
+
if (targetGroup && isGroupEffectivelyCollapsed(targetGroup, collapsedGroups, groupLevels, smartDefaultActive)) {
|
|
129
133
|
target = targetGroup
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -607,13 +611,20 @@ export function TopologyGraph({
|
|
|
607
611
|
// Increment version to invalidate any previous in-flight layout
|
|
608
612
|
const thisLayoutVersion = ++layoutVersionRef.current
|
|
609
613
|
|
|
614
|
+
// The smart-default chip pass only ever materializes namespace groups, so its
|
|
615
|
+
// "no-entry group defaults to collapsed" semantics apply only in namespace
|
|
616
|
+
// mode. Outside it (small clusters, app grouping) a no-entry group stays
|
|
617
|
+
// expanded — see isGroupEffectivelyCollapsed.
|
|
618
|
+
const smartDefaultActive = hasAppliedSmartDefaultRef.current && groupingMode === 'namespace'
|
|
619
|
+
|
|
610
620
|
// Build hierarchical ELK graph
|
|
611
621
|
const { elkGraph, groupMap, nodeToGroup } = buildHierarchicalElkGraph(
|
|
612
622
|
workingNodes,
|
|
613
623
|
workingEdges,
|
|
614
624
|
groupingMode,
|
|
615
625
|
collapsedGroups,
|
|
616
|
-
groupLevels
|
|
626
|
+
groupLevels,
|
|
627
|
+
smartDefaultActive
|
|
617
628
|
)
|
|
618
629
|
groupMapRef.current = groupMap
|
|
619
630
|
|
|
@@ -711,7 +722,9 @@ export function TopologyGraph({
|
|
|
711
722
|
groupingMode,
|
|
712
723
|
isTrafficView,
|
|
713
724
|
nodeToGroup,
|
|
714
|
-
nodesWithHandlers.length
|
|
725
|
+
nodesWithHandlers.length,
|
|
726
|
+
groupLevels,
|
|
727
|
+
smartDefaultActive
|
|
715
728
|
)
|
|
716
729
|
setEdges(builtEdges)
|
|
717
730
|
}
|