@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.
- package/package.json +7 -7
- package/src/App.tsx +69 -16
- 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 +2905 -2081
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +15 -15
- 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/AISettings.tsx +21 -7
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +127 -57
- package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- 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/ResourcesView.tsx +9 -8
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/TimelineView.tsx +17 -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/context/DiagnoseCustomization.tsx +1 -1
- package/src/utils/shell-safe.test.ts +55 -0
- package/src/utils/shell-safe.ts +21 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import type {
|
|
5
|
+
CapacityOverviewResponse,
|
|
6
|
+
CapacityResponseMeta,
|
|
7
|
+
CapacitySourceCoverage,
|
|
8
|
+
} from "@skyhook-io/k8s-ui";
|
|
9
|
+
import { CapacityCard } from "./CapacityCard";
|
|
10
|
+
|
|
11
|
+
let mockKarpenterState = "available";
|
|
12
|
+
vi.mock("../../contexts/CapabilitiesContext", () => ({
|
|
13
|
+
useCapabilitiesContext: () => ({ karpenter: { state: mockKarpenterState } }),
|
|
14
|
+
}));
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockKarpenterState = "available";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const generatedAt = "2026-07-13T08:00:00Z";
|
|
20
|
+
|
|
21
|
+
function sourceCoverage(
|
|
22
|
+
scope: CapacitySourceCoverage["scope"] = "cluster",
|
|
23
|
+
): CapacitySourceCoverage {
|
|
24
|
+
return {
|
|
25
|
+
status: "available",
|
|
26
|
+
scope,
|
|
27
|
+
observedAt: generatedAt,
|
|
28
|
+
impactFields: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const meta: CapacityResponseMeta = {
|
|
33
|
+
schemaVersion: "v1alpha1",
|
|
34
|
+
generatedAt,
|
|
35
|
+
clusterContext: { contextName: "radar-test-eks" },
|
|
36
|
+
provider: {
|
|
37
|
+
type: "karpenter",
|
|
38
|
+
controllerMode: "self_managed",
|
|
39
|
+
apiVersionsByKind: {},
|
|
40
|
+
nodeClassKinds: [],
|
|
41
|
+
features: {},
|
|
42
|
+
},
|
|
43
|
+
coverage: { nodes: sourceCoverage(), pods: sourceCoverage() },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function overview(
|
|
47
|
+
summary: Partial<CapacityOverviewResponse["summary"]> = {},
|
|
48
|
+
coverage: CapacityResponseMeta["coverage"] = meta.coverage,
|
|
49
|
+
): CapacityOverviewResponse {
|
|
50
|
+
return {
|
|
51
|
+
...meta,
|
|
52
|
+
coverage,
|
|
53
|
+
state: "available",
|
|
54
|
+
summary: {
|
|
55
|
+
actions: [],
|
|
56
|
+
poolCount: 2,
|
|
57
|
+
claimCount: 4,
|
|
58
|
+
nodeCount: 6,
|
|
59
|
+
pendingPodCount: 3,
|
|
60
|
+
managers: [],
|
|
61
|
+
...summary,
|
|
62
|
+
},
|
|
63
|
+
pools: [],
|
|
64
|
+
poolsTruncated: false,
|
|
65
|
+
groups: [],
|
|
66
|
+
orphanAutoscalerGroups: [],
|
|
67
|
+
orphanAutoscalerGroupsMeta: { total: 0, returned: 0, truncated: false },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderCard(data: CapacityOverviewResponse): string {
|
|
72
|
+
const client = new QueryClient({
|
|
73
|
+
defaultOptions: { queries: { retry: false, retryOnMount: false } },
|
|
74
|
+
});
|
|
75
|
+
client.setQueryData(["capacity", "overview"], data);
|
|
76
|
+
return renderToString(
|
|
77
|
+
<QueryClientProvider client={client}>
|
|
78
|
+
<CapacityCard onNavigate={() => {}} />
|
|
79
|
+
</QueryClientProvider>,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("CapacityCard", () => {
|
|
84
|
+
it("shows exact counts under cluster-wide coverage", () => {
|
|
85
|
+
const html = renderCard(overview());
|
|
86
|
+
expect(html).toContain(">3<");
|
|
87
|
+
expect(html).not.toContain("≥");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("hedges the pending-pod count when pod coverage is a lower bound", () => {
|
|
91
|
+
const html = renderCard(
|
|
92
|
+
overview(
|
|
93
|
+
{},
|
|
94
|
+
{
|
|
95
|
+
...meta.coverage,
|
|
96
|
+
pods: sourceCoverage("all_authorized_namespaces"),
|
|
97
|
+
},
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
expect(html).toContain("≥3");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders the softened truth for a denied Karpenter instead of vanishing", () => {
|
|
104
|
+
mockKarpenterState = "denied";
|
|
105
|
+
const html = renderCard({
|
|
106
|
+
...overview({ poolCount: undefined, claimCount: undefined }),
|
|
107
|
+
state: "denied",
|
|
108
|
+
});
|
|
109
|
+
expect(html).toContain("Karpenter view unavailable");
|
|
110
|
+
expect(html).not.toContain("No active signals");
|
|
111
|
+
expect(html).toContain("NodePools");
|
|
112
|
+
expect(html).toContain("—");
|
|
113
|
+
expect(html).toContain(">6<");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("renders a cluster card on a Karpenter-less cluster with managers", () => {
|
|
117
|
+
mockKarpenterState = "not_detected";
|
|
118
|
+
const html = renderCard({
|
|
119
|
+
...overview({
|
|
120
|
+
poolCount: undefined,
|
|
121
|
+
claimCount: undefined,
|
|
122
|
+
managers: [
|
|
123
|
+
{ manager: "gke_autoscaler", groupCount: 2, status: "healthy" },
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
state: "not_detected",
|
|
127
|
+
});
|
|
128
|
+
expect(html).toContain("Node groups");
|
|
129
|
+
expect(html).toContain("Managers");
|
|
130
|
+
expect(html).not.toContain("NodePools");
|
|
131
|
+
expect(html).not.toContain("NodeClaims");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps a quiet Home on a bare cluster with no capacity story", () => {
|
|
135
|
+
mockKarpenterState = "not_detected";
|
|
136
|
+
const html = renderCard({
|
|
137
|
+
...overview({ poolCount: undefined, claimCount: undefined, managers: [] }),
|
|
138
|
+
state: "not_detected",
|
|
139
|
+
});
|
|
140
|
+
expect(html).toBe("");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders an omitted NodePool count as unavailable, never zero", () => {
|
|
144
|
+
// The server omits poolCount when NodePools were not observed.
|
|
145
|
+
const html = renderCard(overview({ poolCount: undefined }));
|
|
146
|
+
expect(html).toContain("NodePools");
|
|
147
|
+
expect(html).toContain("—");
|
|
148
|
+
expect(html).not.toContain(">0<");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Layers3 } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { useCapacityOverview } from '../../api/client'
|
|
4
|
+
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
5
|
+
import { coverageIsLowerBound, humanizeCode } from '../capacity/shared'
|
|
6
|
+
|
|
7
|
+
// CapacityCard surfaces the cluster's capacity posture on the Home dashboard
|
|
8
|
+
// so morning triage reaches the Capacity view without knowing it exists. It
|
|
9
|
+
// renders whenever the cluster has a capacity story: Karpenter available, a
|
|
10
|
+
// Karpenter the caller is denied (softened rows, honest header — the denied
|
|
11
|
+
// Overview shape carries real node/pod truth), or detected managers/groups on
|
|
12
|
+
// a Karpenter-less cluster. Bare clusters with no story keep a quiet Home.
|
|
13
|
+
// Numbers the backend omitted (RBAC-denied / unobserved) render as "—",
|
|
14
|
+
// never zero.
|
|
15
|
+
export function CapacityCard({ onNavigate }: { onNavigate: () => void }) {
|
|
16
|
+
const karpenterState = useCapabilitiesContext().karpenter?.state
|
|
17
|
+
const mayHaveStory =
|
|
18
|
+
karpenterState === 'available' || karpenterState === 'denied' || karpenterState === 'not_detected'
|
|
19
|
+
const { data } = useCapacityOverview({ enabled: mayHaveStory })
|
|
20
|
+
if (!mayHaveStory || !data) return null
|
|
21
|
+
const karpenterDenied = data.state === 'denied'
|
|
22
|
+
const karpenterless = data.state === 'not_detected'
|
|
23
|
+
if (!karpenterDenied && !karpenterless && data.state !== 'available') return null
|
|
24
|
+
if (karpenterless && (data.summary.managers?.length ?? 0) === 0 && data.groups.length === 0)
|
|
25
|
+
return null
|
|
26
|
+
|
|
27
|
+
const actions = data.summary.actions ?? []
|
|
28
|
+
const worst = actions.find((a) => a.highestSeverity === 'critical') ?? actions.find((a) => a.highestSeverity === 'warning')
|
|
29
|
+
// A denied Karpenter must not read as healthy: "no signals" would be a
|
|
30
|
+
// claim about a fleet this identity cannot see.
|
|
31
|
+
const headerTone = karpenterDenied
|
|
32
|
+
? 'text-theme-text-tertiary'
|
|
33
|
+
: worst
|
|
34
|
+
? worst.highestSeverity === 'critical'
|
|
35
|
+
? 'text-red-500'
|
|
36
|
+
: 'text-amber-400'
|
|
37
|
+
: 'text-emerald-500'
|
|
38
|
+
const headerLabel = karpenterDenied
|
|
39
|
+
? 'Karpenter view unavailable'
|
|
40
|
+
: worst
|
|
41
|
+
? humanizeCode(worst.code)
|
|
42
|
+
: 'No active signals'
|
|
43
|
+
|
|
44
|
+
// Namespace-scoped pod coverage hides pending pods this identity cannot see,
|
|
45
|
+
// so the count is a floor — it must not read as the cluster total.
|
|
46
|
+
const pendingIsLowerBound = coverageIsLowerBound(data.coverage.pods)
|
|
47
|
+
const pendingStat = {
|
|
48
|
+
label: 'Pending pods',
|
|
49
|
+
value: absentAsDash(data.summary.pendingPodCount, pendingIsLowerBound ? '≥' : ''),
|
|
50
|
+
}
|
|
51
|
+
const stats: { label: string; value: string }[] = karpenterless
|
|
52
|
+
? [
|
|
53
|
+
{ label: 'Node groups', value: `${data.groups.length}` },
|
|
54
|
+
pendingStat,
|
|
55
|
+
{ label: 'Managers', value: `${data.summary.managers?.length ?? 0}` },
|
|
56
|
+
{ label: 'Nodes', value: absentAsDash(data.summary.nodeCount) },
|
|
57
|
+
]
|
|
58
|
+
: [
|
|
59
|
+
{ label: 'NodePools', value: absentAsDash(data.summary.poolCount) },
|
|
60
|
+
pendingStat,
|
|
61
|
+
{ label: 'NodeClaims', value: absentAsDash(data.summary.claimCount) },
|
|
62
|
+
{ label: 'Nodes', value: absentAsDash(data.summary.nodeCount) },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={onNavigate}
|
|
69
|
+
className="group h-[260px] rounded-xl bg-theme-surface shadow-theme-sm hover:-translate-y-1 hover:shadow-theme-md transition-all duration-200 text-left animate-fade-in-up"
|
|
70
|
+
>
|
|
71
|
+
<div className="flex flex-col h-full w-full">
|
|
72
|
+
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
|
|
73
|
+
<div className="flex items-center gap-2">
|
|
74
|
+
<Layers3 className={clsx('h-4 w-4', headerTone)} />
|
|
75
|
+
<span className={clsx('text-xs font-semibold uppercase tracking-wider', headerTone)}>
|
|
76
|
+
Capacity
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<span className={clsx('max-w-[55%] truncate text-[11px] font-medium', headerTone)}>{headerLabel}</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex-1 min-h-0 px-5 py-3">
|
|
83
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
84
|
+
{stats.map((stat) => (
|
|
85
|
+
<div key={stat.label}>
|
|
86
|
+
<div className="text-lg font-semibold tabular-nums text-theme-text-primary">{stat.value}</div>
|
|
87
|
+
<div className="text-[11px] text-theme-text-tertiary">{stat.label}</div>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
{actions.length > 0 && (
|
|
92
|
+
<div className="mt-3 space-y-1">
|
|
93
|
+
{actions.slice(0, 2).map((action) => (
|
|
94
|
+
<div key={action.code} className="flex items-center gap-1.5 text-[11px] text-theme-text-secondary">
|
|
95
|
+
<span
|
|
96
|
+
className={clsx(
|
|
97
|
+
'h-1.5 w-1.5 shrink-0 rounded-full',
|
|
98
|
+
action.highestSeverity === 'critical'
|
|
99
|
+
? 'bg-red-500'
|
|
100
|
+
: action.highestSeverity === 'warning'
|
|
101
|
+
? 'bg-amber-400'
|
|
102
|
+
: 'bg-sky-400',
|
|
103
|
+
)}
|
|
104
|
+
/>
|
|
105
|
+
<span className="truncate">
|
|
106
|
+
{humanizeCode(action.code)}
|
|
107
|
+
{action.count > 1 ? ` (${action.count})` : ''}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="px-5 py-2.5 border-t border-theme-border/50 text-[11px] text-theme-text-tertiary group-hover:text-theme-text-secondary transition-colors">
|
|
116
|
+
Open Capacity →
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</button>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function absentAsDash(value: number | undefined, prefix = ''): string {
|
|
124
|
+
return value === undefined ? '—' : `${prefix}${value}`
|
|
125
|
+
}
|
|
@@ -11,6 +11,8 @@ import { CertificateHealthCard } from './CertificateHealthCard'
|
|
|
11
11
|
import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
|
|
12
12
|
import { CostCard } from './CostCard'
|
|
13
13
|
import { GitOpsControllersCard } from './GitOpsControllersCard'
|
|
14
|
+
import { CapacityCard } from './CapacityCard'
|
|
15
|
+
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
14
16
|
import { Tooltip } from '../ui/Tooltip'
|
|
15
17
|
import {
|
|
16
18
|
AuditCard,
|
|
@@ -45,6 +47,13 @@ interface HomeViewProps {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
|
|
50
|
+
// The card itself decides whether the cluster has a capacity story
|
|
51
|
+
// (available, softened-denied, or karpenterless-with-managers/groups) and
|
|
52
|
+
// returns null otherwise — the outer gate only excludes states with nothing
|
|
53
|
+
// to fetch against.
|
|
54
|
+
const karpenterState = useCapabilitiesContext().karpenter?.state
|
|
55
|
+
const capacityCardPossible =
|
|
56
|
+
karpenterState === 'available' || karpenterState === 'denied' || karpenterState === 'not_detected'
|
|
48
57
|
const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
|
|
49
58
|
const { connection } = useConnection()
|
|
50
59
|
const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
|
|
@@ -175,7 +184,7 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
175
184
|
{/* Posture band — same flex-grow wrap so any subset of compliance cards
|
|
176
185
|
fills its row instead of stranding the last one (the old 3-col grid
|
|
177
186
|
left Cluster Audit alone with two empty cells beside it). */}
|
|
178
|
-
{(data.certificateHealth || data.networkPolicyCoverage || data.audit || data.gitopsControllers) && (
|
|
187
|
+
{(data.certificateHealth || data.networkPolicyCoverage || data.audit || data.gitopsControllers || capacityCardPossible) && (
|
|
179
188
|
<div className="flex flex-wrap gap-6">
|
|
180
189
|
{data.certificateHealth && (
|
|
181
190
|
<BandItem>
|
|
@@ -201,6 +210,11 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
|
|
|
201
210
|
/>
|
|
202
211
|
</BandItem>
|
|
203
212
|
)}
|
|
213
|
+
{capacityCardPossible && (
|
|
214
|
+
<BandItem>
|
|
215
|
+
<CapacityCard onNavigate={() => onNavigateToView('capacity')} />
|
|
216
|
+
</BandItem>
|
|
217
|
+
)}
|
|
204
218
|
{data.audit && (
|
|
205
219
|
<BandItem>
|
|
206
220
|
<AuditCard
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Issue } from "@skyhook-io/k8s-ui";
|
|
3
|
+
import { capacityHrefForIssue } from "./IssuesPane";
|
|
4
|
+
|
|
5
|
+
function issue(partial: Partial<Issue>): Issue {
|
|
6
|
+
return {
|
|
7
|
+
id: "1",
|
|
8
|
+
severity: "critical",
|
|
9
|
+
source: "problem",
|
|
10
|
+
category: "x",
|
|
11
|
+
category_group: "x",
|
|
12
|
+
grouping_scope: "x",
|
|
13
|
+
kind: "Pod",
|
|
14
|
+
name: "p",
|
|
15
|
+
reason: "r",
|
|
16
|
+
...partial,
|
|
17
|
+
} as Issue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("capacityHrefForIssue", () => {
|
|
21
|
+
it("returns null when Karpenter is not detected", () => {
|
|
22
|
+
expect(capacityHrefForIssue(issue({ source: "scheduling" }), false)).toBeNull();
|
|
23
|
+
expect(
|
|
24
|
+
capacityHrefForIssue(
|
|
25
|
+
issue({ kind: "NodePool", group: "karpenter.sh", name: "core" }),
|
|
26
|
+
false),
|
|
27
|
+
).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("links a backend-flagged capacity-relevant pod to the Demand queue", () => {
|
|
31
|
+
expect(
|
|
32
|
+
capacityHrefForIssue(
|
|
33
|
+
issue({ source: "scheduling", capacity_relevant: true }),
|
|
34
|
+
true),
|
|
35
|
+
).toBe("/capacity/demand");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("preserves namespace scope on the Demand link", () => {
|
|
39
|
+
expect(
|
|
40
|
+
capacityHrefForIssue(
|
|
41
|
+
issue({
|
|
42
|
+
source: "scheduling",
|
|
43
|
+
capacity_relevant: true,
|
|
44
|
+
namespace: "payments",
|
|
45
|
+
owner: { kind: "Deployment", name: "api" },
|
|
46
|
+
}),
|
|
47
|
+
true),
|
|
48
|
+
).toBe(`/capacity/demand?owner=${encodeURIComponent("payments/Deployment/api")}`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does NOT link a scheduling failure the backend did not flag", () => {
|
|
52
|
+
// Generic unschedulable (insufficient cpu, node-pinned, zonal, non-Karpenter
|
|
53
|
+
// node group): the backend leaves capacity_relevant unset → no link, even in
|
|
54
|
+
// a Karpenter cluster. No message parsing is involved.
|
|
55
|
+
expect(
|
|
56
|
+
capacityHrefForIssue(
|
|
57
|
+
issue({
|
|
58
|
+
source: "scheduling",
|
|
59
|
+
message: "Unschedulable — Insufficient cpu (0/9 nodes available)",
|
|
60
|
+
}),
|
|
61
|
+
true),
|
|
62
|
+
).toBeNull();
|
|
63
|
+
expect(
|
|
64
|
+
capacityHrefForIssue(
|
|
65
|
+
issue({ source: "scheduling", capacity_relevant: false }),
|
|
66
|
+
true),
|
|
67
|
+
).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("links a NodePool-subject issue to its pool detail", () => {
|
|
71
|
+
expect(
|
|
72
|
+
capacityHrefForIssue(
|
|
73
|
+
issue({
|
|
74
|
+
kind: "NodePool",
|
|
75
|
+
group: "karpenter.sh",
|
|
76
|
+
name: "core-on-demand",
|
|
77
|
+
source: "condition",
|
|
78
|
+
}),
|
|
79
|
+
true),
|
|
80
|
+
).toBe("/capacity/pools/core-on-demand");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not link non-Karpenter issues even when Karpenter is present", () => {
|
|
84
|
+
expect(
|
|
85
|
+
capacityHrefForIssue(issue({ source: "problem", kind: "Service" }), true),
|
|
86
|
+
).toBeNull();
|
|
87
|
+
// A NodePool from a different API group must not be treated as Karpenter's.
|
|
88
|
+
expect(
|
|
89
|
+
capacityHrefForIssue(
|
|
90
|
+
issue({ kind: "NodePool", group: "example.com", name: "x" }),
|
|
91
|
+
true),
|
|
92
|
+
).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("capacityHrefForIssue subject carry", () => {
|
|
97
|
+
it("carries the grouped subject (the workload) into a filtered Demand", () => {
|
|
98
|
+
expect(
|
|
99
|
+
capacityHrefForIssue(
|
|
100
|
+
issue({
|
|
101
|
+
source: "scheduling",
|
|
102
|
+
capacity_relevant: true,
|
|
103
|
+
kind: "Deployment",
|
|
104
|
+
namespace: "shop",
|
|
105
|
+
name: "web",
|
|
106
|
+
}),
|
|
107
|
+
true,
|
|
108
|
+
),
|
|
109
|
+
).toBe("/capacity/demand?owner=shop%2FDeployment%2Fweb");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("prefers the flat row's owner over its Pod subject", () => {
|
|
113
|
+
expect(
|
|
114
|
+
capacityHrefForIssue(
|
|
115
|
+
issue({
|
|
116
|
+
source: "scheduling",
|
|
117
|
+
capacity_relevant: true,
|
|
118
|
+
kind: "Pod",
|
|
119
|
+
namespace: "shop",
|
|
120
|
+
name: "web-abc12",
|
|
121
|
+
owner: { kind: "Deployment", name: "web" },
|
|
122
|
+
}),
|
|
123
|
+
true,
|
|
124
|
+
),
|
|
125
|
+
).toBe("/capacity/demand?owner=shop%2FDeployment%2Fweb");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("fails closed to the unfiltered link when no complete subject exists", () => {
|
|
129
|
+
expect(
|
|
130
|
+
capacityHrefForIssue(
|
|
131
|
+
issue({
|
|
132
|
+
source: "scheduling",
|
|
133
|
+
capacity_relevant: true,
|
|
134
|
+
kind: "Pod",
|
|
135
|
+
namespace: "shop",
|
|
136
|
+
name: "orphan-abc12",
|
|
137
|
+
}),
|
|
138
|
+
true,
|
|
139
|
+
),
|
|
140
|
+
).toBe("/capacity/demand");
|
|
141
|
+
});
|
|
142
|
+
});
|