@skyhook-io/radar-app 1.9.0 → 1.9.1
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/package.json +7 -7
- package/src/App.tsx +27 -9
- package/src/api/client.ts +21 -6
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +10 -15
- package/src/components/diagnose/AISettings.tsx +21 -7
- package/src/components/diagnose/DiagnoseContext.tsx +82 -53
- package/src/components/diagnose/DiagnoseSurface.tsx +13 -8
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- package/src/components/resources/ResourcesView.tsx +9 -8
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/TimelineView.tsx +17 -3
- package/src/context/DiagnoseCustomization.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.10.0",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@eslint/js": "^10.0.1",
|
|
57
|
-
"@playwright/test": "^1.
|
|
57
|
+
"@playwright/test": "^1.62.0",
|
|
58
58
|
"@skyhook-io/k8s-ui": "*",
|
|
59
59
|
"@tailwindcss/typography": "^0.5.20",
|
|
60
|
-
"@tailwindcss/vite": "^4.3.
|
|
61
|
-
"@tanstack/react-query": "^5.101.
|
|
60
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
61
|
+
"@tanstack/react-query": "^5.101.4",
|
|
62
62
|
"@types/node": "^26.1.1",
|
|
63
63
|
"@types/react": "^19.2.17",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
|
-
"@vitejs/plugin-react": "^6.0.
|
|
65
|
+
"@vitejs/plugin-react": "^6.0.4",
|
|
66
66
|
"@xyflow/react": "^12.11.2",
|
|
67
67
|
"clsx": "^2.1.1",
|
|
68
68
|
"elkjs": "^0.11.1",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"react-dom": "^19.2.7",
|
|
78
78
|
"react-router-dom": "^7.18.1",
|
|
79
79
|
"tailwind-merge": "^3.6.0",
|
|
80
|
-
"tailwindcss": "^4.3.
|
|
80
|
+
"tailwindcss": "^4.3.3",
|
|
81
81
|
"typescript": "^6.0.2",
|
|
82
82
|
"typescript-eslint": "^8.62.0",
|
|
83
83
|
"vite": "^8.1.5"
|
package/src/App.tsx
CHANGED
|
@@ -50,7 +50,7 @@ import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
|
50
50
|
import { useEventSource } from './hooks/useEventSource'
|
|
51
51
|
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
52
52
|
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
53
|
-
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
53
|
+
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from './api/config'
|
|
54
54
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
|
|
55
55
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
56
56
|
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
@@ -60,7 +60,7 @@ import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search,
|
|
|
60
60
|
import { useTheme } from './context/ThemeContext'
|
|
61
61
|
import { Tooltip } from './components/ui/Tooltip'
|
|
62
62
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
63
|
-
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
63
|
+
import { SettingsDialog, type SettingsSectionId } from './components/settings/SettingsDialog'
|
|
64
64
|
import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
65
65
|
import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
|
|
66
66
|
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
@@ -343,12 +343,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
343
343
|
// Auth check — detect if auth is enabled but user is not authenticated
|
|
344
344
|
const { data: authMe, isPending: authMePending } = useAuthMe()
|
|
345
345
|
|
|
346
|
-
// Restore navigation path after session-expiry re-auth redirect
|
|
346
|
+
// Restore navigation path after session-expiry re-auth redirect.
|
|
347
|
+
// The stored value is basename-relative, but strip defensively anyway:
|
|
348
|
+
// sessionStorage outlives app upgrades, so a value written by an older
|
|
349
|
+
// version may still carry the basename — and navigate() re-applies the
|
|
350
|
+
// basename, which would double it (/c/abc/c/abc/...). Trade-off: if a host
|
|
351
|
+
// mounts the app at a basename that exactly equals an internal route (e.g.
|
|
352
|
+
// /topology), this second strip eats the route and re-auth lands on home —
|
|
353
|
+
// accepted, since a wrong-but-valid landing beats a doubled URL.
|
|
347
354
|
useEffect(() => {
|
|
348
355
|
const returnPath = sessionStorage.getItem('radar_return_path')
|
|
349
356
|
if (returnPath) {
|
|
350
357
|
sessionStorage.removeItem('radar_return_path')
|
|
351
|
-
navigate(returnPath, { replace: true })
|
|
358
|
+
navigate(stripBasename(returnPath), { replace: true })
|
|
352
359
|
}
|
|
353
360
|
}, [navigate])
|
|
354
361
|
|
|
@@ -507,13 +514,23 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
507
514
|
|
|
508
515
|
// Settings dialog state
|
|
509
516
|
const [showSettings, setShowSettings] = useState(false)
|
|
517
|
+
const [settingsSection, setSettingsSection] = useState<SettingsSectionId>('overview')
|
|
518
|
+
const openSettings = useCallback((section: SettingsSectionId = 'overview') => {
|
|
519
|
+
setSettingsSection(section)
|
|
520
|
+
setShowSettings(true)
|
|
521
|
+
}, [])
|
|
510
522
|
|
|
511
523
|
// Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
|
|
512
524
|
useEffect(() => {
|
|
513
|
-
const handler = () =>
|
|
525
|
+
const handler = (event: Event) => {
|
|
526
|
+
const section =
|
|
527
|
+
(event as CustomEvent<{ section?: SettingsSectionId }>).detail?.section ??
|
|
528
|
+
'overview'
|
|
529
|
+
openSettings(section)
|
|
530
|
+
}
|
|
514
531
|
window.addEventListener('radar:open-settings', handler)
|
|
515
532
|
return () => window.removeEventListener('radar:open-settings', handler)
|
|
516
|
-
}, [])
|
|
533
|
+
}, [openSettings])
|
|
517
534
|
|
|
518
535
|
// Listen for "open-local-terminal" DOM event — the AI surface is portaled above
|
|
519
536
|
// the DockProvider, so it can't call useOpenLocalTerminal directly; it dispatches
|
|
@@ -853,7 +870,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
853
870
|
description: 'Open settings',
|
|
854
871
|
category: 'General' as const,
|
|
855
872
|
scope: 'global' as const,
|
|
856
|
-
handler: () =>
|
|
873
|
+
handler: () => openSettings(),
|
|
857
874
|
}]
|
|
858
875
|
: []),
|
|
859
876
|
])
|
|
@@ -1599,7 +1616,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1599
1616
|
pinned={navRailEffectivePinned}
|
|
1600
1617
|
onTogglePinned={toggleNavRailPinned}
|
|
1601
1618
|
showPinToggle={!railForcedSlim}
|
|
1602
|
-
onOpenSettings={() =>
|
|
1619
|
+
onOpenSettings={() => openSettings()}
|
|
1603
1620
|
accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
|
|
1604
1621
|
/>
|
|
1605
1622
|
)}
|
|
@@ -2186,7 +2203,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2186
2203
|
navigateToResource(resource)
|
|
2187
2204
|
}}
|
|
2188
2205
|
onClearNamespaces={clearAllNamespaces}
|
|
2189
|
-
onOpenSettings={() =>
|
|
2206
|
+
onOpenSettings={() => openSettings()}
|
|
2190
2207
|
/>
|
|
2191
2208
|
)}
|
|
2192
2209
|
|
|
@@ -2417,6 +2434,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2417
2434
|
{/* Settings dialog — My permissions is rendered inline in its own section */}
|
|
2418
2435
|
<SettingsDialog
|
|
2419
2436
|
open={showSettings}
|
|
2437
|
+
initialSection={settingsSection}
|
|
2420
2438
|
onClose={() => setShowSettings(false)}
|
|
2421
2439
|
/>
|
|
2422
2440
|
|
package/src/api/client.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type {
|
|
|
42
42
|
ArgoRevisionMetadata,
|
|
43
43
|
} from '../types'
|
|
44
44
|
import type { GitOpsOperationResponse } from '../types/gitops'
|
|
45
|
-
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
|
|
45
|
+
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
|
|
46
46
|
import { pluralToKind } from '../utils/navigation'
|
|
47
47
|
|
|
48
48
|
// Auto-refresh cadences (ms) — named constants for each polled hook's
|
|
@@ -74,11 +74,13 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
|
|
|
74
74
|
const authPrefix = `${getBasename()}/auth`
|
|
75
75
|
if (response.status === 401 && !window.location.pathname.startsWith(authPrefix)) {
|
|
76
76
|
// Save current location so user returns to where they were after re-auth.
|
|
77
|
+
// Stored basename-relative: the restore path replays it through React
|
|
78
|
+
// Router's navigate(), which re-applies the basename itself.
|
|
77
79
|
// Editor draft is auto-saved by EditableYamlView via sessionStorage.
|
|
78
80
|
try {
|
|
79
81
|
sessionStorage.setItem(
|
|
80
82
|
'radar_return_path',
|
|
81
|
-
window.location.pathname + window.location.search,
|
|
83
|
+
stripBasename(window.location.pathname) + window.location.search,
|
|
82
84
|
)
|
|
83
85
|
} catch {
|
|
84
86
|
/* best-effort */
|
|
@@ -2026,15 +2028,28 @@ export function useNodeMetricsHistory(nodeName: string) {
|
|
|
2026
2028
|
}
|
|
2027
2029
|
|
|
2028
2030
|
// Top metrics types (bulk, for resource table view)
|
|
2031
|
+
export interface ContainerResourceMetrics {
|
|
2032
|
+
name: string
|
|
2033
|
+
cpu: number // nanocores (usage)
|
|
2034
|
+
cpuRequest: number // nanocores
|
|
2035
|
+
cpuLimit: number // nanocores
|
|
2036
|
+
memory: number // bytes (usage)
|
|
2037
|
+
memoryRequest: number // bytes
|
|
2038
|
+
memoryLimit: number // bytes
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2029
2041
|
export interface TopPodMetrics {
|
|
2030
2042
|
namespace: string
|
|
2031
2043
|
name: string
|
|
2032
2044
|
cpu: number // nanocores (usage)
|
|
2033
2045
|
memory: number // bytes (usage)
|
|
2034
|
-
cpuRequest: number // nanocores (sum across containers)
|
|
2035
|
-
cpuLimit: number // nanocores (sum across containers)
|
|
2036
|
-
memoryRequest: number // bytes (sum across containers)
|
|
2037
|
-
memoryLimit: number // bytes (sum across containers)
|
|
2046
|
+
cpuRequest: number // nanocores (sum across running containers)
|
|
2047
|
+
cpuLimit: number // nanocores (sum across running containers)
|
|
2048
|
+
memoryRequest: number // bytes (sum across running containers)
|
|
2049
|
+
memoryLimit: number // bytes (sum across running containers)
|
|
2050
|
+
// Per-container breakdown; present only for pods with more than one running
|
|
2051
|
+
// container (regular + native sidecars). Absent for single-container pods.
|
|
2052
|
+
containers?: ContainerResourceMetrics[]
|
|
2038
2053
|
}
|
|
2039
2054
|
|
|
2040
2055
|
export interface TopNodeMetrics {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { setBasename, stripBasename, routePath } from './config'
|
|
4
|
+
|
|
5
|
+
// setBasename mutates module state — restore standalone default after each test.
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
setBasename('')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('stripBasename', () => {
|
|
11
|
+
it('passes paths through unchanged when no basename is configured', () => {
|
|
12
|
+
expect(stripBasename('/resources/pods')).toBe('/resources/pods')
|
|
13
|
+
expect(stripBasename('/')).toBe('/')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('strips a configured basename prefix', () => {
|
|
17
|
+
setBasename('/c/abc')
|
|
18
|
+
expect(stripBasename('/c/abc/resources/pods')).toBe('/resources/pods')
|
|
19
|
+
expect(stripBasename('/c/abc/timeline?range=1h')).toBe('/timeline?range=1h')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('maps the bare basename to root', () => {
|
|
23
|
+
setBasename('/c/abc')
|
|
24
|
+
expect(stripBasename('/c/abc')).toBe('/')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('strips when the basename is followed directly by a query string', () => {
|
|
28
|
+
setBasename('/c/abc')
|
|
29
|
+
expect(stripBasename('/c/abc?tab=events')).toBe('?tab=events')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('leaves basename-relative paths unchanged (idempotent)', () => {
|
|
33
|
+
setBasename('/c/abc')
|
|
34
|
+
expect(stripBasename('/resources/pods')).toBe('/resources/pods')
|
|
35
|
+
expect(stripBasename(stripBasename('/c/abc/resources/pods'))).toBe('/resources/pods')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does not strip a path that merely shares a string prefix', () => {
|
|
39
|
+
setBasename('/c/abc')
|
|
40
|
+
expect(stripBasename('/c/abcdef/resources')).toBe('/c/abcdef/resources')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('inverts routePath', () => {
|
|
44
|
+
setBasename('/c/abc')
|
|
45
|
+
expect(stripBasename(routePath('/auth/login'))).toBe('/auth/login')
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/api/config.ts
CHANGED
|
@@ -64,6 +64,21 @@ export function routePath(path: string): string {
|
|
|
64
64
|
return prefix + path;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Inverse of `routePath`: strips the configured basename from a window-level
|
|
69
|
+
* path so it can be handed to React Router's navigate(), which re-applies the
|
|
70
|
+
* basename. Passing an already-prefixed path to navigate() doubles the prefix
|
|
71
|
+
* (e.g. /c/abc/c/abc/...). Basename-relative paths pass through unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export function stripBasename(path: string): string {
|
|
74
|
+
const prefix = basename;
|
|
75
|
+
if (!prefix) return path;
|
|
76
|
+
if (path === prefix || path.startsWith(prefix + '/') || path.startsWith(prefix + '?')) {
|
|
77
|
+
return path.slice(prefix.length) || '/';
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
/**
|
|
68
83
|
* Builds a WebSocket URL for a given API path.
|
|
69
84
|
*
|
package/src/api/diagnose.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface AgentInfo {
|
|
|
10
10
|
version: string;
|
|
11
11
|
present: boolean;
|
|
12
12
|
supported: boolean;
|
|
13
|
+
profiles?: ExecutionProfile[];
|
|
14
|
+
consentSurfaces?: Partial<Record<ExecutionProfile, string>>;
|
|
13
15
|
hosted?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -18,9 +20,11 @@ export interface AgentsResponse {
|
|
|
18
20
|
enabled: boolean;
|
|
19
21
|
// Machine-scoped consent per disclosure surface, recorded server-side
|
|
20
22
|
// (~/.radar) — one acknowledgment covers the web panel and the CLI.
|
|
21
|
-
consented?:
|
|
23
|
+
consented?: Record<string, boolean>;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
export type ExecutionProfile = "safeguarded" | "full-local";
|
|
27
|
+
|
|
24
28
|
export interface DiagnoseStep {
|
|
25
29
|
id: string;
|
|
26
30
|
tool: string;
|
|
@@ -64,14 +68,7 @@ export interface ResourceHealthSignal {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
export interface DiagnoseStreamEvent {
|
|
67
|
-
type:
|
|
68
|
-
| "turn"
|
|
69
|
-
| "phase"
|
|
70
|
-
| "step"
|
|
71
|
-
| "thinking"
|
|
72
|
-
| "done"
|
|
73
|
-
| "error"
|
|
74
|
-
| "closed";
|
|
71
|
+
type: "turn" | "phase" | "step" | "thinking" | "done" | "error" | "closed";
|
|
75
72
|
phase?: string;
|
|
76
73
|
step?: DiagnoseStep;
|
|
77
74
|
token?: string;
|
|
@@ -91,7 +88,7 @@ export interface RunSummary {
|
|
|
91
88
|
name: string;
|
|
92
89
|
context: string;
|
|
93
90
|
agent?: string; // backend CLI that drove this run ("claude"/"codex")
|
|
94
|
-
|
|
91
|
+
profile?: ExecutionProfile;
|
|
95
92
|
model?: string;
|
|
96
93
|
effort?: string;
|
|
97
94
|
managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
|
|
@@ -145,7 +142,7 @@ export async function createRun(
|
|
|
145
142
|
},
|
|
146
143
|
opts?: {
|
|
147
144
|
agent?: string;
|
|
148
|
-
|
|
145
|
+
profile?: ExecutionProfile;
|
|
149
146
|
model?: string;
|
|
150
147
|
effort?: string;
|
|
151
148
|
},
|
|
@@ -179,10 +176,8 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
|
|
|
179
176
|
return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
|
|
180
177
|
}
|
|
181
178
|
|
|
182
|
-
// recordConsent acknowledges the current disclosure for
|
|
183
|
-
export async function recordConsent(
|
|
184
|
-
surface: "standard" | "cursor",
|
|
185
|
-
): Promise<void> {
|
|
179
|
+
// recordConsent acknowledges the current disclosure for an execution profile, server-side.
|
|
180
|
+
export async function recordConsent(surface: string): Promise<void> {
|
|
186
181
|
const res = await fetch(`${getApiBase()}/diagnose/consent`, {
|
|
187
182
|
method: "POST",
|
|
188
183
|
credentials: getCredentialsMode(),
|
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
// Settings tabs' layout — this renders only the controls (no card, no heading).
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { Trash2 } from "lucide-react";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
clearHistory,
|
|
10
|
+
type AgentInfo,
|
|
11
|
+
type ExecutionProfile,
|
|
12
|
+
} from "../../api/diagnose";
|
|
9
13
|
import { AgentControls } from "./parts";
|
|
10
14
|
|
|
11
15
|
export interface AIDraft {
|
|
12
16
|
agent: string;
|
|
13
|
-
|
|
17
|
+
profile: ExecutionProfile;
|
|
14
18
|
model: string;
|
|
15
19
|
effort: string;
|
|
16
20
|
}
|
|
@@ -120,17 +124,27 @@ export function AISettingsSection({
|
|
|
120
124
|
// The agent, its model, and how it runs are all fixed by the host — none
|
|
121
125
|
// of the local BYO-agent knobs apply, so there's nothing to configure.
|
|
122
126
|
<p className="text-xs leading-snug text-theme-text-tertiary">
|
|
123
|
-
{agentLabel} manages the model and how it runs — there's nothing
|
|
124
|
-
configure here.
|
|
127
|
+
{agentLabel} manages the model and how it runs — there's nothing
|
|
128
|
+
to configure here.
|
|
125
129
|
</p>
|
|
126
130
|
) : (
|
|
127
131
|
<AgentControls
|
|
128
132
|
agents={agents}
|
|
129
133
|
selectedAgent={draft.agent}
|
|
130
134
|
// Model + effort are agent-specific; reset them when the agent changes.
|
|
131
|
-
onSelectAgent={(a) =>
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
onSelectAgent={(a) => {
|
|
136
|
+
const nextProfile = agents.find(
|
|
137
|
+
(agent) => agent.name === a,
|
|
138
|
+
)?.profiles?.[0];
|
|
139
|
+
onChange({
|
|
140
|
+
agent: a,
|
|
141
|
+
profile: nextProfile ?? draft.profile,
|
|
142
|
+
model: "",
|
|
143
|
+
effort: "",
|
|
144
|
+
});
|
|
145
|
+
}}
|
|
146
|
+
profile={draft.profile}
|
|
147
|
+
onSetProfile={(v) => onChange({ profile: v })}
|
|
134
148
|
model={draft.model}
|
|
135
149
|
onSetModel={(v) => onChange({ model: v })}
|
|
136
150
|
effort={draft.effort}
|
|
@@ -22,7 +22,11 @@ import {
|
|
|
22
22
|
recordConsent,
|
|
23
23
|
DiagnoseError,
|
|
24
24
|
} from "../../api/diagnose";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type RunSummary,
|
|
27
|
+
type AgentInfo,
|
|
28
|
+
type ExecutionProfile,
|
|
29
|
+
} from "../../api/diagnose";
|
|
26
30
|
|
|
27
31
|
export interface Target {
|
|
28
32
|
kind: string;
|
|
@@ -38,8 +42,8 @@ interface DiagnoseCtx {
|
|
|
38
42
|
agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
|
|
39
43
|
selectedAgent: string; // name of the chosen backend ("claude"/"codex")
|
|
40
44
|
setSelectedAgent: (name: string) => void;
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
profile: ExecutionProfile;
|
|
46
|
+
setProfile: (v: ExecutionProfile) => void;
|
|
43
47
|
model: string; // optional model override ("" = the agent's own default)
|
|
44
48
|
setModel: (v: string) => void;
|
|
45
49
|
effort: string; // optional Codex reasoning effort ("" = default)
|
|
@@ -108,15 +112,10 @@ const WIDTH_KEY = "radar-ai-panel-width";
|
|
|
108
112
|
// Consent is machine-scoped and lives server-side (~/.radar): it gates a
|
|
109
113
|
// machine-scoped action (spawn this machine's agent CLI, persist transcripts to
|
|
110
114
|
// this machine's disk), so one acknowledgment covers this panel AND the
|
|
111
|
-
// `radar diagnose` CLI.
|
|
112
|
-
//
|
|
113
|
-
// approving Claude/Codex never bypasses Cursor's distinct disclosure.
|
|
114
|
-
type ConsentSurface = "standard" | "cursor";
|
|
115
|
-
function consentSurfaceFor(agent: string): ConsentSurface {
|
|
116
|
-
return agent === "cursor-agent" ? "cursor" : "standard";
|
|
117
|
-
}
|
|
115
|
+
// `radar diagnose` CLI. The execution profile selects the disclosure because
|
|
116
|
+
// it determines the actual access available to the agent process.
|
|
118
117
|
const AGENT_KEY = "radar-ai-agent";
|
|
119
|
-
const
|
|
118
|
+
const PROFILE_KEY = "radar-ai-profile";
|
|
120
119
|
const MODEL_KEY = "radar-ai-model";
|
|
121
120
|
const EFFORT_KEY = "radar-ai-effort";
|
|
122
121
|
// Push (reflow the app left) only while the app keeps at least this much width to
|
|
@@ -140,7 +139,9 @@ export function agentLabelFor(name: string, fallbackLabel?: string): string {
|
|
|
140
139
|
// openDiagnoseSettings opens the Settings dialog (App.tsx listens for this DOM
|
|
141
140
|
// event) — the canonical home for AI-diagnosis config.
|
|
142
141
|
export function openDiagnoseSettings() {
|
|
143
|
-
window.dispatchEvent(
|
|
142
|
+
window.dispatchEvent(
|
|
143
|
+
new CustomEvent("radar:open-settings", { detail: { section: "ai" } }),
|
|
144
|
+
);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
function readStored(key: string): string | null {
|
|
@@ -161,14 +162,12 @@ function writeStored(key: string, value: string) {
|
|
|
161
162
|
export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
162
163
|
const [available, setAvailable] = useState(false);
|
|
163
164
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
164
|
-
const [consented, setConsented] = useState<
|
|
165
|
-
Record<ConsentSurface, boolean>
|
|
166
|
-
>({ standard: false, cursor: false });
|
|
165
|
+
const [consented, setConsented] = useState<Record<string, boolean>>({});
|
|
167
166
|
const [selectedAgent, setSelectedAgentState] = useState<string>(
|
|
168
167
|
() => readStored(AGENT_KEY) || "",
|
|
169
168
|
);
|
|
170
|
-
const [
|
|
171
|
-
() => readStored(
|
|
169
|
+
const [profile, setProfileState] = useState<ExecutionProfile>(
|
|
170
|
+
() => (readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded",
|
|
172
171
|
);
|
|
173
172
|
const [model, setModelState] = useState<string>(
|
|
174
173
|
() => readStored(MODEL_KEY) || "",
|
|
@@ -205,12 +204,15 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
205
204
|
fetchAgents()
|
|
206
205
|
.then((r) => {
|
|
207
206
|
if (!live) return;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
207
|
+
setConsented(r.consented ?? {});
|
|
208
|
+
const supported = r.agents.filter(
|
|
209
|
+
(a) =>
|
|
210
|
+
a.supported &&
|
|
211
|
+
(a.hosted ||
|
|
212
|
+
(!!a.profiles?.length &&
|
|
213
|
+
a.profiles.every((p) => !!a.consentSurfaces?.[p]))),
|
|
214
|
+
);
|
|
215
|
+
setAvailable(r.enabled && supported.length > 0);
|
|
214
216
|
setAgents(supported);
|
|
215
217
|
// Keep the stored pick only if it's still installed; else default to the
|
|
216
218
|
// first supported agent (matches the server's default selection).
|
|
@@ -220,6 +222,13 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
220
222
|
? stored
|
|
221
223
|
: (supported[0]?.name ?? "");
|
|
222
224
|
setSelectedAgentState(next);
|
|
225
|
+
const profiles = supported.find((a) => a.name === next)?.profiles ?? [];
|
|
226
|
+
const storedProfile =
|
|
227
|
+
(readStored(PROFILE_KEY) as ExecutionProfile) || "safeguarded";
|
|
228
|
+
if (!profiles.includes(storedProfile) && profiles[0]) {
|
|
229
|
+
setProfileState(profiles[0]);
|
|
230
|
+
writeStored(PROFILE_KEY, profiles[0]);
|
|
231
|
+
}
|
|
223
232
|
// Model/effort are agent-specific; if the stored agent is gone, its values
|
|
224
233
|
// don't apply to the fallback agent (e.g. a Codex slug under Claude) — drop them.
|
|
225
234
|
if (next !== stored) {
|
|
@@ -247,23 +256,35 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
247
256
|
(name: string) => {
|
|
248
257
|
setSelectedAgentState(name);
|
|
249
258
|
writeStored(AGENT_KEY, name);
|
|
259
|
+
const nextProfile = agents.find((agent) => agent.name === name)?.profiles?.[0];
|
|
260
|
+
if (nextProfile) {
|
|
261
|
+
setProfileState(nextProfile);
|
|
262
|
+
writeStored(PROFILE_KEY, nextProfile);
|
|
263
|
+
}
|
|
250
264
|
// Model + effort are agent-specific (Claude aliases vs Codex slugs); reset
|
|
251
265
|
// to the new agent's default rather than carry an invalid value across.
|
|
252
266
|
setModel("");
|
|
253
267
|
setEffort("");
|
|
254
268
|
},
|
|
255
|
-
[setModel, setEffort],
|
|
269
|
+
[agents, setModel, setEffort],
|
|
256
270
|
);
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
writeStored(
|
|
271
|
+
const setProfile = useCallback((v: ExecutionProfile) => {
|
|
272
|
+
setProfileState(v);
|
|
273
|
+
writeStored(PROFILE_KEY, v);
|
|
260
274
|
}, []);
|
|
261
275
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
276
|
+
const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
|
|
277
|
+
const effectiveProfile =
|
|
278
|
+
selectedAgentInfo?.profiles?.includes(profile)
|
|
279
|
+
? profile
|
|
280
|
+
: (selectedAgentInfo?.profiles?.[0] ?? profile);
|
|
281
|
+
const agentLabel = agentLabelFor(selectedAgent, selectedAgentInfo?.label);
|
|
282
|
+
const hosted = !!selectedAgentInfo?.hosted;
|
|
283
|
+
// Hosted Radar uses its existing per-user disclosure surface and supplies its
|
|
284
|
+
// own copy. Local agents use the execution profile as the consent contract.
|
|
285
|
+
const consentSurface = hosted
|
|
286
|
+
? "standard"
|
|
287
|
+
: (selectedAgentInfo?.consentSurfaces?.[effectiveProfile] ?? "");
|
|
267
288
|
|
|
268
289
|
useEffect(() => {
|
|
269
290
|
const onResize = () => setViewportW(window.innerWidth);
|
|
@@ -314,10 +335,6 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
314
335
|
return () => clearInterval(t);
|
|
315
336
|
}, [open, available, hasRunning, refreshRuns]);
|
|
316
337
|
|
|
317
|
-
// Keep the live selected agent reachable from the [] -dep callbacks below
|
|
318
|
-
// (consent is agent-specific, so they must read the CURRENT pick, not a closure).
|
|
319
|
-
const selectedAgentRef = useRef(selectedAgent);
|
|
320
|
-
selectedAgentRef.current = selectedAgent;
|
|
321
338
|
const consentedRef = useRef(consented);
|
|
322
339
|
consentedRef.current = consented;
|
|
323
340
|
|
|
@@ -329,7 +346,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
329
346
|
const seq = ++startSeqRef.current;
|
|
330
347
|
createRun(t, {
|
|
331
348
|
agent: selectedAgent || undefined,
|
|
332
|
-
|
|
349
|
+
profile: hosted ? undefined : effectiveProfile,
|
|
333
350
|
model: model || undefined,
|
|
334
351
|
effort: effort || undefined,
|
|
335
352
|
})
|
|
@@ -351,17 +368,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
351
368
|
});
|
|
352
369
|
};
|
|
353
370
|
|
|
354
|
-
const openInvestigation = useCallback(
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
371
|
+
const openInvestigation = useCallback(
|
|
372
|
+
(t: Target) => {
|
|
373
|
+
setStartError(null);
|
|
374
|
+
setOpen(true);
|
|
375
|
+
if (!hosted && !consentSurface) {
|
|
376
|
+
setPendingTarget(null);
|
|
377
|
+
setStartError("Radar can’t run this agent with a verified execution profile.");
|
|
378
|
+
setView("investigation");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!consentedRef.current[consentSurface]) {
|
|
382
|
+
setPendingTarget(t);
|
|
383
|
+
setView("investigation");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
359
386
|
setView("investigation");
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}, []);
|
|
387
|
+
startRunRef.current(t);
|
|
388
|
+
},
|
|
389
|
+
[consentSurface, hosted],
|
|
390
|
+
);
|
|
365
391
|
const openRun = useCallback((id: string) => {
|
|
366
392
|
setStartError(null);
|
|
367
393
|
setActiveRunId(id);
|
|
@@ -401,15 +427,18 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
401
427
|
const consentBusyRef = useRef(false);
|
|
402
428
|
const approveConsent = useCallback(() => {
|
|
403
429
|
if (consentBusyRef.current) return;
|
|
430
|
+
if (!consentSurface) {
|
|
431
|
+
setStartError("Radar can’t record consent for this agent.");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
404
434
|
consentBusyRef.current = true;
|
|
405
435
|
setStartError(null);
|
|
406
|
-
const surface = consentSurfaceFor(selectedAgentRef.current);
|
|
407
436
|
const t = pendingTarget;
|
|
408
437
|
// The server ENFORCES consent at start, so the acknowledgment must land
|
|
409
438
|
// before the run request — awaiting also makes it durable for the CLI.
|
|
410
|
-
recordConsent(
|
|
439
|
+
recordConsent(consentSurface)
|
|
411
440
|
.then(() => {
|
|
412
|
-
setConsented((prev) => ({ ...prev, [
|
|
441
|
+
setConsented((prev) => ({ ...prev, [consentSurface]: true }));
|
|
413
442
|
// Cleared only on success: a failed write keeps the consent card up
|
|
414
443
|
// (needsConsent = !!pendingTarget) so "try again" works in place.
|
|
415
444
|
setPendingTarget(null);
|
|
@@ -421,7 +450,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
421
450
|
.finally(() => {
|
|
422
451
|
consentBusyRef.current = false;
|
|
423
452
|
});
|
|
424
|
-
}, [pendingTarget]);
|
|
453
|
+
}, [consentSurface, pendingTarget]);
|
|
425
454
|
const cancelConsent = useCallback(() => {
|
|
426
455
|
setPendingTarget(null);
|
|
427
456
|
setOpen(false);
|
|
@@ -440,8 +469,8 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
440
469
|
agents,
|
|
441
470
|
selectedAgent,
|
|
442
471
|
setSelectedAgent,
|
|
443
|
-
|
|
444
|
-
|
|
472
|
+
profile: effectiveProfile,
|
|
473
|
+
setProfile,
|
|
445
474
|
model,
|
|
446
475
|
setModel,
|
|
447
476
|
effort,
|