@skyhook-io/radar-app 1.14.5 → 1.14.6
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 +91 -328
- package/src/RadarApp.tsx +3 -8
- 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 +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- 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/diagnose/AgentControls.tsx +26 -3
- 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/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 +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -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 +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- 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/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/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/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- 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/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import { X, Plus, Trash2, Lock } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole } from '../../api/client'
|
|
4
|
+
import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole, useCapabilities } from '../../api/client'
|
|
5
|
+
import { OperatorManagedNotice } from '../settings/OperatorManagedNotice'
|
|
5
6
|
import type { CheckMeta } from '@skyhook-io/k8s-ui'
|
|
6
7
|
import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
|
|
7
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -20,7 +21,9 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
20
21
|
// server-side too). Non-owners get a read-only view. Non-Cloud callers
|
|
21
22
|
// have no role and pass.
|
|
22
23
|
const { canAtLeast } = useCloudRole()
|
|
23
|
-
const
|
|
24
|
+
const { data: capabilities } = useCapabilities()
|
|
25
|
+
const operatorManaged = capabilities?.configManagement === 'operator'
|
|
26
|
+
const canEdit = capabilities != null && !operatorManaged && canAtLeast('owner')
|
|
24
27
|
const [ignoredNs, setIgnoredNs] = useState<string[]>([])
|
|
25
28
|
const [disabledChecks, setDisabledChecks] = useState<string[]>([])
|
|
26
29
|
const [newNs, setNewNs] = useState('')
|
|
@@ -82,7 +85,8 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
82
85
|
</div>
|
|
83
86
|
|
|
84
87
|
<div className="px-5 py-4 overflow-y-auto flex-1">
|
|
85
|
-
{
|
|
88
|
+
{operatorManaged && <div className="mb-4"><OperatorManagedNotice helmValue="audit" /></div>}
|
|
89
|
+
{capabilities && !operatorManaged && !canEdit && (
|
|
86
90
|
<div className="mb-4 rounded-lg border border-theme-border bg-theme-elevated/50 p-3 flex items-start gap-2.5">
|
|
87
91
|
<Lock className="w-3.5 h-3.5 mt-0.5 shrink-0 text-theme-text-tertiary" />
|
|
88
92
|
<p className="text-xs text-theme-text-tertiary">
|
|
@@ -104,13 +108,13 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
104
108
|
{ignoredNs.map(ns => (
|
|
105
109
|
<div key={ns} className="flex items-center justify-between px-3 py-1.5 bg-theme-elevated rounded-lg">
|
|
106
110
|
<span className="text-sm text-theme-text-primary">{ns}</span>
|
|
107
|
-
<button
|
|
111
|
+
{!operatorManaged && <button
|
|
108
112
|
onClick={() => setIgnoredNs(ignoredNs.filter(n => n !== ns))}
|
|
109
113
|
disabled={!canEdit}
|
|
110
114
|
className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-theme-text-tertiary"
|
|
111
115
|
>
|
|
112
116
|
<Trash2 className="w-3.5 h-3.5" />
|
|
113
|
-
</button>
|
|
117
|
+
</button>}
|
|
114
118
|
</div>
|
|
115
119
|
))}
|
|
116
120
|
{ignoredNs.length === 0 && (
|
|
@@ -118,7 +122,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
118
122
|
)}
|
|
119
123
|
</div>
|
|
120
124
|
|
|
121
|
-
<div className="flex gap-2">
|
|
125
|
+
{!operatorManaged && <div className="flex gap-2">
|
|
122
126
|
<Input
|
|
123
127
|
value={newNs}
|
|
124
128
|
onChange={e => setNewNs(e.target.value)}
|
|
@@ -141,7 +145,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
141
145
|
>
|
|
142
146
|
<Plus className="w-4 h-4" />
|
|
143
147
|
</button>
|
|
144
|
-
</div>
|
|
148
|
+
</div>}
|
|
145
149
|
{(newNsError || newNsDuplicate) && (
|
|
146
150
|
<p id="new-ns-help" className="mt-1.5 text-xs text-red-400">
|
|
147
151
|
{newNsDuplicate
|
|
@@ -157,7 +161,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
157
161
|
Enabled Checks
|
|
158
162
|
</label>
|
|
159
163
|
<p className="text-xs text-theme-text-tertiary mt-1 mb-3">
|
|
160
|
-
Uncheck to disable specific checks globally across all views.
|
|
164
|
+
{operatorManaged ? 'Checks enabled by the installation’s audit policy.' : 'Uncheck to disable specific checks globally across all views.'}
|
|
161
165
|
</p>
|
|
162
166
|
|
|
163
167
|
<div className="flex flex-col gap-0.5">
|
|
@@ -191,9 +195,9 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
191
195
|
onClick={onClose}
|
|
192
196
|
className="px-4 py-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary bg-theme-elevated hover:bg-theme-hover border border-theme-border rounded-lg transition-colors"
|
|
193
197
|
>
|
|
194
|
-
Cancel
|
|
198
|
+
{operatorManaged ? 'Close' : 'Cancel'}
|
|
195
199
|
</button>
|
|
196
|
-
<Tooltip
|
|
200
|
+
{!operatorManaged && <Tooltip
|
|
197
201
|
content={
|
|
198
202
|
!canEdit
|
|
199
203
|
? 'Audit settings can only be changed by owners'
|
|
@@ -216,7 +220,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
216
220
|
>
|
|
217
221
|
{updateSettings.isPending ? 'Saving...' : 'Save'}
|
|
218
222
|
</button>
|
|
219
|
-
</Tooltip>
|
|
223
|
+
</Tooltip>}
|
|
220
224
|
</div>
|
|
221
225
|
</div>
|
|
222
226
|
</div>
|
|
@@ -9,6 +9,9 @@ import { useAnimatedUnmount } from "../../hooks/useAnimatedUnmount";
|
|
|
9
9
|
import type { DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
|
|
10
10
|
import type { AgentInfo, ExecutionProfile } from "../../api/diagnose";
|
|
11
11
|
|
|
12
|
+
const OPENCODE_FULL_LOCAL_WARNING =
|
|
13
|
+
"Radar runs OpenCode with --auto, which automatically approves actions that your configuration would normally ask about, including built-in tools and configured MCP servers. Explicit denials still apply. Radar does not enforce a CLI sandbox.";
|
|
14
|
+
|
|
12
15
|
const CURSOR_FULL_LOCAL_WARNING =
|
|
13
16
|
"Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary workspace.";
|
|
14
17
|
|
|
@@ -245,6 +248,7 @@ export function AgentControls({
|
|
|
245
248
|
const isCodex = selectedAgent === "codex";
|
|
246
249
|
const isClaude = selectedAgent === "claude";
|
|
247
250
|
const isCursor = selectedAgent === "cursor-agent";
|
|
251
|
+
const isOpenCode = selectedAgent === "opencode";
|
|
248
252
|
const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
|
|
249
253
|
const selectedAgentLabel =
|
|
250
254
|
selectedAgentInfo?.label || selectedAgent || "agent";
|
|
@@ -302,7 +306,9 @@ export function AgentControls({
|
|
|
302
306
|
? CURSOR_FULL_LOCAL_WARNING
|
|
303
307
|
: isClaude
|
|
304
308
|
? "Claude uses the permissions from your setup; Radar does not override them."
|
|
305
|
-
:
|
|
309
|
+
: isOpenCode
|
|
310
|
+
? OPENCODE_FULL_LOCAL_WARNING
|
|
311
|
+
: "Radar still enables the agent CLI’s own sandbox, but that sandbox does not constrain external MCP servers."}{" "}
|
|
306
312
|
Choose this only when you need that setup.
|
|
307
313
|
</span>
|
|
308
314
|
</div>
|
|
@@ -330,6 +336,13 @@ export function AgentControls({
|
|
|
330
336
|
cluster.{" "}
|
|
331
337
|
{isCursor ? (
|
|
332
338
|
CURSOR_FULL_LOCAL_WARNING
|
|
339
|
+
) : isClaude ? (
|
|
340
|
+
<>
|
|
341
|
+
Claude uses the permissions from your setup; Radar does not
|
|
342
|
+
override them.
|
|
343
|
+
</>
|
|
344
|
+
) : isOpenCode ? (
|
|
345
|
+
OPENCODE_FULL_LOCAL_WARNING
|
|
333
346
|
) : (
|
|
334
347
|
<>
|
|
335
348
|
Radar still enables the agent CLI's own sandbox, but
|
|
@@ -371,9 +384,17 @@ export function AgentControls({
|
|
|
371
384
|
<TextField
|
|
372
385
|
label="Model"
|
|
373
386
|
value={model}
|
|
374
|
-
placeholder=
|
|
387
|
+
placeholder={
|
|
388
|
+
selectedAgent === "opencode"
|
|
389
|
+
? "Default (provider/model)"
|
|
390
|
+
: "Default"
|
|
391
|
+
}
|
|
375
392
|
onChange={onSetModel}
|
|
376
|
-
hint=
|
|
393
|
+
hint={
|
|
394
|
+
selectedAgent === "opencode"
|
|
395
|
+
? "Leave empty for OpenCode's default, or enter a provider/model from opencode models."
|
|
396
|
+
: "Leave empty for the agent's default, or enter a model identifier it supports."
|
|
397
|
+
}
|
|
377
398
|
/>
|
|
378
399
|
)}
|
|
379
400
|
{isCodex && (
|
|
@@ -590,6 +611,8 @@ export function ConsentCard({
|
|
|
590
611
|
Claude uses the permissions from your setup; Radar does not
|
|
591
612
|
override them.
|
|
592
613
|
</>
|
|
614
|
+
) : agent === "opencode" ? (
|
|
615
|
+
OPENCODE_FULL_LOCAL_WARNING
|
|
593
616
|
) : (
|
|
594
617
|
<>
|
|
595
618
|
Radar still enables the agent CLI's own sandbox, but that
|
|
@@ -27,4 +27,10 @@ export const SUPPORTED_AGENTS: AgentInstall[] = [
|
|
|
27
27
|
install: "curl https://cursor.com/install -fsS | bash",
|
|
28
28
|
docs: "https://docs.cursor.com/en/cli/overview",
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
name: "opencode",
|
|
32
|
+
label: "OpenCode",
|
|
33
|
+
install: "npm install -g opencode-ai",
|
|
34
|
+
docs: "https://opencode.ai/docs/",
|
|
35
|
+
},
|
|
30
36
|
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { RunSummary } from "../../api/diagnose";
|
|
3
|
+
import { buildLaunchCommand, launchAgentLabel } from "./launch";
|
|
4
|
+
|
|
5
|
+
describe("OpenCode terminal handoff", () => {
|
|
6
|
+
it("uses the correct label without launching a different agent", () => {
|
|
7
|
+
const run: RunSummary = {
|
|
8
|
+
id: "run-test",
|
|
9
|
+
kind: "Deployment",
|
|
10
|
+
group: "apps",
|
|
11
|
+
namespace: "default",
|
|
12
|
+
name: "demo",
|
|
13
|
+
context: "test",
|
|
14
|
+
agent: "opencode",
|
|
15
|
+
status: "done",
|
|
16
|
+
sessionId: "ses_opencode",
|
|
17
|
+
createdAt: "2026-09-21T00:00:00Z",
|
|
18
|
+
updatedAt: "2026-09-21T00:00:00Z",
|
|
19
|
+
};
|
|
20
|
+
expect(launchAgentLabel(run)).toBe("OpenCode");
|
|
21
|
+
expect(buildLaunchCommand(run, "http://localhost:9280/mcp")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -15,6 +15,7 @@ function sq(s: string): string {
|
|
|
15
15
|
|
|
16
16
|
export function launchAgentLabel(run: RunSummary): string {
|
|
17
17
|
if (run.agent === "codex") return "Codex";
|
|
18
|
+
if (run.agent === "opencode") return "OpenCode";
|
|
18
19
|
if (run.agent === "cursor-agent") return "Cursor";
|
|
19
20
|
return "Claude Code";
|
|
20
21
|
}
|
|
@@ -42,6 +43,9 @@ export function buildLaunchCommand(
|
|
|
42
43
|
): string | null {
|
|
43
44
|
if (!run.sessionId || run.status === "stale") return null;
|
|
44
45
|
|
|
46
|
+
// OpenCode terminal handoff needs a persistent MCP configuration outside the run workspace.
|
|
47
|
+
if (run.agent === "opencode") return null;
|
|
48
|
+
|
|
45
49
|
if (run.agent === "cursor-agent") {
|
|
46
50
|
// Cursor's --resume is workspace-scoped, and Radar runs each investigation in a
|
|
47
51
|
// throwaway per-run workspace the user doesn't have — no command can reattach
|
|
@@ -1031,6 +1031,24 @@ describe("ConsentCard execution profile treatment", () => {
|
|
|
1031
1031
|
expect(html).toContain("Radar does not override them");
|
|
1032
1032
|
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
1033
1033
|
});
|
|
1034
|
+
|
|
1035
|
+
it("does not claim Radar enables a sandbox for OpenCode's normal setup", () => {
|
|
1036
|
+
const html = renderToStaticMarkup(
|
|
1037
|
+
<ConsentCard
|
|
1038
|
+
agentName="OpenCode"
|
|
1039
|
+
agent="opencode"
|
|
1040
|
+
profile="full-local"
|
|
1041
|
+
onApprove={noop}
|
|
1042
|
+
onCancel={noop}
|
|
1043
|
+
/>,
|
|
1044
|
+
);
|
|
1045
|
+
expect(html).toContain("Radar runs OpenCode with --auto");
|
|
1046
|
+
expect(html).toContain("automatically approves actions");
|
|
1047
|
+
expect(html).toContain("built-in tools and configured MCP servers");
|
|
1048
|
+
expect(html).toContain("Explicit denials still apply");
|
|
1049
|
+
expect(html).toContain("Radar does not enforce a CLI sandbox");
|
|
1050
|
+
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
1051
|
+
});
|
|
1034
1052
|
});
|
|
1035
1053
|
|
|
1036
1054
|
// The consent card's error box is the ONLY place a refused approval is
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { NodeTerminalTabProps } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { NodeTerminalTab } from './NodeTerminalTab'
|
|
5
|
+
|
|
6
|
+
const captured = vi.hoisted(() => ({ props: null as NodeTerminalTabProps | null }))
|
|
7
|
+
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
8
|
+
NodeTerminalTab: (props: NodeTerminalTabProps) => { captured.props = props; return null },
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('../../api/config', () => ({
|
|
11
|
+
apiUrl: (path: string) => `/api${path}`,
|
|
12
|
+
getWsUrl: (path: string) => path,
|
|
13
|
+
getAuthHeaders: () => ({ Authorization: 'Bearer test' }),
|
|
14
|
+
getCredentialsMode: () => 'include',
|
|
15
|
+
}))
|
|
16
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
17
|
+
|
|
18
|
+
it('passes the created UID to exact-pod cleanup query parameters with keepalive', async () => {
|
|
19
|
+
const pod = { namespace: 'default', podName: 'debug-a', uid: 'uid/a+b', containerName: 'debug' }
|
|
20
|
+
const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify(pod)))
|
|
21
|
+
.mockResolvedValueOnce(new Response('{}'))
|
|
22
|
+
vi.stubGlobal('fetch', fetch)
|
|
23
|
+
renderToString(<NodeTerminalTab nodeName="node/a" />)
|
|
24
|
+
const props = captured.props!
|
|
25
|
+
const result = await props.createNodeDebugPod('node/a')
|
|
26
|
+
expect(result).toEqual(pod)
|
|
27
|
+
await props.cleanupNodeDebugPod('node/a', result)
|
|
28
|
+
const [url, options] = fetch.mock.calls[1]
|
|
29
|
+
const parsed = new URL(url, 'http://localhost')
|
|
30
|
+
expect(parsed.pathname).toBe('/api/nodes/node%2Fa/debug')
|
|
31
|
+
expect(Object.fromEntries(parsed.searchParams)).toEqual({ namespace: 'default', podName: 'debug-a', uid: 'uid/a+b' })
|
|
32
|
+
expect(options).toMatchObject({ method: 'DELETE', keepalive: true, credentials: 'include', headers: { Authorization: 'Bearer test' } })
|
|
33
|
+
expect(options).not.toHaveProperty('body')
|
|
34
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NodeTerminalTab as SharedNodeTerminalTab } from '@skyhook-io/k8s-ui'
|
|
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
3
|
|
|
4
4
|
interface NodeTerminalTabProps {
|
|
@@ -21,14 +21,16 @@ export function NodeTerminalTab({ nodeName, isActive }: NodeTerminalTabProps) {
|
|
|
21
21
|
return response.json()
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const cleanupNodeDebugPod = async (name
|
|
24
|
+
const cleanupNodeDebugPod: SharedNodeTerminalTabProps['cleanupNodeDebugPod'] = async (name, pod) => {
|
|
25
|
+
const query = new URLSearchParams({ namespace: pod.namespace, podName: pod.podName, uid: pod.uid })
|
|
25
26
|
try {
|
|
26
|
-
await fetch(apiUrl(`/nodes/${encodeURIComponent(name)}/debug`), {
|
|
27
|
+
const response = await fetch(apiUrl(`/nodes/${encodeURIComponent(name)}/debug?${query}`), {
|
|
27
28
|
method: 'DELETE',
|
|
28
29
|
credentials: getCredentialsMode(),
|
|
29
30
|
headers: getAuthHeaders(),
|
|
30
31
|
keepalive: true,
|
|
31
32
|
})
|
|
33
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
32
34
|
} catch (err) {
|
|
33
35
|
console.warn(`[NodeTerminal] Failed to cleanup debug pod for node ${name}:`, err)
|
|
34
36
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { WorkloadLogsTab } from './WorkloadLogsTab'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({ targets: [] as Array<{ kind: string; name: string; role?: string; snapshotOnly?: boolean }> }))
|
|
8
|
+
vi.mock('../../api/client', () => ({
|
|
9
|
+
useResource: (_kind: string, _namespace: string, name: string) => ({
|
|
10
|
+
data: { metadata: { uid: name }, spec: { replicatedJobs: [{ name: name === 'first' ? 'workers' : 'leader' }] } },
|
|
11
|
+
}),
|
|
12
|
+
useWorkloadRuns: (_kind: string, namespace: string, name: string) => ({
|
|
13
|
+
data: { collection: 'members', runs: [{ group: 'batch', kind: 'jobs', namespace, name: `${name}-0`, phase: 'Running', active: true }], total: 1 },
|
|
14
|
+
}),
|
|
15
|
+
}))
|
|
16
|
+
vi.mock('../logs/WorkloadLogsViewer', () => ({
|
|
17
|
+
WorkloadLogsViewer: (props: { kind: string; name: string; role?: string; snapshotOnly?: boolean }) => {
|
|
18
|
+
state.targets.push(props)
|
|
19
|
+
return <div data-log-target={`${props.kind}/${props.name}`} />
|
|
20
|
+
},
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
24
|
+
let root: Root
|
|
25
|
+
let element: HTMLDivElement
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
state.targets = []
|
|
28
|
+
element = document.createElement('div')
|
|
29
|
+
document.body.appendChild(element)
|
|
30
|
+
root = createRoot(element)
|
|
31
|
+
})
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await act(async () => root.unmount())
|
|
34
|
+
element.remove()
|
|
35
|
+
})
|
|
36
|
+
async function render(name: string, kind = 'JobSet') {
|
|
37
|
+
await act(async () => root.render(<WorkloadLogsTab namespace="training" workloadKind={kind} workloadName={name} />))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('dock JobSet logs', () => {
|
|
41
|
+
it.each(['JobSet', 'jobsets'])('routes %s to selected member logs and supports role snapshots', async kind => {
|
|
42
|
+
await render('first', kind)
|
|
43
|
+
expect(element.querySelector('[data-log-target="jobs/first-0"]')).not.toBeNull()
|
|
44
|
+
const scope = element.querySelector<HTMLSelectElement>('#jobset-log-scope')!
|
|
45
|
+
await act(async () => {
|
|
46
|
+
scope.value = 'role'
|
|
47
|
+
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
|
48
|
+
})
|
|
49
|
+
expect(state.targets.at(-1)).toMatchObject({ kind: 'jobsets', name: 'first', role: 'workers', snapshotOnly: true })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('resets aggregate scope and role before rendering another JobSet log source', async () => {
|
|
53
|
+
await render('first')
|
|
54
|
+
const scope = element.querySelector<HTMLSelectElement>('#jobset-log-scope')!
|
|
55
|
+
await act(async () => {
|
|
56
|
+
scope.value = 'role'
|
|
57
|
+
scope.dispatchEvent(new Event('change', { bubbles: true }))
|
|
58
|
+
})
|
|
59
|
+
state.targets = []
|
|
60
|
+
await render('second')
|
|
61
|
+
expect(element.querySelector<HTMLSelectElement>('#jobset-log-scope')?.value).toBe('selected')
|
|
62
|
+
expect(element.textContent).not.toContain('workers')
|
|
63
|
+
expect(state.targets.every(target => target.name === 'second-0' && target.role === undefined)).toBe(true)
|
|
64
|
+
expect(element.querySelector('[data-log-target="jobs/second-0"]')).not.toBeNull()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -17,7 +17,8 @@ export function WorkloadLogsTab({
|
|
|
17
17
|
normalizedKind === 'cronworkflow' || normalizedKind === 'cronworkflows' ||
|
|
18
18
|
normalizedKind === 'workflowtemplate' || normalizedKind === 'workflowtemplates' ||
|
|
19
19
|
normalizedKind === 'clusterworkflowtemplate' || normalizedKind === 'clusterworkflowtemplates' ||
|
|
20
|
-
normalizedKind === 'scaledjob' || normalizedKind === 'scaledjobs'
|
|
20
|
+
normalizedKind === 'scaledjob' || normalizedKind === 'scaledjobs' ||
|
|
21
|
+
normalizedKind === 'jobset' || normalizedKind === 'jobsets'
|
|
21
22
|
|
|
22
23
|
return (
|
|
23
24
|
<div className="h-full">
|
|
@@ -5,7 +5,8 @@ import { BatchExecutionFullscreen } from './BatchExecutionView'
|
|
|
5
5
|
vi.mock('../../api/client', () => ({
|
|
6
6
|
useResource: () => ({}),
|
|
7
7
|
useWorkloadPods: () => ({}),
|
|
8
|
-
|
|
8
|
+
useJobSetResources: () => ({ data: undefined, isLoading: false }),
|
|
9
|
+
useWorkloadRuns: () => ({ data: { collection: 'runs', runs: [], total: 0, truncated: false } }),
|
|
9
10
|
}))
|
|
10
11
|
|
|
11
12
|
describe.each(['Job', 'CronJob', 'ScaledJob'])('%s cleanup retention display', (kind) => {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import type { WorkflowExecutionActivity } from '@skyhook-io/k8s-ui/utils/workflow-execution'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
activityPreviewItems,
|
|
5
|
+
selectedRunMissing,
|
|
6
|
+
effectiveDefinitionResource,
|
|
7
|
+
isDirectRunKind,
|
|
8
|
+
resourceTargetForRun,
|
|
9
|
+
retentionHistoryCopy,
|
|
10
|
+
runMessageNeedsDisclosure,
|
|
11
|
+
workflowDefinitionParameters,
|
|
12
|
+
workflowDefinitionTarget,
|
|
13
|
+
workflowRunArguments,
|
|
14
|
+
workloadRunKey,
|
|
15
|
+
} from './BatchExecutionView'
|
|
4
16
|
|
|
5
17
|
function activity(id: string, tone: WorkflowExecutionActivity['tone'] = 'success'): WorkflowExecutionActivity {
|
|
6
18
|
return { id, at: '2026-01-01T00:00:00Z', label: id, tone }
|
|
@@ -168,3 +180,42 @@ describe('direct run identity', () => {
|
|
|
168
180
|
expect(isDirectRunKind('WorkflowTemplate', 'workflows')).toBe(false)
|
|
169
181
|
})
|
|
170
182
|
})
|
|
183
|
+
|
|
184
|
+
describe('JobSet member presentation', () => {
|
|
185
|
+
it('keeps the selected Job as the core Kubernetes intermediate', () => {
|
|
186
|
+
expect(resourceTargetForRun({
|
|
187
|
+
kind: 'jobs',
|
|
188
|
+
group: 'batch',
|
|
189
|
+
namespace: 'training',
|
|
190
|
+
name: 'distributed-workers-2',
|
|
191
|
+
phase: 'Running',
|
|
192
|
+
active: true,
|
|
193
|
+
})).toEqual({
|
|
194
|
+
kind: 'jobs',
|
|
195
|
+
group: 'batch',
|
|
196
|
+
namespace: 'training',
|
|
197
|
+
name: 'distributed-workers-2',
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('selection across a member refresh', () => {
|
|
203
|
+
it('preserves a selected Job that has fallen outside the shown window', () => {
|
|
204
|
+
const shown = [{ kind: 'jobs', group: 'batch', namespace: 'training', name: 'current', phase: 'Running', active: true }]
|
|
205
|
+
expect(selectedRunMissing(shown, 'jobs/training/earlier')).toBe(true)
|
|
206
|
+
expect(selectedRunMissing(shown, 'jobs/training/current')).toBe(false)
|
|
207
|
+
expect(selectedRunMissing([], 'jobs/training/earlier')).toBe(true)
|
|
208
|
+
expect(selectedRunMissing(shown, '')).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
describe('run navigation identity', () => {
|
|
214
|
+
it('uses the reported group and preserves the released selection address', () => {
|
|
215
|
+
const run = { group: 'batch', kind: 'jobs', namespace: 'training', name: 'worker', phase: 'Running', active: true }
|
|
216
|
+
expect(workloadRunKey(run)).toBe('jobs/training/worker')
|
|
217
|
+
expect(resourceTargetForRun(run).group).toBe('batch')
|
|
218
|
+
expect(resourceTargetForRun({ ...run, group: 'batch.volcano.sh' }).group).toBe('batch.volcano.sh')
|
|
219
|
+
expect(resourceTargetForRun({ ...run, group: 'argoproj.io', kind: 'workflows' }).group).toBe('argoproj.io')
|
|
220
|
+
})
|
|
221
|
+
})
|