@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.
Files changed (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  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.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -0
@@ -5,6 +5,7 @@ import { Activity, Loader2, X, Maximize2, Copy, Check } from 'lucide-react'
5
5
  import { clsx } from 'clsx'
6
6
  import { apiFetch } from '../../api/client'
7
7
  import { apiUrl } from '../../api/config'
8
+ import { readErrorBody } from '../../api/httpErrors'
8
9
  import { Tooltip } from '../ui/Tooltip'
9
10
  import { Input } from '@skyhook-io/k8s-ui'
10
11
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
@@ -296,9 +297,12 @@ export function CurlPanel({
296
297
  headers: { 'Content-Type': 'application/json' },
297
298
  body: JSON.stringify({ namespace, name: serviceName, port: String(port), scheme: vars.scheme, path: vars.path }),
298
299
  })
299
- const data = await res.json().catch(() => ({}))
300
- if (!res.ok) throw new Error(data?.error || `Request failed (${res.status})`)
301
- return data as CurlResult
300
+ if (!res.ok) {
301
+ const error = await readErrorBody(res)
302
+ throw new Error(error.error || `Request failed (${res.status})`)
303
+ }
304
+ // A failing target service still comes back as a 200 carrying its result.
305
+ return (await res.json().catch(() => ({}))) as CurlResult
302
306
  },
303
307
  })
304
308
 
@@ -1099,6 +1099,7 @@ function ToolResultDialog({
1099
1099
  text: string;
1100
1100
  truncated?: boolean;
1101
1101
  }) {
1102
+ const titleId = useId();
1102
1103
  const { theme } = useTheme();
1103
1104
  const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
1104
1105
  const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
@@ -1136,10 +1137,10 @@ function ToolResultDialog({
1136
1137
  };
1137
1138
  }, [open, display, language, theme]);
