@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
|
@@ -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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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
|
|
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
|
|
2
|
-
import { beforeEach, describe, expect, it, vi } from
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
useKueueAdmission: (...args:
|
|
10
|
-
|
|
11
|
-
return
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
expect(
|
|
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
|
|
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
|
|
11
|
-
const
|
|
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
|
|
15
|
-
const
|
|
16
|
-
|
|
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'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
|
|
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
|