@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.
- package/package.json +1 -1
- package/src/App.tsx +42 -7
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2884 -2075
- package/src/api/diagnose.ts +5 -0
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +51 -10
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/utils/shell-safe.test.ts +55 -0
- package/src/utils/shell-safe.ts +21 -0
|
@@ -1,130 +1,200 @@
|
|
|
1
|
-
import { useMemo } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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(
|
|
69
|
-
|
|
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)
|
|
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: {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{ view:
|
|
92
|
-
{ view:
|
|
93
|
-
{ view:
|
|
94
|
-
{ view:
|
|
95
|
-
{ view:
|
|
96
|
-
{ view:
|
|
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({
|
|
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(
|
|
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}`,
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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)
|
|
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 :
|
|
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:
|
|
243
|
+
category: "Clusters",
|
|
156
244
|
icon: Server,
|
|
157
|
-
action: () => {
|
|
158
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
}, [
|
|
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
|
|
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
|
+
})
|