@skyhook-io/radar-app 1.9.1 → 1.9.2

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +42 -7
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2884 -2075
  7. package/src/api/diagnose.ts +5 -0
  8. package/src/components/ConnectionErrorView.test.tsx +88 -0
  9. package/src/components/ConnectionErrorView.tsx +128 -22
  10. package/src/components/capacity/CapacityActivity.tsx +787 -0
  11. package/src/components/capacity/CapacityDemand.tsx +961 -0
  12. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  14. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  15. package/src/components/capacity/CapacityView.tsx +85 -0
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  17. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  18. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  19. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  20. package/src/components/capacity/podDemandGate.test.ts +47 -0
  21. package/src/components/capacity/podDemandGate.ts +22 -0
  22. package/src/components/capacity/schedulingBar.test.ts +244 -0
  23. package/src/components/capacity/shared.tsx +1841 -0
  24. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  25. package/src/components/diagnose/DiagnoseContext.tsx +51 -10
  26. package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
  27. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  28. package/src/components/diagnose/agentCatalog.ts +30 -0
  29. package/src/components/home/CapacityCard.test.tsx +150 -0
  30. package/src/components/home/CapacityCard.tsx +125 -0
  31. package/src/components/home/HomeView.tsx +15 -1
  32. package/src/components/issues/IssuesPane.test.ts +142 -0
  33. package/src/components/issues/IssuesPane.tsx +142 -38
  34. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  35. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  36. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  37. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  38. package/src/components/ui/command-items.ts +222 -98
  39. package/src/components/workload/WorkloadView.tsx +16 -83
  40. package/src/context/ConnectionContext.test.ts +39 -0
  41. package/src/context/ConnectionContext.tsx +155 -51
  42. package/src/utils/shell-safe.test.ts +55 -0
  43. package/src/utils/shell-safe.ts +21 -0
@@ -1,130 +1,200 @@
1
- import { useMemo } from 'react'
2
- import { Home, Network, List, Clock, Package, Activity, Sun, Stethoscope, DollarSign, ShieldCheck, GitBranch, AlertTriangle, Boxes, Server } from 'lucide-react'
3
- import { useNamespaces, useContexts } from '../../api/client'
4
- import { CORE_RESOURCES, useAPIResources } from '../../api/apiResources'
5
- import { getResourceIcon } from '../../utils/resource-icons'
6
- import { parseContextName } from '../../utils/context-name'
1
+ import { useMemo } from "react";
2
+ import {
3
+ Home,
4
+ Network,
5
+ List,
6
+ Clock,
7
+ Package,
8
+ Activity,
9
+ Sun,
10
+ Stethoscope,
11
+ DollarSign,
12
+ Gauge,
13
+ ShieldCheck,
14
+ GitBranch,
15
+ AlertTriangle,
16
+ Boxes,
17
+ Server,
18
+ } from "lucide-react";
19
+ import { useNamespaces, useContexts } from "../../api/client";
20
+ import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
21
+ import { getResourceIcon } from "../../utils/resource-icons";
22
+ import { parseContextName } from "../../utils/context-name";
7
23
 
8
24
  // Drop the disambiguating " (source)" suffix the context list appends, so the
9
25
  // GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
10
26
  function stripSourceSuffix(name: string, source?: string): string {
11
- if (!source) return name
12
- const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
13
- return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
27
+ if (!source) return name;
28
+ const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), "");
14
30
  }
15
31
 
16
- export type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'issues' | 'helm' | 'traffic' | 'cost' | 'checks' | 'gitops' | 'applications'
32
+ export type MainView =
33
+ | "home"
34
+ | "topology"
35
+ | "resources"
36
+ | "timeline"
37
+ | "issues"
38
+ | "helm"
39
+ | "traffic"
40
+ | "cost"
41
+ | "capacity"
42
+ | "checks"
43
+ | "gitops"
44
+ | "applications";
17
45
 
