@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
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { useMemo, useState } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { useIssues } from "../../api/client";
|
|
4
|
+
import { useAPIResources, karpenterCapacityAvailable } from "../../api/apiResources";
|
|
5
|
+
import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
|
|
6
|
+
import { useConnection } from "../../context/ConnectionContext";
|
|
7
|
+
import type { SelectedResource } from "../../types";
|
|
5
8
|
import {
|
|
6
9
|
IssuesView,
|
|
7
10
|
PaneLoader,
|
|
@@ -10,18 +13,73 @@ import {
|
|
|
10
13
|
FreshnessControl,
|
|
11
14
|
ISSUE_SEVERITIES,
|
|
12
15
|
ISSUE_SEVERITY_LABEL,
|
|
16
|
+
type Issue,
|
|
13
17
|
type IssueResourceRef,
|
|
14
18
|
type IssueSeverity,
|
|
15
19
|
type SummaryTone,
|
|
16
|
-
} from
|
|
17
|
-
import { AlertTriangle } from
|
|
18
|
-
import { IssueDiagnoseButton } from
|
|
20
|
+
} from "@skyhook-io/k8s-ui";
|
|
21
|
+
import { AlertTriangle } from "lucide-react";
|
|
22
|
+
import { IssueDiagnoseButton } from "../diagnose/LocalDiagnoseAction";
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
// A capacity-relevant issue links to its Karpenter diagnosis. Karpenter is
|
|
25
|
+
// always single-cluster (unlike Argo hub-and-spoke), so the issue and the
|
|
26
|
+
// Capacity view are guaranteed to be the same cluster — the deep link is
|
|
27
|
+
// unambiguous.
|
|
28
|
+
//
|
|
29
|
+
// Fail closed: return null unless the issue is *definitely* Karpenter's, so the
|
|
30
|
+
// link can't mislead. Only two signals qualify — (1) the subject IS a Karpenter
|
|
31
|
+
// NodePool, or (2) the backend has flagged an unschedulable pod as requiring a
|
|
32
|
+
// Karpenter NodePool (issue.capacity_relevant — a structural pod-spec check
|
|
33
|
+
// server-side, not message parsing). A generic scheduling failure (insufficient
|
|
34
|
+
// cpu, node-pinned, zonal PVC, a non-Karpenter managed node group) is NOT
|
|
35
|
+
// Karpenter's to solve and gets no link, even in a Karpenter cluster. Clusters
|
|
36
|
+
// without Karpenter never reach the signal checks (hasKarpenter is false).
|
|
37
|
+
export function capacityHrefForIssue(
|
|
38
|
+
issue: Issue,
|
|
39
|
+
hasKarpenter: boolean,
|
|
40
|
+
): string | null {
|
|
41
|
+
if (!hasKarpenter) return null;
|
|
42
|
+
// (1) A NodePool-subject issue (not ready, limit pressure, …) → its pool detail.
|
|
43
|
+
if (issue.kind === "NodePool" && issue.group === "karpenter.sh") {
|
|
44
|
+
return `/capacity/pools/${encodeURIComponent(issue.name)}`;
|
|
45
|
+
}
|
|
46
|
+
// (2) A pod the backend flagged as requiring a Karpenter NodePool → the Demand
|
|
47
|
+
// queue, which groups pending pods by scheduling signature and shows which
|
|
48
|
+
// pools can (or can't) take them. The link carries its subject (?owner=) so
|
|
49
|
+
// Demand lands filtered server-side: grouped scheduling issues have the
|
|
50
|
+
// workload AS their subject (grouping promotes the owner); flat pod rows
|
|
51
|
+
// carry issue.owner. Fail closed to the unfiltered link when no complete
|
|
52
|
+
// subject exists. No STATE filter on purpose — the issue doesn't map cleanly
|
|
53
|
+
// to a single demand state (blocked vs awaiting capacity), and a state filter
|
|
54
|
+
// could hide the very group being investigated. Capacity is deliberately
|
|
55
|
+
// cluster-wide — no namespace view-filter forwarding.
|
|
56
|
+
if (issue.capacity_relevant) {
|
|
57
|
+
const owner =
|
|
58
|
+
issue.owner?.kind && issue.owner.name
|
|
59
|
+
? {
|
|
60
|
+
kind: issue.owner.kind,
|
|
61
|
+
namespace: issue.owner.namespace ?? issue.namespace,
|
|
62
|
+
name: issue.owner.name,
|
|
63
|
+
}
|
|
64
|
+
: issue.kind && issue.kind !== "Pod" && issue.name
|
|
65
|
+
? { kind: issue.kind, namespace: issue.namespace, name: issue.name }
|
|
66
|
+
: undefined;
|
|
67
|
+
if (owner?.namespace) {
|
|
68
|
+
return `/capacity/demand?owner=${encodeURIComponent(`${owner.namespace}/${owner.kind}/${owner.name}`)}`;
|
|
69
|
+
}
|
|
70
|
+
return "/capacity/demand";
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = {
|
|
76
|
+
critical: "error",
|
|
77
|
+
warning: "warning",
|
|
78
|
+
};
|
|
21
79
|
|
|
22
80
|
interface IssuesPaneProps {
|
|
23
|
-
namespaces: string[]
|
|
24
|
-
onNavigateToResource: (resource: SelectedResource) => void
|
|
81
|
+
namespaces: string[];
|
|
82
|
+
onNavigateToResource: (resource: SelectedResource) => void;
|
|
25
83
|
}
|
|
26
84
|
|
|
27
85
|
// The per-cluster Issues surface. Renders the same shared triage queue
|
|
@@ -32,31 +90,51 @@ interface IssuesPaneProps {
|
|
|
32
90
|
// (IssuesView is a pure list); single-cluster gets a light severity filter via
|
|
33
91
|
// the header status tiles (clickable → filter), matching the Applications /
|
|
34
92
|
// GitOps header-tile pattern rather than Hub's fleet facet sidebar.
|
|
35
|
-
export function IssuesPane({
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
93
|
+
export function IssuesPane({
|
|
94
|
+
namespaces,
|
|
95
|
+
onNavigateToResource,
|
|
96
|
+
}: IssuesPaneProps) {
|
|
97
|
+
const { data, isLoading, error, dataUpdatedAt, refetch } =
|
|
98
|
+
useIssues(namespaces);
|
|
99
|
+
const { connection } = useConnection();
|
|
100
|
+
const navigate = useNavigate();
|
|
101
|
+
const apiResources = useAPIResources();
|
|
102
|
+
const hasKarpenter = karpenterCapacityAvailable(
|
|
103
|
+
useCapabilitiesContext().karpenter,
|
|
104
|
+
apiResources.data,
|
|
105
|
+
);
|
|
106
|
+
const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(
|
|
107
|
+
new Set(),
|
|
108
|
+
);
|
|
39
109
|
|
|
40
|
-
const allIssues = useMemo(() => data?.issues ?? [], [data])
|
|
110
|
+
const allIssues = useMemo(() => data?.issues ?? [], [data]);
|
|
41
111
|
const totals = useMemo(() => {
|
|
42
|
-
const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
|
|
43
|
-
for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
|
|
44
|
-
return t
|
|
45
|
-
}, [allIssues])
|
|
46
|
-
const shown = severityFilter.size
|
|
112
|
+
const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 };
|
|
113
|
+
for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1;
|
|
114
|
+
return t;
|
|
115
|
+
}, [allIssues]);
|
|
116
|
+
const shown = severityFilter.size
|
|
117
|
+
? allIssues.filter((i) => severityFilter.has(i.severity))
|
|
118
|
+
: allIssues;
|
|
47
119
|
|
|
48
120
|
const toggleSeverity = (s: IssueSeverity) =>
|
|
49
121
|
setSeverityFilter((prev) => {
|
|
50
|
-
const next = new Set(prev)
|
|
51
|
-
if (next.has(s)) next.delete(s);
|
|
52
|
-
|
|
53
|
-
|
|
122
|
+
const next = new Set(prev);
|
|
123
|
+
if (next.has(s)) next.delete(s);
|
|
124
|
+
else next.add(s);
|
|
125
|
+
return next;
|
|
126
|
+
});
|
|
54
127
|
|
|
55
128
|
const onResourceClick = (ref: IssueResourceRef) =>
|
|
56
|
-
onNavigateToResource({
|
|
129
|
+
onNavigateToResource({
|
|
130
|
+
kind: ref.kind,
|
|
131
|
+
namespace: ref.namespace ?? "",
|
|
132
|
+
name: ref.name,
|
|
133
|
+
group: ref.group ?? "",
|
|
134
|
+
});
|
|
57
135
|
|
|
58
136
|
if (isLoading) {
|
|
59
|
-
return <PaneLoader label="Loading issues…" className="flex-1"
|
|
137
|
+
return <PaneLoader label="Loading issues…" className="flex-1" />;
|
|
60
138
|
}
|
|
61
139
|
|
|
62
140
|
if (error) {
|
|
@@ -64,7 +142,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
64
142
|
<div className="flex-1 flex items-center justify-center text-theme-text-secondary">
|
|
65
143
|
<p>Failed to load issues</p>
|
|
66
144
|
</div>
|
|
67
|
-
)
|
|
145
|
+
);
|
|
68
146
|
}
|
|
69
147
|
|
|
70
148
|
return (
|
|
@@ -83,7 +161,10 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
83
161
|
/>
|
|
84
162
|
{allIssues.length > 0 && (
|
|
85
163
|
<>
|
|
86
|
-
<SummaryTile
|
|
164
|
+
<SummaryTile
|
|
165
|
+
label={allIssues.length === 1 ? "issue" : "issues"}
|
|
166
|
+
value={allIssues.length}
|
|
167
|
+
/>
|
|
87
168
|
{ISSUE_SEVERITIES.map((s) =>
|
|
88
169
|
totals[s] > 0 || severityFilter.has(s) ? (
|
|
89
170
|
<SummaryTile
|
|
@@ -108,17 +189,22 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
108
189
|
{data?.visibility?.impact && (
|
|
109
190
|
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
|
|
110
191
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
|
111
|
-
<span>
|
|
192
|
+
<span>
|
|
193
|
+
Limited visibility — {data.visibility.impact} Results may be
|
|
194
|
+
incomplete.
|
|
195
|
+
</span>
|
|
112
196
|
</div>
|
|
113
197
|
)}
|
|
114
198
|
|
|
115
199
|
{/* Truncation honesty: when more issues matched than were returned, say
|
|
116
200
|
so — don't present a capped list as the complete picture. */}
|
|
117
|
-
{data?.total_matched != null &&
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
201
|
+
{data?.total_matched != null &&
|
|
202
|
+
data.total_matched > (data.issues?.length ?? 0) && (
|
|
203
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
204
|
+
Showing {data.issues?.length ?? 0} of {data.total_matched} issues
|
|
205
|
+
(capped) — narrow by namespace to see the rest.
|
|
206
|
+
</p>
|
|
207
|
+
)}
|
|
122
208
|
|
|
123
209
|
{/* Filtered-empty is NOT the healthy empty state: when a severity filter
|
|
124
210
|
hides every row but issues still exist, say "no matches" rather than
|
|
@@ -141,11 +227,29 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
141
227
|
issues={shown}
|
|
142
228
|
anyData={!!data}
|
|
143
229
|
onResourceClick={onResourceClick}
|
|
144
|
-
renderActions={({ issue }) =>
|
|
145
|
-
|
|
146
|
-
|
|
230
|
+
renderActions={({ issue }) => {
|
|
231
|
+
const capacityHref = capacityHrefForIssue(issue, hasKarpenter);
|
|
232
|
+
return (
|
|
233
|
+
<div className="flex items-center gap-2">
|
|
234
|
+
{capacityHref && (
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={() => navigate(capacityHref)}
|
|
238
|
+
className="rounded-md border border-theme-border px-2 py-1 text-xs font-medium text-accent-text transition-colors hover:bg-theme-hover"
|
|
239
|
+
>
|
|
240
|
+
View in Capacity →
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
<IssueDiagnoseButton
|
|
244
|
+
kind={issue.kind}
|
|
245
|
+
namespace={issue.namespace ?? ""}
|
|
246
|
+
name={issue.name}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}}
|
|
147
251
|
/>
|
|
148
252
|
)}
|
|
149
253
|
</div>
|
|
150
|
-
)
|
|
254
|
+
);
|
|
151
255
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { PrimaryNavRail } from "./PrimaryNavRail";
|
|
4
|
+
|
|
5
|
+
function renderRail() {
|
|
6
|
+
return renderToString(
|
|
7
|
+
<PrimaryNavRail
|
|
8
|
+
activeView="home"
|
|
9
|
+
onNavigate={() => {}}
|
|
10
|
+
pinned
|
|
11
|
+
onTogglePinned={() => {}}
|
|
12
|
+
/>,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("PrimaryNavRail", () => {
|
|
17
|
+
it("always surfaces Capacity — the view reads cluster capacity across every node manager, not just Karpenter", () => {
|
|
18
|
+
expect(renderRail()).toContain("Capacity");
|
|
19
|
+
});
|
|
20
|
+
});
|