@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.
Files changed (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -0,0 +1,96 @@
1
+ import { ArrowLeft, ArrowUpRight } from 'lucide-react'
2
+ import { SELF_HOSTED_DOCS_URL, signupUrlFor } from './cloudConnectHandoff'
3
+
4
+ // What the Cloud dialog shows after "Run Radar Cloud yourself…". Running your
5
+ // own control plane starts with a Radar Cloud account, because the Hub signs
6
+ // the Self-Managed license, so this view names that as step 1 of 3 before the
7
+ // click instead of letting a sign-up page surprise someone who came to host
8
+ // it themselves. The link carries intent=self-hosting: the Hub's sign-in page
9
+ // repeats the steps and, after sign-up or sign-in, continues on its
10
+ // Self-hosting page, where the trial, the license and the install command are.
11
+ export function SelfManagedStart({ appUrl, onBack }: { appUrl: string; onBack: () => void }) {
12
+ const signupUrl = `${signupUrlFor(appUrl, 'self-managed-signup')}&intent=self-hosting`
13
+ const steps = [
14
+ {
15
+ title: 'Create your Radar Cloud account',
16
+ body: 'Your license is issued from it. An existing account works too.',
17
+ },
18
+ {
19
+ title: 'Generate your install command',
20
+ body: 'Self-Managed is part of Enterprise, with a 14-day free trial.',
21
+ },
22
+ {
23
+ title: 'Install the control plane, then connect each cluster',
24
+ body: 'One Helm command installs it. Then one command per cluster to connect it.',
25
+ },
26
+ ]
27
+ return (
28
+ <>
29
+ <div className="min-h-0 overflow-y-auto">
30
+ <div className="px-8 pb-5">
31
+ <h3 className="text-[20px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-1.5">
32
+ Run Radar Cloud yourself
33
+ </h3>
34
+ <p className="text-[13px] leading-relaxed text-theme-text-secondary mb-5">
35
+ Run the Radar Cloud control plane in your own infrastructure: the same fleet view, alerts, history
36
+ and team access, and your cluster data never leaves your network.
37
+ </p>
38
+ <ol className="space-y-3.5">
39
+ {steps.map((step, i) => (
40
+ <li key={step.title} className="flex gap-3">
41
+ <span
42
+ className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10.5px] font-semibold ${
43
+ i === 0 ? 'bg-emerald-500 text-emerald-950' : 'border border-theme-border text-theme-text-tertiary'
44
+ }`}
45
+ >
46
+ {i + 1}
47
+ </span>
48
+ <div className="min-w-0">
49
+ <p className={`text-[13px] font-medium ${i === 0 ? 'text-theme-text-primary' : 'text-theme-text-secondary'}`}>
50
+ {step.title}
51
+ </p>
52
+ <p className="text-[12px] leading-relaxed text-theme-text-tertiary">{step.body}</p>
53
+ </div>
54
+ </li>
55
+ ))}
56
+ </ol>
57
+ {/* What a platform engineer has to bring, split by stage: a trial
58
+ needs only an address clusters can reach (a cloud load balancer
59
+ is the install page's default; Postgres and a built-in admin come
60
+ bundled); production adds their own Postgres and IdP. */}
61
+ <div className="mt-5 space-y-1 text-[12px] leading-relaxed text-theme-text-tertiary">
62
+ <p>
63
+ <b className="font-medium text-theme-text-secondary">For a trial</b>, you just need a hostname your
64
+ clusters can reach the hub on.
65
+ </p>
66
+ <p>
67
+ <b className="font-medium text-theme-text-secondary">For production</b>, connect your own Postgres and
68
+ your IdP to manage users.
69
+ </p>
70
+ <a href={SELF_HOSTED_DOCS_URL} target="_blank" rel="noopener noreferrer" className="inline-block underline underline-offset-2 hover:text-theme-text-primary">
71
+ Read the Self-Managed guide →
72
+ </a>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ <div className="shrink-0 px-8 py-4 bg-theme-base border-t border-theme-border flex flex-wrap items-center gap-x-3 gap-y-2.5">
77
+ <button
78
+ onClick={onBack}
79
+ className="inline-flex items-center gap-1.5 whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary transition-colors"
80
+ >
81
+ <ArrowLeft className="w-3.5 h-3.5" />
82
+ Back to Radar Cloud
83
+ </button>
84
+ <a
85
+ href={signupUrl}
86
+ target="_blank"
87
+ rel="noopener noreferrer"
88
+ className="ml-auto inline-flex items-center gap-1.5 whitespace-nowrap px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] transition-all"
89
+ >
90
+ Create your Radar Cloud account
91
+ <ArrowUpRight className="w-4 h-4" />
92
+ </a>
93
+ </div>
94
+ </>
95
+ )
96
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { AppWorkload, Issue } from '@skyhook-io/k8s-ui'
4
+ import { appIssuesForWorkloads } from './ApplicationsView'
5
+
6
+ function workload(group: string): AppWorkload {
7
+ return { kind: 'Job', group, namespace: 'ml', name: 'train', health: 'healthy', ready: 1, desired: 1, restarts: 0 }
8
+ }
9
+
10
+ function issue(id: string, group?: string): Issue {
11
+ return {
12
+ id,
13
+ severity: 'warning',
14
+ source: 'condition',
15
+ category: 'job_failed',
16
+ category_group: 'runtime',
17
+ grouping_scope: 'workload',
18
+ kind: 'Job',
19
+ group,
20
+ namespace: 'ml',
21
+ name: 'train',
22
+ reason: id,
23
+ message: id,
24
+ }
25
+ }
26
+
27
+ describe('appIssuesForWorkloads identity', () => {
28
+ it('does not share an issue between same-named core and Volcano Jobs', () => {
29
+ const issues = [issue('core', 'batch'), issue('volcano', 'batch.volcano.sh')]
30
+
31
+ expect(appIssuesForWorkloads(issues, [workload('batch')]).map(item => item.id)).toEqual(['core'])
32
+ expect(appIssuesForWorkloads(issues, [workload('batch.volcano.sh')]).map(item => item.id)).toEqual(['volcano'])
33
+ })
34
+
35
+ it('treats an omitted built-in group as the canonical built-in identity', () => {
36
+ expect(appIssuesForWorkloads([issue('legacy')], [workload('batch')]).map(item => item.id)).toEqual(['legacy'])
37
+ expect(appIssuesForWorkloads([issue('legacy')], [workload('batch.volcano.sh')])).toEqual([])
38
+ })
39
+ })
@@ -23,6 +23,7 @@ import {
23
23
  memberRef,
24
24
  subjectRef,
25
25
  compareIssueSortAnchors,
26
+ canonicalResourceGroup,
26
27
  type AppRow,
27
28
  type AppWorkload,
28
29
  type AppIdentityInstance,
@@ -490,6 +491,7 @@ function AppDetailRoute({
490
491
  source.kind,
491
492
  source.namespace,
492
493
  source.name,
494
+ source.group,
493
495
  );
494
496
  if (path) navigate(path);
495
497
  return;
@@ -899,7 +901,7 @@ function compareAppOverviewIssues(a: Issue, b: Issue): number {
899
901
  return a.id.localeCompare(b.id);
900
902
  }
901
903
 
902
- function appIssuesForWorkloads(
904
+ export function appIssuesForWorkloads(
903
905
  issues: Issue[],
904
906
  workloads: AppWorkload[],
905
907
  ): Issue[] {
@@ -918,11 +920,11 @@ function appIssuesForWorkloads(
918
920
  }
919
921
 
920
922
  function workloadIssueKey(workload: AppWorkload): string {
921
- return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
923
+ return `${canonicalResourceGroup(workload.kind, workload.group) ?? "?"}|${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
922
924
  }
923
925
 
924
926
  function issueRefKey(ref: IssueResourceRef): string {
925
- return `${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
927
+ return `${canonicalResourceGroup(ref.kind, ref.group) ?? "?"}|${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
926
928
  }
927
929
 
928
930
  function issueRefs(issue: Issue): IssueResourceRef[] {
@@ -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>
@@ -171,7 +171,7 @@ describe('composeAdminNote — an ask, then the link, then the evidence', () =>
171
171
  expect(note).toContain('Please confirm nothing is already installed before a fresh install.')
172
172
  // The object of the request and the answer to "no SaaS", before the evidence.
173
173
  expect(note.indexOf('What it is: Radar, the open-source Kubernetes tool')).toBeLessThan(note.indexOf('Details:'))
174
- expect(note).toContain(`can be self-hosted in-house: ${SELF_HOSTED_DOCS_URL}`)
174
+ expect(note).toContain(`can run in-house as Radar Cloud Self-Managed: ${SELF_HOSTED_DOCS_URL}`)
175
175
  // Never the card's second person.
176
176
  expect(note).not.toMatch(/\byour\b/i)
177
177
  })
@@ -144,7 +144,7 @@ export interface AdminNoteContext {
144
144
  export const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
145
145
  const WHAT_IT_IS = [
146
146
  'What it is: Radar, the open-source Kubernetes tool, installed in the cluster as a Helm chart. It opens an outbound connection to a hosted control plane (nothing listens inbound); Radar Cloud users reach this Radar only through that connection, with the permissions of the ServiceAccount it runs as.',
147
- `If a hosted service is not an option, the same control plane can be self-hosted in-house: ${SELF_HOSTED_DOCS_URL}`,
147
+ `If a hosted service is not an option, the same control plane can run in-house as Radar Cloud Self-Managed: ${SELF_HOSTED_DOCS_URL}`,
148
148
  ].join(' ')
149
149
 
150
150
  export function composeAdminNote(blocked: CloudInstallBlocked, exit: BlockedExit, where: AdminNoteContext = {}): string {
@@ -1099,6 +1099,7 @@ function ToolResultDialog({
1099
1099
  text: string;
1100
1100
  truncated?: boolean;
1101
1101
  }) {
1102
+ const titleId = useId();
1102
1103
  const { theme } = useTheme();
1103
1104
  const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
1104
1105
  const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
@@ -1136,10 +1137,10 @@ function ToolResultDialog({
1136
1137
  };
1137
1138
  }, [open, display, language, theme]);
1138
1139
  return (
1139
- <DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="w-[min(90vw,820px)]">
1140
1141
  <div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
1141
1142
  <div className="min-w-0">
1142
- <div className="truncate font-mono text-sm text-theme-text-primary">
1143
+ <div id={titleId} className="truncate font-mono text-sm text-theme-text-primary">
1143
1144
  {title}
1144
1145
  </div>
1145
1146
  {truncated && (
@@ -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
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useState, useId } from "react";
2
2
  import {
3
3
  CheckCircle2,
4
4
  AlertTriangle,
@@ -41,6 +41,7 @@ export function ApplyDialog({
41
41
  managedBy?: string; // GitOps/Helm owner of the resource, if any
42
42
  confidence?: number;
43
43
  }) {
44
+ const titleId = useId();
44
45
  const fixText = fix?.trim();
45
46
  const lowConfidence = confidence != null && confidence < 0.5;
46
47
  // A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
@@ -56,6 +57,7 @@ export function ApplyDialog({
56
57
  const applyBlocked = !!managedBy && !acked;
57
58
  return (
58
59
  <DialogPortal
60
+ ariaLabelledBy={titleId}
59
61
  open={open}
60
62
  onClose={onClose}
61
63
  className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden"
@@ -65,7 +67,7 @@ export function ApplyDialog({
65
67
  <AlertTriangle className="h-5 w-5 text-amber-500" />
66
68
  </div>
67
69
  <div className="min-w-0 flex-1">
68
- <h3 className="text-lg font-semibold text-theme-text-primary">
70
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">
69
71
  Apply this fix?
70
72
  </h3>
71
73
  <p className="mt-1 text-sm text-theme-text-secondary">
@@ -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,69 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // Screen readers announce a dialog by its name; an unnamed one is read as just
6
+ // "dialog". Every DialogPortal must pass ariaLabelledBy (its visible title) or
7
+ // ariaLabel.
8
+ const repoRoot = join(__dirname, '..', '..', '..')
9
+ const roots = ['web/src', 'packages/k8s-ui/src'].map(r => join(repoRoot, r))
10
+
11
+ function sourceFiles(dir: string): string[] {
12
+ return readdirSync(dir).flatMap(name => {
13
+ const path = join(dir, name)
14
+ if (statSync(path).isDirectory()) return name === 'node_modules' ? [] : sourceFiles(path)
15
+ return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [path] : []
16
+ })
17
+ }
18
+
19
+ // The opening tag ends at the first '>' outside a {…} expression or a string.
20
+ function openingTags(source: string, component: string): string[] {
21
+ const tags: string[] = []
22
+ let from = 0
23
+ for (;;) {
24
+ const start = source.indexOf(`<${component}`, from)
25
+ if (start < 0) return tags
26
+ let depth = 0
27
+ let i = start + component.length + 1
28
+ for (; i < source.length; i++) {
29
+ const c = source[i]
30
+ if (c === '"' || c === "'" || c === '`') {
31
+ i++
32
+ while (i < source.length && source[i] !== c) i += source[i] === '\\' ? 2 : 1
33
+ } else if (c === '{') depth++
34
+ else if (c === '}') depth--
35
+ else if (c === '>' && depth === 0) break
36
+ }
37
+ tags.push(source.slice(start, i + 1))
38
+ from = i
39
+ }
40
+ }
41
+
42
+ function isNamed(tag: string): boolean {
43
+ const withoutStrings = tag.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/gs, '""')
44
+ return /\saria(Label|LabelledBy)\s*=/.test(withoutStrings)
45
+ }
46
+
47
+ describe('dialogs', () => {
48
+ it('every DialogPortal has an accessible name', () => {
49
+ const unnamed = roots.flatMap(sourceFiles).flatMap(file => {
50
+ if (file.endsWith('DialogPortal.tsx')) return []
51
+ return openingTags(readFileSync(file, 'utf8'), 'DialogPortal')
52
+ .filter(tag => /^<DialogPortal[\s>]/.test(tag) && !isNamed(tag))
53
+ .map(() => relative(repoRoot, file))
54
+ })
55
+ expect(unnamed).toEqual([])
56
+ })
57
+
58
+ it('reads attributes, not text that looks like them', () => {
59
+ const [unnamed, spaced, braceInString] = openingTags(
60
+ `<DialogPortal onClose={() => log("ariaLabel=unused")}>` +
61
+ `<DialogPortal ariaLabel = "Named">` +
62
+ `<DialogPortal title={"}"} open>`,
63
+ 'DialogPortal',
64
+ )
65
+ expect(isNamed(unnamed)).toBe(false)
66
+ expect(isNamed(spaced)).toBe(true)
67
+ expect(braceInString).toBe('<DialogPortal title={"}"} open>')
68
+ })
69
+ })
@@ -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
  }