18
46
  export interface CommandItem {
19
- id: string
20
- label: string
21
- sublabel?: string
22
- category: string
23
- icon?: React.ComponentType<{ className?: string }>
24
- shortcut?: string
25
- action: () => void
47
+ id: string;
48
+ label: string;
49
+ sublabel?: string;
50
+ category: string;
51
+ icon?: React.ComponentType<{ className?: string }>;
52
+ shortcut?: string;
53
+ action: () => void;
26
54
  /** Extra terms to match against during search (not displayed). */
27
- searchTerms?: string[]
55
+ searchTerms?: string[];
28
56
  /** Small priority bonus added to the final score (only if the item matched). */
29
- priorityBonus?: number
57
+ priorityBonus?: number;
30
58
  }
31
59
 
32
60
  // Built-in k8s API groups. Used to nudge these above CRDs on tied matches.
33
- const CORE_GROUP_BONUS = 10
34
- const WELL_KNOWN_GROUP_BONUS = 5
61
+ const CORE_GROUP_BONUS = 10;
62
+ const WELL_KNOWN_GROUP_BONUS = 5;
35
63
  const WELL_KNOWN_GROUPS = new Set([
36
- 'apps', 'batch', 'autoscaling', 'policy', 'networking.k8s.io', 'rbac.authorization.k8s.io',
37
- 'storage.k8s.io', 'scheduling.k8s.io', 'coordination.k8s.io', 'apiextensions.k8s.io',
38
- 'admissionregistration.k8s.io', 'apiregistration.k8s.io', 'certificates.k8s.io',
39
- 'events.k8s.io', 'discovery.k8s.io', 'flowcontrol.apiserver.k8s.io', 'node.k8s.io',
40
- 'authentication.k8s.io', 'authorization.k8s.io',
41
- ])
64
+ "apps",
65
+ "batch",
66
+ "autoscaling",
67
+ "policy",
68
+ "networking.k8s.io",
69
+ "rbac.authorization.k8s.io",
70
+ "storage.k8s.io",
71
+ "scheduling.k8s.io",
72
+ "coordination.k8s.io",
73
+ "apiextensions.k8s.io",
74
+ "admissionregistration.k8s.io",
75
+ "apiregistration.k8s.io",
76
+ "certificates.k8s.io",
77
+ "events.k8s.io",
78
+ "discovery.k8s.io",
79
+ "flowcontrol.apiserver.k8s.io",
80
+ "node.k8s.io",
81
+ "authentication.k8s.io",
82
+ "authorization.k8s.io",
83
+ ]);
42
84
 
43
85
  function groupPriorityBonus(group: string): number {
44
- if (!group) return CORE_GROUP_BONUS
45
- if (WELL_KNOWN_GROUPS.has(group)) return WELL_KNOWN_GROUP_BONUS
46
- return 0
86
+ if (!group) return CORE_GROUP_BONUS;
87
+ if (WELL_KNOWN_GROUPS.has(group)) return WELL_KNOWN_GROUP_BONUS;
88
+ return 0;
47
89
  }
48
90
 
49
91
  // Fuzzy match scoring: exact > prefix > word boundary > substring. Within a
50
92
  // tier, a coverage bonus (up to +20) breaks ties in favor of shorter labels.
51
93
  export function scoreMatch(text: string, query: string): number {
52
- const lower = text.toLowerCase()
53
- const q = query.toLowerCase()
54
- if (!lower.includes(q)) return 0
55
- let base: number
56
- if (lower === q) base = 150
57
- else if (lower.startsWith(q)) base = 100
94
+ const lower = text.toLowerCase();
95
+ const q = query.toLowerCase();
96
+ if (!lower.includes(q)) return 0;
97
+ let base: number;
98
+ if (lower === q) base = 150;
99
+ else if (lower.startsWith(q)) base = 100;
58
100
  else {
59
- const wordStart = lower.indexOf(q)
60
- const prev = lower[wordStart - 1]
61
- base = wordStart > 0 && (prev === ' ' || prev === '/' || prev === '-' || prev === '.') ? 75 : 50
101
+ const wordStart = lower.indexOf(q);
102
+ const prev = lower[wordStart - 1];
103
+ base =
104
+ wordStart > 0 &&
105
+ (prev === " " || prev === "/" || prev === "-" || prev === ".")
106
+ ? 75
107
+ : 50;
62
108
  }
63
- return base + (q.length / lower.length) * 20
109
+ return base + (q.length / lower.length) * 20;
64
110
  }
