@skyhook-io/radar-app 1.14.5 → 1.15.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 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- 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/useContextSwitchFlow.tsx +147 -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.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -0,0 +1,165 @@
|
|
|
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" },
|
|
44
|
+
},
|
|
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
|
+
});
|
|
117
|
+
|
|
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
|
+
});
|
|
136
|
+
|
|
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
|
+
});
|
|
151
|
+
|
|
152
|
+
|
|
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
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { KueueAdmissionSection } from '@skyhook-io/k8s-ui/components/resources/KueueAdmissionSection'
|
|
2
|
+
import { ApiError, useKueueAdmission } from '../../api/client'
|
|
3
|
+
|
|
4
|
+
export function KueueAdmission({ resource, namespace, name, onNavigate, presentation }: {
|
|
5
|
+
presentation?: 'card' | 'drawer'
|
|
6
|
+
resource: any
|
|
7
|
+
namespace: string
|
|
8
|
+
name: string
|
|
9
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
10
|
+
}) {
|
|
11
|
+
const isJob = resource.apiVersion === 'batch/v1' && resource.kind === 'Job'
|
|
12
|
+
const isRayJob = resource.apiVersion === 'ray.io/v1' && resource.kind === 'RayJob'
|
|
13
|
+
const labels = resource.metadata?.labels
|
|
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} />
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { KueueAdmission as JobSetAdmission }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { JobSetMemberComparison } from './JobSetMemberComparison'
|
|
4
|
+
import type { JobSetUsage } from '../../api/client'
|
|
5
|
+
|
|
6
|
+
const empty: JobSetUsage = { runningPods: 0, reportingPods: 0, stalePods: 0, cpu: null, memory: null, cpuRequest: 0, memoryRequest: 0 }
|
|
7
|
+
function render(usage: JobSetUsage) {
|
|
8
|
+
return renderToStaticMarkup(<JobSetMemberComparison runs={[{ group: 'batch', kind: 'jobs', namespace: 'ns', name: 'leader', phase: 'Running', active: true, jobset: { replicatedJob: 'leader', jobIndex: '0' } }]} total={250} filteredTotal={1} truncated={false} loading={false} error={null} roles={['leader']} role="leader" onRoleChange={() => {}} search="" onSearchChange={() => {}} state="all" onStateChange={() => {}} selected="jobs/ns/leader" resourcesLoading={false} resourceError={null} durationFor={() => '2m'} resources={{ uid: 'root', source: 'metrics.k8s.io', total: usage, members: { leader: usage } }} />)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('JobSet resource comparison', () => {
|
|
12
|
+
it('keeps absent usage distinct from measured zero and completed Pods', () => {
|
|
13
|
+
const absent = render({ ...empty, runningPods: 2 })
|
|
14
|
+
expect(absent).toContain('0/2 running Pods reporting')
|
|
15
|
+
expect(absent).not.toContain('0m')
|
|
16
|
+
const zero = render({ ...empty, runningPods: 1, reportingPods: 1, cpu: 0, memory: 0 })
|
|
17
|
+
expect(zero).toContain('1/1 running Pods reporting')
|
|
18
|
+
expect(zero).not.toContain('Unavailable')
|
|
19
|
+
expect(render(empty)).toContain('No running Pods')
|
|
20
|
+
})
|
|
21
|
+
it('shows partial coverage, stale samples, independent counts and declared extended requests', () => {
|
|
22
|
+
const html = render({ ...empty, runningPods: 2, reportingPods: 1, stalePods: 1, cpu: 25000000, memory: 33554432, cpuRequest: 100000000, memoryRequest: 67108864, extendedRequests: { 'nvidia.com/gpu': '2' } })
|
|
23
|
+
expect(html).toContain('1/2 running Pods reporting')
|
|
24
|
+
expect(html).toContain('1 stale')
|
|
25
|
+
expect(html).toContain('250 retained')
|
|
26
|
+
expect(html).toContain('1 matching')
|
|
27
|
+
expect(html).toContain('usage not collected')
|
|
28
|
+
expect(html).toContain('outside these filters')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Badge, EmptyState, FetchResult } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import { formatCPUNanocores, formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
|
|
3
|
+
import type { JobSetResources, JobSetUsage, WorkloadRun } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
runs: WorkloadRun[]
|
|
7
|
+
total: number
|
|
8
|
+
filteredTotal: number
|
|
9
|
+
truncated: boolean
|
|
10
|
+
loading: boolean
|
|
11
|
+
error: unknown
|
|
12
|
+
roles: string[]
|
|
13
|
+
role: string
|
|
14
|
+
onRoleChange: (value: string) => void
|
|
15
|
+
search: string
|
|
16
|
+
onSearchChange: (value: string) => void
|
|
17
|
+
state: 'all' | 'active' | 'failed'
|
|
18
|
+
onStateChange: (value: 'all' | 'active' | 'failed') => void
|
|
19
|
+
selected: string
|
|
20
|
+
onSelect?: (key: string) => void
|
|
21
|
+
resources?: JobSetResources
|
|
22
|
+
resourcesLoading: boolean
|
|
23
|
+
resourceError: unknown
|
|
24
|
+
durationFor: (run: WorkloadRun) => string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function UsageValue({ usage, resource }: { usage?: JobSetUsage; resource: 'cpu' | 'memory' }) {
|
|
28
|
+
const value = usage?.[resource]
|
|
29
|
+
if (value === undefined || value === null) return <span className="text-theme-text-tertiary">—</span>
|
|
30
|
+
const format = resource === 'cpu' ? formatCPUNanocores : formatMemoryBytes
|
|
31
|
+
const request = resource === 'cpu' ? usage!.cpuRequest : usage!.memoryRequest
|
|
32
|
+
return <><span className="tabular-nums text-theme-text-primary">{format(value)}</span><span className="block text-[10px] text-theme-text-tertiary">{request > 0 ? `${format(request)} requested` : 'No request reported'}</span></>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Coverage({ usage }: { usage?: JobSetUsage }) {
|
|
36
|
+
if (!usage) return <span className="text-theme-text-tertiary">Unavailable</span>
|
|
37
|
+
return <span className="text-theme-text-secondary">{usage.runningPods === 0 ? 'No running Pods' : `${usage.reportingPods}/${usage.runningPods} running Pods reporting`}{usage.stalePods > 0 && ` · ${usage.stalePods} stale`}</span>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function JobSetMemberComparison(props: Props) {
|
|
41
|
+
const { resources, runs, role, roles } = props
|
|
42
|
+
const ordered = [...runs].sort((a, b) => (a.jobset?.replicatedJob ?? '').localeCompare(b.jobset?.replicatedJob ?? ''))
|
|
43
|
+
const inputClass = 'rounded-md border border-theme-border bg-theme-elevated px-2 py-1.5 text-xs text-theme-text-primary'
|
|
44
|
+
return <section className="rounded-lg border border-theme-border bg-theme-surface" aria-label="Member Jobs">
|
|
45
|
+
<div className="space-y-3 border-b border-theme-border p-3">
|
|
46
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
47
|
+
<h3 className="text-sm font-semibold text-theme-text-primary">Member Jobs <span className="font-normal text-theme-text-tertiary">{props.loading ? '· Loading Jobs…' : runs.length === props.total ? `· ${props.total} Jobs` : `· ${runs.length} shown · ${props.filteredTotal} matching · ${props.total} retained`}</span></h3>
|
|
48
|
+
<span className="text-xs text-theme-text-tertiary">Controller-owned Jobs currently in Kubernetes</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
51
|
+
<select aria-label="Filter member role" value={role} onChange={e => props.onRoleChange(e.target.value)} className={inputClass}>
|
|
52
|
+
<option value="">All roles</option>{[...new Set([...roles, ...runs.map(run => run.jobset?.replicatedJob).filter((name): name is string => Boolean(name)), ...(role ? [role] : [])])].map(name => <option key={name} value={name}>{name}</option>)}
|
|
53
|
+
</select>
|
|
54
|
+
<select aria-label="Filter member state" value={props.state} onChange={e => props.onStateChange(e.target.value as Props['state'])} className={inputClass}><option value="all">All states</option><option value="active">Active</option><option value="failed">Failed</option></select>
|
|
55
|
+
<input aria-label="Search all member Jobs" value={props.search} onChange={e => props.onSearchChange(e.target.value)} placeholder="Search all member Jobs" className={`${inputClass} min-w-48 flex-1`} />
|
|
56
|
+
{(role || props.state !== 'all' || props.search) && <button type="button" className="text-xs text-accent-text hover:underline" onClick={() => { props.onRoleChange(''); props.onStateChange('all'); props.onSearchChange('') }}>Clear filters</button>}
|
|
57
|
+
</div>
|
|
58
|
+
{resources && <div className="flex flex-wrap items-start gap-x-5 gap-y-2 text-xs">
|
|
59
|
+
<span className="font-medium text-theme-text-secondary">Whole JobSet<br/><span className="font-normal text-theme-text-tertiary">Current resource usage</span></span>
|
|
60
|
+
<span>CPU <UsageValue usage={resources.total} resource="cpu" /></span>
|
|
61
|
+
<span>Memory <UsageValue usage={resources.total} resource="memory" /></span>
|
|
62
|
+
<span><Coverage usage={resources.total}/>{resources.total.observedAt && <span className="block text-[10px] text-theme-text-tertiary">Oldest sample: {new Date(resources.total.observedAt).toLocaleTimeString()}</span>}</span>
|
|
63
|
+
{resources.total.extendedRequests && Object.entries(resources.total.extendedRequests).map(([name, count]) => <span key={name} className="text-theme-text-secondary">{name}: {count} requested<span className="block text-[10px] text-theme-text-tertiary">Nonterminal Pods · usage not collected</span></span>)}
|
|
64
|
+
</div>}
|
|
65
|
+
{Boolean(resources?.unavailable || props.resourceError) && <p className="text-xs text-theme-text-secondary">{resources?.unavailable || (props.resourceError instanceof Error ? props.resourceError.message : 'Resource usage unavailable.')}</p>}
|
|
66
|
+
{!resources && !props.resourceError && props.resourcesLoading && <p className="text-xs text-theme-text-tertiary">Loading resource usage…</p>}
|
|
67
|
+
</div>
|
|
68
|
+
{props.loading ? <FetchResult loading className="min-h-24" /> : props.error ? <EmptyState variant="card" tone="neutral" headline="Member Jobs unavailable" body={props.error instanceof Error ? props.error.message : 'Unable to read child Jobs.'} /> : runs.length === 0 ? <EmptyState variant="card" tone="neutral" headline={props.total ? 'No Jobs match these filters' : 'No child Jobs currently retained'} body={props.total ? 'Change the role, state, or name filter.' : 'Jobs may not exist yet, may be managed externally, or may already have been cleaned up.'} /> : <div className="max-h-72 overflow-auto">
|
|
69
|
+
<table className="w-full text-left text-xs">
|
|
70
|
+
<thead className="sticky top-0 bg-theme-surface text-theme-text-tertiary"><tr>{['Role', 'Job', 'State', 'Duration', 'CPU usage', 'Memory usage', 'Metric coverage'].map(label => <th key={label} className="whitespace-nowrap border-b border-theme-border px-3 py-2 font-medium">{label}</th>)}</tr></thead>
|
|
71
|
+
<tbody>{ordered.map(run => {
|
|
72
|
+
const key = `${run.kind}/${run.namespace}/${run.name}`
|
|
73
|
+
const usage = resources?.members[run.name]
|
|
74
|
+
return <tr key={key} className={`border-b border-theme-border-subtle last:border-0 ${props.selected === key ? 'selection' : 'hover:bg-theme-hover'}`}>
|
|
75
|
+
<td className="px-3 py-2 text-theme-text-secondary">{run.jobset?.replicatedJob ?? 'Not reported'}{run.jobset?.jobIndex !== undefined && ` #${run.jobset.jobIndex}`}</td>
|
|
76
|
+
<th scope="row" className="max-w-xs px-3 py-2 font-medium"><button type="button" aria-pressed={props.selected === key} className="block max-w-full truncate text-accent-text hover:underline" onClick={() => props.onSelect?.(key)}>{run.name}</button></th>
|
|
77
|
+
<td className="px-3 py-2"><Badge severity={run.phase === 'Failed' || run.phase === 'Error' ? 'error' : run.phase === 'Succeeded' ? 'success' : run.active ? 'info' : 'neutral'}>{run.phase}{run.deleting ? ' · deleting' : ''}</Badge></td>
|
|
78
|
+
<td className="whitespace-nowrap px-3 py-2 text-theme-text-secondary">{props.durationFor(run) || '—'}</td>
|
|
79
|
+
<td className="whitespace-nowrap px-3 py-2"><UsageValue usage={usage} resource="cpu" /></td>
|
|
80
|
+
<td className="whitespace-nowrap px-3 py-2"><UsageValue usage={usage} resource="memory" /></td>
|
|
81
|
+
<td className="px-3 py-2"><Coverage usage={usage}/>{usage?.extendedRequests && Object.entries(usage.extendedRequests).map(([name, count]) => <span key={name} className="block text-[10px] text-theme-text-tertiary">{name}: {count} requested · usage not collected</span>)}</td>
|
|
82
|
+
</tr>
|
|
83
|
+
})}</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>}
|
|
86
|
+
<p className="border-t border-theme-border px-3 py-2 text-[10px] text-theme-text-tertiary">Usage and requests cover reporting running Pods only; missing and stale samples are excluded. Whole-JobSet totals include members outside these filters.{props.truncated && ' Only the first 200 matching Jobs are shown. Refine the search to find another member.'}</p>
|
|
87
|
+
</section>
|
|
88
|
+
}
|
|
@@ -5,6 +5,7 @@ import { batchRunNextStep } from './batch-run-actions'
|
|
|
5
5
|
function run(phase: string, counts: Partial<Pick<WorkloadRun, 'podFailed' | 'podSucceeded' | 'podRunning' | 'podPending'>> = {}): WorkloadRun {
|
|
6
6
|
return {
|
|
7
7
|
kind: 'jobs',
|
|
8
|
+
group: 'batch',
|
|
8
9
|
namespace: 'jobs',
|
|
9
10
|
name: 'example',
|
|
10
11
|
phase,
|
|
@@ -7,6 +7,7 @@ describe('workloadRunTimelineEvents', () => {
|
|
|
7
7
|
const runs: WorkloadRun[] = [
|
|
8
8
|
{
|
|
9
9
|
kind: 'jobs',
|
|
10
|
+
group: 'batch',
|
|
10
11
|
namespace: 'dev',
|
|
11
12
|
name: 'nightly-1',
|
|
12
13
|
phase: 'Succeeded',
|
|
@@ -17,6 +18,7 @@ describe('workloadRunTimelineEvents', () => {
|
|
|
17
18
|
},
|
|
18
19
|
{
|
|
19
20
|
kind: 'jobs',
|
|
21
|
+
group: 'batch',
|
|
20
22
|
namespace: 'dev',
|
|
21
23
|
name: 'nightly-2',
|
|
22
24
|
phase: 'Failed',
|
|
@@ -43,6 +45,7 @@ describe('workloadRunTimelineEvents', () => {
|
|
|
43
45
|
it('uses Workflow resource identity for Argo runs', () => {
|
|
44
46
|
const events = workloadRunTimelineEvents([{
|
|
45
47
|
kind: 'workflows',
|
|
48
|
+
group: 'argoproj.io',
|
|
46
49
|
namespace: 'dev',
|
|
47
50
|
name: 'migration-abc',
|
|
48
51
|
phase: 'Error',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { executionDefinitionFingerprint, executionDefinitionSummary } from './execution-definition'
|
|
2
|
+
import { executionDefinitionDiffers, executionDefinitionFingerprint, executionDefinitionSummary } from './execution-definition'
|
|
3
3
|
|
|
4
4
|
describe('executionDefinitionSummary', () => {
|
|
5
5
|
it('explains a Kubernetes Job pod template and effective defaults', () => {
|
|
@@ -139,6 +139,20 @@ describe('executionDefinitionSummary', () => {
|
|
|
139
139
|
expect(executionDefinitionFingerprint(stored)).toBe(executionDefinitionFingerprint(current))
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
+
it('only reports drift when both definitions are comparable', () => {
|
|
143
|
+
const captured = executionDefinitionSummary('Job', {
|
|
144
|
+
spec: { template: { spec: { containers: [{ name: 'worker', image: 'example/worker:v1' }] } } },
|
|
145
|
+
})
|
|
146
|
+
const changed = executionDefinitionSummary('Job', {
|
|
147
|
+
spec: { template: { spec: { containers: [{ name: 'worker', image: 'example/worker:v2' }] } } },
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(executionDefinitionDiffers(null, captured)).toBe(false)
|
|
151
|
+
expect(executionDefinitionDiffers(captured, null)).toBe(false)
|
|
152
|
+
expect(executionDefinitionDiffers(captured, captured)).toBe(false)
|
|
153
|
+
expect(executionDefinitionDiffers(captured, changed)).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
142
156
|
it('reads a ScaledJob embedded Job target', () => {
|
|
143
157
|
const summary = executionDefinitionSummary('ScaledJob', {
|
|
144
158
|
spec: {
|
|
@@ -56,6 +56,10 @@ export function executionDefinitionFingerprint(summary: ExecutionDefinitionSumma
|
|
|
56
56
|
return JSON.stringify(summary)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export function executionDefinitionDiffers(current: ExecutionDefinitionSummary | null, captured: ExecutionDefinitionSummary | null): boolean {
|
|
60
|
+
return current != null && captured != null && executionDefinitionFingerprint(current) !== executionDefinitionFingerprint(captured)
|
|
61
|
+
}
|
|
62
|
+
|
|
59
63
|
function kubernetesJobSummary(jobSpec: any): ExecutionDefinitionSummary {
|
|
60
64
|
const podSpec = jobSpec.template?.spec ?? {}
|
|
61
65
|
const containers = Array.isArray(podSpec.containers) ? podSpec.containers : []
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { WorkloadRun, WorkloadRunsResponse } from '../../api/client'
|
|
4
|
+
import { BatchExecutionFullscreen } from './BatchExecutionView'
|
|
5
|
+
import { ScheduledWorkloadLogsViewer, workloadRunLogsKey } from '../logs/ScheduledWorkloadLogsViewer'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({
|
|
8
|
+
response: { collection: 'members', runs: [], total: 0, truncated: false } as WorkloadRunsResponse | undefined,
|
|
9
|
+
isLoading: false,
|
|
10
|
+
error: undefined as Error | undefined,
|
|
11
|
+
useResource: vi.fn().mockReturnValue({ data: { spec: {} } }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock('../../api/client', () => ({
|
|
15
|
+
useResource: (...args: unknown[]) => state.useResource(...args),
|
|
16
|
+
useWorkloadPods: () => ({ data: { pods: [], total: 0, truncated: false } }),
|
|
17
|
+
useJobSetResources: () => ({ data: undefined, isLoading: false }),
|
|
18
|
+
useWorkloadRuns: () => ({ data: state.response, isLoading: state.isLoading, error: state.error }),
|
|
19
|
+
}))
|
|
20
|
+
vi.mock('../logs/WorkloadLogsViewer', () => ({
|
|
21
|
+
WorkloadLogsViewer: ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => <div data-log-target={`${kind}/${namespace}/${name}`}>Logs for {name}</div>,
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('./JobSetAdmission', () => ({ JobSetAdmission: () => null }))
|
|
24
|
+
|
|
25
|
+
const member: WorkloadRun = {
|
|
26
|
+
group: 'batch', kind: 'jobs', namespace: 'training', name: 'distributed-workers-0',
|
|
27
|
+
phase: 'Running', active: true,
|
|
28
|
+
jobset: { replicatedJob: 'workers', jobIndex: '0', replicatedJobReplicas: '2', restartAttempt: '0', jobRestartAttempt: '0' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function overview(selectedRunKey?: string, apiVersion = 'jobset.x-k8s.io/v1alpha2') {
|
|
32
|
+
return renderToStaticMarkup(<BatchExecutionFullscreen kind="JobSet" apiKind="jobsets" namespace="training" name="distributed" resource={{ apiVersion, kind: 'JobSet', spec: { replicatedJobs: [{ name: 'workers', replicas: 2 }] } }} selectedRunKey={selectedRunKey} />)
|
|
33
|
+
}
|
|
34
|
+
function logs(selectedRunKey?: string) {
|
|
35
|
+
return renderToStaticMarkup(<ScheduledWorkloadLogsViewer kind="JobSet" namespace="training" name="distributed" selectedRunKey={selectedRunKey} />)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
state.response = { collection: 'members', runs: [member], total: 1, truncated: false }
|
|
40
|
+
state.isLoading = false
|
|
41
|
+
state.error = undefined
|
|
42
|
+
state.useResource.mockReset()
|
|
43
|
+
state.useResource.mockReturnValue({ data: { spec: {} } })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('member collection consumers', () => {
|
|
47
|
+
it('keeps the root and declared roles visible during the first member load', () => {
|
|
48
|
+
state.response = undefined
|
|
49
|
+
state.isLoading = true
|
|
50
|
+
const html = overview()
|
|
51
|
+
expect(html).toContain('JobSet overview')
|
|
52
|
+
expect(html).toContain('Role progress')
|
|
53
|
+
expect(html).toContain('Loading Jobs')
|
|
54
|
+
expect(html).not.toContain('No child Jobs currently retained')
|
|
55
|
+
expect(html).not.toContain('Inspect')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps the root visible when member reads fail, without claiming absence', () => {
|
|
59
|
+
state.response = undefined
|
|
60
|
+
state.error = new Error('Forbidden: Jobs are not readable')
|
|
61
|
+
const html = overview()
|
|
62
|
+
expect(html).toContain('JobSet overview')
|
|
63
|
+
expect(html).toContain('Member Jobs unavailable')
|
|
64
|
+
expect(html).toContain('Forbidden: Jobs are not readable')
|
|
65
|
+
expect(html).not.toContain('No child Jobs currently retained')
|
|
66
|
+
expect(overview(undefined, 'other.example/v1')).not.toContain('JobSet overview')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders nested role, zero index and selected member in Overview and Logs', () => {
|
|
70
|
+
const html = overview('jobs/training/distributed-workers-0')
|
|
71
|
+
expect(html).toContain('Member Jobs')
|
|
72
|
+
expect(html).toContain('workers #0')
|
|
73
|
+
expect(html).toContain('JobSet restart attempt')
|
|
74
|
+
expect(html).not.toContain('Run history')
|
|
75
|
+
expect(state.useResource).toHaveBeenCalledWith('jobs', 'training', member.name, 'batch', expect.objectContaining({ enabled: true }))
|
|
76
|
+
const logHTML = logs('jobs/training/distributed-workers-0')
|
|
77
|
+
expect(logHTML).toContain('workers #0')
|
|
78
|
+
expect(logHTML).toContain(`Logs for ${member.name}`)
|
|
79
|
+
expect(logHTML).toContain(`data-log-target="jobs/training/${member.name}"`)
|
|
80
|
+
expect(logHTML).not.toContain('data-log-target="jobsets/')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does not silently select a different member outside a truncated window', () => {
|
|
84
|
+
state.response = { collection: 'members', runs: [member], total: 201, truncated: true }
|
|
85
|
+
const missing = 'jobs/training/omitted-worker'
|
|
86
|
+
expect(overview(missing)).toContain('Selected Job is currently unavailable')
|
|
87
|
+
const html = logs(missing)
|
|
88
|
+
expect(html).toContain('Select a shown member Job')
|
|
89
|
+
expect(html).not.toContain('Logs for')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it.each([{ remaining: [] }, { remaining: [{ ...member, name: 'other-worker' }] }])('preserves an explicit member through a recreation gap with $remaining', ({ remaining }) => {
|
|
93
|
+
const selected = 'jobs/training/distributed-workers-0'
|
|
94
|
+
state.response = { collection: 'members', runs: remaining, total: remaining.length, truncated: false }
|
|
95
|
+
expect(overview(selected)).toContain('Selected Job is currently unavailable')
|
|
96
|
+
const html = logs(selected)
|
|
97
|
+
expect(html).toContain('Your selection is preserved if it reappears')
|
|
98
|
+
expect(html).not.toContain('truncated window')
|
|
99
|
+
expect(html).not.toContain('Logs for')
|
|
100
|
+
if (remaining.length === 0) expect(html).not.toContain('aria-label="Select a shown member Job"')
|
|
101
|
+
state.response = { collection: 'members', runs: [member], total: 1, truncated: false }
|
|
102
|
+
expect(logs(selected)).toContain(`Logs for ${member.name}`)
|
|
103
|
+
expect(overview(selected)).not.toContain('Selected Job is currently unavailable')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('opens the exact selected member outside the list in Overview and Logs', () => {
|
|
107
|
+
const selected = { ...member, name: 'off-window' }
|
|
108
|
+
state.response = { collection: 'members', runs: [member], selected, total: 250, filteredTotal: 250, truncated: true }
|
|
109
|
+
const key = 'jobs/training/off-window'
|
|
110
|
+
expect(overview(key)).toContain('Selected Job is outside the current filter or shown window')
|
|
111
|
+
expect(logs(key)).toContain('Logs for off-window')
|
|
112
|
+
expect(logs(key)).not.toContain('Selected Job is currently unavailable')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('waits for declared roles before enabling a role log scope', () => {
|
|
116
|
+
state.useResource.mockReturnValue({ data: undefined })
|
|
117
|
+
expect(logs()).toContain('<option value="role" disabled="">Role</option>')
|
|
118
|
+
state.useResource.mockReturnValue({ data: { spec: { replicatedJobs: [{ name: 'prepare' }, { name: 'workers' }] } } })
|
|
119
|
+
expect(logs()).toContain('<option value="role">Role</option>')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('uses collection semantics even when there are no members', () => {
|
|
123
|
+
state.response = { collection: 'members', runs: [], total: 0, truncated: false }
|
|
124
|
+
expect(overview()).toContain('Member Jobs')
|
|
125
|
+
expect(overview()).toContain('No child Jobs currently retained')
|
|
126
|
+
expect(logs()).toContain('No child Jobs found')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('reads response semantics instead of guessing membership from the parent kind', () => {
|
|
130
|
+
state.response = { collection: 'runs', runs: [{ ...member, jobset: undefined }], total: 1, truncated: false }
|
|
131
|
+
expect(overview()).toContain('Run history')
|
|
132
|
+
expect(overview()).not.toContain('Member Jobs')
|
|
133
|
+
expect(logs()).not.toContain('workers #0')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('restarts logs for either native retry scope at the same Job address', () => {
|
|
137
|
+
expect(workloadRunLogsKey({ ...member })).toBe(workloadRunLogsKey(member))
|
|
138
|
+
expect(workloadRunLogsKey({ ...member, jobset: { ...member.jobset, restartAttempt: '1' } })).not.toBe(workloadRunLogsKey(member))
|
|
139
|
+
expect(workloadRunLogsKey({ ...member, jobset: { ...member.jobset, jobRestartAttempt: '1' } })).not.toBe(workloadRunLogsKey(member))
|
|
140
|
+
expect(workloadRunLogsKey({ ...member, jobset: {} })).not.toBe(workloadRunLogsKey(member))
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
formatGitOpsSourceUrl,
|
|
20
20
|
getGitOpsResourceStatus,
|
|
21
21
|
getGitOpsTool,
|
|
22
|
+
isDestinationRef,
|
|
22
23
|
isArgoOperationInProgress,
|
|
23
24
|
isArgoSuspendedByRadar,
|
|
24
25
|
gitOpsInsightChangeKey,
|
|
@@ -40,11 +41,15 @@ import {
|
|
|
40
41
|
type GitOpsRow,
|
|
41
42
|
type GitOpsRowAction,
|
|
42
43
|
type GitOpsTreeFilters,
|
|
44
|
+
type GitOpsTreeNode,
|
|
43
45
|
type GitOpsTreeRef,
|
|
44
46
|
type GitOpsTreePreset,
|
|
45
47
|
type SelectedResource,
|
|
46
48
|
} from '@skyhook-io/k8s-ui'
|
|
47
49
|
import { useToast } from '../ui/Toast'
|
|
50
|
+
import { useContextSwitchFlow } from '../useContextSwitchFlow'
|
|
51
|
+
import { useDestinationCluster } from './useDestinationCluster'
|
|
52
|
+
import { destinationToast } from './destination-toast'
|
|
48
53
|
|
|
49
54
|
import {
|
|
50
55
|
fetchJSON,
|
|
@@ -352,7 +357,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
352
357
|
function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOpsViewProps) {
|
|
353
358
|
const location = useLocation()
|
|
354
359
|
const navigate = useNavigate()
|
|
355
|
-
const { showError, showSuccess } = useToast()
|
|
360
|
+
const { showError, showSuccess, showToast } = useToast()
|
|
356
361
|
const parts = location.pathname.split('/').filter(Boolean)
|
|
357
362
|
const kind = parts[2] || 'applications'
|
|
358
363
|
const namespace = parts[3] === '_' ? '' : decodePathPart(parts[3] || '')
|
|
@@ -380,6 +385,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
380
385
|
const resourceQ = useResource<any>(kind, namespace, name, group)
|
|
381
386
|
const treeQ = useGitOpsTree(kind, namespace, name, group, namespaces)
|
|
382
387
|
const insightsQ = useGitOpsInsights(kind, namespace, name, group, namespaces)
|
|
388
|
+
const switchFlow = useContextSwitchFlow()
|
|
383
389
|
const status = resourceQ.data ? getGitOpsResourceStatus(kind, resourceQ.data) : null
|
|
384
390
|
const tool = getGitOpsTool(kind, group)
|
|
385
391
|
// Argo "auto-sync ON" is determined by spec.syncPolicy.automated being set,
|
|
@@ -463,8 +469,32 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
463
469
|
roles: graphRoles,
|
|
464
470
|
}), [graphHealth, graphKinds, graphNamespaces, graphRoles, graphSync])
|
|
465
471
|
const graphFacets = useMemo(() => buildTreeFacets(tree), [tree])
|
|
472
|
+
// The tree and insights load separately; either one seeing a remote
|
|
473
|
+
// destination is enough to keep its resources from opening here.
|
|
474
|
+
const remoteDestination = !!(tree?.remoteDestination || insightsQ.data?.summary?.remoteDestination)
|
|
475
|
+
const destination = useDestinationCluster(remoteDestination, kind, namespace, name)
|
|
466
476
|
|
|
467
|
-
function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef) {
|
|
477
|
+
function openResourceFromTree(ref: GitOpsTreeRef | GitOpsInsightRef, node?: GitOpsTreeNode) {
|
|
478
|
+
// Locality comes from the node, not the name: a remote app can deploy a
|
|
479
|
+
// same-named copy of itself to its destination.
|
|
480
|
+
if (node?.role !== 'root' && isDestinationRef(tree, remoteDestination, ref)) {
|
|
481
|
+
const toast = destinationToast(`${ref.kind} ${ref.namespace ? `${ref.namespace}/` : ''}${ref.name}`, destination)
|
|
482
|
+
const target = destination.context
|
|
483
|
+
showToast(toast.message, {
|
|
484
|
+
type: 'info',
|
|
485
|
+
detail: toast.detail,
|
|
486
|
+
action: toast.actionLabel && target ? {
|
|
487
|
+
label: toast.actionLabel,
|
|
488
|
+
onClick: () => switchFlow.requestSwitch(target, {
|
|
489
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
|
|
490
|
+
namespace: ref.namespace || '',
|
|
491
|
+
name: ref.name,
|
|
492
|
+
group: ref.group,
|
|
493
|
+
}),
|
|
494
|
+
} : undefined,
|
|
495
|
+
})
|
|
496
|
+
return
|
|
497
|
+
}
|
|
468
498
|
if (isGitOpsDetailRef(ref) && isValidKubernetesName(ref.name)) {
|
|
469
499
|
const detailKind = kindToPluralWithGroup(ref.kind, ref.group ?? '')
|
|
470
500
|
// The tree's root node is this page's own subject — clicking it must not
|
|
@@ -822,6 +852,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
|
|
|
822
852
|
}}
|
|
823
853
|
>
|
|
824
854
|
{/* Modals — portaled to body, only render the ones for the current tool. */}
|
|
855
|
+
{switchFlow.confirmDialog}
|
|
825
856
|
{isArgoApp && (
|
|
826
857
|
<>
|
|
827
858
|
<SyncOptionsDialog
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { destinationToast } from './destination-toast'
|
|
3
|
+
|
|
4
|
+
const ctx = {
|
|
5
|
+
name: 'arn:aws:eks:eu-central-1:420859418125:cluster/eks-prod-cluster',
|
|
6
|
+
cluster: 'arn:aws:eks:eu-central-1:420859418125:cluster/eks-prod-cluster',
|
|
7
|
+
user: 'u',
|
|
8
|
+
namespace: '',
|
|
9
|
+
isCurrent: false,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('destinationToast', () => {
|
|
13
|
+
test('offers to open it in the matching context', () => {
|
|
14
|
+
const t = destinationToast('ConfigMap ingress-nginx/controller', { context: ctx, server: 'https://65b5.gr7.eu-central-1.eks.amazonaws.com', canSwitch: true })
|
|
15
|
+
expect(t.message).toBe('ConfigMap ingress-nginx/controller is on eks-prod-cluster')
|
|
16
|
+
expect(t.actionLabel).toBe('Open in eks-prod-cluster')
|
|
17
|
+
})
|
|
18
|
+
test('names the cluster without an action when switching is off', () => {
|
|
19
|
+
const t = destinationToast('ConfigMap a/b', { context: ctx, server: 'https://65b5.gr7.eu-central-1.eks.amazonaws.com', canSwitch: false })
|
|
20
|
+
expect(t.message).toContain('eks-prod-cluster')
|
|
21
|
+
expect(t.actionLabel).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
test('says which server is missing from the kubeconfig', () => {
|
|
24
|
+
const t = destinationToast('ConfigMap a/b', { server: 'https://34.73.119.131', canSwitch: true })
|
|
25
|
+
expect(t.detail).toContain('34.73.119.131')
|
|
26
|
+
expect(t.actionLabel).toBeUndefined()
|
|
27
|
+
})
|
|
28
|
+
test('unknown destination', () => {
|
|
29
|
+
const t = destinationToast('ConfigMap a/b', { canSwitch: true })
|
|
30
|
+
expect(t.message).toBe('ConfigMap a/b is on another cluster')
|
|
31
|
+
expect(t.actionLabel).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|