@skyhook-io/k8s-ui 1.8.5 → 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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/components/resources/get-pod-phase-display.test.ts +115 -3
  3. package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
  4. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
  5. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
  6. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
  7. package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
  8. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
  9. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
  10. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
  11. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
  12. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
  13. package/src/components/resources/renderers/JobRenderer.tsx +6 -5
  14. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
  15. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
  16. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
  17. package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
  18. package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
  19. package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
  20. package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
  21. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
  22. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
  23. package/src/components/resources/renderers/contour-cells.tsx +6 -5
  24. package/src/components/resources/renderers/index.ts +4 -0
  25. package/src/components/resources/renderers/istio-cells.tsx +16 -25
  26. package/src/components/resources/resource-utils-cnpg.ts +1 -1
  27. package/src/components/resources/resource-utils-keda.ts +12 -6
  28. package/src/components/resources/resource-utils.ts +172 -57
  29. package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
  30. package/src/components/topology/TopologyControls.tsx +41 -9
  31. package/src/components/ui/Badge.tsx +66 -3
  32. package/src/components/ui/ClusterName.tsx +12 -4
  33. package/src/components/ui/PaneLoader.tsx +6 -1
  34. package/src/types/core.ts +37 -0
  35. package/src/utils/asset-url.ts +13 -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:
@@ -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':
@@ -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')}
@@ -9,11 +9,31 @@ import type { ReactNode } from 'react'
9
9
  export type BadgeSeverity = 'success' | 'warning' | 'alert' | 'error' | 'info' | 'neutral'
10
10
  export type BadgeSize = 'sm' | 'default'
11
11
 