65
111
 
66
112
  export function bestScore(item: CommandItem, query: string): number {
67
- let best = scoreMatch(item.label, query)
68
- const secondary = Math.floor(Math.max(scoreMatch(item.sublabel || '', query), scoreMatch(item.category, query)) * 0.6)
69
- best = Math.max(best, secondary)
113
+ let best = scoreMatch(item.label, query);
114
+ const secondary = Math.floor(
115
+ Math.max(
116
+ scoreMatch(item.sublabel || "", query),
117
+ scoreMatch(item.category, query),
118
+ ) * 0.6,
119
+ );
120
+ best = Math.max(best, secondary);
70
121
  if (item.searchTerms) {
71
- for (const term of item.searchTerms) best = Math.max(best, scoreMatch(term, query))
122
+ for (const term of item.searchTerms)
123
+ best = Math.max(best, scoreMatch(term, query));
72
124
  }
73
- return best > 0 ? best + (item.priorityBonus || 0) : 0
125
+ return best > 0 ? best + (item.priorityBonus || 0) : 0;
74
126
  }
75
127
 
76
128
  export interface CommandItemCallbacks {
77
- onNavigateView: (view: MainView) => void
78
- onNavigateKind: (kind: string, group: string) => void
79
- onSwitchContext: (name: string) => void
80
- onSetNamespaces: (ns: string[]) => void
81
- onToggleTheme: () => void
82
- onShowDiagnostics?: () => void
129
+ onNavigateView: (view: MainView) => void;
130
+ onNavigateKind: (kind: string, group: string) => void;
131
+ onSwitchContext: (name: string) => void;
132
+ onSetNamespaces: (ns: string[]) => void;
133
+ onToggleTheme: () => void;
134
+ onShowDiagnostics?: () => void;
83
135
  }
84
136
 
