@skyhook-io/radar-app 1.14.6 → 1.16.0
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 +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
package/src/api/diagnose.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
addTurn,
|
|
4
|
+
createRun,
|
|
5
|
+
DiagnoseError,
|
|
6
|
+
subscribeRun,
|
|
7
|
+
type DiagnoseStreamEvent,
|
|
8
|
+
} from "./diagnose";
|
|
3
9
|
|
|
4
10
|
type SSEListener = (event: { data: string; lastEventId: string }) => void;
|
|
5
11
|
|
|
@@ -266,3 +272,98 @@ describe("investigation start requests", () => {
|
|
|
266
272
|
expect(JSON.parse(init.body as string).issueId).toBe("issue-1");
|
|
267
273
|
});
|
|
268
274
|
});
|
|
275
|
+
|
|
276
|
+
describe("refused requests", () => {
|
|
277
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
278
|
+
|
|
279
|
+
const target = { kind: "Pod", group: "", namespace: "prod", name: "api-0" };
|
|
280
|
+
|
|
281
|
+
const refuse = (status: number, body: string) =>
|
|
282
|
+
vi.stubGlobal(
|
|
283
|
+
"fetch",
|
|
284
|
+
vi.fn().mockResolvedValue(new Response(body, { status })),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const startError = async () => {
|
|
288
|
+
try {
|
|
289
|
+
await createRun(target);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return e as DiagnoseError;
|
|
292
|
+
}
|
|
293
|
+
throw new Error("createRun resolved");
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
it("shows the sentence a host sends in detail, not its code", async () => {
|
|
297
|
+
refuse(
|
|
298
|
+
402,
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
error: "ai_quota_exhausted",
|
|
301
|
+
detail: "Your organization has used this month's investigations.",
|
|
302
|
+
reason: "allowance_used_free",
|
|
303
|
+
action: "upgrade",
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
const e = await startError();
|
|
307
|
+
expect(e).toBeInstanceOf(DiagnoseError);
|
|
308
|
+
expect(e.status).toBe(402);
|
|
309
|
+
expect(e.message).toBe(
|
|
310
|
+
"Your organization has used this month's investigations.",
|
|
311
|
+
);
|
|
312
|
+
expect(e.code).toBe("ai_quota_exhausted");
|
|
313
|
+
expect(e.reason).toBe("allowance_used_free");
|
|
314
|
+
expect(e.action).toBe("upgrade");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("applies the same reading to follow-up turns", async () => {
|
|
318
|
+
refuse(
|
|
319
|
+
402,
|
|
320
|
+
JSON.stringify({ error: "ai_quota_exhausted", detail: "Limit reached." }),
|
|
321
|
+
);
|
|
322
|
+
await expect(addTurn("run-1", { question: "why?" })).rejects.toMatchObject({
|
|
323
|
+
status: 402,
|
|
324
|
+
message: "Limit reached.",
|
|
325
|
+
code: "ai_quota_exhausted",
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("reads a code sent beside a sentence repeated in error", async () => {
|
|
330
|
+
const sentence = "This investigation has reached its follow-up limit.";
|
|
331
|
+
refuse(
|
|
332
|
+
409,
|
|
333
|
+
JSON.stringify({
|
|
334
|
+
error: sentence,
|
|
335
|
+
detail: sentence,
|
|
336
|
+
code: "investigation_turn_limit",
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
await expect(addTurn("run-1", { question: "why?" })).rejects.toMatchObject({
|
|
340
|
+
status: 409,
|
|
341
|
+
message: sentence,
|
|
342
|
+
code: "investigation_turn_limit",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("never reports the sentence as the code", async () => {
|
|
347
|
+
const sentence = "Busy.";
|
|
348
|
+
refuse(409, JSON.stringify({ error: sentence, detail: sentence }));
|
|
349
|
+
expect((await startError()).code).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("shows error when it is the only sentence", async () => {
|
|
353
|
+
refuse(409, JSON.stringify({ error: "Too many investigations running." }));
|
|
354
|
+
const e = await startError();
|
|
355
|
+
expect(e.message).toBe("Too many investigations running.");
|
|
356
|
+
expect(e.code).toBeUndefined();
|
|
357
|
+
expect(e.reason).toBeUndefined();
|
|
358
|
+
expect(e.action).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it.each([
|
|
362
|
+
["an empty detail", JSON.stringify({ error: "Busy.", detail: "" }), "Busy."],
|
|
363
|
+
["a non-JSON body", "upstream timeout", "request failed (502)"],
|
|
364
|
+
["no message at all", JSON.stringify({ error: " " }), "request failed (502)"],
|
|
365
|
+
])("falls back past %s", async (_name, body, message) => {
|
|
366
|
+
refuse(502, body);
|
|
367
|
+
expect((await startError()).message).toBe(message);
|
|
368
|
+
});
|
|
369
|
+
});
|
package/src/api/diagnose.ts
CHANGED
|
@@ -260,22 +260,53 @@ export async function fetchAgents(
|
|
|
260
260
|
return res.json();
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Why a host refused an investigation, and what would unblock it. Hosts that
|
|
265
|
+
* meter investigations send these beside the sentence to show, so an embedding
|
|
266
|
+
* app can offer the matching action. Radar's own backend never sends them.
|
|
267
|
+
*/
|
|
268
|
+
export interface DiagnoseRefusal {
|
|
269
|
+
/** Machine-readable code, e.g. "ai_quota_exhausted". */
|
|
270
|
+
code?: string;
|
|
271
|
+
reason?: string;
|
|
272
|
+
action?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The sentence to show is `detail` when a host sends one, else `error`. A
|
|
276
|
+
// host's machine-readable code is `code`, or `error` when that is not the
|
|
277
|
+
// sentence itself.
|
|
278
|
+
async function diagnoseError(res: Response): Promise<DiagnoseError> {
|
|
279
|
+
let body: Record<string, unknown> | null = null;
|
|
264
280
|
try {
|
|
265
|
-
const d = await res.json();
|
|
266
|
-
if (d && typeof d
|
|
281
|
+
const d: unknown = await res.json();
|
|
282
|
+
if (d && typeof d === "object") body = d as Record<string, unknown>;
|
|
267
283
|
} catch {
|
|
268
284
|
/* ignore */
|
|
269
285
|
}
|
|
270
|
-
|
|
286
|
+
const str = (v: unknown) =>
|
|
287
|
+
typeof v === "string" && v.trim() !== "" ? v : undefined;
|
|
288
|
+
const detail = str(body?.detail);
|
|
289
|
+
const error = str(body?.error);
|
|
290
|
+
const message = detail ?? error ?? `request failed (${res.status})`;
|
|
291
|
+
return new DiagnoseError(res.status, message, {
|
|
292
|
+
code: str(body?.code) ?? (detail && error !== detail ? error : undefined),
|
|
293
|
+
reason: str(body?.reason),
|
|
294
|
+
action: str(body?.action),
|
|
295
|
+
});
|
|
271
296
|
}
|
|
272
297
|
|
|
273
298
|
// DiagnoseError carries the HTTP status so callers can special-case (e.g. 409 cap).
|
|
274
299
|
export class DiagnoseError extends Error {
|
|
275
300
|
status: number;
|
|
276
|
-
|
|
301
|
+
code?: string;
|
|
302
|
+
reason?: string;
|
|
303
|
+
action?: string;
|
|
304
|
+
constructor(status: number, message: string, refusal?: DiagnoseRefusal) {
|
|
277
305
|
super(message);
|
|
278
306
|
this.status = status;
|
|
307
|
+
this.code = refusal?.code;
|
|
308
|
+
this.reason = refusal?.reason;
|
|
309
|
+
this.action = refusal?.action;
|
|
279
310
|
}
|
|
280
311
|
}
|
|
281
312
|
|
|
@@ -313,7 +344,7 @@ export async function createRun(
|
|
|
313
344
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
314
345
|
body: JSON.stringify({ ...target, ...opts }),
|
|
315
346
|
});
|
|
316
|
-
if (!res.ok) throw
|
|
347
|
+
if (!res.ok) throw await diagnoseError(res);
|
|
317
348
|
return res.json();
|
|
318
349
|
}
|
|
319
350
|
|
|
@@ -332,7 +363,7 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
|
|
|
332
363
|
headers: getAuthHeaders(),
|
|
333
364
|
signal,
|
|
334
365
|
});
|
|
335
|
-
if (!res.ok) throw
|
|
366
|
+
if (!res.ok) throw await diagnoseError(res);
|
|
336
367
|
const d = await res.json();
|
|
337
368
|
return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
|
|
338
369
|
}
|
|
@@ -348,7 +379,7 @@ export async function getRun(
|
|
|
348
379
|
headers: getAuthHeaders(),
|
|
349
380
|
signal,
|
|
350
381
|
});
|
|
351
|
-
if (!res.ok) throw
|
|
382
|
+
if (!res.ok) throw await diagnoseError(res);
|
|
352
383
|
return res.json();
|
|
353
384
|
}
|
|
354
385
|
|
|
@@ -360,7 +391,7 @@ export async function recordConsent(surface: string): Promise<void> {
|
|
|
360
391
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
361
392
|
body: JSON.stringify({ surface }),
|
|
362
393
|
});
|
|
363
|
-
if (!res.ok) throw
|
|
394
|
+
if (!res.ok) throw await diagnoseError(res);
|
|
364
395
|
}
|
|
365
396
|
|
|
366
397
|
// clearHistory wipes the persisted investigation history (finished runs); live
|
|
@@ -371,7 +402,7 @@ export async function clearHistory(): Promise<void> {
|
|
|
371
402
|
credentials: getCredentialsMode(),
|
|
372
403
|
headers: getAuthHeaders(),
|
|
373
404
|
});
|
|
374
|
-
if (!res.ok) throw
|
|
405
|
+
if (!res.ok) throw await diagnoseError(res);
|
|
375
406
|
}
|
|
376
407
|
|
|
377
408
|
// addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
|
|
@@ -391,7 +422,7 @@ export async function addTurn(
|
|
|
391
422
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
392
423
|
body: JSON.stringify(body),
|
|
393
424
|
});
|
|
394
|
-
if (!res.ok) throw
|
|
425
|
+
if (!res.ok) throw await diagnoseError(res);
|
|
395
426
|
}
|
|
396
427
|
|
|
397
428
|
// stopRun cancels a run's in-flight agent.
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
describeDrainResult,
|
|
4
|
-
DrainPlanUnsupportedError,
|
|
5
4
|
drainPlanBody,
|
|
6
|
-
drainPlanFetchError,
|
|
7
5
|
drainPlanPath,
|
|
8
6
|
} from "./client";
|
|
9
7
|
|
|
@@ -24,14 +22,6 @@ describe("drain plan request", () => {
|
|
|
24
22
|
force: true,
|
|
25
23
|
});
|
|
26
24
|
});
|
|
27
|
-
|
|
28
|
-
it("reads a plain-text 404 as an unsupported endpoint, not as a missing node", () => {
|
|
29
|
-
expect(drainPlanFetchError(404, null)).toBeInstanceOf(DrainPlanUnsupportedError);
|
|
30
|
-
const nodeGone = drainPlanFetchError(404, { error: 'nodes "worker-1" not found' });
|
|
31
|
-
expect(nodeGone).not.toBeInstanceOf(DrainPlanUnsupportedError);
|
|
32
|
-
expect(nodeGone.message).toBe('nodes "worker-1" not found');
|
|
33
|
-
expect(drainPlanFetchError(503, null).message).toBe("HTTP 503");
|
|
34
|
-
});
|
|
35
25
|
});
|
|
36
26
|
|
|
37
27
|
describe("describeDrainResult", () => {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { ApiError, fetchJSON } from './client'
|
|
3
|
+
import { CHI_UNKNOWN_ROUTE_BODY, httpStatusMessage, isUnknownRouteResponse, nonJsonErrorMessage, readErrorResponse } from './httpErrors'
|
|
4
|
+
|
|
5
|
+
const TEXT = 'text/plain; charset=utf-8'
|
|
6
|
+
|
|
7
|
+
function response(status: number, body: string, contentType: string | null, statusText = ''): Response {
|
|
8
|
+
const headers = new Headers()
|
|
9
|
+
if (contentType) headers.set('Content-Type', contentType)
|
|
10
|
+
return new Response(body, { status, statusText, headers })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('isUnknownRouteResponse', () => {
|
|
14
|
+
it("matches only chi's own unknown-route 404", () => {
|
|
15
|
+
expect(isUnknownRouteResponse(404, TEXT, CHI_UNKNOWN_ROUTE_BODY)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("rejects a handler's JSON 404", () => {
|
|
19
|
+
expect(isUnknownRouteResponse(404, 'application/json', '{"error":"pods \\"web\\" not found"}')).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("rejects Radar Hub's plain-text cluster lookup 404", () => {
|
|
23
|
+
expect(isUnknownRouteResponse(404, TEXT, 'not found\n')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('rejects the same body under another status or content type', () => {
|
|
27
|
+
expect(isUnknownRouteResponse(405, TEXT, CHI_UNKNOWN_ROUTE_BODY)).toBe(false)
|
|
28
|
+
expect(isUnknownRouteResponse(500, TEXT, CHI_UNKNOWN_ROUTE_BODY)).toBe(false)
|
|
29
|
+
expect(isUnknownRouteResponse(404, 'text/html', CHI_UNKNOWN_ROUTE_BODY)).toBe(false)
|
|
30
|
+
expect(isUnknownRouteResponse(404, null, CHI_UNKNOWN_ROUTE_BODY)).toBe(false)
|
|
31
|
+
expect(isUnknownRouteResponse(404, TEXT, '<html>404 page not found</html>')).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('readErrorResponse', () => {
|
|
36
|
+
it('returns a JSON envelope as-is', async () => {
|
|
37
|
+
const { body, unknownRoute } = await readErrorResponse(response(404, '{"error":"gone","code":7}', 'application/json'))
|
|
38
|
+
expect(body).toEqual({ error: 'gone', code: 7 })
|
|
39
|
+
expect(unknownRoute).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('names the status instead of "Unknown error" for a non-JSON body', async () => {
|
|
43
|
+
const chi = await readErrorResponse(response(404, CHI_UNKNOWN_ROUTE_BODY, TEXT))
|
|
44
|
+
expect(chi).toEqual({ body: { error: 'HTTP 404 (Not Found)' }, unknownRoute: true })
|
|
45
|
+
const gateway = await readErrorResponse(response(502, '<html>Bad gateway</html>', 'text/html'))
|
|
46
|
+
expect(gateway).toEqual({ body: { error: 'HTTP 502 (Bad Gateway)' }, unknownRoute: false })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("keeps a short plain-text reason such as Radar Hub's", async () => {
|
|
50
|
+
const offline = await readErrorResponse(response(503, 'cluster "prod" not connected\n', TEXT))
|
|
51
|
+
expect(offline.body.error).toBe('cluster "prod" not connected (HTTP 503)')
|
|
52
|
+
// Hub's bare "not found" only repeats the status.
|
|
53
|
+
const hub = await readErrorResponse(response(404, 'not found\n', TEXT))
|
|
54
|
+
expect(hub.body.error).toBe('HTTP 404 (Not Found)')
|
|
55
|
+
const long = await readErrorResponse(response(500, 'x'.repeat(500), TEXT))
|
|
56
|
+
expect(long.body.error).toBe('HTTP 500 (Internal Server Error)')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('nonJsonErrorMessage', () => {
|
|
61
|
+
it('reads a body the caller already consumed', () => {
|
|
62
|
+
expect(nonJsonErrorMessage(response(502, '', TEXT), 'upstream connect error\n')).toBe(
|
|
63
|
+
'upstream connect error (HTTP 502)',
|
|
64
|
+
)
|
|
65
|
+
expect(nonJsonErrorMessage(response(404, '', TEXT), CHI_UNKNOWN_ROUTE_BODY)).toBe('HTTP 404 (Not Found)')
|
|
66
|
+
expect(nonJsonErrorMessage(response(502, '', 'text/html'), '<html>bad</html>')).toBe('HTTP 502 (Bad Gateway)')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('httpStatusMessage', () => {
|
|
71
|
+
it('prefers the response status text and falls back to the table', () => {
|
|
72
|
+
expect(httpStatusMessage(404, 'Nope')).toBe('HTTP 404 (Nope)')
|
|
73
|
+
expect(httpStatusMessage(404, '')).toBe('HTTP 404 (Not Found)')
|
|
74
|
+
expect(httpStatusMessage(418)).toBe('HTTP 418')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('fetchJSON error fallback', () => {
|
|
79
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
80
|
+
|
|
81
|
+
it('reports the status and flags an unknown route', async () => {
|
|
82
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(response(404, CHI_UNKNOWN_ROUTE_BODY, TEXT))))
|
|
83
|
+
const error = await fetchJSON('/policy/resource/pods/default/web').catch((e: unknown) => e)
|
|
84
|
+
expect(error).toBeInstanceOf(ApiError)
|
|
85
|
+
expect((error as ApiError).message).toBe('HTTP 404 (Not Found)')
|
|
86
|
+
expect((error as ApiError).unknownRoute).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("keeps a handler's own message and does not flag it", async () => {
|
|
90
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(response(404, '{"error":"pods \\"web\\" not found"}', 'application/json'))))
|
|
91
|
+
const error = (await fetchJSON('/pods/default/web/environment').catch((e: unknown) => e)) as ApiError
|
|
92
|
+
expect(error.message).toBe('pods "web" not found')
|
|
93
|
+
expect(error.unknownRoute).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Error-body handling shared by every fetch site. Kept free of client.ts
|
|
2
|
+
// imports so the pure helpers can be tested and reused without the hooks.
|
|
3
|
+
|
|
4
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
5
|
+
400: 'Bad Request',
|
|
6
|
+
401: 'Unauthorized',
|
|
7
|
+
403: 'Forbidden',
|
|
8
|
+
404: 'Not Found',
|
|
9
|
+
405: 'Method Not Allowed',
|
|
10
|
+
408: 'Request Timeout',
|
|
11
|
+
409: 'Conflict',
|
|
12
|
+
413: 'Payload Too Large',
|
|
13
|
+
429: 'Too Many Requests',
|
|
14
|
+
500: 'Internal Server Error',
|
|
15
|
+
502: 'Bad Gateway',
|
|
16
|
+
503: 'Service Unavailable',
|
|
17
|
+
504: 'Gateway Timeout',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* "HTTP 404 (Not Found)". The table backs up `statusText`, which HTTP/2
|
|
22
|
+
* responses leave empty.
|
|
23
|
+
*/
|
|
24
|
+
export function httpStatusMessage(status: number, statusText?: string): string {
|
|
25
|
+
const text = statusText || STATUS_TEXT[status]
|
|
26
|
+
return text ? `HTTP ${status} (${text})` : `HTTP ${status}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What chi's default NotFound handler writes for a route the router doesn't have. */
|
|
30
|
+
export const CHI_UNKNOWN_ROUTE_BODY = '404 page not found\n'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True only for chi's own unknown-route response. Radar's handlers answer a
|
|
34
|
+
* missing resource with a JSON envelope, and Radar Hub's proxy answers a
|
|
35
|
+
* cluster lookup failure with a plain-text "not found", so neither matches.
|
|
36
|
+
* That exactness is what lets a caller read this as "the connected Radar
|
|
37
|
+
* predates this endpoint" rather than "something is missing".
|
|
38
|
+
*/
|
|
39
|
+
export function isUnknownRouteResponse(status: number, contentType: string | null, body: string): boolean {
|
|
40
|
+
return (
|
|
41
|
+
status === 404 &&
|
|
42
|
+
(contentType ?? '').toLowerCase().startsWith('text/plain') &&
|
|
43
|
+
body === CHI_UNKNOWN_ROUTE_BODY
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- error envelopes carry handler-specific fields
|
|
48
|
+
export type ErrorBody = { error?: string; [key: string]: any }
|
|
49
|
+
|
|
50
|
+
export interface ErrorResponse {
|
|
51
|
+
body: ErrorBody
|
|
52
|
+
unknownRoute: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MAX_PLAIN_TEXT_REASON = 200
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reads a failed response once. A JSON object body is returned as-is; anything
|
|
59
|
+
* else becomes `{ error: <message> }` (see nonJsonErrorMessage) so callers
|
|
60
|
+
* never surface a contentless "Unknown error".
|
|
61
|
+
*/
|
|
62
|
+
export async function readErrorResponse(response: Response): Promise<ErrorResponse> {
|
|
63
|
+
const text = await response.text().catch(() => '')
|
|
64
|
+
const contentType = response.headers.get('content-type')
|
|
65
|
+
const unknownRoute = isUnknownRouteResponse(response.status, contentType, text)
|
|
66
|
+
let parsed: unknown
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(text)
|
|
69
|
+
} catch {
|
|
70
|
+
parsed = undefined
|
|
71
|
+
}
|
|
72
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
73
|
+
return { body: parsed as ErrorBody, unknownRoute }
|
|
74
|
+
}
|
|
75
|
+
return { body: { error: nonJsonErrorMessage(response, text) }, unknownRoute }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The message for a failed response whose body is not a JSON object, for
|
|
80
|
+
* callers that already read the body as text. A short plain-text reason, such
|
|
81
|
+
* as Radar Hub's `cluster "x" not connected`, leads and the status trails it
|
|
82
|
+
* ("cluster "x" not connected (HTTP 503)"), so it reads like Radar's own
|
|
83
|
+
* errors. Without one (an HTML page, chi's unknown-route body, an empty body)
|
|
84
|
+
* the status is the message: "HTTP 502 (Bad Gateway)".
|
|
85
|
+
*/
|
|
86
|
+
export function nonJsonErrorMessage(response: Response, text: string): string {
|
|
87
|
+
const status = httpStatusMessage(response.status, response.statusText)
|
|
88
|
+
const contentType = response.headers.get('content-type')
|
|
89
|
+
const reason = text.trim()
|
|
90
|
+
const showReason =
|
|
91
|
+
!isUnknownRouteResponse(response.status, contentType, text) &&
|
|
92
|
+
(contentType ?? '').toLowerCase().startsWith('text/plain') &&
|
|
93
|
+
reason.length > 0 &&
|
|
94
|
+
reason.length <= MAX_PLAIN_TEXT_REASON &&
|
|
95
|
+
!status.toLowerCase().includes(reason.toLowerCase())
|
|
96
|
+
return showReason ? `${reason} (HTTP ${response.status})` : status
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function readErrorBody(response: Response): Promise<ErrorBody> {
|
|
100
|
+
return (await readErrorResponse(response)).body
|
|
101
|
+
}
|
package/src/api/policy.ts
CHANGED
|
@@ -7,7 +7,17 @@ import type {
|
|
|
7
7
|
VeleroStoredBackupsResponse,
|
|
8
8
|
VeleroRunMessagesResponse,
|
|
9
9
|
} from '@skyhook-io/k8s-ui'
|
|
10
|
-
import { fetchJSON } from './client'
|
|
10
|
+
import { fetchJSON, useRadarFeature } from './client'
|
|
11
|
+
import { hasPolicyReports, useAPIResources } from './apiResources'
|
|
12
|
+
import { isRadarFeatureUnsupported } from './radarFeatures'
|
|
13
|
+
|
|
14
|
+
const POLICY_NOT_INSTALLED: PolicyResourceResponse = {
|
|
15
|
+
evaluated: false,
|
|
16
|
+
status: 'not_installed',
|
|
17
|
+
liveUpdates: false,
|
|
18
|
+
counts: { pass: 0, fail: 0, warn: 0, error: 0, skip: 0 },
|
|
19
|
+
findings: [],
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
// /api/policy/resource/{kind}/{namespace}/{name}
|
|
13
23
|
//
|
|
@@ -15,11 +25,15 @@ import { fetchJSON } from './client'
|
|
|
15
25
|
// a short stale window keeps drawer navigation instant without going stale in a
|
|
16
26
|
// way an operator would notice.
|
|
17
27
|
export function usePolicyResource(kind: string, namespace: string, name: string, enabled = true) {
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
const { guard, gatedKey } = useRadarFeature('policyResource')
|
|
29
|
+
const { data: apiResources, isPending: discoveryPending } = useAPIResources()
|
|
30
|
+
const query = useQuery<PolicyResourceResponse>({
|
|
31
|
+
queryKey: ['policy', 'resource', kind, namespace, name, ...gatedKey],
|
|
20
32
|
queryFn: () =>
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
guard(() =>
|
|
34
|
+
fetchJSON<PolicyResourceResponse>(
|
|
35
|
+
`/policy/resource/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
|
|
36
|
+
),
|
|
23
37
|
),
|
|
24
38
|
enabled: enabled && !!kind && !!namespace && !!name,
|
|
25
39
|
staleTime: 15000,
|
|
@@ -27,6 +41,20 @@ export function usePolicyResource(kind: string, namespace: string, name: string,
|
|
|
27
41
|
// would just repeat the denial on every drawer open.
|
|
28
42
|
retry: false,
|
|
29
43
|
})
|
|
44
|
+
// A Radar too old for policy results, on a cluster with no policy engine,
|
|
45
|
+
// gets the answer a current Radar would give: not installed. Prompting an
|
|
46
|
+
// upgrade there would promise a section that stays empty. Derived at render
|
|
47
|
+
// so it follows discovery whenever /api-resources answers; until it does the
|
|
48
|
+
// section stays loading rather than flashing a note it may withdraw.
|
|
49
|
+
if (isRadarFeatureUnsupported(query.error)) {
|
|
50
|
+
if (apiResources === undefined && discoveryPending) {
|
|
51
|
+
return { ...query, error: null, isError: false, isLoading: true, isPending: true, status: 'pending' as const }
|
|
52
|
+
}
|
|
53
|
+
if (apiResources !== undefined && !hasPolicyReports(apiResources)) {
|
|
54
|
+
return { ...query, data: POLICY_NOT_INSTALLED, error: null, isError: false, isSuccess: true, status: 'success' as const }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return query
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
// /api/policy/policies/{policy}
|