12
+ // Intent tokens beyond severity/kind. Named by PURPOSE, not hue: two tokens may
13
+ // share a color value today yet stay separate names so a future retune moves
14
+ // exactly the right ones. See DESIGN.md "Badge decision tree".
15
+ //
16
+ // - protocol: transport/scheme tags (HTTP/TCP/UDP/…). Its OWN family because it
17
+ // is a recurring, meaningful axis and must NOT collide with severity hues
18
+ // (e.g. a green "HTTPS" must not read as "success").
19
+ // - note: neutral attention/FYI markers (cross-namespace, wildcard, default,
20
+ // immutable). Distinct from severity-info — it flags "noteworthy", not a status.
21
+ // - accent1/2/3: LOCAL categorical distinction (rw/ro, spot/on-demand,
22
+ // control-plane/worker) where the hue carries no cross-screen meaning — just
23
+ // "tell these sibling options apart". The only place sharing is intended.
24
+ // - structural: ports/paths/hosts/weights/names — neutral data fragments, not a
25
+ // status. Its own name so it can diverge from severity-neutral later.
26
+ export type BadgeTone = 'note' | 'accent1' | 'accent2' | 'accent3' | 'structural'
27
+
12
28
  interface BadgeProps {
13
29
  /** Severity-based coloring (status badges) */
14
30
  severity?: BadgeSeverity
15
31
  /** K8s resource kind coloring (kind badges) */
16
32
  kind?: string
33
+ /** Transport protocol/scheme tag (http/https/tls/tcp/udp/grpc/h2) */
34
+ protocol?: string
35
+ /** Categorical/accent intent tone (see BadgeTone) */
36
+ tone?: BadgeTone
17
37
  /** Explicit color class override (bypasses severity/kind lookup) */
18
38
  colorClass?: string
19
39
  /** Size variant */
@@ -135,12 +155,49 @@ const KIND: Record<string, string> = {
135
155
 
136
156
  const DEFAULT_KIND_COLOR = 'bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-400 dark:border-fuchsia-800/40'
137
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // PROTOCOL COLORS — transport/scheme tags. Deliberately avoid severity hues
160
+ // (emerald/amber/orange/red/sky) so a protocol never reads as a status.
161
+ // ---------------------------------------------------------------------------
162
+ const PROTOCOL: Record<string, string> = {
163
+ http: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
164
+ http2: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
165
+ h2: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
166
+ h2c: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
167
+ https: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
168
+ tls: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
169
+ tcp: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
170
+ udp: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
171
+ grpc: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
172
+ 'grpc-web': 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
173
+ }
174
+ const DEFAULT_PROTOCOL_COLOR = 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40'
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // TONE COLORS — note / local-categorical accents / structural.
178
+ // ---------------------------------------------------------------------------
179
+ const TONE: Record<BadgeTone, string> = {
180
+ // attention/FYI — distinct from severity so it doesn't read as warning/info
181
+ note: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
182
+ // three visually-distinct accents for local "tell siblings apart" use
183
+ accent1: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
184
+ accent2: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
185
+ accent3: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
186
+ // neutral data fragment (ports/paths/hosts/names)
187
+ structural: 'bg-theme-elevated text-theme-text-secondary border-theme-border',
188
+ }
189
+
138
190
  // Structure classes
139
191
  const SIZE_CLASSES: Record<BadgeSize, string> = {
140
192
  default: 'badge',
141
193
  sm: 'badge-sm',
142
194
  }
143
195
 
196
+ /** Resolve a protocol/scheme label (case-insensitive) to its color class. */
197
+ export function getProtocolColorClass(protocol: string): string {
198
+ return PROTOCOL[protocol.toLowerCase().trim()] ?? DEFAULT_PROTOCOL_COLOR
199
+ }
200
+
144
201
  /** Resolve kind color with fuzzy matching for plural/lowercase forms */
145
202
  export function getKindColorClass(kind: string): string {
146
203
  // Direct match
@@ -185,8 +242,14 @@ export function getSeverityColorClass(severity: BadgeSeverity): string {
185
242
  * Badge component — the ONE source of truth for badge rendering.
186
243
  * Use severity for status badges, kind for resource type badges, or colorClass for custom.
187
244
  */
188
- export function Badge({ severity, kind, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
189
- const color = colorClass ?? (severity ? SEVERITY[severity] : kind ? getKindColorClass(kind) : '')
245
+ export function Badge({ severity, kind, protocol, tone, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
246
+ const color =
247
+ colorClass ??
248
+ (severity ? SEVERITY[severity]
249
+ : protocol ? getProtocolColorClass(protocol)
250
+ : tone ? TONE[tone]
251
+ : kind ? getKindColorClass(kind)
252
+ : '')
190
253
  const cls = clsx(SIZE_CLASSES[size], color, className)
191
254
 
192
255
  if (onClick) {
@@ -196,4 +259,4 @@ export function Badge({ severity, kind, colorClass, size = 'default', className,
196
259
  }
197
260
 
198
261
  // Re-export the raw color maps for backwards compat (used by badge-colors.ts consumers)
199
- export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS }
262
+ export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS, PROTOCOL as BADGE_PROTOCOL_COLORS, TONE as BADGE_TONE_COLORS }
@@ -3,10 +3,18 @@ import { MiddleEllipsis } from './MiddleEllipsis'
3
3
  import { Tooltip } from './Tooltip'
4
4
  import { parseContextName } from '../../utils/context-name'
5
5
  import type { ParsedContextName } from '../../utils/context-name'
6
- import awsLogo from './provider-logos/aws.png'
7
- import awsLogoDark from './provider-logos/aws-dark.png'
8
- import gcpLogo from './provider-logos/gcp.png'
9
- import azureLogo from './provider-logos/azure.svg'
6
+ import { assetUrl } from '../../utils/asset-url'
7
+ import awsLogoAsset from './provider-logos/aws.png'
8
+ import awsLogoDarkAsset from './provider-logos/aws-dark.png'
9
+ import gcpLogoAsset from './provider-logos/gcp.png'
10
+ import azureLogoAsset from './provider-logos/azure.svg'
11
+
12
+ // assetUrl normalizes the bundler-specific asset-import type (string under Vite,
13
+ // StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
14
+ const awsLogo = assetUrl(awsLogoAsset)
15
+ const awsLogoDark = assetUrl(awsLogoDarkAsset)
16
+ const gcpLogo = assetUrl(gcpLogoAsset)
17
+ const azureLogo = assetUrl(azureLogoAsset)
10
18
 
11
19
  // ClusterName renders a kubectl context string with the meaningful
12
20
  // cluster identity surfaced as primary text and provider/region pushed
@@ -1,4 +1,9 @@
1
- import radarLoadingIcon from '../../assets/radar/radar-icon-loading.svg'
1
+ import { assetUrl } from '../../utils/asset-url'
2
+ import radarLoadingIconAsset from '../../assets/radar/radar-icon-loading.svg'
3
+
4
+ // assetUrl normalizes the bundler-specific asset-import type (string under Vite,
5
+ // StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
6
+ const radarLoadingIcon = assetUrl(radarLoadingIconAsset)
2
7
 
3
8
  // PaneLoader — center-of-pane loading state. Animated radar icon stacked
4
9
  // above a label so swapping the label across the loading chain doesn't
package/src/types/core.ts CHANGED
@@ -554,6 +554,8 @@ export interface HelmRelease {
554
554
  status: string
555
555
  revision: number
556
556
  updated: string // ISO date string
557
+ lastOperation?: HelmOperation
558
+ operations?: HelmOperation[]
557
559
  // Health summary from owned resources
558
560
  resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
559
561
  healthIssue?: string // Primary issue if unhealthy (e.g., "OOMKilled")
@@ -574,6 +576,27 @@ export interface HelmRevision {
574
576
  updated: string // ISO date string
575
577
  }
576
578
 
579
+ export type HelmOperationKind = 'release_failed' | 'upgrade_failed' | 'upgrade_rolled_back' | 'rollback' | 'pending'
580
+ export type HelmOperationStatus = 'failed' | 'rolled_back' | 'completed' | 'stuck_pending'
581
+ export type HelmOperationConfidence = 'high' | 'medium' | 'low'
582
+ export type HelmOperationSource = 'helm_status' | 'helm_history'
583
+
584
+ export interface HelmOperation {
585
+ kind: HelmOperationKind
586
+ status: HelmOperationStatus
587
+ source: HelmOperationSource
588
+ confidence: HelmOperationConfidence
589
+ message: string
590
+ evidence?: string
591
+ failureDescription?: string
592
+ revision?: number
593
+ failedRevision?: number
594
+ rollbackRevision?: number
595
+ targetRevision?: number
596
+ pendingStatus?: string
597
+ updated?: string
598
+ }
599
+
577
600
  export interface HelmReleaseDetail {
578
601
  name: string
579
602
  namespace: string
@@ -589,9 +612,14 @@ export interface HelmReleaseDetail {
589
612
  notes: string
590
613
  history: HelmRevision[]
591
614
  resources: HelmOwnedResource[]
615
+ resourceHealth?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown'
616
+ healthIssue?: string
617
+ healthSummary?: string
592
618
  hooks?: HelmHook[]
593
619
  readme?: string
594
620
  dependencies?: ChartDependency[]
621
+ lastOperation?: HelmOperation
622
+ operations?: HelmOperation[]
595
623
  // When set, this release was installed by Flux's helm-controller — see
596
624
  // HelmRelease.managedByFluxHelmRelease for context. Format: "namespace/name".
597
625
  managedByFluxHelmRelease?: string
@@ -649,7 +677,16 @@ export interface UpgradeInfo {
649
677
  latestVersion?: string
650
678
  updateAvailable: boolean
651
679
  repositoryName?: string
680
+ // 'repository' for classic HTTP-repo matches, 'oci' when discovered via a
681
+ // registered OCI chart source. Absent when the source couldn't be determined.
682
+ sourceType?: 'repository' | 'oci'
683
+ // oci:// chart reference an OCI-sourced upgrade lives at (display only).
684
+ chartRef?: string
652
685
  error?: string
686
+ // True only when the error is a genuinely untracked source (registering a
687
+ // chart source could fix it) — NOT for repo-side errors like a stale index or
688
+ // classic ambiguity. Gates the "track source" affordance.
689
+ untracked?: boolean
653
690
  }
654
691
 
655
692
  // Batch upgrade info keyed by "storageNamespace/name".
@@ -0,0 +1,13 @@
1
+ // k8s-ui ships TS source, so each consumer's bundler types asset imports
2
+ // differently: Vite resolves `import x from './x.png'` to a URL string, while
3
+ // webpack/Next resolves it to a StaticImageData object. Reading `.src` off the
4
+ // object (vs Vite's bare string) yields the bundler's own emitted URL, which is
5
+ // the one that's correct in both client and SSR output — unlike
6
+ // `new URL(asset, import.meta.url)`, which can produce a `file://` URL under SSR.
7
+ // The structural `{ src: string }` type matches Next's StaticImageData without
8
+ // depending on the Next-only type, so this also type-checks under Vite.
9
+ export type AssetImport = string | { src: string }
10
+
11
+ export function assetUrl(asset: AssetImport): string {
12
+ return typeof asset === 'string' ? asset : asset.src
13
+ }