@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.
Files changed (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. 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 canEdit = canAtLeast('owner')
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
- {!canEdit && (
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
- : "Radar still enables the agent CLI’s own sandbox, but that sandbox does not constrain external MCP servers."}{" "}
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&apos;s own sandbox, but
@@ -371,9 +384,17 @@ export function AgentControls({
371
384
  <TextField
372
385
  label="Model"
373
386
  value={model}
374
- placeholder="Default"
387
+ placeholder={
388
+ selectedAgent === "opencode"
389
+ ? "Default (provider/model)"
390
+ : "Default"
391
+ }
375
392
  onChange={onSetModel}
376
- hint="Leave empty for the agent's default, or enter a model identifier it supports."
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&apos;s own sandbox, but that
@@ -179,6 +179,7 @@ const AGENT_LABELS: Record<string, string> = {
179
179
  codex: "Codex",
180
180
  gemini: "Gemini CLI",
181
181
  "cursor-agent": "Cursor Agent",
182
+ opencode: "OpenCode",
182
183
  };
183
184
 
184
185
  export function agentLabelFor(name: string, fallbackLabel?: string): string {
@@ -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: string) => {
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
- useWorkloadRuns: () => ({ data: { runs: [] } }),
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 { activityPreviewItems, effectiveDefinitionResource, isDirectRunKind, retentionHistoryCopy, runMessageNeedsDisclosure, workflowDefinitionParameters, workflowDefinitionTarget, workflowRunArguments } from './BatchExecutionView'
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
+ })