@skyhook-io/radar-app 1.8.8 → 1.8.10
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 +4 -4
- package/src/App.tsx +11 -11
- package/src/RadarApp.tsx +15 -1
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.ts +0 -10
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +19 -10
- package/src/components/diagnose/InvestigationView.tsx +16 -3
- package/src/components/diagnose/parts.tsx +140 -73
- package/src/components/home/mcpToolCatalog.ts +11 -5
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +56 -5
- package/src/index.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.10",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"diff": "^9.0.0",
|
|
36
36
|
"monaco-editor": "^0.55.1",
|
|
37
37
|
"react-markdown": "^10.1.0",
|
|
38
|
-
"react-virtuoso": "^4.18.
|
|
38
|
+
"react-virtuoso": "^4.18.11",
|
|
39
39
|
"remark-gfm": "^4.0.1",
|
|
40
40
|
"shiki": "^4.2.0",
|
|
41
41
|
"yaml": "^2.9.0"
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"@tailwindcss/typography": "^0.5.20",
|
|
60
60
|
"@tailwindcss/vite": "^4.3.1",
|
|
61
61
|
"@tanstack/react-query": "^5.101.2",
|
|
62
|
-
"@types/node": "^26.
|
|
62
|
+
"@types/node": "^26.1.1",
|
|
63
63
|
"@types/react": "^19.2.17",
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"@vitejs/plugin-react": "^6.0.2",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"tailwindcss": "^4.3.1",
|
|
81
81
|
"typescript": "^6.0.2",
|
|
82
82
|
"typescript-eslint": "^8.62.0",
|
|
83
|
-
"vite": "^8.
|
|
83
|
+
"vite": "^8.1.5"
|
|
84
84
|
},
|
|
85
85
|
"sideEffects": [
|
|
86
86
|
"*.css",
|
package/src/App.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
|
|
|
11
11
|
import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
|
|
12
12
|
import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
13
13
|
import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
14
|
-
import { useAPIResources,
|
|
14
|
+
import { useAPIResources, findAPIResourceForRoute } from './api/apiResources'
|
|
15
15
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
16
16
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
17
17
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
@@ -61,7 +61,7 @@ import { useTheme } from './context/ThemeContext'
|
|
|
61
61
|
import { Tooltip } from './components/ui/Tooltip'
|
|
62
62
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
63
63
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
64
|
-
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
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'
|
|
67
67
|
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
@@ -150,7 +150,8 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
150
150
|
function namespaceFilterDisabled(
|
|
151
151
|
view: ExtendedMainView,
|
|
152
152
|
pathname: string,
|
|
153
|
-
|
|
153
|
+
search = '',
|
|
154
|
+
apiResources?: APIResource[],
|
|
154
155
|
): { disabled: boolean; tooltip?: string } {
|
|
155
156
|
if (
|
|
156
157
|
view === 'cost' &&
|
|
@@ -170,7 +171,8 @@ function namespaceFilterDisabled(
|
|
|
170
171
|
}
|
|
171
172
|
if (view === 'resources') {
|
|
172
173
|
const kindSlug = segments[1]
|
|
173
|
-
const
|
|
174
|
+
const group = new URLSearchParams(search).get('apiGroup') || ''
|
|
175
|
+
const match = kindSlug ? findAPIResourceForRoute(apiResources, kindSlug, group) : undefined
|
|
174
176
|
if (match && !match.namespaced) {
|
|
175
177
|
return {
|
|
176
178
|
disabled: true,
|
|
@@ -185,7 +187,7 @@ function namespaceFilterDisabled(
|
|
|
185
187
|
// correct regardless of which component renders it. A detail drawer that opens
|
|
186
188
|
// over a list (?resource=…) is deliberately NOT titled — it's the same page, so
|
|
187
189
|
// it keeps the list's title.
|
|
188
|
-
function radarPageTitle(pathname: string, search = '', apiResources?:
|
|
190
|
+
function radarPageTitle(pathname: string, search = '', apiResources?: APIResource[]): string | null {
|
|
189
191
|
const decode = (s: string) => {
|
|
190
192
|
try {
|
|
191
193
|
return decodeURIComponent(s)
|
|
@@ -196,7 +198,7 @@ function radarPageTitle(pathname: string, search = '', apiResources?: { name: st
|
|
|
196
198
|
const capitalize = (text: string) =>
|
|
197
199
|
text ? text.charAt(0).toUpperCase() + text.slice(1) : text
|
|
198
200
|
const pluralKindTitle = (kind: string, resourceName: string) =>
|
|
199
|
-
kind.toLowerCase() === resourceName.toLowerCase() ? kind : englishPlural(kind)
|
|
201
|
+
kind.toLowerCase() === resourceName.toLowerCase() || /Metrics$/.test(kind) ? kind : englishPlural(kind)
|
|
200
202
|
const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
|
|
201
203
|
const view = getViewFromPath(pathname)
|
|
202
204
|
|
|
@@ -207,7 +209,8 @@ function radarPageTitle(pathname: string, search = '', apiResources?: { name: st
|
|
|
207
209
|
if (view === 'resources') {
|
|
208
210
|
const resourceName = decode(pathSegments[1] ?? '')
|
|
209
211
|
if (!resourceName) return 'Resources'
|
|
210
|
-
const
|
|
212
|
+
const group = new URLSearchParams(search).get('apiGroup') || ''
|
|
213
|
+
const match = findAPIResourceForRoute(apiResources, resourceName, group)
|
|
211
214
|
return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
|
|
212
215
|
}
|
|
213
216
|
// GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
|
|
@@ -387,10 +390,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
387
390
|
|
|
388
391
|
// View-aware namespace scope: disabled on cluster-scoped surfaces so the
|
|
389
392
|
// chip isn't a dead control next to the cluster switcher.
|
|
390
|
-
|
|
391
|
-
// chip disables immediately on a cluster-scoped core kind (Nodes, PVs, …)
|
|
392
|
-
// instead of flickering enabled until /api-resources resolves.
|
|
393
|
-
const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, navApiResources ?? CORE_RESOURCES)
|
|
393
|
+
const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, location.search, navApiResources)
|
|
394
394
|
|
|
395
395
|
// One URL-derived tab title for every view (see radarPageTitle). Driving it
|
|
396
396
|
// from the URL — not the mounted component. Off unless the host opts in
|
package/src/RadarApp.tsx
CHANGED
|
@@ -40,7 +40,10 @@ import type { ClusterLoadState } from "./types/clusterLoadState";
|
|
|
40
40
|
import { TimelineSourceProvider } from "./context/TimelineSource";
|
|
41
41
|
import type { TimelineSourceConfig } from "./api/timelineSource";
|
|
42
42
|
import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
|
|
43
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
RenderDiagnoseAction,
|
|
45
|
+
DiagnoseConsentCopy,
|
|
46
|
+
} from "./context/DiagnoseCustomization";
|
|
44
47
|
import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
|
|
45
48
|
import { DiagnoseProvider } from "./components/diagnose/DiagnoseContext";
|
|
46
49
|
|
|
@@ -110,6 +113,15 @@ export interface RadarAppProps {
|
|
|
110
113
|
* agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
|
|
111
114
|
*/
|
|
112
115
|
renderDiagnoseAction?: RenderDiagnoseAction;
|
|
116
|
+
/**
|
|
117
|
+
* Replaces the first-run consent card's trust copy. REQUIRED of any host whose
|
|
118
|
+
* backend runs the agent somewhere other than the user's own machine — the
|
|
119
|
+
* default copy states the agent runs locally, under the user's own model
|
|
120
|
+
* account, with transcripts kept on their disk, and none of that is true of a
|
|
121
|
+
* hosted runner. Radar keeps the card's chrome and the Approve/Cancel flow; a
|
|
122
|
+
* host only supplies the claims. See ./context/DiagnoseCustomization.
|
|
123
|
+
*/
|
|
124
|
+
diagnoseConsent?: DiagnoseConsentCopy;
|
|
113
125
|
/**
|
|
114
126
|
* Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
|
|
115
127
|
* deep-link a specific view (e.g. '/topology') without owning the URL bar —
|
|
@@ -184,6 +196,7 @@ export function RadarApp({
|
|
|
184
196
|
manageDocumentTitle = false,
|
|
185
197
|
documentTitleSuffix,
|
|
186
198
|
renderDiagnoseAction,
|
|
199
|
+
diagnoseConsent,
|
|
187
200
|
initialPath,
|
|
188
201
|
onClusterLoadStateChange,
|
|
189
202
|
timelineSource,
|
|
@@ -213,6 +226,7 @@ export function RadarApp({
|
|
|
213
226
|
<TimelineSourceProvider config={timelineSource}>
|
|
214
227
|
<DiagnoseCustomizationProvider
|
|
215
228
|
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
229
|
+
consentCopy={diagnoseConsent}
|
|
216
230
|
>
|
|
217
231
|
<DiagnoseProvider>
|
|
218
232
|
<App
|
package/src/api/apiResources.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { APIResource } from '../types'
|
|
|
3
3
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
|
|
4
4
|
|
|
5
5
|
// Re-export pure functions from package
|
|
6
|
-
export { categorizeResources, CORE_RESOURCES, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
|
|
6
|
+
export { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute, formatGroupName, shortenGroupName, getKindLabel, getKindPlural } from '@skyhook-io/k8s-ui'
|
|
7
7
|
export type { ResourceCategory } from '@skyhook-io/k8s-ui'
|
|
8
8
|
|
|
9
9
|
async function fetchJSON<T>(path: string): Promise<T> {
|
package/src/api/client.ts
CHANGED
|
@@ -255,15 +255,6 @@ export interface DashboardResourceCounts {
|
|
|
255
255
|
restricted?: string[] // Resource kinds the user cannot list due to RBAC
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
export interface DashboardEvent {
|
|
259
|
-
type: string
|
|
260
|
-
reason: string
|
|
261
|
-
message: string
|
|
262
|
-
involvedObject: string
|
|
263
|
-
namespace: string
|
|
264
|
-
timestamp: string
|
|
265
|
-
}
|
|
266
|
-
|
|
267
258
|
export interface DashboardChange {
|
|
268
259
|
kind: string
|
|
269
260
|
namespace: string
|
|
@@ -387,7 +378,6 @@ export interface DashboardResponse {
|
|
|
387
378
|
health: DashboardHealth
|
|
388
379
|
problems: DashboardProblem[]
|
|
389
380
|
resourceCounts: DashboardResourceCounts
|
|
390
|
-
recentEvents: DashboardEvent[]
|
|
391
381
|
recentChanges: DashboardChange[]
|
|
392
382
|
topologySummary: DashboardTopologySummary
|
|
393
383
|
trafficSummary: DashboardTrafficSummary | null
|
|
@@ -361,6 +361,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
361
361
|
startRunRef.current(t);
|
|
362
362
|
}, []);
|
|
363
363
|
const openRun = useCallback((id: string) => {
|
|
364
|
+
setStartError(null);
|
|
364
365
|
setActiveRunId(id);
|
|
365
366
|
setView("investigation");
|
|
366
367
|
setOpen(true);
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
agentLabelFor,
|
|
24
24
|
openDiagnoseSettings,
|
|
25
25
|
} from "./DiagnoseContext";
|
|
26
|
+
import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
|
|
26
27
|
import { InvestigationView } from "./InvestigationView";
|
|
27
28
|
import { RecentList } from "./Home";
|
|
28
29
|
import { ConsentCard } from "./parts";
|
|
@@ -136,6 +137,11 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
136
137
|
// Helm drawers, so it no longer floats viewport-fixed or DOM-measures the chrome.
|
|
137
138
|
export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
138
139
|
const d = useDiagnose();
|
|
140
|
+
// Injected settings action: undefined = Radar's own Settings dialog;
|
|
141
|
+
// null = hide the gear + links.
|
|
142
|
+
const { consentCopy, onOpenSettings: hostOpenSettings } = useDiagnoseCustomization();
|
|
143
|
+
const openSettings =
|
|
144
|
+
hostOpenSettings === undefined ? openDiagnoseSettings : hostOpenSettings;
|
|
139
145
|
const {
|
|
140
146
|
maximized,
|
|
141
147
|
setMaximized,
|
|
@@ -203,7 +209,8 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
203
209
|
agentName={d.agentLabel}
|
|
204
210
|
agent={d.selectedAgent}
|
|
205
211
|
isolated={d.isolated}
|
|
206
|
-
|
|
212
|
+
copy={consentCopy}
|
|
213
|
+
onOpenSettings={openSettings ?? undefined}
|
|
207
214
|
onApprove={d.approveConsent}
|
|
208
215
|
onCancel={d.cancelConsent}
|
|
209
216
|
/>
|
|
@@ -303,15 +310,17 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
303
310
|
</div>
|
|
304
311
|
<div className="flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
305
312
|
<span className="truncate">{configLine}</span>
|
|
306
|
-
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
{openSettings && (
|
|
314
|
+
<Tooltip content="AI settings" position="bottom">
|
|
315
|
+
<button
|
|
316
|
+
onClick={openSettings}
|
|
317
|
+
className="shrink-0 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
|
|
318
|
+
aria-label="AI settings"
|
|
319
|
+
>
|
|
320
|
+
<Settings2 className="h-3 w-3" />
|
|
321
|
+
</button>
|
|
322
|
+
</Tooltip>
|
|
323
|
+
)}
|
|
315
324
|
</div>
|
|
316
325
|
</div>
|
|
317
326
|
</div>
|
|
@@ -44,7 +44,11 @@ export function InvestigationView({
|
|
|
44
44
|
maximized: boolean;
|
|
45
45
|
}) {
|
|
46
46
|
const { kind, namespace, name } = run;
|
|
47
|
-
const { refreshRuns, openInvestigation } = useDiagnose();
|
|
47
|
+
const { refreshRuns, openInvestigation, startError } = useDiagnose();
|
|
48
|
+
const retryDiagnosis = useCallback(
|
|
49
|
+
() => openInvestigation({ kind, namespace, name }),
|
|
50
|
+
[openInvestigation, kind, namespace, name],
|
|
51
|
+
);
|
|
48
52
|
const queryClient = useQueryClient();
|
|
49
53
|
const [turns, setTurns] = useState<Turn[]>([]);
|
|
50
54
|
// The run is gone server-side (evicted past the retention cap, or lost on a
|
|
@@ -505,14 +509,23 @@ export function InvestigationView({
|
|
|
505
509
|
onApply={canApply ? requestApply : undefined}
|
|
506
510
|
onAsk={isLast && !busy && !stale ? askFollowup : undefined}
|
|
507
511
|
onCheckStatus={canCheck ? checkStatus : undefined}
|
|
512
|
+
onRetryDiagnosis={
|
|
513
|
+
isLast &&
|
|
514
|
+
t.status === "error" &&
|
|
515
|
+
!t.question &&
|
|
516
|
+
!t.apply &&
|
|
517
|
+
!stale
|
|
518
|
+
? retryDiagnosis
|
|
519
|
+
: undefined
|
|
520
|
+
}
|
|
508
521
|
hideVerdict={pinned && i === pinnedIdx}
|
|
509
522
|
/>
|
|
510
523
|
);
|
|
511
524
|
})}
|
|
512
|
-
{actionError && (
|
|
525
|
+
{(actionError || startError) && (
|
|
513
526
|
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
|
|
514
527
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
515
|
-
<span>{actionError}</span>
|
|
528
|
+
<span>{actionError || startError}</span>
|
|
516
529
|
</div>
|
|
517
530
|
)}
|
|
518
531
|
</div>
|
|
@@ -22,6 +22,7 @@ import { stringify as toYaml } from "yaml";
|
|
|
22
22
|
import { codeToHtml } from "shiki";
|
|
23
23
|
import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
|
|
24
24
|
import { useTheme } from "../../context/ThemeContext";
|
|
25
|
+
import { type DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
|
|
25
26
|
import {
|
|
26
27
|
type Diagnosis,
|
|
27
28
|
type DiagnoseStep,
|
|
@@ -402,6 +403,7 @@ export function TurnView({
|
|
|
402
403
|
onApply,
|
|
403
404
|
onAsk,
|
|
404
405
|
onCheckStatus,
|
|
406
|
+
onRetryDiagnosis,
|
|
405
407
|
hideVerdict = false,
|
|
406
408
|
}: {
|
|
407
409
|
turn: Turn;
|
|
@@ -410,6 +412,7 @@ export function TurnView({
|
|
|
410
412
|
onApply?: (fix: string) => void;
|
|
411
413
|
onAsk?: (question: string) => void;
|
|
412
414
|
onCheckStatus?: () => void;
|
|
415
|
+
onRetryDiagnosis?: () => void;
|
|
413
416
|
// In the maximized workspace the pinned turn's verdict renders in the side rail,
|
|
414
417
|
// so the transcript suppresses its own copy (reasoning + tool calls still show).
|
|
415
418
|
hideVerdict?: boolean;
|
|
@@ -469,7 +472,18 @@ export function TurnView({
|
|
|
469
472
|
{turn.status === "error" && turn.error && (
|
|
470
473
|
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
|
|
471
474
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
472
|
-
<
|
|
475
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
476
|
+
<span className="whitespace-pre-wrap break-words">{turn.error}</span>
|
|
477
|
+
{onRetryDiagnosis && (
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
onClick={onRetryDiagnosis}
|
|
481
|
+
className="btn-brand self-start px-3 py-1 text-xs"
|
|
482
|
+
>
|
|
483
|
+
Retry diagnosis
|
|
484
|
+
</button>
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
473
487
|
</div>
|
|
474
488
|
)}
|
|
475
489
|
</div>
|
|
@@ -568,95 +582,51 @@ export function RunContextCard({ run }: { run: RunSummary }) {
|
|
|
568
582
|
);
|
|
569
583
|
}
|
|
570
584
|
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
export function ConsentCard({
|
|
581
|
-
agentName,
|
|
582
|
-
agent,
|
|
583
|
-
isolated = true,
|
|
585
|
+
// ConsentCardShell owns the card chrome (icon, layout, settings link, Approve/
|
|
586
|
+
// Cancel) so every copy tier — the OSS default, the hosted fallback, and a host
|
|
587
|
+
// override — feeds the same frame instead of duplicating it.
|
|
588
|
+
function ConsentCardShell({
|
|
589
|
+
title,
|
|
590
|
+
body,
|
|
591
|
+
bullets,
|
|
592
|
+
settingsLabel,
|
|
593
|
+
approveLabel = "Approve & investigate",
|
|
584
594
|
onOpenSettings,
|
|
585
595
|
onApprove,
|
|
586
596
|
onCancel,
|
|
587
|
-
}: {
|
|
588
|
-
agentName: string;
|
|
589
|
-
agent?: string;
|
|
590
|
-
isolated?: boolean;
|
|
597
|
+
}: DiagnoseConsentCopy & {
|
|
591
598
|
onOpenSettings?: () => void;
|
|
592
599
|
onApprove: () => void;
|
|
593
600
|
onCancel: () => void;
|
|
594
601
|
}) {
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
const
|
|
602
|
+
// `settingsLabel: null` hides the link outright — a host with one fixed agent
|
|
603
|
+
// has nothing behind it. `undefined` keeps the OSS default label.
|
|
604
|
+
const resolvedSettingsLabel =
|
|
605
|
+
settingsLabel === undefined
|
|
606
|
+
? "Change the agent and how it runs in Settings"
|
|
607
|
+
: settingsLabel;
|
|
598
608
|
return (
|
|
599
609
|
<div className="rounded-lg border border-theme-border bg-theme-elevated p-4">
|
|
600
610
|
<div className="mb-2 flex items-center gap-2">
|
|
601
611
|
<ShieldCheck className="h-4 w-4 text-accent" />
|
|
602
612
|
<div className="text-sm font-medium text-theme-text-primary">
|
|
603
|
-
{
|
|
604
|
-
? "Run a read-only AI investigation?"
|
|
605
|
-
: "Run an AI investigation?"}
|
|
613
|
+
{title}
|
|
606
614
|
</div>
|
|
607
615
|
</div>
|
|
608
|
-
<
|
|
609
|
-
|
|
610
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
{isolated && !isCursor && (
|
|
618
|
-
<>
|
|
619
|
-
{" "}
|
|
620
|
-
Through Radar the agent can only{" "}
|
|
621
|
-
<span className="font-medium">read</span> — it cannot change your
|
|
622
|
-
cluster.
|
|
623
|
-
</>
|
|
624
|
-
)}
|
|
625
|
-
</p>
|
|
626
|
-
<ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
|
|
627
|
-
{isCursor ? (
|
|
628
|
-
<li>
|
|
629
|
-
• Through Radar the agent only <span className="font-medium">reads</span>{" "}
|
|
630
|
-
your cluster. But Cursor also loads your own global MCP servers and
|
|
631
|
-
Radar can't exclude them (unlike Claude or Codex), so if any of
|
|
632
|
-
those can make changes, Cursor could use them.
|
|
633
|
-
</li>
|
|
634
|
-
) : isolated ? (
|
|
635
|
-
<li>
|
|
636
|
-
• Isolated: only Radar's read-only investigation tools — your
|
|
637
|
-
other CLI config and MCP servers are excluded.
|
|
638
|
-
{agent === "codex" && (
|
|
639
|
-
<>
|
|
640
|
-
{" "}
|
|
641
|
-
Codex's sandboxed shell can still <em>read</em> files on
|
|
642
|
-
your machine (it cannot write or reach the network).
|
|
643
|
-
</>
|
|
644
|
-
)}
|
|
645
|
-
</li>
|
|
646
|
-
) : (
|
|
647
|
-
<li>
|
|
648
|
-
• “My setup”: the agent also runs with your own CLI
|
|
649
|
-
config + MCP servers and can read local files. Only Radar's own
|
|
650
|
-
tools are read-only.
|
|
651
|
-
</li>
|
|
652
|
-
)}
|
|
653
|
-
</ul>
|
|
654
|
-
{onOpenSettings && (
|
|
616
|
+
<div className="text-sm leading-relaxed text-theme-text-secondary">{body}</div>
|
|
617
|
+
{bullets && bullets.length > 0 && (
|
|
618
|
+
<ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
|
|
619
|
+
{bullets.map((b, i) => (
|
|
620
|
+
<li key={i}>• {b}</li>
|
|
621
|
+
))}
|
|
622
|
+
</ul>
|
|
623
|
+
)}
|
|
624
|
+
{onOpenSettings && resolvedSettingsLabel && (
|
|
655
625
|
<button
|
|
656
626
|
onClick={onOpenSettings}
|
|
657
627
|
className="mt-3 text-xs text-accent hover:underline"
|
|
658
628
|
>
|
|
659
|
-
|
|
629
|
+
{resolvedSettingsLabel}
|
|
660
630
|
</button>
|
|
661
631
|
)}
|
|
662
632
|
<div className="mt-4 flex gap-2">
|
|
@@ -670,13 +640,110 @@ export function ConsentCard({
|
|
|
670
640
|
onClick={onApprove}
|
|
671
641
|
className="flex-1 rounded-lg btn-brand py-1.5 text-sm"
|
|
672
642
|
>
|
|
673
|
-
|
|
643
|
+
{approveLabel}
|
|
674
644
|
</button>
|
|
675
645
|
</div>
|
|
676
646
|
</div>
|
|
677
647
|
);
|
|
678
648
|
}
|
|
679
649
|
|
|
650
|
+
// The first-run consent + trust card. The copy is checkable fact about a data
|
|
651
|
+
// flow — not marketing — and the wrong claim is a lie, not a typo. It resolves
|
|
652
|
+
// in two tiers:
|
|
653
|
+
//
|
|
654
|
+
// 1. `copy` — the embedding host tells its own trust story. Only the host
|
|
655
|
+
// knows where its agent runs, whose key pays, and where transcripts live,
|
|
656
|
+
// so any host that runs the agent somewhere other than the OSS local CLI
|
|
657
|
+
// MUST override rather than let Radar assert the local story over its flow.
|
|
658
|
+
// 2. No `copy` — the OSS bring-your-own-local-CLI default, the only tier where
|
|
659
|
+
// "on your machine / no Radar cloud / your account" actually holds.
|
|
660
|
+
export function ConsentCard({
|
|
661
|
+
agentName,
|
|
662
|
+
agent,
|
|
663
|
+
isolated = true,
|
|
664
|
+
copy,
|
|
665
|
+
onOpenSettings,
|
|
666
|
+
onApprove,
|
|
667
|
+
onCancel,
|
|
668
|
+
}: {
|
|
669
|
+
agentName: string;
|
|
670
|
+
agent?: string;
|
|
671
|
+
isolated?: boolean;
|
|
672
|
+
copy?: DiagnoseConsentCopy;
|
|
673
|
+
onOpenSettings?: () => void;
|
|
674
|
+
onApprove: () => void;
|
|
675
|
+
onCancel: () => void;
|
|
676
|
+
}) {
|
|
677
|
+
const chrome = { onOpenSettings, onApprove, onCancel };
|
|
678
|
+
|
|
679
|
+
// Tier 1: a host (e.g. radar-hub-web) supplied its own copy — use it verbatim.
|
|
680
|
+
if (copy) return <ConsentCardShell {...copy} {...chrome} />;
|
|
681
|
+
|
|
682
|
+
// Tier 2: OSS BYO-local default. Cursor can't be isolated (no flag suppresses
|
|
683
|
+
// its global MCP servers), so it gets its own honest framing rather than the
|
|
684
|
+
// isolated/my-setup pair.
|
|
685
|
+
const isCursor = agent === "cursor-agent";
|
|
686
|
+
return (
|
|
687
|
+
<ConsentCardShell
|
|
688
|
+
{...chrome}
|
|
689
|
+
title={
|
|
690
|
+
isolated && !isCursor
|
|
691
|
+
? "Run a read-only AI investigation?"
|
|
692
|
+
: "Run an AI investigation?"
|
|
693
|
+
}
|
|
694
|
+
body={
|
|
695
|
+
<>
|
|
696
|
+
This runs{" "}
|
|
697
|
+
<span className="font-medium text-theme-text-primary">
|
|
698
|
+
your own {agentName}
|
|
699
|
+
</span>{" "}
|
|
700
|
+
on your machine — no Radar cloud, no API key, no account. Radar sends
|
|
701
|
+
this resource's spec, recent events, and pod logs to it (and on to
|
|
702
|
+
its model provider under your account, not to Radar). Transcripts are
|
|
703
|
+
kept in your local Radar history on this machine until cleared.
|
|
704
|
+
{isolated && !isCursor && (
|
|
705
|
+
<>
|
|
706
|
+
{" "}
|
|
707
|
+
Through Radar the agent can only{" "}
|
|
708
|
+
<span className="font-medium">read</span> — it cannot change your
|
|
709
|
+
cluster.
|
|
710
|
+
</>
|
|
711
|
+
)}
|
|
712
|
+
</>
|
|
713
|
+
}
|
|
714
|
+
bullets={[
|
|
715
|
+
isCursor ? (
|
|
716
|
+
<>
|
|
717
|
+
Through Radar the agent only{" "}
|
|
718
|
+
<span className="font-medium">reads</span> your cluster. But Cursor
|
|
719
|
+
also loads your own global MCP servers and Radar can't exclude
|
|
720
|
+
them (unlike Claude or Codex), so if any of those can make changes,
|
|
721
|
+
Cursor could use them.
|
|
722
|
+
</>
|
|
723
|
+
) : isolated ? (
|
|
724
|
+
<>
|
|
725
|
+
Isolated: only Radar's read-only investigation tools — your
|
|
726
|
+
other CLI config and MCP servers are excluded.
|
|
727
|
+
{agent === "codex" && (
|
|
728
|
+
<>
|
|
729
|
+
{" "}
|
|
730
|
+
Codex's sandboxed shell can still <em>read</em> files on
|
|
731
|
+
your machine (it cannot write or reach the network).
|
|
732
|
+
</>
|
|
733
|
+
)}
|
|
734
|
+
</>
|
|
735
|
+
) : (
|
|
736
|
+
<>
|
|
737
|
+
“My setup”: the agent also runs with your own CLI config
|
|
738
|
+
+ MCP servers and can read local files. Only Radar's own tools
|
|
739
|
+
are read-only.
|
|
740
|
+
</>
|
|
741
|
+
),
|
|
742
|
+
]}
|
|
743
|
+
/>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
680
747
|
// The Apply confirmation — wider than a generic confirm so the recommended fix
|
|
681
748
|
// (rendered markdown) is legible, making it unambiguous what the one click does.
|
|
682
749
|
export function ApplyDialog({
|
|
@@ -96,13 +96,13 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
name: 'get_pod_logs',
|
|
99
|
-
desc: '
|
|
99
|
+
desc: 'Pod logs with secret redaction. Without grep, prioritizes errors and warnings and falls back to recent tail lines; grep returns only regex matches instead.',
|
|
100
100
|
params: [
|
|
101
101
|
{ arg: 'namespace', required: true, desc: 'pod namespace' },
|
|
102
102
|
{ arg: 'name', required: true, desc: 'pod name' },
|
|
103
103
|
{ arg: 'container', desc: 'container name (defaults to first)' },
|
|
104
104
|
{ arg: 'tail_lines', desc: 'lines from end (default 200)' },
|
|
105
|
-
{ arg: 'grep', desc: 'regex to
|
|
105
|
+
{ arg: 'grep', desc: 'regex matches to return instead of diagnostic auto-filtering' },
|
|
106
106
|
{ arg: 'since', desc: 'only logs newer than this duration (e.g. 30s, 10m, 1h)' },
|
|
107
107
|
{ arg: 'previous', desc: 'logs from the previous terminated container (CrashLoopBackOff)' },
|
|
108
108
|
],
|
|
@@ -194,11 +194,17 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
194
194
|
},
|
|
195
195
|
{
|
|
196
196
|
name: 'get_subject_permissions',
|
|
197
|
-
desc: 'Effective RBAC for a ServiceAccount, User, or Group
|
|
197
|
+
desc: 'Effective RBAC for a ServiceAccount, User, or Group. Returns the full bindings/rules dump by default, or an authoritative SubjectAccessReview answer when verb and resource are supplied for a ServiceAccount.',
|
|
198
198
|
params: [
|
|
199
199
|
{ arg: 'kind', required: true, desc: 'ServiceAccount, User, or Group' },
|
|
200
200
|
{ arg: 'name', required: true, desc: 'subject name' },
|
|
201
201
|
{ arg: 'namespace', desc: 'required for ServiceAccount; omit for User/Group' },
|
|
202
|
+
{ arg: 'verb', desc: 'access check: Kubernetes API verb; requires resource' },
|
|
203
|
+
{ arg: 'resource', desc: 'access check: plural API resource; requires verb' },
|
|
204
|
+
{ arg: 'group', desc: 'access check: API group; omit for core/v1' },
|
|
205
|
+
{ arg: 'resource_namespace', desc: 'access check target; defaults to subject namespace' },
|
|
206
|
+
{ arg: 'subresource', desc: 'access check subresource, e.g. log for pods/log' },
|
|
207
|
+
{ arg: 'resource_name', desc: 'access check target resource name' },
|
|
202
208
|
],
|
|
203
209
|
},
|
|
204
210
|
{
|
|
@@ -237,14 +243,14 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
237
243
|
},
|
|
238
244
|
{
|
|
239
245
|
name: 'get_workload_logs',
|
|
240
|
-
desc: 'Aggregated
|
|
246
|
+
desc: 'Aggregated logs across all pods of a workload. Without grep, filters for diagnostic relevance; grep returns only matching timestamp-prefixed lines instead.',
|
|
241
247
|
params: [
|
|
242
248
|
{ arg: 'kind', desc: 'deployment (default), statefulset, daemonset, job, or workflow' },
|
|
243
249
|
{ arg: 'namespace', required: true, desc: 'workload namespace' },
|
|
244
250
|
{ arg: 'name', required: true, desc: 'workload name' },
|
|
245
251
|
{ arg: 'container', desc: 'specific container (defaults to all)' },
|
|
246
252
|
{ arg: 'tail_lines', desc: 'lines per pod (default 100)' },
|
|
247
|
-
{ arg: 'grep', desc: 'regex to
|
|
253
|
+
{ arg: 'grep', desc: 'regex matches to return instead of diagnostic auto-filtering' },
|
|
248
254
|
{ arg: 'since', desc: 'only logs newer than this duration' },
|
|
249
255
|
{ arg: 'previous', desc: 'logs from the previous terminated container' },
|
|
250
256
|
],
|
|
@@ -288,7 +288,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
|
|
|
288
288
|
const uncordonMutation = useUncordonNode()
|
|
289
289
|
const drainMutation = useDrainNode()
|
|
290
290
|
|
|
291
|
-
const renderDiagnose = useDiagnoseCustomization()
|
|
291
|
+
const { renderAction: renderDiagnose } = useDiagnoseCustomization()
|
|
292
292
|
|
|
293
293
|
return {
|
|
294
294
|
canExec,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Slot-based injection of a resource-level "Diagnose" action
|
|
1
|
+
// Slot-based injection of a resource-level "Diagnose" action, and of the
|
|
2
|
+
// consent card's trust copy.
|
|
2
3
|
//
|
|
3
4
|
// Lets an embedding host (e.g. Radar Hub) inject a "Diagnose with AI" button
|
|
4
5
|
// into every resource detail action bar — without forking WorkloadView or the
|
|
@@ -8,7 +9,7 @@
|
|
|
8
9
|
//
|
|
9
10
|
// Default (no provider): Radar renders no Diagnose button — OSS stays
|
|
10
11
|
// agent-free.
|
|
11
|
-
import { createContext, useContext } from 'react';
|
|
12
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
12
13
|
import type { ReactNode } from 'react';
|
|
13
14
|
|
|
14
15
|
/** Render prop for the resource-level Diagnose action. */
|
|
@@ -21,22 +22,72 @@ export type RenderDiagnoseAction = (ctx: {
|
|
|
21
22
|
health?: "problem" | "healthy" | "unknown";
|
|
22
23
|
}) => ReactNode;
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Trust copy for the first-run consent card.
|
|
27
|
+
*
|
|
28
|
+
* The card makes concrete, checkable claims about *where* the agent runs,
|
|
29
|
+
* *whose* model account it bills, and *where* the transcript is stored. Those
|
|
30
|
+
* claims are only true of OSS's bring-your-own-local-CLI agent. A host that
|
|
31
|
+
* runs the agent anywhere else (Radar Cloud runs it as a sandboxed Job under a
|
|
32
|
+
* managed key) MUST supply its own copy — shipping OSS's over a different data
|
|
33
|
+
* flow states the opposite of what happens.
|
|
34
|
+
*
|
|
35
|
+
* Radar owns the card's chrome (icon, layout, Approve/Cancel) either way; a
|
|
36
|
+
* host only replaces the claims.
|
|
37
|
+
*/
|
|
38
|
+
export type DiagnoseConsentCopy = {
|
|
39
|
+
title: string;
|
|
40
|
+
body: ReactNode;
|
|
41
|
+
/** Detail list under the body; each entry is rendered as its own "•" row. */
|
|
42
|
+
bullets?: ReactNode[];
|
|
43
|
+
/** Label for the settings link. `null` hides it — for hosts with one fixed
|
|
44
|
+
* agent and no isolation choice, where it would open an empty dialog. */
|
|
45
|
+
settingsLabel?: string | null;
|
|
46
|
+
approveLabel?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// One context for the whole customization seam — the values are host config
|
|
50
|
+
// set once at mount, so per-value re-render isolation buys nothing.
|
|
51
|
+
export interface DiagnoseCustomization {
|
|
52
|
+
renderAction: RenderDiagnoseAction | undefined;
|
|
53
|
+
consentCopy: DiagnoseConsentCopy | undefined;
|
|
54
|
+
// undefined = default (CustomEvent → Radar's own Settings dialog);
|
|
55
|
+
// null = hide the settings affordances.
|
|
56
|
+
onOpenSettings: (() => void) | null | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULTS: DiagnoseCustomization = {
|
|
60
|
+
renderAction: undefined,
|
|
61
|
+
consentCopy: undefined,
|
|
62
|
+
onOpenSettings: undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const DiagnoseCustomizationContext = createContext<DiagnoseCustomization>(DEFAULTS);
|
|
25
66
|
|
|
26
67
|
export function DiagnoseCustomizationProvider({
|
|
27
68
|
value,
|
|
69
|
+
consentCopy,
|
|
70
|
+
onOpenSettings,
|
|
28
71
|
children,
|
|
29
72
|
}: {
|
|
30
73
|
value: RenderDiagnoseAction | undefined;
|
|
74
|
+
consentCopy?: DiagnoseConsentCopy;
|
|
75
|
+
/** Where "AI settings" affordances lead. Omit for Radar's own Settings
|
|
76
|
+
* dialog; pass `null` to hide them. */
|
|
77
|
+
onOpenSettings?: (() => void) | null;
|
|
31
78
|
children: ReactNode;
|
|
32
79
|
}) {
|
|
80
|
+
const ctx = useMemo(
|
|
81
|
+
() => ({ renderAction: value, consentCopy, onOpenSettings }),
|
|
82
|
+
[value, consentCopy, onOpenSettings],
|
|
83
|
+
);
|
|
33
84
|
return (
|
|
34
|
-
<DiagnoseCustomizationContext.Provider value={
|
|
85
|
+
<DiagnoseCustomizationContext.Provider value={ctx}>
|
|
35
86
|
{children}
|
|
36
87
|
</DiagnoseCustomizationContext.Provider>
|
|
37
88
|
);
|
|
38
89
|
}
|
|
39
90
|
|
|
40
|
-
export function useDiagnoseCustomization():
|
|
91
|
+
export function useDiagnoseCustomization(): DiagnoseCustomization {
|
|
41
92
|
return useContext(DiagnoseCustomizationContext);
|
|
42
93
|
}
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,28 @@ export type {
|
|
|
26
26
|
TimelineCoverageSpan,
|
|
27
27
|
TimelineOverviewResult,
|
|
28
28
|
} from './api/timelineSource';
|
|
29
|
-
export type {
|
|
29
|
+
export type {
|
|
30
|
+
RenderDiagnoseAction,
|
|
31
|
+
DiagnoseConsentCopy,
|
|
32
|
+
} from './context/DiagnoseCustomization';
|
|
33
|
+
|
|
34
|
+
// Standalone AI-diagnose surface — mount the investigation panel outside a
|
|
35
|
+
// full <RadarApp>. No router dependency, no client-side cluster state: the
|
|
36
|
+
// backend set via setApiBase() picks the cluster, so hosts remount
|
|
37
|
+
// <DiagnoseProvider key={cluster}> to switch. Mount order: ThemeProvider >
|
|
38
|
+
// DiagnoseCustomizationProvider > DiagnoseProvider > DiagnoseSurface, under a
|
|
39
|
+
// @tanstack/react-query QueryClientProvider.
|
|
40
|
+
export {
|
|
41
|
+
DiagnoseProvider,
|
|
42
|
+
useDiagnose,
|
|
43
|
+
useDiagnoseLayout,
|
|
44
|
+
} from './components/diagnose/DiagnoseContext';
|
|
45
|
+
export type { Target as DiagnoseTarget } from './components/diagnose/DiagnoseContext';
|
|
46
|
+
export { DiagnoseSurface } from './components/diagnose/DiagnoseSurface';
|
|
47
|
+
export { DiagnoseCustomizationProvider } from './context/DiagnoseCustomization';
|
|
48
|
+
// The panel reads Radar's ThemeContext (throws unprovided). It follows
|
|
49
|
+
// localStorage['radar-theme'], so host and panel flip together.
|
|
50
|
+
export { ThemeProvider } from './context/ThemeContext';
|
|
30
51
|
export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
|
|
31
52
|
|
|
32
53
|
// Shared cluster-switcher primitive — re-exported from @skyhook-io/k8s-ui so
|