1138
1139
  return (
1139
- <DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
1141
  <div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
1141
1142
  <div className="min-w-0">
1142
- <div className="truncate font-mono text-sm text-theme-text-primary">
1143
+ <div id={titleId} className="truncate font-mono text-sm text-theme-text-primary">
1143
1144
  {title}
1144
1145
  </div>
1145
1146
  {truncated && (
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useState, useId } from "react";
2
2
  import {
3
3
  CheckCircle2,
4
4
  AlertTriangle,
@@ -41,6 +41,7 @@ export function ApplyDialog({
41
41
  managedBy?: string; // GitOps/Helm owner of the resource, if any
42
42
  confidence?: number;
43
43
  }) {
44
+ const titleId = useId();
44
45
  const fixText = fix?.trim();
45
46
  const lowConfidence = confidence != null && confidence < 0.5;
46
47
  // A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
@@ -56,6 +57,7 @@ export function ApplyDialog({
56
57
  const applyBlocked = !!managedBy && !acked;
57
58
  return (
58
59
  <DialogPortal
60
+ ariaLabelledBy={titleId}
59
61
  open={open}
60
62
  onClose={onClose}
61
63
  className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden"
@@ -65,7 +67,7 @@ export function ApplyDialog({
65
67
  <AlertTriangle className="h-5 w-5 text-amber-500" />
66
68
  </div>
67
69
  <div className="min-w-0 flex-1">
68
- <h3 className="text-lg font-semibold text-theme-text-primary">
70
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">
69
71
  Apply this fix?
70
72
  </h3>
71
73
  <p className="mt-1 text-sm text-theme-text-secondary">
@@ -0,0 +1,69 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // Screen readers announce a dialog by its name; an unnamed one is read as just
6
+ // "dialog". Every DialogPortal must pass ariaLabelledBy (its visible title) or
7
+ // ariaLabel.
8
+ const repoRoot = join(__dirname, '..', '..', '..')
9
+ const roots = ['web/src', 'packages/k8s-ui/src'].map(r => join(repoRoot, r))
10
+
11
+ function sourceFiles(dir: string): string[] {
12
+ return readdirSync(dir).flatMap(name => {
13
+ const path = join(dir, name)
14
+ if (statSync(path).isDirectory()) return name === 'node_modules' ? [] : sourceFiles(path)
15
+ return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [path] : []
16
+ })
17
+ }
18
+
19
+ // The opening tag ends at the first '>' outside a {…} expression or a string.
20
+ function openingTags(source: string, component: string): string[] {
21
+ const tags: string[] = []
22
+ let from = 0
23
+ for (;;) {
24
+ const start = source.indexOf(`<${component}`, from)
25
+ if (start < 0) return tags
26
+ let depth = 0
27
+ let i = start + component.length + 1
28
+ for (; i < source.length; i++) {
29
+ const c = source[i]
30
+ if (c === '"' || c === "'" || c === '`') {
31
+ i++
32
+ while (i < source.length && source[i] !== c) i += source[i] === '\\' ? 2 : 1
33
+ } else if (c === '{') depth++
34
+ else if (c === '}') depth--
35
+ else if (c === '>' && depth === 0) break
36
+ }
37
+ tags.push(source.slice(start, i + 1))
38
+ from = i
39
+ }
40
+ }
41
+
42
+ function isNamed(tag: string): boolean {
43
+ const withoutStrings = tag.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/gs, '""')
44
+ return /\saria(Label|LabelledBy)\s*=/.test(withoutStrings)
45
+ }
46
+
47
+ describe('dialogs', () => {
48
+ it('every DialogPortal has an accessible name', () => {
49
+ const unnamed = roots.flatMap(sourceFiles).flatMap(file => {
50
+ if (file.endsWith('DialogPortal.tsx')) return []
51
+ return openingTags(readFileSync(file, 'utf8'), 'DialogPortal')
52
+ .filter(tag => /^<DialogPortal[\s>]/.test(tag) && !isNamed(tag))
53
+ .map(() => relative(repoRoot, file))
54
+ })
55
+ expect(unnamed).toEqual([])
56
+ })
57
+
58
+ it('reads attributes, not text that looks like them', () => {
59
+ const [unnamed, spaced, braceInString] = openingTags(
60
+ `<DialogPortal onClose={() => log("ariaLabel=unused")}>` +
61
+ `<DialogPortal ariaLabel = "Named">` +
62
+ `<DialogPortal title={"}"} open>`,
63
+ 'DialogPortal',
64
+ )
65
+ expect(isNamed(unnamed)).toBe(false)
66
+ expect(isNamed(spaced)).toBe(true)
67
+ expect(braceInString).toBe('<DialogPortal title={"}"} open>')
68
+ })
69
+ })
@@ -1,5 +1,6 @@
1
1
  import { NodeTerminalTab as SharedNodeTerminalTab, type NodeTerminalTabProps as SharedNodeTerminalTabProps } from '@skyhook-io/k8s-ui'
2
2
  import { apiUrl, getWsUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
3
+ import { readErrorBody } from '../../api/httpErrors'
3
4
 
4
5
  interface NodeTerminalTabProps {
5
6
  nodeName: string
@@ -15,7 +16,7 @@ export function NodeTerminalTab({ nodeName, isActive }: NodeTerminalTabProps) {
15
16
  body: JSON.stringify({}),
16
17
  })
17
18
  if (!response.ok) {
18
- const err = await response.json().catch(() => ({ error: 'Unknown error' }))
19
+ const err = await readErrorBody(response)
19
20
  throw new Error(err.error || `HTTP ${response.status}`)
20
21
  }
21
22
  return response.json()
@@ -1,5 +1,6 @@
1
1
  import { TerminalTab as SharedTerminalTab } from '@skyhook-io/k8s-ui'
2
2
  import { apiUrl, getWsUrl } from '../../api/config'
3
+ import { readErrorBody } from '../../api/httpErrors'
3
4
 
4
5
  interface TerminalTabProps {
5
6
  namespace: string
@@ -22,7 +23,7 @@ export function TerminalTab({ namespace, podName, containerName, containers, isA
22
23
  body: JSON.stringify({ targetContainer }),
23
24
  })
24
25
  if (!response.ok) {
25
- const err = await response.json().catch(() => ({ error: 'Unknown error' }))
26
+ const err = await readErrorBody(response)
26
27
  throw new Error(err.error || `HTTP ${response.status}`)
27
28
  }
28
29
  return response.json()
@@ -3,7 +3,7 @@ import { TrafficFlowList } from '../traffic/TrafficFlowList'
3
3
  import { List } from 'lucide-react'
4
4
 
5
5
  export function TrafficFlowListTab() {
6
- const { flows } = useTrafficFlowList()
6
+ const { flows, responsesCallerOriented } = useTrafficFlowList()
7
7
 
8
8
  if (flows.length === 0) {
9
9
  return (
@@ -14,5 +14,5 @@ export function TrafficFlowListTab() {
14
14
  )
15
15
  }
16
16
 
17
- return <TrafficFlowList flows={flows} />
17
+ return <TrafficFlowList flows={flows} responsesCallerOriented={responsesCallerOriented} />
18
18
  }
@@ -344,7 +344,7 @@ export function BatchExecutionFullscreen({ kind, apiKind, namespace, name, resou
344
344
  <JobSetRenderer data={resource} mode="overview" shownMemberCounts={shownMemberCounts} onSelectRole={memberCollection ? selectRole : undefined} />
345
345
  </section>
346
346
  )}
347
- {jobSetRoot && <JobSetAdmission resource={resource} namespace={namespace} name={name} onNavigate={onNavigateToResource} />}
347
+ {(jobSetRoot || (resource.apiVersion === 'batch/v1' && resource.kind === 'Job')) && <JobSetAdmission resource={resource} namespace={namespace} name={name} onNavigate={onNavigateToResource} />}
348
348
  {memberShell && <JobSetMemberComparison
349
349
  runs={runs} total={runsQuery.data?.total ?? 0} filteredTotal={runsQuery.data?.filteredTotal ?? runs.length}
350
350
  truncated={runsQuery.data?.truncated ?? false} loading={runsQuery.isLoading} error={runsQuery.error}
@@ -1,42 +1,165 @@
1
- import { renderToStaticMarkup } from 'react-dom/server'
2
- import { beforeEach, describe, expect, it, vi } from 'vitest'
3
- import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
4
- import { JobSetAdmission } from './JobSetAdmission'
5
-
6
- const state = vi.hoisted(() => ({ data: undefined as KueueAdmissionResponse | undefined, query: vi.fn() }))
7
- vi.mock('../../api/client', () => ({
8
- ApiError: class extends Error {},
9
- useKueueAdmission: (...args: unknown[]) => {
10
- state.query(...args)
11
- return { data: state.data, isLoading: false, refetch: vi.fn() }
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { KueueAdmission } from "./JobSetAdmission";
4
+ const mock = vi.hoisted(() => ({ result: {} as any, calls: [] as any[] }));
5
+ vi.mock("../../api/client", () => ({
6
+ ApiError: class extends Error {
7
+ status = 403;
8
+ },
9
+ useKueueAdmission: (...args: any[]) => {
10
+ mock.calls.push(args);
11
+ return mock.result;
12
+ },
13
+ }));
14
+ const job = {
15
+ apiVersion: "batch/v1",
16
+ kind: "Job",
17
+ metadata: { name: "train", namespace: "ml", uid: "current" },
18
+ spec: { suspend: true },
19
+ };
20
+ const empty = {
21
+ uid: "current",
22
+ installed: true,
23
+ workloads: [],
24
+ total: 0,
25
+ truncated: false,
26
+ };
27
+ const evidence = {
28
+ ...empty,
29
+ total: 1,
30
+ workloads: [
31
+ {
32
+ name: "owned-workload",
33
+ uid: "w",
34
+ apiVersion: "kueue.x-k8s.io/v1beta2",
35
+ projection: "unsupported",
36
+ },
37
+ ],
38
+ };
39
+ const hinted = {
40
+ ...job,
41
+ metadata: {
42
+ ...job.metadata,
43
+ labels: { "kueue.x-k8s.io/queue-name": "ready" },
12
44
  },
13
- }))
45
+ };
46
+ const render = (resource: any = job) =>
47
+ renderToStaticMarkup(
48
+ <KueueAdmission resource={resource} namespace="ml" name="train" />,
49
+ );
50
+ describe("Job admission host", () => {
51
+ beforeEach(() => {
52
+ mock.result = {};
53
+ mock.calls = [];
54
+ });
55
+ it.each([
56
+ { isLoading: true },
57
+ { data: empty },
58
+ { error: new Error("denied") },
59
+ ])("keeps plain suspended Jobs quiet: %j", (result) => {
60
+ mock.result = result;
61
+ expect(render()).toBe("");
62
+ expect(mock.calls[0][3]).toEqual({
63
+ isJob: true,
64
+ isRayJob: false,
65
+ hinted: false,
66
+ terminal: false,
67
+ });
68
+ });
69
+ it("discovers unlabeled evidence but hides it after errors", () => {
70
+ mock.result = { data: evidence };
71
+ expect(render()).toContain("owned-workload");
72
+ mock.result.error = new Error("cache unavailable");
73
+ expect(render()).toContain("cache unavailable");
74
+ expect(render()).not.toContain("owned-workload");
75
+ });
76
+ it("rejects reused-name evidence", () => {
77
+ mock.result = { data: { ...evidence, uid: "old" } };
78
+ expect(render(hinted)).toContain("different workload instance");
79
+ expect(render(hinted)).not.toContain("owned-workload");
80
+ });
81
+ it("shows missing evidence and conditional owner guidance for a hinted child", () => {
82
+ mock.result = { data: empty };
83
+ const html = render({
84
+ ...hinted,
85
+ metadata: {
86
+ ...hinted.metadata,
87
+ ownerReferences: [{ controller: true, kind: "JobSet", name: "parent" }],
88
+ },
89
+ });
90
+ expect(html).toContain("Admission may be tracked on the owning resource");
91
+ expect(html).not.toContain("Admission is tracked");
92
+ });
93
+ it("does not use failed Pod counts as terminal or arbitrary managedBy as Kueue hint", () => {
94
+ render({
95
+ ...job,
96
+ spec: { managedBy: "other.io/controller" },
97
+ status: { failed: 1 },
98
+ });
99
+ expect(mock.calls[0][3]).toEqual({
100
+ isJob: true,
101
+ isRayJob: false,
102
+ hinted: false,
103
+ terminal: false,
104
+ });
105
+ });
106
+ it("preserves JobSet errors", () => {
107
+ mock.result = { error: new Error("denied") };
108
+ expect(
109
+ render({
110
+ ...job,
111
+ apiVersion: "jobset.x-k8s.io/v1alpha2",
112
+ kind: "JobSet",
113
+ }),
114
+ ).toContain("denied");
115
+ });
116
+ });
14
117
 
15
- beforeEach(() => {
16
- state.query.mockClear()
17
- state.data = { uid: 'old-uid', installed: true, total: 1, truncated: false, workloads: [{
18
- apiVersion: 'kueue.x-k8s.io/v1beta2', namespace: 'ml', name: 'old-workload', uid: 'workload-uid', generation: 1,
19
- createdAt: null, deleting: false, projection: 'unsupported', linksLimited: false,
20
- }] }
21
- })
118
+ describe("JobSet admission identity", () => {
119
+ it("rejects evidence and absence for a different root instance", () => {
120
+ const resource = {
121
+ ...job,
122
+ apiVersion: "jobset.x-k8s.io/v1alpha2",
123
+ kind: "JobSet",
124
+ };
125
+ mock.result = { data: evidence };
126
+ expect(render(resource)).toContain("owned-workload");
127
+ mock.result = { data: { ...evidence, uid: "old" } };
128
+ expect(render(resource)).toContain("different workload instance");
129
+ expect(render(resource)).not.toContain("owned-workload");
130
+ mock.result = { data: { ...empty, uid: "old", installed: false } };
131
+ expect(render(resource)).toContain("different workload instance");
132
+ mock.result.data.uid = "current";
133
+ expect(render(resource)).toBe("");
134
+ });
135
+ });
22
136
 
23
- function render(uid: string) {
24
- return renderToStaticMarkup(<JobSetAdmission resource={{ metadata: { uid } }} namespace="ml" name="training" />)
25
- }
137
+ it("does not suggest parent admission for a CronJob-created Job", () => {
138
+ mock.result = { data: empty };
139
+ const html = render({
140
+ ...hinted,
141
+ metadata: {
142
+ ...hinted.metadata,
143
+ ownerReferences: [
144
+ { apiVersion: "batch/v1", kind: "CronJob", controller: true },
145
+ ],
146
+ },
147
+ });
148
+ expect(html).toContain("No controller-owned Kueue Workload observed");
149
+ expect(html).not.toContain("Admission may be tracked");
150
+ });
26
151
 
27
- describe('JobSet admission identity', () => {
28
- it('rejects old evidence after recreation under the same name', () => {
29
- expect(render('old-uid')).toContain('old-workload')
30
- const html = render('new-uid')
31
- expect(state.query).toHaveBeenLastCalledWith('ml', 'training', 'new-uid')
32
- expect(html).not.toContain('old-workload')
33
- expect(html).toContain('different JobSet instance')
34
- })
35
152
 
36
- it('accepts absence only for the displayed root instance', () => {
37
- state.data = { uid: 'old-uid', installed: false, total: 0, truncated: false, workloads: [] }
38
- expect(render('new-uid')).toContain('different JobSet instance')
39
- state.data.uid = 'new-uid'
40
- expect(render('new-uid')).toBe('')
41
- })
42
- })
153
+ describe("RayJob admission", () => {
154
+ it("uses RayJob identity and controller terminal status without inventing a Kueue hint", () => {
155
+ mock.result = { data: empty };
156
+ expect(render({ ...job, apiVersion: "ray.io/v1", kind: "RayJob", spec: { managedBy: "ray.io/kuberay-operator" }, status: { jobDeploymentStatus: "Complete" } })).toBe("");
157
+ expect(mock.calls.at(-1)?.[3]).toEqual({ isJob: false, isRayJob: true, hinted: false, terminal: true });
158
+ });
159
+ it("keeps MultiKueue admission visible separately from remote execution", () => {
160
+ mock.result = { data: empty };
161
+ const html = render({ ...job, apiVersion: "ray.io/v1", kind: "RayJob", spec: { managedBy: "kueue.x-k8s.io/multikueue" } });
162
+ expect(html).toContain("No controller-owned Kueue Workload observed");
163
+ expect(mock.calls.at(-1)?.[3]).toEqual({ isJob: false, isRayJob: true, hinted: true, terminal: false });
164
+ });
165
+ });
@@ -1,17 +1,26 @@
1
1
  import { KueueAdmissionSection } from '@skyhook-io/k8s-ui/components/resources/KueueAdmissionSection'
2
2
  import { ApiError, useKueueAdmission } from '../../api/client'
3
3
 
4
- export function JobSetAdmission({ resource, namespace, name, onNavigate }: {
4
+ export function KueueAdmission({ resource, namespace, name, onNavigate, presentation }: {
5
+ presentation?: 'card' | 'drawer'
5
6
  resource: any
6
7
  namespace: string
7
8
  name: string
8
9
  onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
9
10
  }) {
10
- const query = useKueueAdmission(namespace, name, resource.metadata?.uid)
11
- const data = query.data?.uid === resource.metadata?.uid ? query.data : undefined
12
- const error = query.error?.message ?? (query.data && !data ? 'Admission evidence belongs to a different JobSet instance; waiting for a fresh observation.' : undefined)
11
+ const isJob = resource.apiVersion === 'batch/v1' && resource.kind === 'Job'
12
+ const isRayJob = resource.apiVersion === 'ray.io/v1' && resource.kind === 'RayJob'
13
13
  const labels = resource.metadata?.labels
14
- const externalExecution = Boolean(resource.spec?.managedBy && resource.spec.managedBy !== 'jobset.sigs.k8s.io/jobset-controller')
15
- const hinted = Boolean(labels?.['kueue.x-k8s.io/queue-name'] || labels?.['kueue.x-k8s.io/prebuilt-workload-name'] || externalExecution)
16
- return <KueueAdmissionSection data={data} loading={query.isLoading} error={error} forbidden={query.error instanceof ApiError && query.error.status === 403} hinted={hinted} externalExecution={externalExecution} onRetry={() => { void query.refetch() }} onNavigate={onNavigate} />
14
+ const annotations = resource.metadata?.annotations
15
+ const externalExecution = Boolean(resource.spec?.managedBy && resource.spec.managedBy !== (isRayJob ? 'ray.io/kuberay-operator' : isJob ? 'kubernetes.io/job-controller' : 'jobset.sigs.k8s.io/jobset-controller'))
16
+ const hinted = Boolean(labels?.['kueue.x-k8s.io/queue-name'] || labels?.['kueue.x-k8s.io/prebuilt-workload-name'] || annotations?.['kueue.x-k8s.io/prebuilt-workload-name'] || resource.spec?.managedBy === 'kueue.x-k8s.io/multikueue' || (!isJob && !isRayJob && externalExecution))
17
+ const terminal = (isRayJob && ['Complete', 'Failed', 'ValidationFailed'].includes(resource.status?.jobDeploymentStatus)) || (resource.status?.conditions ?? []).some((c: any) => ['Complete', 'Failed'].includes(c.type) && c.status === 'True')
18
+ const query = useKueueAdmission(namespace, name, resource.metadata?.uid, { isJob, isRayJob, hinted, terminal })
19
+ const data = query.data?.uid === resource.metadata?.uid ? query.data : undefined
20
+ const error = query.error?.message ?? (query.data && !data ? 'Admission evidence belongs to a different workload instance; waiting for a fresh observation.' : undefined)
21
+ if ((isJob || isRayJob) && !hinted && !data?.workloads.length) return null
22
+ const hasOwner = (resource.metadata?.ownerReferences ?? []).some((owner: any) => owner.controller === true && !(owner.apiVersion === 'batch/v1' && owner.kind === 'CronJob'))
23
+ return <KueueAdmissionSection presentation={presentation} data={data} loading={query.isLoading} error={error} forbidden={query.error instanceof ApiError && query.error.status === 403} hinted={hinted} externalExecution={externalExecution} hasOwner={isJob && hasOwner} onRetry={() => { void query.refetch() }} onNavigate={onNavigate} />
17
24
  }
25
+
26
+ export { KueueAdmission as JobSetAdmission }
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import yaml from 'yaml'
5
+ import { hasHealthSettingsHint } from '@skyhook-io/k8s-ui/components/gitops/GitOpsHealthSourceNotice'
6
+ import { previousSettingsNote, usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
5
7
  import {
6
8
  GitOpsActivityInsightView,
7
9
  GitOpsChangesView,
@@ -412,6 +414,14 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
412
414
  const terminatingChipTooltip = terminatingDescriptions.chipTooltip
413
415
  const terminatingActionTooltip = terminatingDescriptions.actionDisabledTooltip
414
416
  const [appView, setAppView] = useState<GitOpsDetailTab>('topology')
417
+ const insight = insightsQ.data
418
+ const showDiffSettingsHint = appView === 'changes' && insight?.summary.tool === 'argocd' && insight.summary.kind === 'Application'
419
+ && !!insight.changes?.length && !insight.capabilities?.argoDiffAvailable
420
+ const previousSettings = usePreviousIntegrationSettings(!!onOpenSettings && (
421
+ hasHealthSettingsHint(insight?.summary, insight?.changes)
422
+ || showDiffSettingsHint
423
+ ))
424
+ const settingsNote = previousSettings.argocd ? previousSettingsNote('argocd', previousSettings.argocd) : undefined
415
425
  // When the user clicks an actionable issue alert ("OutOfSync — NodePool
416
426
  // default is out of sync · View →"), we navigate to Changes and focus
417
427
  // that resource. The ref is stringified to a stable key so GitOpsChangesView
@@ -670,6 +680,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
670
680
  healthDocsUrl={GITOPS_HEALTH_DOCS_URL}
671
681
  remoteDestinationHint={<RemoteDestinationCloudHint />}
672
682
  onOpenSettings={onOpenSettings}
683
+ settingsNote={showDiffSettingsHint ? undefined : settingsNote}
673
684
  renderRevisionMeta={
674
685
  isArgoApp && insightsQ.data?.capabilities?.revisionMetadataAvailable
675
686
  ? (revision) => (
@@ -809,6 +820,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
809
820
  <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} />
810
821
  ) : undefined}
811
822
  onOpenSettings={onOpenSettings}
823
+ settingsNote={settingsNote}
812
824
  />
813
825
  )
814
826
  }
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useState, useId } from 'react'
2
2
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
3
  import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
@@ -50,6 +50,7 @@ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError
50
50
  // GitOps) Radar can only track upgrades once the user declares where they live.
51
51
  // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
52
52
  export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue, sourceError }: TrackChartSourceDialogProps) {
53
+ const titleId = useId()
53
54
  const [value, setValue] = useState('')
54
55
  const { data: sources } = useHelmOCISources()
55
56
  const { data: clusterInfo } = useClusterInfo()
@@ -78,13 +79,13 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
78
79
  }
79
80
 
80
81
  return (
81
- <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
82
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="max-w-lg w-full">
82
83
  <div className="flex items-start gap-3 p-4 border-b border-theme-border">
83
84
  <div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
84
85
  <Link2 className="w-5 h-5 text-theme-text-secondary" />
85
86
  </div>
86
87
  <div className="flex-1 min-w-0">
87
- <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
88
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
88
89
  <p className="text-sm text-theme-text-secondary mt-1">
89
90
  Helm doesn&apos;t record where a chart was installed from. Radar checks registered OCI
90
91
  prefixes for newer versions of your charts.
@@ -92,6 +93,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
92
93
  </div>
93
94
  <button
94
95
  onClick={onClose}
96
+ aria-label="Close"
95
97
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
96
98
  >
97
99
  <X className="w-5 h-5" />
@@ -52,9 +52,11 @@ interface HomeViewProps {
52
52
  onNavigateToUpgradeImpact?: () => void
53
53
  onNavigateToHelmRelease?: (namespace: string, release: string) => void
54
54
  onNavigateToManagerPath?: (path: string) => void
55
+ // Present only when release notes exist for this version.
56
+ onShowWhatsNew?: () => void
55
57
  }
56
58
 
57
- export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath }: HomeViewProps) {
59
+ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath, onShowWhatsNew }: HomeViewProps) {
58
60
  // The card itself decides whether the cluster has a capacity story
59
61
  // (available, softened-denied, or karpenterless-with-managers/groups) and
60
62
  // returns null otherwise — the outer gate only excludes states with nothing
@@ -138,9 +140,11 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
138
140
  )}
139
141
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
140
142
  <ClusterHealthCard
141
- radarVersion={deploymentMode === 'in-cluster' && versionInfo ? (
143
+ radarVersion={versionInfo && deploymentMode !== 'cloud' ? (
142
144
  <RadarVersionLine
143
145
  version={versionInfo}
146
+ showUpgrade={deploymentMode === 'in-cluster'}
147
+ onShowWhatsNew={onShowWhatsNew}
144
148
  manager={installationManager}
145
149
  managerLoading={installationManagerLoading}
146
150
  onNavigateToHelmRelease={onNavigateToHelmRelease}
@@ -347,7 +351,7 @@ function ProblemsPanel({
347
351
  <div className="flex items-center gap-2">
348
352
  <button
349
353
  type="button"
350
- className="rounded-md px-2 py-1 text-xs font-medium text-accent-text transition-colors hover:bg-accent-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40"
354
+ className="rounded-md px-2 py-1 text-xs font-medium text-accent-text transition-colors hover:bg-accent-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40"
351
355
  onClick={onNavigateToIssues}
352
356
  >
353
357
  View all