@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.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsView.tsx +2 -0
  3. package/src/components/audit/AuditAlerts.tsx +4 -0
  4. package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
  5. package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
  6. package/src/components/audit/AuditFindingsTable.tsx +4 -0
  7. package/src/components/audit/index.ts +1 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
  9. package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
  10. package/src/components/gitops/GitOpsTableView.tsx +3 -1
  11. package/src/components/issues/IssuesView.tsx +9 -36
  12. package/src/components/issues/ResourceIssuesSection.tsx +142 -0
  13. package/src/components/issues/diagnostic.ts +64 -0
  14. package/src/components/issues/index.ts +2 -1
  15. package/src/components/issues/severity.ts +10 -9
  16. package/src/components/issues/types.ts +5 -0
  17. package/src/components/resources/ResourcesView.tsx +38 -2
  18. package/src/components/resources/cron-to-human.test.ts +41 -0
  19. package/src/components/resources/get-pod-phase-display.test.ts +115 -3
  20. package/src/components/resources/get-pod-problems.test.ts +18 -0
  21. package/src/components/resources/health-golden.test.ts +66 -0
  22. package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
  23. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
  24. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
  25. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
  26. package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
  27. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
  28. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
  29. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
  30. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
  31. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
  32. package/src/components/resources/renderers/JobRenderer.tsx +12 -7
  33. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  34. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
  35. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  36. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  37. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  39. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
  40. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
  41. package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
  42. package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
  43. package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
  44. package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
  45. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
  46. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  47. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
  48. package/src/components/resources/renderers/contour-cells.tsx +6 -5
  49. package/src/components/resources/renderers/index.ts +4 -0
  50. package/src/components/resources/renderers/istio-cells.tsx +16 -25
  51. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  52. package/src/components/resources/resource-utils-argo.ts +5 -1
  53. package/src/components/resources/resource-utils-cnpg.ts +1 -1
  54. package/src/components/resources/resource-utils-keda.ts +17 -7
  55. package/src/components/resources/resource-utils.ts +206 -71
  56. package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
  57. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  58. package/src/components/timeline/shared.tsx +15 -4
  59. package/src/components/topology/K8sResourceNode.tsx +28 -1
  60. package/src/components/topology/TopologyControls.tsx +41 -9
  61. package/src/components/topology/layout.ts +11 -5
  62. package/src/components/ui/Badge.tsx +66 -3
  63. package/src/components/ui/ClusterName.tsx +12 -4
  64. package/src/components/ui/PaneLoader.tsx +30 -7
  65. package/src/components/ui/drawer-components.test.tsx +35 -0
  66. package/src/components/ui/drawer-components.tsx +13 -1
  67. package/src/components/workload/WorkloadView.tsx +35 -5
  68. package/src/types/core.ts +136 -2
  69. package/src/utils/applications.test.ts +55 -1
  70. package/src/utils/applications.ts +28 -7
  71. package/src/utils/asset-url.ts +13 -0
  72. 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
- // Check for terminating
120
- if (pod.metadata?.deletionTimestamp) {
121
- return { text: 'Terminating', color: healthColors.degraded, level: 'degraded' }
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
- // Check container states for issues
125
- for (const cs of containerStatuses) {
126
- if (cs.state?.waiting?.reason) {
127
- const reason = cs.state.waiting.reason
128
- if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull', 'CreateContainerConfigError'].includes(reason)) {
129
- return { text: reason, color: healthColors.unhealthy, level: 'unhealthy' }
130
- }
131
- }
132
- if (cs.state?.terminated?.reason === 'OOMKilled') {
133
- return { text: 'OOMKilled', color: healthColors.unhealthy, level: 'unhealthy' }
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
- // Check if all containers are ready
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
- if (total > 0 && ready < total) {
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
- return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
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: 'Pod has a deletionTimestamp set; awaiting graceful termination.',
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
- const notReady = totalContainers > 0 && readyContainers < totalContainers
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
- return { phase, text: 'Pending', level: 'degraded' }
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
- const deleteTime = new Date(pod.metadata.deletionTimestamp).getTime()
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 readyContainers = containerStatuses.filter((c: any) => c.ready).length
492
+ const unsettled = containerStatuses.filter((c: any) => !containerSettledOk(c)).length
388
493
  const totalContainers = containerStatuses.length
389
- if (totalContainers > 0 && readyContainers < totalContainers) {
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
- if (desired === 0) return { text: '0 nodes', color: healthColors.unknown, level: 'unknown' }
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
- return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
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
- return { text: 'Complete', color: healthColors.healthy, level: 'healthy' }
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.degraded, level: 'degraded' }
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.degraded, level: 'degraded' }
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
- if (minute !== '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
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
- if (minute.startsWith('*/')) {
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
- if (dayOfWeek === '1-5' || dayOfWeek === 'MON-FRI') {
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
- return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
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.degraded, level: 'degraded' }
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
- let currentHealth = 'unknown'
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 !== 'unknown') {
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 !== 'unknown') {
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
- <button
73
- onClick={() => onViewModeChange('traffic')}
74
- className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
75
- viewMode === 'traffic'
76
- ? 'bg-skyhook-600 text-white'
77
- : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
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
- Traffic
81
- </button>
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')}