@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.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.10",
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.0.1",
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.0.12"
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, CORE_RESOURCES } from './api/apiResources'
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
- apiResources?: { name: string; kind: string; namespaced: boolean }[],
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 match = kindSlug ? apiResources?.find(r => r.name === kindSlug) : undefined
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?: { name: string; kind: string; group?: string }[]): string | null {
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 match = apiResources?.find((r) => r.name === resourceName)
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
- // Fall back to the static core-resource list before discovery loads, so the
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 { RenderDiagnoseAction } from "./context/DiagnoseCustomization";
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
@@ -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
- onOpenSettings={openDiagnoseSettings}
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
- <Tooltip content="AI settings" position="bottom">
307
- <button
308
- onClick={openDiagnoseSettings}
309
- className="shrink-0 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
310
- aria-label="AI settings"
311
- >
312
- <Settings2 className="h-3 w-3" />
313
- </button>
314
- </Tooltip>
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
- <span>{turn.error}</span>
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
- // The first-run consent + trust card. Its copy is the OSS BYO-local trust story
572
- // ("your own agent, on your machine, nothing to Radar").
573
- // TODO(cloud): this copy must become embedder-overridable for Radar Cloud, where
574
- // the agent runs in the cloud (the company's self-hosted instance + their key /
575
- // local LLM, OR our SaaS) — a different, honestly-different trust story ("runs in
576
- // your Radar Cloud, audited, managed key"). Plumb an override through the
577
- // DiagnoseCustomization seam (same place Hub overrides the entry button) before
578
- // the k8s-ui lift, so OSS and Cloud don't ship the same claim over different data
579
- // flows. This is also a natural "upgrade to Cloud" surface.
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
- // Cursor can't be isolated (no flag suppresses its global MCP servers), so it
596
- // gets its own honest framing rather than the isolated/my-setup pair.
597
- const isCursor = agent === "cursor-agent";
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
- {isolated && !isCursor
604
- ? "Run a read-only AI investigation?"
605
- : "Run an AI investigation?"}
613
+ {title}
606
614
  </div>
607
615
  </div>
608
- <p className="text-sm leading-relaxed text-theme-text-secondary">
609
- This runs{" "}
610
- <span className="font-medium text-theme-text-primary">
611
- your own {agentName}
612
- </span>{" "}
613
- on your machine — no Radar cloud, no API key, no account. Radar sends
614
- this resource&apos;s spec, recent events, and pod logs to it (and on to
615
- its model provider under your account, not to Radar). Transcripts are
616
- kept in your local Radar history on this machine until cleared.
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&apos;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&apos;s read-only investigation tools — your
637
- other CLI config and MCP servers are excluded.
638
- {agent === "codex" && (
639
- <>
640
- {" "}
641
- Codex&apos;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
- • &ldquo;My setup&rdquo;: the agent also runs with your own CLI
649
- config + MCP servers and can read local files. Only Radar&apos;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
- Change the agent and how it runs in Settings
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
- Approve &amp; investigate
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&apos;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&apos;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&apos;s read-only investigation tools — your
726
+ other CLI config and MCP servers are excluded.
727
+ {agent === "codex" && (
728
+ <>
729
+ {" "}
730
+ Codex&apos;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
+ &ldquo;My setup&rdquo;: the agent also runs with your own CLI config
738
+ + MCP servers and can read local files. Only Radar&apos;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: 'Filtered log lines from a pod, prioritizing errors and warnings, falling back to recent tail lines. Optional grep, since, and previous-container logs.',
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 filter lines, like kubectl logs | grep' },
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: the bindings that grant access, a flattened rule list, and (for SAs) the Pods running as it. Answers "what\'s the blast radius if compromised?".',
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, filtered logs across all pods of a workload (Deployment, StatefulSet, DaemonSet, Job, or Argo Workflow) collected concurrently, filtered for errors/warnings, and deduplicated.',
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 filter lines, like kubectl logs | grep' },
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
- const DiagnoseCustomizationContext = createContext<RenderDiagnoseAction | undefined>(undefined);
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={value}>
85
+ <DiagnoseCustomizationContext.Provider value={ctx}>
35
86
  {children}
36
87
  </DiagnoseCustomizationContext.Provider>
37
88
  );
38
89
  }
39
90
 
40
- export function useDiagnoseCustomization(): RenderDiagnoseAction | undefined {
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 { RenderDiagnoseAction } from './context/DiagnoseCustomization';
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