@skyhook-io/radar-app 1.8.8 → 1.8.9
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 +1 -1
- 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 +3 -0
- package/src/components/diagnose/InvestigationView.tsx +16 -3
- package/src/components/diagnose/parts.tsx +140 -73
- package/src/context/DiagnoseCustomization.tsx +36 -2
- package/src/index.ts +4 -1
package/package.json
CHANGED
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 { useDiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
|
|
26
27
|
import { InvestigationView } from "./InvestigationView";
|
|
27
28
|
import { RecentList } from "./Home";
|
|
28
29
|
import { ConsentCard } from "./parts";
|
|
@@ -136,6 +137,7 @@ 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
|
+
const consentCopy = useDiagnoseConsentCopy();
|
|
139
141
|
const {
|
|
140
142
|
maximized,
|
|
141
143
|
setMaximized,
|
|
@@ -203,6 +205,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
203
205
|
agentName={d.agentLabel}
|
|
204
206
|
agent={d.selectedAgent}
|
|
205
207
|
isolated={d.isolated}
|
|
208
|
+
copy={consentCopy}
|
|
206
209
|
onOpenSettings={openDiagnoseSettings}
|
|
207
210
|
onApprove={d.approveConsent}
|
|
208
211
|
onCancel={d.cancelConsent}
|
|
@@ -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({
|
|
@@ -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
|
|
@@ -21,18 +22,47 @@ export type RenderDiagnoseAction = (ctx: {
|
|
|
21
22
|
health?: "problem" | "healthy" | "unknown";
|
|
22
23
|
}) => ReactNode;
|
|
23
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
|
+
|
|
24
49
|
const DiagnoseCustomizationContext = createContext<RenderDiagnoseAction | undefined>(undefined);
|
|
50
|
+
const DiagnoseConsentCopyContext = createContext<DiagnoseConsentCopy | undefined>(undefined);
|
|
25
51
|
|
|
26
52
|
export function DiagnoseCustomizationProvider({
|
|
27
53
|
value,
|
|
54
|
+
consentCopy,
|
|
28
55
|
children,
|
|
29
56
|
}: {
|
|
30
57
|
value: RenderDiagnoseAction | undefined;
|
|
58
|
+
consentCopy?: DiagnoseConsentCopy;
|
|
31
59
|
children: ReactNode;
|
|
32
60
|
}) {
|
|
33
61
|
return (
|
|
34
62
|
<DiagnoseCustomizationContext.Provider value={value}>
|
|
35
|
-
{
|
|
63
|
+
<DiagnoseConsentCopyContext.Provider value={consentCopy}>
|
|
64
|
+
{children}
|
|
65
|
+
</DiagnoseConsentCopyContext.Provider>
|
|
36
66
|
</DiagnoseCustomizationContext.Provider>
|
|
37
67
|
);
|
|
38
68
|
}
|
|
@@ -40,3 +70,7 @@ export function DiagnoseCustomizationProvider({
|
|
|
40
70
|
export function useDiagnoseCustomization(): RenderDiagnoseAction | undefined {
|
|
41
71
|
return useContext(DiagnoseCustomizationContext);
|
|
42
72
|
}
|
|
73
|
+
|
|
74
|
+
export function useDiagnoseConsentCopy(): DiagnoseConsentCopy | undefined {
|
|
75
|
+
return useContext(DiagnoseConsentCopyContext);
|
|
76
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,10 @@ 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';
|
|
30
33
|
export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
|
|
31
34
|
|
|
32
35
|
// Shared cluster-switcher primitive — re-exported from @skyhook-io/k8s-ui so
|