85
- const VIEW_ENTRIES: { view: MainView; label: string; icon: React.ComponentType<{ className?: string }>; shortcut: string }[] = [
86
- { view: 'home', label: 'Home', icon: Home, shortcut: 'g h' },
87
- { view: 'resources', label: 'Resources', icon: List, shortcut: 'g r' },
88
- { view: 'issues', label: 'Issues', icon: AlertTriangle, shortcut: 'g i' },
89
- { view: 'topology', label: 'Topology', icon: Network, shortcut: 'g t' },
90
- { view: 'applications', label: 'Applications', icon: Boxes, shortcut: 'g a' },
91
- { view: 'timeline', label: 'Timeline', icon: Clock, shortcut: 'g l' },
92
- { view: 'helm', label: 'Helm', icon: Package, shortcut: 'g m' },
93
- { view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: 'g o' },
94
- { view: 'traffic', label: 'Live Traffic', icon: Activity, shortcut: 'g f' },
95
- { view: 'checks', label: 'Checks', icon: ShieldCheck, shortcut: 'g u' },
96
- { view: 'cost', label: 'Cost', icon: DollarSign, shortcut: 'g c' },
97
- ]
137
+ const VIEW_ENTRIES: {
138
+ view: MainView;
139
+ label: string;
140
+ icon: React.ComponentType<{ className?: string }>;
141
+ shortcut: string;
142
+ }[] = [
143
+ { view: "home", label: "Home", icon: Home, shortcut: "g h" },
144
+ { view: "resources", label: "Resources", icon: List, shortcut: "g r" },
145
+ { view: "issues", label: "Issues", icon: AlertTriangle, shortcut: "g i" },
146
+ { view: "topology", label: "Topology", icon: Network, shortcut: "g t" },
147
+ { view: "applications", label: "Applications", icon: Boxes, shortcut: "g a" },
148
+ { view: "timeline", label: "Timeline", icon: Clock, shortcut: "g l" },
149
+ { view: "helm", label: "Helm", icon: Package, shortcut: "g m" },
150
+ { view: "gitops", label: "GitOps", icon: GitBranch, shortcut: "g o" },
151
+ { view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" },
152
+ { view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" },
153
+ { view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" },
154
+ { view: "cost", label: "Cost", icon: DollarSign, shortcut: "g c" },
155
+ ];
98
156
 
99
157
  // The static command-palette items (Views, Resource Kinds, Contexts,
100
158
  // Namespaces, Actions) — shared by the centered modal (embedded) and the
101
159
  // standalone omnibar so the two never drift.
102
160
  export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
103
- const { data: namespacesData } = useNamespaces()
104
- const { data: contexts } = useContexts()
105
- const { data: apiResources } = useAPIResources()
161
+ const { data: namespacesData } = useNamespaces();
162
+ const { data: contexts } = useContexts();
163
+ const { data: apiResources } = useAPIResources();
106
164
 
107
165
  return useMemo<CommandItem[]>(() => {
108
- const result: CommandItem[] = []
166
+ const result: CommandItem[] = [];
109
167
 
110
168
  for (const v of VIEW_ENTRIES) {
111
- result.push({ id: `view-${v.view}`, label: `Go to ${v.label}`, category: 'Views', icon: v.icon, shortcut: v.shortcut, action: () => cb.onNavigateView(v.view) })
169
+ result.push({
170
+ id: `view-${v.view}`,
171
+ label: `Go to ${v.label}`,
172
+ category: "Views",
173
+ icon: v.icon,
174
+ shortcut: v.shortcut,
175
+ action: () => cb.onNavigateView(v.view),
176
+ });
112
177
  }
113
178
 
114
- const resources = apiResources || CORE_RESOURCES
115
- const seenKinds = new Set<string>()
179
+ const resources = apiResources || CORE_RESOURCES;
180
+ const seenKinds = new Set<string>();
116
181
  for (const r of resources) {
117
- if (!r.verbs?.includes('list')) continue
118
- const kindKey = `${r.name}/${r.group}`
119
- if (seenKinds.has(kindKey)) continue
120
- seenKinds.add(kindKey)
182
+ if (!r.verbs?.includes("list")) continue;
183
+ const kindKey = `${r.name}/${r.group}`;
184
+ if (seenKinds.has(kindKey)) continue;
185
+ seenKinds.add(kindKey);
121
186
  result.push({
122
187
  // Group shown only when it disambiguates (CRDs) — "core" is noise on
123
188
  // built-in kinds. priorityBonus still nudges core/well-known above CRDs.
124
- id: `kind-${r.name}-${r.group}`, label: r.kind, sublabel: r.group || undefined, category: 'Resource Kinds',
125
- icon: getResourceIcon(r.kind), action: () => cb.onNavigateKind(r.name, r.group),
126
- searchTerms: [r.name, r.kind], priorityBonus: groupPriorityBonus(r.group),
127
- })
189
+ id: `kind-${r.name}-${r.group}`,
190
+ label: r.kind,
191
+ sublabel: r.group || undefined,
192
+ category: "Resource Kinds",
193
+ icon: getResourceIcon(r.kind),
194
+ action: () => cb.onNavigateKind(r.name, r.group),
195
+ searchTerms: [r.name, r.kind],
196
+ priorityBonus: groupPriorityBonus(r.group),
197
+ });
128
198
  }
129
199
 
130
200
  if (contexts) {
@@ -134,45 +204,99 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
134
204
  // it. Count display names so genuine duplicates (same cluster name from
135
205
  // different kubeconfig sources) stay distinguishable; unique ones stay clean.
136
206
  const parsedCtx = contexts.map((ctx) => {
137
- const parsed = parseContextName(stripSourceSuffix(ctx.name, ctx.source))
138
- const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null
139
- const meta = [parsed.provider ?? fromCluster?.provider, parsed.region ?? fromCluster?.region].filter(Boolean).join(' · ')
140
- return { ctx, clusterName: parsed.clusterName, account: parsed.account, base: ctx.isCurrent ? 'current' : meta }
141
- })
207
+ const parsed = parseContextName(
208
+ stripSourceSuffix(ctx.name, ctx.source),
209
+ );
210
+ const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null;
211
+ const meta = [
212
+ parsed.provider ?? fromCluster?.provider,
213
+ parsed.region ?? fromCluster?.region,
214
+ ]
215
+ .filter(Boolean)
216
+ .join(" · ");
217
+ return {
218
+ ctx,
219
+ clusterName: parsed.clusterName,
220
+ account: parsed.account,
221
+ base: ctx.isCurrent ? "current" : meta,
222
+ };
223
+ });
142
224
  // Disambiguate on the FINAL visible (label, sublabel) pair, not just the
143
225
  // cluster name — same name + same provider/region from the same kubeconfig
144
226
  // file would otherwise render identically while switching different
145
227
  // contexts. Collisions fall back to the raw context name (unique by id).
146
- const pairCount = new Map<string, number>()
147
- for (const p of parsedCtx) pairCount.set(`${p.clusterName}\x00${p.base}`, (pairCount.get(`${p.clusterName}\x00${p.base}`) ?? 0) + 1)
228
+ const pairCount = new Map<string, number>();
229
+ for (const p of parsedCtx)
230
+ pairCount.set(
231
+ `${p.clusterName}\x00${p.base}`,
232
+ (pairCount.get(`${p.clusterName}\x00${p.base}`) ?? 0) + 1,
233
+ );
148
234
  for (const { ctx, clusterName, account, base } of parsedCtx) {
149
- const collides = (pairCount.get(`${clusterName}\x00${base}`) ?? 0) > 1
150
- const sub = [base, collides ? ctx.name : ''].filter(Boolean).join(' · ')
235
+ const collides = (pairCount.get(`${clusterName}\x00${base}`) ?? 0) > 1;
236
+ const sub = [base, collides ? ctx.name : ""]
237
+ .filter(Boolean)
238
+ .join(" · ");
151
239
  result.push({
152
240
  id: `context-${ctx.name}`,
153
241
  label: clusterName,
154
242
  sublabel: sub || undefined,
155
- category: 'Clusters',
243
+ category: "Clusters",
156
244
  icon: Server,
157
- action: () => { if (!ctx.isCurrent) cb.onSwitchContext(ctx.name) },
158
- searchTerms: [ctx.name, account || ''].filter(Boolean),
159
- })
245
+ action: () => {
246
+ if (!ctx.isCurrent) cb.onSwitchContext(ctx.name);
247
+ },
248
+ searchTerms: [ctx.name, account || ""].filter(Boolean),
249
+ });
160
250
  }
