@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
@@ -1,7 +1,10 @@
1
- import { useMemo, useState } from 'react'
2
- import { useIssues } from '../../api/client'
3
- import { useConnection } from '../../context/ConnectionContext'
4
- import type { SelectedResource } from '../../types'
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 '@skyhook-io/k8s-ui'
17
- import { AlertTriangle } from 'lucide-react'
18
- import { IssueDiagnoseButton } from '../diagnose/LocalDiagnoseAction'
20
+ } from "@skyhook-io/k8s-ui";
21
+ import { AlertTriangle } from "lucide-react";
22
+ import { IssueDiagnoseButton } from "../diagnose/LocalDiagnoseAction";
19
23
 
20
- const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = { critical: 'error', warning: 'warning' }
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({ namespaces, onNavigateToResource }: IssuesPaneProps) {
36
- const { data, isLoading, error, dataUpdatedAt, refetch } = useIssues(namespaces)
37
- const { connection } = useConnection()
38
- const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
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 ? allIssues.filter((i) => severityFilter.has(i.severity)) : allIssues
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); else next.add(s)
52
- return next
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({ kind: ref.kind, namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' })
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 label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
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>Limited visibility — {data.visibility.impact} Results may be incomplete.</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 && data.total_matched > (data.issues?.length ?? 0) && (
118
- <p className="text-xs text-theme-text-tertiary">
119
- Showing {data.issues?.length ?? 0} of {data.total_matched} issues (capped) — narrow by namespace to see the rest.
120
- </p>
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
- <IssueDiagnoseButton kind={issue.kind} namespace={issue.namespace ?? ''} name={issue.name} />
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
+ });