@skyhook-io/radar-app 1.15.0 → 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.
Files changed (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +232 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +190 -0
  17. package/src/api/radarFeatures.test.ts +133 -0
  18. package/src/api/radarFeatures.ts +147 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +36 -13
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -1,5 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { createRun, subscribeRun, type DiagnoseStreamEvent } from "./diagnose";
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
+ });
@@ -260,22 +260,53 @@ export async function fetchAgents(
260
260
  return res.json();
261
261
  }
262
262
 
263
- async function errorText(res: Response): Promise<string> {
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.error === "string") return d.error;
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
- return `request failed (${res.status})`;
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
- constructor(status: number, message: string) {
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 new DiagnoseError(res.status, await errorText(res));
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 new DiagnoseError(res.status, await errorText(res));
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 new DiagnoseError(res.status, await errorText(res));
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 new DiagnoseError(res.status, await errorText(res));
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 new DiagnoseError(res.status, await errorText(res));
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 new DiagnoseError(res.status, await errorText(res));
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
- return useQuery<PolicyResourceResponse>({
19
- queryKey: ['policy', 'resource', kind, namespace, name],
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
- fetchJSON<PolicyResourceResponse>(
22
- `/policy/resource/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
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}