@skyhook-io/radar-app 0.2.2 → 0.3.1
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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ApplyResourceError, formatApplyResourceError } from './client'
|
|
3
|
+
|
|
4
|
+
describe('formatApplyResourceError', () => {
|
|
5
|
+
it('reports resources persisted before a multi-document failure', () => {
|
|
6
|
+
expect(
|
|
7
|
+
formatApplyResourceError(
|
|
8
|
+
{
|
|
9
|
+
error: 'document 3: admission denied the Service',
|
|
10
|
+
failedIndex: 2,
|
|
11
|
+
total: 4,
|
|
12
|
+
results: [
|
|
13
|
+
{ kind: 'Namespace', namespace: '', name: 'checkout', created: true },
|
|
14
|
+
{ kind: 'Deployment', namespace: 'checkout', name: 'api', created: true },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
422,
|
|
18
|
+
),
|
|
19
|
+
).toBe(
|
|
20
|
+
'2 of 4 resources were applied before document 3 failed. Applied: Namespace checkout, Deployment checkout/api. admission denied the Service',
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('preserves ordinary apply errors', () => {
|
|
25
|
+
expect(formatApplyResourceError({ error: 'field is invalid' }, 422)).toBe('field is invalid')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves structured partial-apply results for recovery', () => {
|
|
29
|
+
const result = { kind: 'Namespace', namespace: '', name: 'checkout', created: true }
|
|
30
|
+
const error = new ApplyResourceError(
|
|
31
|
+
{
|
|
32
|
+
error: 'document 2: admission denied',
|
|
33
|
+
failedIndex: 1,
|
|
34
|
+
total: 3,
|
|
35
|
+
results: [result],
|
|
36
|
+
},
|
|
37
|
+
422,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(error.message).toContain('1 of 3 resource was applied')
|
|
41
|
+
expect(error.appliedResults).toEqual([result])
|
|
42
|
+
expect(error.failedIndex).toBe(1)
|
|
43
|
+
expect(error.total).toBe(3)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
|
|
2
|
+
// on the user's own machine/subscription against Radar's MCP; this just starts
|
|
3
|
+
// the investigation and consumes its SSE event stream.
|
|
4
|
+
import { getApiBase, getCredentialsMode } from "./config";
|
|
5
|
+
|
|
6
|
+
export interface AgentInfo {
|
|
7
|
+
name: string;
|
|
8
|
+
label: string;
|
|
9
|
+
path: string;
|
|
10
|
+
version: string;
|
|
11
|
+
present: boolean;
|
|
12
|
+
supported: boolean;
|
|
13
|
+
hosted?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AgentsResponse {
|
|
17
|
+
agents: AgentInfo[];
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
// Machine-scoped consent per disclosure surface, recorded server-side
|
|
20
|
+
// (~/.radar) — one acknowledgment covers the web panel and the CLI.
|
|
21
|
+
consented?: { standard?: boolean; cursor?: boolean };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiagnoseStep {
|
|
25
|
+
id: string;
|
|
26
|
+
tool: string;
|
|
27
|
+
status: "running" | "done";
|
|
28
|
+
ms?: number;
|
|
29
|
+
summary?: string; // input args (on running)
|
|
30
|
+
result?: string; // result text (on done), capped
|
|
31
|
+
truncated?: boolean; // result was capped — payload shown/copied is partial
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Diagnosis {
|
|
35
|
+
healthy?: boolean;
|
|
36
|
+
inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
|
|
37
|
+
rootCause: string;
|
|
38
|
+
report: string;
|
|
39
|
+
remediation: string[];
|
|
40
|
+
recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
|
|
41
|
+
recommendedReason?: string; // why the recommended step is the safe pick
|
|
42
|
+
confidence?: number;
|
|
43
|
+
costUsd?: number;
|
|
44
|
+
turns?: number;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HealthLine {
|
|
49
|
+
severity?: string;
|
|
50
|
+
reason?: string; // issue Reason / audit CheckID
|
|
51
|
+
message?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResourceHealthSignal {
|
|
55
|
+
health?: string;
|
|
56
|
+
issueCount?: number;
|
|
57
|
+
highestSeverity?: string;
|
|
58
|
+
topReason?: string;
|
|
59
|
+
issues?: HealthLine[];
|
|
60
|
+
auditCount?: number;
|
|
61
|
+
auditSeverity?: string;
|
|
62
|
+
topFinding?: string;
|
|
63
|
+
auditFindings?: HealthLine[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DiagnoseStreamEvent {
|
|
67
|
+
type:
|
|
68
|
+
| "turn"
|
|
69
|
+
| "phase"
|
|
70
|
+
| "step"
|
|
71
|
+
| "thinking"
|
|
72
|
+
| "done"
|
|
73
|
+
| "error"
|
|
74
|
+
| "closed";
|
|
75
|
+
phase?: string;
|
|
76
|
+
step?: DiagnoseStep;
|
|
77
|
+
token?: string;
|
|
78
|
+
diagnosis?: Diagnosis;
|
|
79
|
+
error?: string;
|
|
80
|
+
question?: string; // on "turn"
|
|
81
|
+
apply?: boolean; // on "turn"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A run is a durable, server-owned investigation. Its lifetime is independent of
|
|
85
|
+
// any browser tab — it survives panel close / navigation / refresh while the radar
|
|
86
|
+
// server runs.
|
|
87
|
+
export interface RunSummary {
|
|
88
|
+
id: string;
|
|
89
|
+
kind: string;
|
|
90
|
+
namespace: string;
|
|
91
|
+
name: string;
|
|
92
|
+
context: string;
|
|
93
|
+
agent?: string; // backend CLI that drove this run ("claude"/"codex")
|
|
94
|
+
isolated?: boolean;
|
|
95
|
+
model?: string;
|
|
96
|
+
effort?: string;
|
|
97
|
+
managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
|
|
98
|
+
health?: ResourceHealthSignal;
|
|
99
|
+
status: "running" | "done" | "error" | "stopped" | "stale";
|
|
100
|
+
sessionId?: string;
|
|
101
|
+
preview?: string;
|
|
102
|
+
createdAt: string;
|
|
103
|
+
updatedAt: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function fetchAgents(
|
|
107
|
+
signal?: AbortSignal,
|
|
108
|
+
): Promise<AgentsResponse> {
|
|
109
|
+
const res = await fetch(`${getApiBase()}/agents`, {
|
|
110
|
+
credentials: getCredentialsMode(),
|
|
111
|
+
signal,
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) throw new Error(`agents: ${res.status}`);
|
|
114
|
+
return res.json();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function errorText(res: Response): Promise<string> {
|
|
118
|
+
try {
|
|
119
|
+
const d = await res.json();
|
|
120
|
+
if (d && typeof d.error === "string") return d.error;
|
|
121
|
+
} catch {
|
|
122
|
+
/* ignore */
|
|
123
|
+
}
|
|
124
|
+
return `request failed (${res.status})`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// DiagnoseError carries the HTTP status so callers can special-case (e.g. 409 cap).
|
|
128
|
+
export class DiagnoseError extends Error {
|
|
129
|
+
status: number;
|
|
130
|
+
constructor(status: number, message: string) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.status = status;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const RUNS = () => `${getApiBase()}/diagnose/runs`;
|
|
137
|
+
|
|
138
|
+
// createRun starts a server-side investigation (or focuses a live one for the same
|
|
139
|
+
// target) and returns its run summary.
|
|
140
|
+
export async function createRun(
|
|
141
|
+
target: {
|
|
142
|
+
kind: string;
|
|
143
|
+
namespace: string;
|
|
144
|
+
name: string;
|
|
145
|
+
},
|
|
146
|
+
opts?: {
|
|
147
|
+
agent?: string;
|
|
148
|
+
isolated?: boolean;
|
|
149
|
+
model?: string;
|
|
150
|
+
effort?: string;
|
|
151
|
+
},
|
|
152
|
+
): Promise<RunSummary> {
|
|
153
|
+
const res = await fetch(RUNS(), {
|
|
154
|
+
method: "POST",
|
|
155
|
+
credentials: getCredentialsMode(),
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify({ ...target, ...opts }),
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
160
|
+
return res.json();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface RunsResponse {
|
|
164
|
+
runs: RunSummary[];
|
|
165
|
+
// Persistence stopped working (disk error) — history will NOT survive a
|
|
166
|
+
// restart; the UI should say so instead of letting the user assume otherwise.
|
|
167
|
+
historyDegraded?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// listRuns returns all server-side runs (newest first) — the source of truth for
|
|
171
|
+
// the recent-investigations list.
|
|
172
|
+
export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
|
|
173
|
+
const res = await fetch(RUNS(), {
|
|
174
|
+
credentials: getCredentialsMode(),
|
|
175
|
+
signal,
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
178
|
+
const d = await res.json();
|
|
179
|
+
return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// recordConsent acknowledges the current disclosure for a surface, server-side.
|
|
183
|
+
export async function recordConsent(
|
|
184
|
+
surface: "standard" | "cursor",
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const res = await fetch(`${getApiBase()}/diagnose/consent`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
credentials: getCredentialsMode(),
|
|
189
|
+
headers: { "Content-Type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ surface }),
|
|
191
|
+
});
|
|
192
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// clearHistory wipes the persisted investigation history (finished runs); live
|
|
196
|
+
// investigations survive.
|
|
197
|
+
export async function clearHistory(): Promise<void> {
|
|
198
|
+
const res = await fetch(`${getApiBase()}/diagnose/history/clear`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
credentials: getCredentialsMode(),
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
|
|
206
|
+
export async function addTurn(
|
|
207
|
+
id: string,
|
|
208
|
+
body: { question?: string; apply?: boolean; fix?: string },
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
const res = await fetch(`${RUNS()}/${id}/turns`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
credentials: getCredentialsMode(),
|
|
213
|
+
headers: { "Content-Type": "application/json" },
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
});
|
|
216
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// stopRun cancels a run's in-flight agent.
|
|
220
|
+
export async function stopRun(id: string): Promise<void> {
|
|
221
|
+
await fetch(`${RUNS()}/${id}/stop`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
credentials: getCredentialsMode(),
|
|
224
|
+
}).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface SubscribeHandlers {
|
|
228
|
+
onEvent: (ev: DiagnoseStreamEvent) => void;
|
|
229
|
+
onClosed?: () => void; // the run can no longer produce events (stale/evicted)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Subscribes to a run's event stream: the server replays everything (so a fresh
|
|
234
|
+
* tab reconstructs the whole transcript) then streams live. Closing this only
|
|
235
|
+
* unsubscribes — the run keeps running server-side. The EventSource auto-reconnects
|
|
236
|
+
* on transient errors (resuming via Last-Event-ID); a "closed" event means the run
|
|
237
|
+
* is gone, so we stop for good.
|
|
238
|
+
*/
|
|
239
|
+
export function subscribeRun(
|
|
240
|
+
id: string,
|
|
241
|
+
handlers: SubscribeHandlers,
|
|
242
|
+
): () => void {
|
|
243
|
+
const es = new EventSource(`${RUNS()}/${id}/stream`, {
|
|
244
|
+
withCredentials: getCredentialsMode() === "include",
|
|
245
|
+
});
|
|
246
|
+
let closed = false;
|
|
247
|
+
const close = () => {
|
|
248
|
+
if (closed) return;
|
|
249
|
+
closed = true;
|
|
250
|
+
es.close();
|
|
251
|
+
};
|
|
252
|
+
const dispatch = (e: MessageEvent) => {
|
|
253
|
+
let ev: DiagnoseStreamEvent;
|
|
254
|
+
try {
|
|
255
|
+
ev = JSON.parse(e.data);
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (ev.type === "closed") {
|
|
260
|
+
close();
|
|
261
|
+
handlers.onClosed?.();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
handlers.onEvent(ev);
|
|
265
|
+
};
|
|
266
|
+
for (const t of [
|
|
267
|
+
"turn",
|
|
268
|
+
"phase",
|
|
269
|
+
"step",
|
|
270
|
+
"thinking",
|
|
271
|
+
"done",
|
|
272
|
+
"error",
|
|
273
|
+
"closed",
|
|
274
|
+
] as const) {
|
|
275
|
+
es.addEventListener(t, dispatch);
|
|
276
|
+
}
|
|
277
|
+
es.onerror = () => {
|
|
278
|
+
// A permanent failure (readyState CLOSED) means the run is gone — a 404 because
|
|
279
|
+
// it was evicted (retention cap) or lost on a server restart. Surface it as
|
|
280
|
+
// closed so the view shows a "no longer available" state instead of a silent
|
|
281
|
+
// blank. Transient blips stay CONNECTING and auto-reconnect (Last-Event-ID
|
|
282
|
+
// replays only what we missed), so leave those to EventSource.
|
|
283
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
284
|
+
close();
|
|
285
|
+
handlers.onClosed?.();
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
return close;
|
|
289
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { fetchJSON } from './client'
|
|
3
|
+
|
|
4
|
+
// useNamespaceQuotas fetches a namespace's ResourceQuota objects via
|
|
5
|
+
// /api/resources/resourcequotas?namespace=<ns> (a bare array). Backs the
|
|
6
|
+
// NamespaceRenderer quota-usage section — quota saturation is otherwise
|
|
7
|
+
// surfaced nowhere in the UI, yet it's exactly why a namespace stops
|
|
8
|
+
// admitting new pods.
|
|
9
|
+
export function useNamespaceQuotas(namespace: string, enabled = true) {
|
|
10
|
+
return useQuery<any[]>({
|
|
11
|
+
queryKey: ['resourcequotas', namespace],
|
|
12
|
+
queryFn: () => fetchJSON<any[]>(`/resources/resourcequotas?namespace=${encodeURIComponent(namespace)}`),
|
|
13
|
+
enabled: enabled && !!namespace,
|
|
14
|
+
staleTime: 15000,
|
|
15
|
+
})
|
|
16
|
+
}
|
package/src/api/rbac.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import type {
|
|
3
|
+
RBACSubjectResponse,
|
|
4
|
+
RBACRoleResponse,
|
|
5
|
+
RBACWhoamiResponse,
|
|
6
|
+
RBACNamespaceResponse,
|
|
7
|
+
} from '@skyhook-io/k8s-ui'
|
|
8
|
+
import { fetchJSON } from './client'
|
|
9
|
+
|
|
10
|
+
// /api/rbac/subject/{kind}/{namespace}/{name} (ServiceAccount)
|
|
11
|
+
// /api/rbac/subject/{kind}/{name} (User/Group — no namespace)
|
|
12
|
+
export function useRBACSubject(kind: 'ServiceAccount' | 'User' | 'Group', namespace: string, name: string, enabled = true) {
|
|
13
|
+
// Subject lookups depend on cluster-wide RBAC. They don't change often,
|
|
14
|
+
// and operators bouncing between Pod/SA pages re-hit the same SA. Use a
|
|
15
|
+
// 15s stale window so cross-page navigation is instant.
|
|
16
|
+
const path =
|
|
17
|
+
kind === 'ServiceAccount'
|
|
18
|
+
? `/rbac/subject/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`
|
|
19
|
+
: `/rbac/subject/${kind}/${encodeURIComponent(name)}`
|
|
20
|
+
return useQuery<RBACSubjectResponse>({
|
|
21
|
+
queryKey: ['rbac', 'subject', kind, namespace, name],
|
|
22
|
+
queryFn: () => fetchJSON<RBACSubjectResponse>(path),
|
|
23
|
+
enabled: enabled && !!name && (kind !== 'ServiceAccount' || !!namespace),
|
|
24
|
+
staleTime: 15000,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// /api/rbac/role/{kind}/{namespace}/{name} (use "_" for ClusterRole's empty namespace)
|
|
29
|
+
export function useRBACRole(kind: 'Role' | 'ClusterRole', namespace: string, name: string, enabled = true) {
|
|
30
|
+
const nsSegment = kind === 'ClusterRole' ? '_' : encodeURIComponent(namespace)
|
|
31
|
+
return useQuery<RBACRoleResponse>({
|
|
32
|
+
queryKey: ['rbac', 'role', kind, namespace, name],
|
|
33
|
+
queryFn: () => fetchJSON<RBACRoleResponse>(`/rbac/role/${kind}/${nsSegment}/${encodeURIComponent(name)}`),
|
|
34
|
+
enabled: enabled && !!name && (kind !== 'Role' || !!namespace),
|
|
35
|
+
staleTime: 15000,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// /api/rbac/namespace/{namespace}
|
|
40
|
+
export function useRBACNamespace(namespace: string, enabled = true) {
|
|
41
|
+
return useQuery<RBACNamespaceResponse>({
|
|
42
|
+
queryKey: ['rbac', 'namespace', namespace],
|
|
43
|
+
queryFn: () => fetchJSON<RBACNamespaceResponse>(`/rbac/namespace/${encodeURIComponent(namespace)}`),
|
|
44
|
+
enabled: enabled && !!namespace,
|
|
45
|
+
staleTime: 15000,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// /api/rbac/whoami?namespace=<ns>
|
|
50
|
+
export function useRBACWhoami(namespace: string, enabled = true) {
|
|
51
|
+
return useQuery<RBACWhoamiResponse>({
|
|
52
|
+
queryKey: ['rbac', 'whoami', namespace],
|
|
53
|
+
queryFn: () => fetchJSON<RBACWhoamiResponse>(`/rbac/whoami?namespace=${encodeURIComponent(namespace)}`),
|
|
54
|
+
enabled: enabled && !!namespace,
|
|
55
|
+
staleTime: 30000,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// The retained NDJSON parser goes through apiFetch; mock the client module so a
|
|
4
|
+
// test can hand fetchRetainedWindow an arbitrary streamed body. ApiError is only
|
|
5
|
+
// hit on the non-ok branch (not exercised here) but must exist for module load.
|
|
6
|
+
vi.mock('./client', () => ({
|
|
7
|
+
apiFetch: vi.fn(),
|
|
8
|
+
useChanges: vi.fn(),
|
|
9
|
+
ApiError: class ApiError extends Error {
|
|
10
|
+
status: number
|
|
11
|
+
constructor(message: string, status: number) {
|
|
12
|
+
super(message)
|
|
13
|
+
this.status = status
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { apiFetch } from './client'
|
|
19
|
+
import {
|
|
20
|
+
fetchRetainedWindow,
|
|
21
|
+
applyClientFilters,
|
|
22
|
+
localOverviewFromEvents,
|
|
23
|
+
LIVE_WINDOW_MS,
|
|
24
|
+
} from './timelineSource'
|
|
25
|
+
import { BASE_QUANTIZE_STEP_MS } from '@skyhook-io/k8s-ui'
|
|
26
|
+
import type { TimelineEvent } from '../types'
|
|
27
|
+
|
|
28
|
+
const mockApiFetch = vi.mocked(apiFetch)
|
|
29
|
+
|
|
30
|
+
// A minimal streamed Response: each string in `chunks` is delivered as one
|
|
31
|
+
// reader.read() so tests can split a JSON line across chunk boundaries.
|
|
32
|
+
function streamResponse(chunks: string[]): Response {
|
|
33
|
+
const encoder = new TextEncoder()
|
|
34
|
+
let i = 0
|
|
35
|
+
const body = {
|
|
36
|
+
getReader() {
|
|
37
|
+
return {
|
|
38
|
+
read() {
|
|
39
|
+
if (i < chunks.length) {
|
|
40
|
+
return Promise.resolve({ done: false, value: encoder.encode(chunks[i++]) })
|
|
41
|
+
}
|
|
42
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, body } as unknown as Response
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const T0 = Date.parse('2024-01-01T00:00:00.000Z')
|
|
51
|
+
|
|
52
|
+
function ev(over: Partial<TimelineEvent> & { id: string }): TimelineEvent {
|
|
53
|
+
return {
|
|
54
|
+
timestamp: new Date(T0).toISOString(),
|
|
55
|
+
source: 'informer',
|
|
56
|
+
kind: 'Pod',
|
|
57
|
+
namespace: 'default',
|
|
58
|
+
name: over.id,
|
|
59
|
+
eventType: 'update',
|
|
60
|
+
...over,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
mockApiFetch.mockReset()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('fetchRetainedWindow (NDJSON stream parser)', () => {
|
|
69
|
+
it('reassembles a JSON line split across chunk boundaries', async () => {
|
|
70
|
+
mockApiFetch.mockResolvedValue(
|
|
71
|
+
streamResponse([
|
|
72
|
+
'{"id":"a","kind":"Pod","name":"a","namespace":"ns","source":"informer","eventType":"update"',
|
|
73
|
+
',"timestamp":"2024-01-01T00:00:00.000Z"}\n{"type":"end"}\n',
|
|
74
|
+
]),
|
|
75
|
+
)
|
|
76
|
+
const { events } = await fetchRetainedWindow(0, 1000)
|
|
77
|
+
expect(events).toHaveLength(1)
|
|
78
|
+
expect(events[0].id).toBe('a')
|
|
79
|
+
expect(events[0].kind).toBe('Pod')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('separates coverage records from events', async () => {
|
|
83
|
+
mockApiFetch.mockResolvedValue(
|
|
84
|
+
streamResponse([
|
|
85
|
+
'{"id":"a","kind":"Pod","name":"a","namespace":"ns","source":"informer","eventType":"update","timestamp":"2024-01-01T00:00:00.000Z"}\n',
|
|
86
|
+
'{"type":"coverage","eventTimeStartMs":1,"eventTimeEndMs":2}\n',
|
|
87
|
+
'{"type":"end"}\n',
|
|
88
|
+
]),
|
|
89
|
+
)
|
|
90
|
+
const { events, coverage } = await fetchRetainedWindow(0, 1000)
|
|
91
|
+
expect(events).toHaveLength(1)
|
|
92
|
+
expect(coverage).toHaveLength(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('throws on a truncated stream (missing terminal record)', async () => {
|
|
96
|
+
mockApiFetch.mockResolvedValue(
|
|
97
|
+
streamResponse([
|
|
98
|
+
'{"id":"a","kind":"Pod","name":"a","namespace":"ns","source":"informer","eventType":"update","timestamp":"2024-01-01T00:00:00.000Z"}\n',
|
|
99
|
+
]),
|
|
100
|
+
)
|
|
101
|
+
await expect(fetchRetainedWindow(0, 1000)).rejects.toThrow('truncated')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('throws the message carried by an error terminal record', async () => {
|
|
105
|
+
mockApiFetch.mockResolvedValue(
|
|
106
|
+
streamResponse(['{"type":"error","message":"backend exploded"}\n']),
|
|
107
|
+
)
|
|
108
|
+
await expect(fetchRetainedWindow(0, 1000)).rejects.toThrow('backend exploded')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('applyClientFilters', () => {
|
|
113
|
+
const events: TimelineEvent[] = [
|
|
114
|
+
ev({ id: 'p1', kind: 'Pod', namespace: 'ns-a', timestamp: new Date(T0 + 3000).toISOString() }),
|
|
115
|
+
ev({ id: 'd1', kind: 'Deployment', namespace: 'ns-b', timestamp: new Date(T0 + 1000).toISOString() }),
|
|
116
|
+
ev({ id: 'k1', kind: 'Pod', namespace: 'ns-a', source: 'k8s_event', eventType: 'Warning', timestamp: new Date(T0 + 2000).toISOString() }),
|
|
117
|
+
ev({ id: 'del1', kind: 'Pod', namespace: 'ns-b', eventType: 'delete', timestamp: new Date(T0 + 4000).toISOString() }),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
it('filters by namespace', () => {
|
|
121
|
+
const out = applyClientFilters(events, { namespaces: ['ns-a'] })
|
|
122
|
+
expect(out.map((e) => e.id).sort()).toEqual(['k1', 'p1'])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('filters by kind', () => {
|
|
126
|
+
const out = applyClientFilters(events, { kinds: ['Deployment'] })
|
|
127
|
+
expect(out.map((e) => e.id)).toEqual(['d1'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('drops k8s events when includeK8sEvents is false', () => {
|
|
131
|
+
const out = applyClientFilters(events, { includeK8sEvents: false })
|
|
132
|
+
expect(out.some((e) => e.id === 'k1')).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('drops deletes when includeDeleted is false', () => {
|
|
136
|
+
const out = applyClientFilters(events, { includeDeleted: false })
|
|
137
|
+
expect(out.some((e) => e.id === 'del1')).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('bounds the result by [fromMs,toMs] event time', () => {
|
|
141
|
+
const out = applyClientFilters(events, { fromMs: T0 + 1500, toMs: T0 + 3500 })
|
|
142
|
+
expect(out.map((e) => e.id).sort()).toEqual(['k1', 'p1'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('sorts newest-first', () => {
|
|
146
|
+
const out = applyClientFilters(events, {})
|
|
147
|
+
expect(out.map((e) => e.id)).toEqual(['del1', 'p1', 'k1', 'd1'])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('caps the result at limit (keeping the newest)', () => {
|
|
151
|
+
const out = applyClientFilters(events, { limit: 2 })
|
|
152
|
+
expect(out.map((e) => e.id)).toEqual(['del1', 'p1'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Mirrors Go's TimelineEvent.IsManaged: owned, or ReplicaSet/Pod/Event.
|
|
156
|
+
// Enforced client-side so retained mode (whose endpoint has no
|
|
157
|
+
// include_managed param) behaves exactly like local mode.
|
|
158
|
+
it('drops managed rows only when includeManaged is explicitly false', () => {
|
|
159
|
+
const mixed: TimelineEvent[] = [
|
|
160
|
+
ev({ id: 'dep', kind: 'Deployment', namespace: 'ns-a' }),
|
|
161
|
+
ev({ id: 'pod', kind: 'Pod', namespace: 'ns-a' }),
|
|
162
|
+
ev({ id: 'rs', kind: 'ReplicaSet', namespace: 'ns-a' }),
|
|
163
|
+
ev({ id: 'owned-cm', kind: 'ConfigMap', namespace: 'ns-a', owner: { kind: 'Deployment', name: 'web' } }),
|
|
164
|
+
]
|
|
165
|
+
const filtered = applyClientFilters(mixed, { includeManaged: false })
|
|
166
|
+
expect(filtered.map((e) => e.id)).toEqual(['dep'])
|
|
167
|
+
// Default (unset) keeps machinery rows — the swimlane's child lanes need them.
|
|
168
|
+
expect(applyClientFilters(mixed, {})).toHaveLength(4)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('localOverviewFromEvents', () => {
|
|
173
|
+
it('buckets by the hour, floors availableFromMs, and tallies by type', () => {
|
|
174
|
+
const h0 = Date.parse('2024-01-01T00:10:00.000Z')
|
|
175
|
+
const h0b = Date.parse('2024-01-01T00:50:00.000Z')
|
|
176
|
+
const h1 = Date.parse('2024-01-01T01:05:00.000Z')
|
|
177
|
+
const res = localOverviewFromEvents([
|
|
178
|
+
ev({ id: 'a', eventType: 'add', timestamp: new Date(h0).toISOString() }),
|
|
179
|
+
ev({ id: 'b', eventType: 'update', timestamp: new Date(h0b).toISOString() }),
|
|
180
|
+
ev({ id: 'c', eventType: 'delete', timestamp: new Date(h1).toISOString(), namespace: 'other' }),
|
|
181
|
+
])
|
|
182
|
+
expect(res.buckets).toHaveLength(2)
|
|
183
|
+
expect(res.availableFromMs).toBe(h0)
|
|
184
|
+
const [b0, b1] = res.buckets
|
|
185
|
+
expect(b0.startMs).toBe(Date.parse('2024-01-01T00:00:00.000Z'))
|
|
186
|
+
expect(b0.summary.total).toBe(2)
|
|
187
|
+
expect(b0.summary.adds).toBe(1)
|
|
188
|
+
expect(b0.summary.updates).toBe(1)
|
|
189
|
+
expect(b0.summary.namespaces).toEqual(['default'])
|
|
190
|
+
expect(b1.summary.deletes).toBe(1)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('marks a bucket unhealthy when it holds a Warning event', () => {
|
|
194
|
+
const res = localOverviewFromEvents([
|
|
195
|
+
ev({ id: 'w', eventType: 'Warning', source: 'k8s_event', timestamp: new Date(T0 + 60_000).toISOString() }),
|
|
196
|
+
])
|
|
197
|
+
expect(res.buckets[0].summary.warnings).toBe(1)
|
|
198
|
+
expect(res.buckets[0].summary.worstHealth).toBe('unhealthy')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('rebuckets finer with a custom bucketSizeMs', () => {
|
|
202
|
+
const FIVE_MIN = 5 * 60_000
|
|
203
|
+
const events = [
|
|
204
|
+
ev({ id: 'a', timestamp: new Date(T0).toISOString() }),
|
|
205
|
+
ev({ id: 'b', timestamp: new Date(T0 + 10 * 60_000).toISOString() }),
|
|
206
|
+
]
|
|
207
|
+
// Same hour → one hourly bucket, but two distinct 5-minute buckets.
|
|
208
|
+
expect(localOverviewFromEvents(events).buckets).toHaveLength(1)
|
|
209
|
+
expect(localOverviewFromEvents(events, FIVE_MIN).buckets).toHaveLength(2)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('cross-package live-window invariant', () => {
|
|
214
|
+
it('quantization step stays under the live poll window so no fetch hole opens', () => {
|
|
215
|
+
expect(BASE_QUANTIZE_STEP_MS).toBeLessThan(LIVE_WINDOW_MS)
|
|
216
|
+
})
|
|
217
|
+
})
|