@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.
Files changed (55) hide show
  1. package/package.json +2 -2
  2. package/src/components/charts/PrometheusChartsView.tsx +3 -8
  3. package/src/components/gitops/GitOpsDetailLayout.tsx +2 -3
  4. package/src/components/gitops/GitOpsTableView.tsx +2 -4
  5. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -6
  6. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +3 -7
  7. package/src/components/logs/LogCore.tsx +1 -1
  8. package/src/components/resources/ResourcesView.tsx +34 -5
  9. package/src/components/resources/get-pod-phase-display.test.ts +115 -3
  10. package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
  11. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
  12. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
  13. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
  14. package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
  15. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
  16. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
  17. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
  18. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
  19. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
  20. package/src/components/resources/renderers/JobRenderer.tsx +6 -5
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
  22. package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
  24. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
  25. package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
  26. package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
  27. package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
  28. package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
  29. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
  30. package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
  31. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
  32. package/src/components/resources/renderers/contour-cells.tsx +6 -5
  33. package/src/components/resources/renderers/index.ts +4 -0
  34. package/src/components/resources/renderers/istio-cells.tsx +16 -25
  35. package/src/components/resources/resource-utils-cnpg.ts +1 -1
  36. package/src/components/resources/resource-utils-keda.ts +12 -6
  37. package/src/components/resources/resource-utils.ts +172 -57
  38. package/src/components/shared/ResourceActionsBar.tsx +1 -1
  39. package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
  40. package/src/components/topology/TopologyControls.tsx +41 -9
  41. package/src/components/topology/TopologyGraph.tsx +20 -7
  42. package/src/components/topology/layout-elk-graph.test.ts +222 -0
  43. package/src/components/topology/layout.ts +96 -33
  44. package/src/components/topology/layout.worker.ts +18 -34
  45. package/src/components/ui/Badge.tsx +66 -3
  46. package/src/components/ui/ClusterName.tsx +12 -4
  47. package/src/components/ui/CodeViewer.tsx +1 -1
  48. package/src/components/ui/PaneLoader.tsx +6 -1
  49. package/src/components/ui/RestrictedState.tsx +152 -0
  50. package/src/components/ui/YamlEditor.tsx +1 -1
  51. package/src/components/ui/drawer-components.tsx +1 -1
  52. package/src/components/ui/index.ts +1 -0
  53. package/src/components/workload/WorkloadView.tsx +1 -1
  54. package/src/types/core.ts +37 -0
  55. 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
- return { text: 'Idle', color: healthColors.degraded, level: 'degraded' }
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
- return { text: 'Idle', color: healthColors.degraded, level: 'degraded' }
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
- // 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:
@@ -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
- return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
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.degraded, level: 'degraded' }
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.degraded, level: 'degraded' }
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
- return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
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.degraded, level: 'degraded' }
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
- <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')}
@@ -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 && collapsedGroups.has(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 && collapsedGroups.has(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
  }