@skyhook-io/radar-app 1.9.0 → 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 (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  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 +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. package/src/utils/shell-safe.ts +21 -0
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import { MemoryRouter } from "react-router-dom";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import type {
6
+ CapacityBoundedResultMeta,
7
+ CapacityDemandGroup,
8
+ CapacityDemandResponse,
9
+ CapacityDemandSummary,
10
+ CapacityQuantityObservation,
11
+ CapacityResponseMeta,
12
+ CapacitySourceCoverage,
13
+ } from "@skyhook-io/k8s-ui";
14
+ import { CapacityView } from "./CapacityView";
15
+
16
+ vi.mock("../../context/ConnectionContext", () => ({
17
+ useConnection: () => ({ connection: { state: "connected" } }),
18
+ }));
19
+
20
+ const generatedAt = "2026-07-13T08:00:00Z";
21
+
22
+ function sourceCoverage(
23
+ status: CapacitySourceCoverage["status"] = "available",
24
+ ): CapacitySourceCoverage {
25
+ return {
26
+ status,
27
+ scope: "cluster",
28
+ observedAt: generatedAt,
29
+ impactFields: [],
30
+ };
31
+ }
32
+
33
+ const meta: CapacityResponseMeta = {
34
+ schemaVersion: "v1alpha1",
35
+ generatedAt,
36
+ clusterContext: { contextName: "radar-test-eks", clusterName: "radar-test" },
37
+ provider: {
38
+ type: "karpenter",
39
+ controllerMode: "self_managed",
40
+ apiVersionsByKind: { NodePool: ["v1"], NodeClaim: ["v1"] },
41
+ nodeClassKinds: [],
42
+ features: { staticCapacity: true, metrics: true },
43
+ },
44
+ coverage: {
45
+ nodePools: sourceCoverage(),
46
+ nodeClaims: sourceCoverage(),
47
+ nodeClasses: sourceCoverage(),
48
+ nodes: sourceCoverage(),
49
+ pods: sourceCoverage(),
50
+ workloads: sourceCoverage(),
51
+ nodeMetrics: sourceCoverage(),
52
+ karpenterObjectEvents: sourceCoverage(),
53
+ timeline: sourceCoverage(),
54
+ },
55
+ };
56
+
57
+ function quantity(
58
+ resources: Record<string, string>,
59
+ ): CapacityQuantityObservation {
60
+ return {
61
+ resources,
62
+ certainty: "exact",
63
+ sources: ["karpenter.status"],
64
+ asOf: generatedAt,
65
+ granularity: "aggregate",
66
+ };
67
+ }
68
+
69
+ function boundedMeta(count: number): CapacityBoundedResultMeta {
70
+ return { total: count, returned: count, truncated: false };
71
+ }
72
+
73
+ const demandGroup: CapacityDemandGroup = {
74
+ id: "grp-1",
75
+ fingerprint: "sched-fp:abc123",
76
+ owner: { kind: "Deployment", namespace: "payments", name: "checkout" },
77
+ pods: [{ ref: { kind: "Pod", namespace: "payments", name: "checkout-abc" } }],
78
+ podsMeta: boundedMeta(1),
79
+ namespace: "payments",
80
+ firstSeen: generatedAt,
81
+ lastSeen: generatedAt,
82
+ podCount: 3,
83
+ perPodRequests: quantity({ cpu: "2", memory: "4Gi" }),
84
+ aggregateRequests: quantity({ cpu: "6", memory: "12Gi" }),
85
+ schedulingSignature: {
86
+ fingerprint: "sched-fp:abc123",
87
+ constraints: [],
88
+ constraintsMeta: boundedMeta(0),
89
+ tolerations: [],
90
+ tolerationsMeta: boundedMeta(0),
91
+ },
92
+ state: "awaiting_capacity",
93
+ schedulerReasons: [],
94
+ schedulerReasonsMeta: boundedMeta(0),
95
+ poolEvaluations: [],
96
+ poolEvaluationsMeta: boundedMeta(0),
97
+ poolEvaluationCounts: { declaredCompatible: 0, incompatible: 0, unknown: 0 },
98
+ issues: [],
99
+ };
100
+
101
+ const demandSummary: CapacityDemandSummary = {
102
+ total: { podCount: 3, groupCount: 1 },
103
+ byState: {
104
+ waiting_for_scheduler: { podCount: 0, groupCount: 0 },
105
+ held: { podCount: 0, groupCount: 0 },
106
+ awaiting_capacity: { podCount: 3, groupCount: 1 },
107
+ blocked: { podCount: 0, groupCount: 0 },
108
+ unknown: { podCount: 0, groupCount: 0 },
109
+ },
110
+ };
111
+
112
+ function demandResponse(
113
+ group: CapacityDemandGroup = demandGroup,
114
+ ): CapacityDemandResponse {
115
+ return {
116
+ ...meta,
117
+ state: "available",
118
+ summary: demandSummary,
119
+ items: [group],
120
+ page: { hasMore: false },
121
+ };
122
+ }
123
+
124
+ function renderDemand(group: CapacityDemandGroup): string {
125
+ const client = new QueryClient({
126
+ defaultOptions: { queries: { retry: false, retryOnMount: false } },
127
+ });
128
+ client.setQueryData(
129
+ ["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
130
+ demandResponse(group),
131
+ );
132
+ return renderToString(
133
+ <MemoryRouter initialEntries={["/capacity/demand"]}>
134
+ <QueryClientProvider client={client}>
135
+ <CapacityView onOpenResource={() => {}} />
136
+ </QueryClientProvider>
137
+ </MemoryRouter>,
138
+ );
139
+ }
140
+
141
+ describe("CapacityDemand nomination chip", () => {
142
+ it("renders a nomination chip when the group holds nominated pods", () => {
143
+ const html = renderDemand({ ...demandGroup, nominatedPodCount: 2 });
144
+ expect(html).toContain("2 nominated");
145
+ });
146
+
147
+ it("omits the nomination chip when no pods are nominated", () => {
148
+ const html = renderDemand({ ...demandGroup, nominatedPodCount: undefined });
149
+ expect(html).not.toContain("nominated");
150
+ });
151
+ });
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import type {
4
+ CapacityActivityState,
5
+ CapacityQuantityObservation,
6
+ } from "@skyhook-io/k8s-ui";
7
+ import {
8
+ DeniedCapacityState,
9
+ ActivityStateBadge,
10
+ CertaintyGlyph,
11
+ InventoryQuantityCell,
12
+ QuantityInline,
13
+ certaintyGlyph,
14
+ managerStatusRank,
15
+ } from "./shared";
16
+
17
+ function observation(
18
+ certainty: CapacityQuantityObservation["certainty"],
19
+ resources: Record<string, string> = { cpu: "2" },
20
+ ): CapacityQuantityObservation {
21
+ return {
22
+ resources,
23
+ certainty,
24
+ sources: ["test"],
25
+ asOf: "2026-07-15T00:00:00Z",
26
+ granularity: "aggregate",
27
+ };
28
+ }
29
+
30
+ describe("certainty glyph mapping", () => {
31
+ // The =/≥/≤/? vocabulary is the feature's trust contract — pin every arm.
32
+ it("maps each certainty to its glyph", () => {
33
+ expect(certaintyGlyph("exact")).toBe("=");
34
+ expect(certaintyGlyph("lower_bound")).toBe("≥");
35
+ expect(certaintyGlyph("upper_bound")).toBe("≤");
36
+ expect(certaintyGlyph("unknown")).toBe("?");
37
+ });
38
+
39
+ it("renders a keyboard-reachable trigger", () => {
40
+ const html = renderToString(<CertaintyGlyph certainty="lower_bound" />);
41
+ expect(html).toContain('tabindex="0"');
42
+ expect(html).toContain("≥");
43
+ });
44
+ });
45
+
46
+ describe("QuantityInline certainty", () => {
47
+ it("renders exact values without a hedging glyph", () => {
48
+ const html = renderToString(
49
+ <QuantityInline observation={observation("exact")} empty="0" />,
50
+ );
51
+ expect(html).toContain("2 cores");
52
+ expect(html).not.toContain("≥");
53
+ expect(html).not.toContain("?");
54
+ });
55
+
56
+ it("hedges non-exact values with their glyph", () => {
57
+ const html = renderToString(
58
+ <QuantityInline observation={observation("lower_bound")} empty="0" />,
59
+ );
60
+ expect(html).toContain("≥");
61
+ });
62
+
63
+ it("never renders an empty unknown observation as the zero label", () => {
64
+ const html = renderToString(
65
+ <QuantityInline observation={observation("unknown", {})} empty="0" />,
66
+ );
67
+ expect(html).toContain("Unknown");
68
+ expect(html).not.toContain(">0<");
69
+ });
70
+
71
+ it("hedges an empty lower-bound observation instead of a bare zero", () => {
72
+ const html = renderToString(
73
+ <QuantityInline observation={observation("lower_bound", {})} empty="0" />,
74
+ );
75
+ expect(html).toContain("≥");
76
+ });
77
+ });
78
+
79
+ describe("observed zero vs unobserved source", () => {
80
+ // "Not observed" is this page's RBAC-gap wording. A pool scaled to zero is a
81
+ // measured zero and must not borrow it.
82
+ it("renders an exact empty observation as 0, not the unobserved label", () => {
83
+ const html = renderToString(
84
+ <InventoryQuantityCell
85
+ observation={observation("exact", {})}
86
+ empty="Not observed"
87
+ />,
88
+ );
89
+ expect(html).toContain(">0<");
90
+ expect(html).not.toContain("Not observed");
91
+ });
92
+
93
+ it("renders an absent observation as the unobserved label", () => {
94
+ const html = renderToString(
95
+ <InventoryQuantityCell observation={undefined} empty="Not observed" />,
96
+ );
97
+ expect(html).toContain("Not observed");
98
+ expect(html).not.toContain(">0<");
99
+ });
100
+
101
+ it("keeps an empty unknown observation as Unknown", () => {
102
+ const html = renderToString(
103
+ <InventoryQuantityCell
104
+ observation={observation("unknown", {})}
105
+ empty="Not observed"
106
+ />,
107
+ );
108
+ expect(html).toContain("Unknown");
109
+ expect(html).not.toContain(">0<");
110
+ });
111
+
112
+ it("keeps an empty lower-bound observation hedged, not zeroed", () => {
113
+ const html = renderToString(
114
+ <InventoryQuantityCell
115
+ observation={observation("lower_bound", {})}
116
+ empty="Not observed"
117
+ />,
118
+ );
119
+ expect(html).toContain("Not observed");
120
+ expect(html).toContain("≥");
121
+ });
122
+
123
+ it("applies the same rule to QuantityInline", () => {
124
+ expect(
125
+ renderToString(
126
+ <QuantityInline observation={observation("exact", {})} empty="—" />,
127
+ ),
128
+ ).toContain(">0<");
129
+ expect(
130
+ renderToString(<QuantityInline observation={undefined} empty="—" />),
131
+ ).toContain("—");
132
+ });
133
+ });
134
+
135
+ describe("managerStatusRank", () => {
136
+ it("ranks degraded worst and healthy best", () => {
137
+ expect(managerStatusRank("degraded")).toBeLessThan(
138
+ managerStatusRank("unknown"),
139
+ );
140
+ expect(managerStatusRank("unknown")).toBeLessThan(
141
+ managerStatusRank("healthy"),
142
+ );
143
+ });
144
+
145
+ it("ranks a status this build does not know as unknown", () => {
146
+ // A newer server adding a rollup status must not produce NaN comparisons
147
+ // that silently misrank a real degradation.
148
+ expect(managerStatusRank("suspended_for_maintenance")).toBe(
149
+ managerStatusRank("unknown"),
150
+ );
151
+ });
152
+ });
153
+
154
+ describe("ActivityStateBadge", () => {
155
+ const render = (state: CapacityActivityState) =>
156
+ renderToString(<ActivityStateBadge state={state} />);
157
+
158
+ it("renders `ended` without claiming success or failure", () => {
159
+ const html = render("ended");
160
+ expect(html).toContain("ended");
161
+ // Terminalized with no cause recorded — the success and error palettes
162
+ // would both assert evidence Radar never saw.
163
+ expect(html).not.toContain("emerald");
164
+ expect(html).not.toContain("red-");
165
+ expect(html).toContain("text-theme-text-secondary");
166
+ });
167
+
168
+ it("keeps completed and failed distinctly toned", () => {
169
+ expect(render("completed")).toContain("emerald");
170
+ expect(render("failed")).toContain("red-");
171
+ });
172
+
173
+ it("falls back to a neutral badge for a state this build does not know", () => {
174
+ const html = render("superseded" as CapacityActivityState);
175
+ expect(html).toContain("superseded");
176
+ expect(html).toContain("text-theme-text-secondary");
177
+ });
178
+ });
179
+
180
+ describe("DeniedCapacityState", () => {
181
+ it("converts the 403 into a next step: fallback guidance plus the exact grant", () => {
182
+ const html = renderToString(
183
+ <DeniedCapacityState detail="no access to NodePools (cluster-scoped resource requires explicit RBAC)" />,
184
+ );
185
+ expect(html).toContain("Capacity access denied");
186
+ expect(html).toContain("Pod drawer and Issues");
187
+ expect(html).toContain("Permissions to request");
188
+ expect(html).toContain("radar-capacity-viewer");
189
+ expect(html).toContain("nodepools");
190
+ });
191
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { CapacitySourceCoverage } from "@skyhook-io/k8s-ui";
3
+ import {
4
+ autoscalerDetectionFailure,
5
+ autoscalerNotPublishedDetail,
6
+ coverageCertainty,
7
+ nodeGroupsCertainty,
8
+ } from "./CapacityOverview";
9
+
10
+ function cov(
11
+ status: CapacitySourceCoverage["status"],
12
+ scope: CapacitySourceCoverage["scope"] = "cluster",
13
+ ): CapacitySourceCoverage {
14
+ return { status, scope, impactFields: [] };
15
+ }
16
+
17
+ function reasoned(
18
+ status: CapacitySourceCoverage["status"],
19
+ reasonCode: string,
20
+ ): CapacitySourceCoverage {
21
+ return { status, reasonCode, scope: "cluster", impactFields: [] };
22
+ }
23
+
24
+ describe("coverageCertainty", () => {
25
+ it("is exact only for an observed, cluster-scoped source", () => {
26
+ expect(coverageCertainty(cov("available"))).toBe("exact");
27
+ });
28
+
29
+ it("is a lower bound when observed but namespace-scoped or partial", () => {
30
+ expect(coverageCertainty(cov("available", "explicit_namespaces"))).toBe(
31
+ "lower_bound",
32
+ );
33
+ expect(coverageCertainty(cov("partial"))).toBe("lower_bound");
34
+ });
35
+
36
+ it("is unknown — never a false exact — when the source was not observed", () => {
37
+ // The trust thesis: syncing / unavailable / error / absent must not read
38
+ // as "=" (exact) on the KPI glyphs.
39
+ for (const status of [
40
+ "denied",
41
+ "syncing",
42
+ "unavailable",
43
+ "error",
44
+ ] as const) {
45
+ expect(coverageCertainty(cov(status))).toBe("unknown");
46
+ }
47
+ expect(coverageCertainty(undefined)).toBe("unknown");
48
+ });
49
+ });
50
+
51
+ describe("nodeGroupsCertainty", () => {
52
+ it("is exact only when nodes and NodePools were both observed", () => {
53
+ expect(
54
+ nodeGroupsCertainty(
55
+ { nodes: cov("available"), nodePools: cov("available") },
56
+ "available",
57
+ ).certainty,
58
+ ).toBe("exact");
59
+ });
60
+
61
+ it("is a lower bound when NodePools are hidden — zero-node groups vanish", () => {
62
+ for (const status of [
63
+ "denied",
64
+ "unavailable",
65
+ "error",
66
+ "syncing",
67
+ ] as const) {
68
+ const result = nodeGroupsCertainty(
69
+ { nodes: cov("available"), nodePools: cov(status) },
70
+ "denied",
71
+ );
72
+ expect(result.certainty).toBe("lower_bound");
73
+ expect(result.title).toContain("lower bound");
74
+ }
75
+ });
76
+
77
+ it("stays unknown when nodes themselves were not observed", () => {
78
+ expect(
79
+ nodeGroupsCertainty(
80
+ { nodes: cov("denied"), nodePools: cov("denied") },
81
+ "denied",
82
+ ).certainty,
83
+ ).toBe("unknown");
84
+ });
85
+
86
+ it("does not hedge a cluster that simply has no NodePool source", () => {
87
+ expect(
88
+ nodeGroupsCertainty({ nodes: cov("available") }, "not_detected")
89
+ .certainty,
90
+ ).toBe("exact");
91
+ });
92
+
93
+ it("does not hedge when Karpenter is absent — nothing is hidden", () => {
94
+ // The server reports unavailable NodePool coverage on a Karpenter-less
95
+ // cluster, but there are no NodePools to conceal scale-to-zero groups.
96
+ const result = nodeGroupsCertainty(
97
+ { nodes: cov("available"), nodePools: cov("unavailable") },
98
+ "not_detected",
99
+ );
100
+ expect(result.certainty).toBe("exact");
101
+ expect(result.title).not.toContain("lower bound");
102
+ });
103
+ });
104
+
105
+ describe("autoscalerDetectionFailure", () => {
106
+ it("reports every unreadable source as a detection failure", () => {
107
+ expect(autoscalerDetectionFailure(cov("denied"))).toContain("permissions");
108
+ for (const reasonCode of [
109
+ "autoscaler_status_cache_scope",
110
+ "autoscaler_status_cache_unavailable",
111
+ "autoscaler_status_read_failed",
112
+ "autoscaler_status_parse_failed",
113
+ ]) {
114
+ expect(
115
+ autoscalerDetectionFailure(reasoned("unavailable", reasonCode)),
116
+ ).toBeTruthy();
117
+ }
118
+ expect(autoscalerDetectionFailure(cov("error"))).toBeTruthy();
119
+ });
120
+
121
+ it("treats a published-nothing reading as an observation, not a failure", () => {
122
+ // This is the ONLY reading that may render as "None detected".
123
+ expect(
124
+ autoscalerDetectionFailure(
125
+ reasoned("unavailable", "autoscaler_status_not_published"),
126
+ ),
127
+ ).toBeNull();
128
+ expect(autoscalerDetectionFailure(cov("available"))).toBeNull();
129
+ expect(autoscalerDetectionFailure(undefined)).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe("autoscalerNotPublishedDetail", () => {
134
+ it("scopes the claim to the namespace the server probed", () => {
135
+ // Unscoped, "None detected" reads as "this cluster has no autoscaler" when
136
+ // it only means "no status ConfigMap in the one place it can live".
137
+ expect(
138
+ autoscalerNotPublishedDetail({
139
+ ...reasoned("unavailable", "autoscaler_status_not_published"),
140
+ namespaces: ["kube-system"],
141
+ }),
142
+ ).toBe("No cluster-autoscaler-status ConfigMap in kube-system.");
143
+ });
144
+
145
+ it("names kube-system when the server reported no namespace list", () => {
146
+ expect(
147
+ autoscalerNotPublishedDetail(
148
+ reasoned("unavailable", "autoscaler_status_not_published"),
149
+ ),
150
+ ).toContain("kube-system");
151
+ });
152
+
153
+ it("says nothing for any other reading", () => {
154
+ expect(
155
+ autoscalerNotPublishedDetail(
156
+ reasoned("unavailable", "autoscaler_status_read_failed"),
157
+ ),
158
+ ).toBeNull();
159
+ expect(autoscalerNotPublishedDetail(cov("available"))).toBeNull();
160
+ expect(autoscalerNotPublishedDetail(undefined)).toBeNull();
161
+ });
162
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { podAwaitsScheduling, workloadPodAwaitsScheduling } from "./podDemandGate";
3
+
4
+ describe("podAwaitsScheduling", () => {
5
+ it("admits a Pending pod the scheduler has not placed", () => {
6
+ expect(podAwaitsScheduling({ status: { phase: "Pending" } })).toBe(true);
7
+ });
8
+
9
+ it("rejects a Pending pod that already holds a node", () => {
10
+ // ContainerCreating / ImagePullBackOff pods are Pending but absent from
11
+ // Demand — linking them there is a dead end.
12
+ expect(
13
+ podAwaitsScheduling({
14
+ spec: { nodeName: "ip-10-0-1-1" },
15
+ status: { phase: "Pending" },
16
+ }),
17
+ ).toBe(false);
18
+ });
19
+
20
+ it("rejects running, succeeded and unknown-shape pods", () => {
21
+ expect(
22
+ podAwaitsScheduling({
23
+ spec: { nodeName: "ip-10-0-1-1" },
24
+ status: { phase: "Running" },
25
+ }),
26
+ ).toBe(false);
27
+ expect(podAwaitsScheduling({ status: { phase: "Succeeded" } })).toBe(false);
28
+ expect(podAwaitsScheduling({})).toBe(false);
29
+ expect(podAwaitsScheduling(undefined)).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("workloadPodAwaitsScheduling (flat WorkloadPodInfo shape)", () => {
34
+ it("admits an unscheduled Pending pod", () => {
35
+ expect(workloadPodAwaitsScheduling({ phase: "Pending" })).toBe(true);
36
+ });
37
+ it("rejects Pending pods already bound to a node", () => {
38
+ expect(
39
+ workloadPodAwaitsScheduling({ phase: "Pending", nodeName: "ip-10-0-0-1" }),
40
+ ).toBe(false);
41
+ });
42
+ it("rejects running, empty, and absent pods", () => {
43
+ expect(workloadPodAwaitsScheduling({ phase: "Running" })).toBe(false);
44
+ expect(workloadPodAwaitsScheduling({})).toBe(false);
45
+ expect(workloadPodAwaitsScheduling(undefined)).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,22 @@
1
+ interface PodLike {
2
+ spec?: { nodeName?: string };
3
+ status?: { phase?: string };
4
+ }
5
+
6
+ /**
7
+ * Whether Capacity Demand would have anything to say about this pod. Demand
8
+ * only admits pods the scheduler has not placed, so a Pending pod that already
9
+ * holds a node (ContainerCreating, ImagePullBackOff) must not be offered a link
10
+ * that lands on a screen it is absent from.
11
+ */
12
+ export function podAwaitsScheduling(pod: PodLike | undefined | null): boolean {
13
+ return pod?.status?.phase === "Pending" && !pod?.spec?.nodeName;
14
+ }
15
+
16
+ // The workload page's pods arrive as the flattened WorkloadPodInfo wire shape
17
+ // rather than full Pod objects — same rule, different field paths.
18
+ export function workloadPodAwaitsScheduling(
19
+ pod: { phase?: string; nodeName?: string } | undefined | null,
20
+ ): boolean {
21
+ return pod?.phase === "Pending" && !pod?.nodeName;
22
+ }