161
251
  }
162
252
 
163
253
  if (namespacesData) {
164
254
  for (const ns of namespacesData) {
165
- result.push({ id: `ns-${ns.name}`, label: ns.name, category: 'Namespaces', action: () => cb.onSetNamespaces([ns.name]) })
255
+ result.push({
256
+ id: `ns-${ns.name}`,
257
+ label: ns.name,
258
+ category: "Namespaces",
259
+ action: () => cb.onSetNamespaces([ns.name]),
260
+ });
166
261
  }
167
- result.push({ id: 'ns-all', label: 'All Namespaces', category: 'Namespaces', action: () => cb.onSetNamespaces([]) })
262
+ result.push({
263
+ id: "ns-all",
264
+ label: "All Namespaces",
265
+ category: "Namespaces",
266
+ action: () => cb.onSetNamespaces([]),
267
+ });
168
268
  }
169
269
 
170
- result.push({ id: 'action-theme', label: 'Toggle Theme', category: 'Actions', icon: Sun, shortcut: 't', action: () => cb.onToggleTheme() })
270
+ result.push({
271
+ id: "action-theme",
272
+ label: "Toggle Theme",
273
+ category: "Actions",
274
+ icon: Sun,
275
+ shortcut: "t",
276
+ action: () => cb.onToggleTheme(),
277
+ });
171
278
  if (cb.onShowDiagnostics) {
172
- result.push({ id: 'action-diagnostics', label: 'Diagnostics', category: 'Actions', icon: Stethoscope, action: () => cb.onShowDiagnostics?.(), searchTerms: ['debug', 'health', 'status', 'snapshot'] })
279
+ result.push({
280
+ id: "action-diagnostics",
281
+ label: "Diagnostics",
282
+ category: "Actions",
283
+ icon: Stethoscope,
284
+ action: () => cb.onShowDiagnostics?.(),
285
+ searchTerms: ["debug", "health", "status", "snapshot"],
286
+ });
173
287
  }
174
288
 
175
- return result
289
+ return result;
176
290
  // eslint-disable-next-line react-hooks/exhaustive-deps
177
- }, [apiResources, contexts, namespacesData, cb.onNavigateView, cb.onNavigateKind, cb.onSwitchContext, cb.onSetNamespaces, cb.onToggleTheme, cb.onShowDiagnostics])
291
+ }, [
292
+ apiResources,
293
+ contexts,
294
+ namespacesData,
295
+ cb.onNavigateView,
296
+ cb.onNavigateKind,
297
+ cb.onSwitchContext,
298
+ cb.onSetNamespaces,
299
+ cb.onToggleTheme,
300
+ cb.onShowDiagnostics,
301
+ ]);
178
302
  }
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useEffect, useCallback, useState } from 'react'
2
2
  import { useQueries, useQueryClient } from '@tanstack/react-query'
3
3
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
4
+ import { workloadPodAwaitsScheduling } from '../capacity/podDemandGate'
4
5
  import { clsx } from 'clsx'
5
6
  import { Terminal } from 'lucide-react'
6
7
  import {
@@ -19,10 +20,9 @@ import {
19
20
  gitOpsRouteForOwner,
20
21
  gitOpsOwnerFromRelationships,
21
22
  getGitOpsResourceStatus,
22
- resolvedEnvFromKey,
23
23
  } from '@skyhook-io/k8s-ui'
24
24
  import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
25
- import type { SelectedResource, ResourceRef, Relationships, ResolvedEnvFrom } from '../../types'
25
+ import type { SelectedResource, ResourceRef, Relationships } from '../../types'
26
26
  import {
27
27
  kindToPlural,
28
28
  pluralToKind,
@@ -95,6 +95,7 @@ import {
95
95
  import { useToast } from '../ui/Toast'
96
96
  import { Tooltip } from '../ui/Tooltip'
97
97
  import { PodRenderer } from '../resources/renderers/PodRenderer'
98
+ import { KarpenterNodePoolRenderer } from '../resources/renderers/KarpenterNodePoolRenderer'
98
99
  import { NodeRenderer } from '../resources/renderers/NodeRenderer'
99
100
  import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
100
101
  import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
@@ -126,6 +127,7 @@ const BATCH_EXECUTION_KINDS = new Set([
126
127
  // Stable reference — web renderer wrappers inject platform hooks internally
127
128
  const rendererOverrides: RendererOverrides = {
128
129
  PodRenderer,
130
+ KarpenterNodePoolRenderer,
129
131
  NodeRenderer,
130
132
  ServiceRenderer,
131
133
  WorkloadRenderer,
@@ -548,85 +550,6 @@ export function WorkloadView({
548
550
  [helmOwner, helmSourceResource],
549
551
  )
550
552
 
551
- // For pods: extract envFrom ConfigMap/Secret names and resolve their keys
552
- const isPod = apiKind === 'pods'
553
- const { envFromConfigMapNames, envFromSecretNames } = useMemo(() => {
554
- if (!isPod || !resource)
555
- return {
556
- envFromConfigMapNames: [] as string[],
557
- envFromSecretNames: [] as string[],
558
- }
559
- const cmNames = new Set<string>()
560
- const secretNames = new Set<string>()
561
- const containers = [
562
- ...(resource.spec?.containers || []),
563
- ...(resource.spec?.initContainers || []),
564
- ]
565
- for (const c of containers) {
566
- for (const ef of c.envFrom || []) {
567
- if (ef.configMapRef?.name) cmNames.add(ef.configMapRef.name)
568
- if (ef.secretRef?.name) secretNames.add(ef.secretRef.name)
569
- }
570
- }
571
- return {
572
- envFromConfigMapNames: Array.from(cmNames),
573
- envFromSecretNames: Array.from(secretNames),
574
- }
575
- }, [isPod, resource])
576
-
577
- const configMapQueries = useQueries({
578
- queries: envFromConfigMapNames.map((cmName) => ({
579
- queryKey: ['resources', 'configmaps', namespace, cmName],
580
- queryFn: () => fetchJSON<any>(`/resources/configmaps/${namespace}/${cmName}`),
581
- enabled: isPod,
582
- staleTime: 30000,
583
- })),
584
- })
585
-
586
- const secretQueries = useQueries({
587
- queries: envFromSecretNames.map((secretName) => ({
588
- queryKey: ['resources', 'secrets', namespace, secretName],
589
- queryFn: () => fetchJSON<any>(`/resources/secrets/${namespace}/${secretName}`),
590
- enabled: isPod,
591
- staleTime: 30000,
592
- })),
593
- })
594
-
595
- const resolvedEnvFrom = useMemo(() => {
596
- if (!isPod || (envFromConfigMapNames.length === 0 && envFromSecretNames.length === 0))
597
- return undefined
598
- const result: ResolvedEnvFrom = {}
599
- envFromConfigMapNames.forEach((n, i) => {
600
- // Single-resource endpoint returns { resource, relationships } wrapper
601
- const cm = configMapQueries[i]?.data?.resource ?? configMapQueries[i]?.data
602
- if (cm)
603
- result[resolvedEnvFromKey('configmap', n)] = {
604
- keys: Object.keys(cm.data || {}),
605
- values: cm.data || {},
606
- isSecret: false,
607
- }
608
- })
609
- envFromSecretNames.forEach((n, i) => {
610
- const secret = secretQueries[i]?.data?.resource ?? secretQueries[i]?.data
611
- if (secret) {
612
- const decodedValues: Record<string, string> = {}
613
- for (const [k, v] of Object.entries(secret.data || {})) {
614
- try {
615
- decodedValues[k] = atob(v as string)
616
- } catch {
617
- decodedValues[k] = v as string
618
- }
619
- }
620
- result[resolvedEnvFromKey('secret', n)] = {
621
- keys: Object.keys(decodedValues),
622
- values: decodedValues,
623
- isSecret: true,
624
- }
625
- }
626
- })
627
- return Object.keys(result).length > 0 ? result : undefined
628
- }, [isPod, envFromConfigMapNames, envFromSecretNames, configMapQueries, secretQueries])
629
-
630
553
  // Fetch topology for hierarchy building (only when expanded)
631
554
  const { data: topology } = useTopology([namespace], 'resources', {
632
555
  enabled: expanded,
@@ -654,7 +577,7 @@ export function WorkloadView({
654
577
 
655
578
  // RBAC
656
579
  const canUpdateSecrets = useCanUpdateSecrets()
657
- const { features } = useCapabilitiesContext()
580
+ const { features, karpenter } = useCapabilitiesContext()
658
581
  const { canPortForward } = useNamespacedCapabilities(namespace)
659
582
  const isLocalDeployment = useIsLocalDeployment()
660
583
  const showServingPortForward = canPortForward || !isLocalDeployment
@@ -837,6 +760,9 @@ export function WorkloadView({
837
760
 
838
761
  const supportsWorkloadPods = ['deployments', 'statefulsets', 'daemonsets'].includes(apiKind)
839
762
  const workloadPodsQuery = useWorkloadPods(supportsWorkloadPods ? apiKind : '', namespace, name)
763
+ const workloadAwaitsCapacity =
764
+ karpenter?.state === 'available' &&
765
+ (workloadPodsQuery.data?.pods ?? []).some(workloadPodAwaitsScheduling)
840
766
  const servingRefs = useMemo(() => collectServingRefs(relationships), [relationships])
841
767
  const servingQueries = useQueries({
842
768
  queries: servingRefs.map((ref) => {
@@ -887,6 +813,14 @@ export function WorkloadView({
887
813
  certificateInfo={certificateInfo}
888
814
  hpaDiagnosis={hpaDiagnosis}
889
815
  workloadPods={supportsWorkloadPods ? workloadPodsQuery.data?.pods : undefined}
816
+ onEvaluateCapacity={
817
+ workloadAwaitsCapacity
818
+ ? () =>
819
+ navigateRouter(
820
+ `/capacity/demand?owner=${encodeURIComponent(`${namespace}/${pluralToKind(apiKind)}/${name}`)}`,
821
+ )
822
+ : undefined
823
+ }
890
824
  workloadPodsLoading={supportsWorkloadPods ? workloadPodsQuery.isLoading : false}
891
825
  workloadPodsError={supportsWorkloadPods ? (workloadPodsQuery.error as Error | null) : null}
892
826
  servingResources={servingResources}
@@ -969,7 +903,6 @@ export function WorkloadView({
969
903
  onDownload={desktopDownload}
970
904
  actionsBarProps={actionsBarProps}
971
905
  rendererOverrides={rendererOverrides}
972
- resolvedEnvFrom={resolvedEnvFrom}
973
906
  renderOverviewExtra={({ kind: k, namespace: ns, name: n }) => (
974
907
  <>
975
908
  <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { shouldApplyPolledConnection, shouldAutoRetryConnection } from './ConnectionContext'
4
+
5
+ describe('shouldAutoRetryConnection', () => {
6
+ it('retries transient failures', () => {
7
+ expect(shouldAutoRetryConnection('network')).toBe(true)
8
+ expect(shouldAutoRetryConnection('timeout')).toBe(true)
9
+ expect(shouldAutoRetryConnection(undefined)).toBe(true)
10
+ })
11
+
12
+ it('leaves auth-shaped failures to the server-side recovery loop', () => {
13
+ expect(shouldAutoRetryConnection('auth')).toBe(false)
14
+ expect(shouldAutoRetryConnection('auth-rejected')).toBe(false)
15
+ })
16
+
17
+ it('leaves configuration and RBAC errors for the user to resolve', () => {
18
+ expect(shouldAutoRetryConnection('config')).toBe(false)
19
+ expect(shouldAutoRetryConnection('rbac')).toBe(false)
20
+ })
21
+ })
22
+
23
+ describe('shouldApplyPolledConnection', () => {
24
+ it('recovers a missed disconnected SSE frame', () => {
25
+ expect(shouldApplyPolledConnection('connected', 'disconnected', 2, 2)).toBe(true)
26
+ })
27
+
28
+ it('does not flash a connected UI back to startup progress', () => {
29
+ expect(shouldApplyPolledConnection('connected', 'connecting', 2, 2)).toBe(false)
30
+ })
31
+
32
+ it('does not let a poll started before an SSE update overwrite it', () => {
33
+ expect(shouldApplyPolledConnection('disconnected', 'connected', 1, 2)).toBe(false)
34
+ })
35
+
36
+ it('allows a fresh fallback poll to observe recovery after an SSE update', () => {
37
+ expect(shouldApplyPolledConnection('disconnected', 'connected', 2, 2)).toBe(true)
38
+ })
39
+ })