@skyhook-io/radar-app 1.9.6 → 1.9.7
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 +2 -2
- package/src/api/client.ts +5 -2
- package/src/api/diagnose.ts +12 -0
- package/src/components/diagnose/DiagnoseContext.tsx +38 -6
- package/src/components/diagnose/DiagnoseSurface.test.tsx +70 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +50 -0
- package/src/components/diagnose/InvestigationView.tsx +44 -7
- package/src/components/diagnose/parts.test.tsx +39 -0
- package/src/components/diagnose/parts.tsx +22 -1
- package/src/components/home/HomeView.tsx +6 -1
- package/src/components/workload/WorkloadView.tsx +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.7",
|
|
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",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"prettier": "^3.9.5",
|
|
76
76
|
"react": "^19.2.8",
|
|
77
77
|
"react-dom": "^19.2.8",
|
|
78
|
-
"react-router-dom": "^7.18.
|
|
78
|
+
"react-router-dom": "^7.18.2",
|
|
79
79
|
"tailwind-merge": "^3.6.0",
|
|
80
80
|
"tailwindcss": "^4.3.3",
|
|
81
81
|
"typescript": "^6.0.2",
|
package/src/api/client.ts
CHANGED
|
@@ -313,6 +313,7 @@ export interface DashboardProblem {
|
|
|
313
313
|
ageSeconds: number;
|
|
314
314
|
duration: string;
|
|
315
315
|
durationSeconds: number;
|
|
316
|
+
onsetUnknown?: boolean;
|
|
316
317
|
podCount?: number;
|
|
317
318
|
}
|
|
318
319
|
|
|
@@ -3621,6 +3622,7 @@ export function useUpdateResource() {
|
|
|
3621
3622
|
// Cascade delete preview — shows resources that will be garbage-collected
|
|
3622
3623
|
export interface CascadeDeletePreview {
|
|
3623
3624
|
root: { kind: string; namespace: string; name: string; group?: string };
|
|
3625
|
+
rootResolved: boolean;
|
|
3624
3626
|
dependents: {
|
|
3625
3627
|
kind: string;
|
|
3626
3628
|
namespace: string;
|
|
@@ -3633,13 +3635,14 @@ export function useCascadeDeletePreview(
|
|
|
3633
3635
|
kind: string,
|
|
3634
3636
|
namespace: string,
|
|
3635
3637
|
name: string,
|
|
3638
|
+
group: string | undefined,
|
|
3636
3639
|
enabled: boolean,
|
|
3637
3640
|
) {
|
|
3638
3641
|
return useQuery<CascadeDeletePreview>({
|
|
3639
|
-
queryKey: ["cascade-preview", kind, namespace, name],
|
|
3642
|
+
queryKey: ["cascade-preview", kind, group, namespace, name],
|
|
3640
3643
|
queryFn: () =>
|
|
3641
3644
|
fetchJSON<CascadeDeletePreview>(
|
|
3642
|
-
`/resources/${kind}/${namespace}/${name}/cascade-preview`,
|
|
3645
|
+
`/resources/${kind}/${namespace}/${name}/cascade-preview${group ? `?group=${encodeURIComponent(group)}` : ""}`,
|
|
3643
3646
|
),
|
|
3644
3647
|
enabled,
|
|
3645
3648
|
staleTime: 30_000,
|
package/src/api/diagnose.ts
CHANGED
|
@@ -91,6 +91,9 @@ export interface RunSummary {
|
|
|
91
91
|
kind: string;
|
|
92
92
|
namespace: string;
|
|
93
93
|
name: string;
|
|
94
|
+
/** The issue this session is for, on hosts that key sessions by issue. Always
|
|
95
|
+
* absent from Radar's own backend, which records no issue. */
|
|
96
|
+
issueId?: string;
|
|
94
97
|
context: string;
|
|
95
98
|
agent?: string; // backend CLI that drove this run ("claude"/"codex")
|
|
96
99
|
profile?: ExecutionProfile;
|
|
@@ -144,6 +147,15 @@ export async function createRun(
|
|
|
144
147
|
kind: string;
|
|
145
148
|
namespace: string;
|
|
146
149
|
name: string;
|
|
150
|
+
// Associates the session with the issue it was started from, for hosts that
|
|
151
|
+
// group sessions that way. Inert for Radar's own backend, which neither reads
|
|
152
|
+
// it on start nor emits it on RunSummary — carried so both hosts share one
|
|
153
|
+
// request shape.
|
|
154
|
+
issueId?: string;
|
|
155
|
+
// Start a new session rather than continuing whatever the backend would
|
|
156
|
+
// otherwise hand back for this target. Inert for Radar's own backend, which
|
|
157
|
+
// only ever continues an in-flight run — and that one is never bypassed.
|
|
158
|
+
fresh?: boolean;
|
|
147
159
|
},
|
|
148
160
|
opts?: {
|
|
149
161
|
agent?: string;
|
|
@@ -32,6 +32,14 @@ export interface Target {
|
|
|
32
32
|
kind: string;
|
|
33
33
|
namespace: string;
|
|
34
34
|
name: string;
|
|
35
|
+
/** The issue this investigation is for, when it came from an issue. Hosts
|
|
36
|
+
* that group sessions by issue key on it; the rest carry it and ignore it. */
|
|
37
|
+
issueId?: string;
|
|
38
|
+
/** Start a new session instead of continuing one the backend would otherwise
|
|
39
|
+
* return for this target. Rides here rather than as a separate argument so
|
|
40
|
+
* the consent-deferred path replays one object — a parallel "was it fresh?"
|
|
41
|
+
* state is a thing that can fall out of sync with the target it describes. */
|
|
42
|
+
fresh?: boolean;
|
|
35
43
|
}
|
|
36
44
|
export type DiagnoseView = "home" | "investigation";
|
|
37
45
|
|
|
@@ -66,6 +74,10 @@ interface DiagnoseCtx {
|
|
|
66
74
|
historyDegraded: boolean; // persistence broke — history won't survive a restart
|
|
67
75
|
needsConsent: boolean; // a start is pending the one-time consent
|
|
68
76
|
startError: string | null;
|
|
77
|
+
// Kept apart from startError: the consent card renders this as "why your
|
|
78
|
+
// approval was refused", and a run-start failure landing in the same slot
|
|
79
|
+
// would be read as exactly that — the two paths don't share a lifecycle.
|
|
80
|
+
consentError: string | null;
|
|
69
81
|
openInvestigation: (t: Target) => void;
|
|
70
82
|
openRun: (id: string) => void;
|
|
71
83
|
openHome: () => void;
|
|
@@ -200,6 +212,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
200
212
|
const [historyDegraded, setHistoryDegraded] = useState(false);
|
|
201
213
|
const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
|
|
202
214
|
const [startError, setStartError] = useState<string | null>(null);
|
|
215
|
+
const [consentError, setConsentError] = useState<string | null>(null);
|
|
203
216
|
const [width, setWidth] = useState<number>(() => {
|
|
204
217
|
try {
|
|
205
218
|
const v = Number(localStorage.getItem(WIDTH_KEY));
|
|
@@ -341,7 +354,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
341
354
|
// panel closed — and only re-render when the set actually changes, not every poll.
|
|
342
355
|
const runningSig = runs
|
|
343
356
|
.filter((r) => r.status === "running")
|
|
344
|
-
.map((r) =>
|
|
357
|
+
.map((r) => runTargetKey(r.kind, r.namespace, r.name))
|
|
345
358
|
.sort()
|
|
346
359
|
.join("|");
|
|
347
360
|
const runningKeys = useMemo(
|
|
@@ -398,6 +411,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
398
411
|
const openInvestigation = useCallback(
|
|
399
412
|
(t: Target) => {
|
|
400
413
|
setStartError(null);
|
|
414
|
+
setConsentError(null);
|
|
401
415
|
setOpen(true);
|
|
402
416
|
if (!hosted && !consentSurface) {
|
|
403
417
|
setPendingTarget(null);
|
|
@@ -449,21 +463,29 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
449
463
|
}
|
|
450
464
|
if (id) openRun(id);
|
|
451
465
|
}, [available, openRun]);
|
|
466
|
+
// Leaving the detail pane drops the failure that belonged to it. startError
|
|
467
|
+
// renders as the entire pane (maximized home still shows `detail`), where a
|
|
468
|
+
// message about a resource you just navigated away from has nothing to attach
|
|
469
|
+
// to and no way to be dismissed.
|
|
452
470
|
const openHome = useCallback(() => {
|
|
453
471
|
setView("home");
|
|
472
|
+
setStartError(null);
|
|
454
473
|
setOpen(true);
|
|
455
474
|
}, []);
|
|
456
|
-
const goHome = useCallback(() =>
|
|
475
|
+
const goHome = useCallback(() => {
|
|
476
|
+
setView("home");
|
|
477
|
+
setStartError(null);
|
|
478
|
+
}, []);
|
|
457
479
|
const close = useCallback(() => setOpen(false), []);
|
|
458
480
|
const consentBusyRef = useRef(false);
|
|
459
481
|
const approveConsent = useCallback(() => {
|
|
460
482
|
if (consentBusyRef.current) return;
|
|
461
483
|
if (!consentSurface) {
|
|
462
|
-
|
|
484
|
+
setConsentError("Radar can’t record consent for this agent.");
|
|
463
485
|
return;
|
|
464
486
|
}
|
|
465
487
|
consentBusyRef.current = true;
|
|
466
|
-
|
|
488
|
+
setConsentError(null);
|
|
467
489
|
const t = pendingTarget;
|
|
468
490
|
// The server ENFORCES consent at start, so the acknowledgment must land
|
|
469
491
|
// before the run request — awaiting also makes it durable for the CLI.
|
|
@@ -475,8 +497,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
475
497
|
setPendingTarget(null);
|
|
476
498
|
if (t) startRunRef.current(t);
|
|
477
499
|
})
|
|
478
|
-
.catch(() => {
|
|
479
|
-
|
|
500
|
+
.catch((e) => {
|
|
501
|
+
// The server's message, when it has one. A host that records consent
|
|
502
|
+
// above the individual refuses whoever isn't allowed to grant it, and
|
|
503
|
+
// only its message can say who is — "try again" sends them at something
|
|
504
|
+
// that can never succeed.
|
|
505
|
+
setConsentError(
|
|
506
|
+
e instanceof DiagnoseError && e.message
|
|
507
|
+
? e.message
|
|
508
|
+
: "Couldn't record your consent — try again.",
|
|
509
|
+
);
|
|
480
510
|
})
|
|
481
511
|
.finally(() => {
|
|
482
512
|
consentBusyRef.current = false;
|
|
@@ -484,6 +514,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
484
514
|
}, [consentSurface, pendingTarget]);
|
|
485
515
|
const cancelConsent = useCallback(() => {
|
|
486
516
|
setPendingTarget(null);
|
|
517
|
+
setConsentError(null);
|
|
487
518
|
setOpen(false);
|
|
488
519
|
}, []);
|
|
489
520
|
const dismissError = useCallback(() => setStartError(null), []);
|
|
@@ -517,6 +548,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
517
548
|
// cleared on approve/cancel — so its presence is exactly "consent needed now".
|
|
518
549
|
needsConsent: !!pendingTarget,
|
|
519
550
|
startError,
|
|
551
|
+
consentError,
|
|
520
552
|
openInvestigation,
|
|
521
553
|
openRun,
|
|
522
554
|
openHome,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { canStartNewInvestigation } from "./DiagnoseSurface";
|
|
3
|
+
import type { RunSummary } from "../../api/diagnose";
|
|
4
|
+
|
|
5
|
+
// The "new investigation" button dispatches an agent and spends the user's own
|
|
6
|
+
// tokens, so every one of these clauses is load-bearing rather than cosmetic.
|
|
7
|
+
// Each case below is a way it misfired before the gate existed.
|
|
8
|
+
function run(status: RunSummary["status"]): RunSummary {
|
|
9
|
+
return {
|
|
10
|
+
id: "r1",
|
|
11
|
+
kind: "Deployment",
|
|
12
|
+
namespace: "prod",
|
|
13
|
+
name: "payments",
|
|
14
|
+
context: "prod-cluster",
|
|
15
|
+
status,
|
|
16
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
17
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("canStartNewInvestigation", () => {
|
|
22
|
+
it("offers a new investigation on a finished run", () => {
|
|
23
|
+
expect(canStartNewInvestigation("investigation", run("done"), false)).toBe(
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stays hidden on the investigations list", () => {
|
|
29
|
+
// goHome() leaves activeRunId set, so the header still has a run to read.
|
|
30
|
+
// Without the view check the click starts an agent on a resource the user
|
|
31
|
+
// navigated away from, over a list of unrelated investigations.
|
|
32
|
+
expect(canStartNewInvestigation("home", run("done"), false)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("stays hidden while a turn is in flight", () => {
|
|
36
|
+
// A start would be handed back the live run, so the button does nothing.
|
|
37
|
+
expect(canStartNewInvestigation("investigation", run("running"), false)).toBe(
|
|
38
|
+
false,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("stays hidden on a stale run", () => {
|
|
43
|
+
// The body offers "Re-run on current cluster" WITH the context-changed
|
|
44
|
+
// warning. A bare + carries none of it, at a resource that may not exist in
|
|
45
|
+
// the context it would now run against.
|
|
46
|
+
expect(canStartNewInvestigation("investigation", run("stale"), false)).toBe(
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stays hidden while consent is pending", () => {
|
|
52
|
+
expect(canStartNewInvestigation("investigation", run("done"), true)).toBe(
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("stays hidden with no focused run", () => {
|
|
58
|
+
expect(canStartNewInvestigation("investigation", null, false)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("offers one on an errored or stopped run", () => {
|
|
62
|
+
// Those are the runs a person most wants to start over from.
|
|
63
|
+
expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
|
|
64
|
+
true,
|
|
65
|
+
);
|
|
66
|
+
expect(canStartNewInvestigation("investigation", run("stopped"), false)).toBe(
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
TerminalSquare,
|
|
16
16
|
Copy,
|
|
17
17
|
Check,
|
|
18
|
+
Plus,
|
|
18
19
|
} from "lucide-react";
|
|
19
20
|
import { Tooltip } from "../ui/Tooltip";
|
|
20
21
|
import {
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
useDiagnoseLayout,
|
|
23
24
|
agentLabelFor,
|
|
24
25
|
openDiagnoseSettings,
|
|
26
|
+
type DiagnoseView,
|
|
25
27
|
} from "./DiagnoseContext";
|
|
26
28
|
import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
|
|
27
29
|
import { InvestigationView } from "./InvestigationView";
|
|
@@ -141,6 +143,33 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
|
|
|
141
143
|
// header, right of the nav rail) — App renders it there and passes topInset (the
|
|
142
144
|
// header height; 0 in chromeless embeds). It shares that frame with the resource/
|
|
143
145
|
// Helm drawers, so it no longer floats viewport-fixed or DOM-measures the chrome.
|
|
146
|
+
// Whether the header offers a new investigation on the focused run's resource.
|
|
147
|
+
// Every clause is a failure this button actually had:
|
|
148
|
+
//
|
|
149
|
+
// view goHome() leaves activeRunId set, so the header keeps rendering
|
|
150
|
+
// the last focused run. Without this the button dispatches an
|
|
151
|
+
// agent — real tokens — from a screen showing an unrelated list.
|
|
152
|
+
// run nothing to take a resource from.
|
|
153
|
+
// running a start is handed back the live run, so the click does nothing
|
|
154
|
+
// and the button reads as broken.
|
|
155
|
+
// stale the body already offers "Re-run on current cluster" WITH the
|
|
156
|
+
// warning that the context changed; a bare + carries none of it,
|
|
157
|
+
// and the resource may not exist in the context it'd run against.
|
|
158
|
+
// needsConsent the consent card owns the surface until it's answered.
|
|
159
|
+
export function canStartNewInvestigation(
|
|
160
|
+
view: DiagnoseView,
|
|
161
|
+
run: RunSummary | null,
|
|
162
|
+
needsConsent: boolean,
|
|
163
|
+
): boolean {
|
|
164
|
+
return (
|
|
165
|
+
view === "investigation" &&
|
|
166
|
+
!!run &&
|
|
167
|
+
run.status !== "running" &&
|
|
168
|
+
run.status !== "stale" &&
|
|
169
|
+
!needsConsent
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
144
173
|
export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
145
174
|
const d = useDiagnose();
|
|
146
175
|
// Injected settings action: undefined = Radar's own Settings dialog;
|
|
@@ -223,6 +252,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
223
252
|
profile={d.profile}
|
|
224
253
|
copy={consentCopy}
|
|
225
254
|
onOpenSettings={openSettings ?? undefined}
|
|
255
|
+
error={d.consentError}
|
|
226
256
|
onApprove={d.approveConsent}
|
|
227
257
|
onCancel={d.cancelConsent}
|
|
228
258
|
/>
|
|
@@ -341,6 +371,26 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
|
|
|
341
371
|
</div>
|
|
342
372
|
</div>
|
|
343
373
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
374
|
+
{activeRun &&
|
|
375
|
+
canStartNewInvestigation(d.view, activeRun, d.needsConsent) && (
|
|
376
|
+
<Tooltip content="New investigation on this resource" position="bottom">
|
|
377
|
+
<button
|
|
378
|
+
onClick={() =>
|
|
379
|
+
d.openInvestigation({
|
|
380
|
+
kind: activeRun.kind,
|
|
381
|
+
namespace: activeRun.namespace,
|
|
382
|
+
name: activeRun.name,
|
|
383
|
+
issueId: activeRun.issueId,
|
|
384
|
+
fresh: true,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
388
|
+
aria-label="New investigation on this resource"
|
|
389
|
+
>
|
|
390
|
+
<Plus className="h-4 w-4" />
|
|
391
|
+
</button>
|
|
392
|
+
</Tooltip>
|
|
393
|
+
)}
|
|
344
394
|
{activeRun && <InvestigationMenu run={activeRun} />}
|
|
345
395
|
<Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
|
|
346
396
|
<button
|
|
@@ -46,10 +46,21 @@ export function InvestigationView({
|
|
|
46
46
|
const { kind, namespace, name } = run;
|
|
47
47
|
// Apply is off for hosted agents (read-only server-side). Keyed on the selected
|
|
48
48
|
// agent, which matches run.agent unless a deployment mixes hosted + local agents.
|
|
49
|
-
const { refreshRuns, openInvestigation, startError, hosted } =
|
|
49
|
+
const { refreshRuns, openInvestigation, startError, dismissError, hosted } =
|
|
50
|
+
useDiagnose();
|
|
51
|
+
// Re-run means look again, so it asks for a new session explicitly and only
|
|
52
|
+
// carries the issue forward — being handed the previous answer is the one
|
|
53
|
+
// thing someone clicking this doesn't want.
|
|
50
54
|
const retryDiagnosis = useCallback(
|
|
51
|
-
() =>
|
|
52
|
-
|
|
55
|
+
() =>
|
|
56
|
+
openInvestigation({
|
|
57
|
+
kind,
|
|
58
|
+
namespace,
|
|
59
|
+
name,
|
|
60
|
+
issueId: run.issueId,
|
|
61
|
+
fresh: true,
|
|
62
|
+
}),
|
|
63
|
+
[openInvestigation, kind, namespace, name, run.issueId],
|
|
53
64
|
);
|
|
54
65
|
const queryClient = useQueryClient();
|
|
55
66
|
const [turns, setTurns] = useState<Turn[]>([]);
|
|
@@ -470,7 +481,7 @@ export function InvestigationView({
|
|
|
470
481
|
</span>
|
|
471
482
|
</div>
|
|
472
483
|
<button
|
|
473
|
-
onClick={
|
|
484
|
+
onClick={retryDiagnosis}
|
|
474
485
|
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-amber-500/50 px-2.5 py-1 font-medium text-amber-600 hover:bg-amber-500/10 dark:text-amber-400"
|
|
475
486
|
>
|
|
476
487
|
<Send className="h-3 w-3" />
|
|
@@ -489,7 +500,7 @@ export function InvestigationView({
|
|
|
489
500
|
</span>
|
|
490
501
|
</div>
|
|
491
502
|
<button
|
|
492
|
-
onClick={
|
|
503
|
+
onClick={retryDiagnosis}
|
|
493
504
|
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
|
|
494
505
|
>
|
|
495
506
|
<Send className="h-3 w-3" />
|
|
@@ -525,10 +536,36 @@ export function InvestigationView({
|
|
|
525
536
|
/>
|
|
526
537
|
);
|
|
527
538
|
})}
|
|
528
|
-
{
|
|
539
|
+
{actionError && (
|
|
529
540
|
<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">
|
|
530
541
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
531
|
-
<span>{actionError
|
|
542
|
+
<span>{actionError}</span>
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
{/* A start that failed belongs to the investigation that never began,
|
|
546
|
+
not to the one on screen. Unlabelled at the foot of a finished
|
|
547
|
+
transcript — verdict directly above — it reads as "this
|
|
548
|
+
investigation failed". It also outlives the click that caused it,
|
|
549
|
+
and this is the only place it surfaces while a run is focused, so
|
|
550
|
+
it needs a way out. */}
|
|
551
|
+
{startError && (
|
|
552
|
+
<div
|
|
553
|
+
role="alert"
|
|
554
|
+
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"
|
|
555
|
+
>
|
|
556
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
|
557
|
+
<div className="min-w-0 flex-1">
|
|
558
|
+
<div className="font-medium">
|
|
559
|
+
Couldn't start a new investigation
|
|
560
|
+
</div>
|
|
561
|
+
<div className="text-theme-text-secondary">{startError}</div>
|
|
562
|
+
</div>
|
|
563
|
+
<button
|
|
564
|
+
onClick={dismissError}
|
|
565
|
+
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
566
|
+
>
|
|
567
|
+
Dismiss
|
|
568
|
+
</button>
|
|
532
569
|
</div>
|
|
533
570
|
)}
|
|
534
571
|
</div>
|
|
@@ -123,3 +123,42 @@ describe("ConsentCard execution profile treatment", () => {
|
|
|
123
123
|
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
// The consent card's error box is the ONLY place a refused approval is
|
|
128
|
+
// explained: the card stays mounted on failure, focus doesn't move, and nothing
|
|
129
|
+
// else on screen changes. Red (not the card's own amber) and role="alert", or a
|
|
130
|
+
// screen-reader user re-presses a button the server will never accept.
|
|
131
|
+
describe("ConsentCard error", () => {
|
|
132
|
+
const base = {
|
|
133
|
+
agentName: "Claude",
|
|
134
|
+
profile: "safeguarded" as ExecutionProfile,
|
|
135
|
+
onApprove: noop,
|
|
136
|
+
onCancel: noop,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
it("renders the server's refusal as an alert", () => {
|
|
140
|
+
const html = renderToStaticMarkup(
|
|
141
|
+
<ConsentCard {...base} error="An owner has to allow it once." />,
|
|
142
|
+
);
|
|
143
|
+
expect(html).toContain('role="alert"');
|
|
144
|
+
expect(html).toContain("An owner has to allow it once.");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses the error tone, not the card's warning tone", () => {
|
|
148
|
+
// full-local renders the card itself amber; an amber box on it reads as
|
|
149
|
+
// another paragraph of body copy rather than a failure.
|
|
150
|
+
const html = renderToStaticMarkup(
|
|
151
|
+
<ConsentCard
|
|
152
|
+
{...base}
|
|
153
|
+
profile={"full-local" as ExecutionProfile}
|
|
154
|
+
error="Refused."
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
157
|
+
expect(html).toContain("border-red-500/30");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("renders no alert when the approval hasn't failed", () => {
|
|
161
|
+
const html = renderToStaticMarkup(<ConsentCard {...base} />);
|
|
162
|
+
expect(html).not.toContain('role="alert"');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -649,11 +649,17 @@ function ConsentCardShell({
|
|
|
649
649
|
settingsLabel,
|
|
650
650
|
approveLabel = "Approve & investigate",
|
|
651
651
|
warning = false,
|
|
652
|
+
error,
|
|
652
653
|
onOpenSettings,
|
|
653
654
|
onApprove,
|
|
654
655
|
onCancel,
|
|
655
656
|
}: DiagnoseConsentCopy & {
|
|
656
657
|
warning?: boolean;
|
|
658
|
+
// Why the last approval failed, straight from the server. The card stays up on
|
|
659
|
+
// failure so retrying works in place, which means this is the ONLY chance to
|
|
660
|
+
// say why — a host that records consent above the individual refuses the
|
|
661
|
+
// wrong person here, and only its message knows who to ask.
|
|
662
|
+
error?: string | null;
|
|
657
663
|
onOpenSettings?: () => void;
|
|
658
664
|
onApprove: () => void;
|
|
659
665
|
onCancel: () => void;
|
|
@@ -698,6 +704,19 @@ function ConsentCardShell({
|
|
|
698
704
|
{resolvedSettingsLabel}
|
|
699
705
|
</button>
|
|
700
706
|
)}
|
|
707
|
+
{/* Red, not amber: the full-local card is itself amber, so an amber box on
|
|
708
|
+
it reads as another paragraph of body copy. role="alert" because nothing
|
|
709
|
+
else moves on failure — focus stays on Approve, so without it a screen
|
|
710
|
+
reader says nothing and the user re-presses a button that cannot succeed. */}
|
|
711
|
+
{error && (
|
|
712
|
+
<div
|
|
713
|
+
role="alert"
|
|
714
|
+
className="mt-3 flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-2 text-xs text-theme-text-primary"
|
|
715
|
+
>
|
|
716
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400" />
|
|
717
|
+
<span>{error}</span>
|
|
718
|
+
</div>
|
|
719
|
+
)}
|
|
701
720
|
<div className="mt-4 flex gap-2">
|
|
702
721
|
<button
|
|
703
722
|
onClick={onCancel}
|
|
@@ -731,6 +750,7 @@ export function ConsentCard({
|
|
|
731
750
|
agent,
|
|
732
751
|
profile,
|
|
733
752
|
copy,
|
|
753
|
+
error,
|
|
734
754
|
onOpenSettings,
|
|
735
755
|
onApprove,
|
|
736
756
|
onCancel,
|
|
@@ -739,11 +759,12 @@ export function ConsentCard({
|
|
|
739
759
|
agent?: string;
|
|
740
760
|
profile: ExecutionProfile;
|
|
741
761
|
copy?: DiagnoseConsentCopy;
|
|
762
|
+
error?: string | null;
|
|
742
763
|
onOpenSettings?: () => void;
|
|
743
764
|
onApprove: () => void;
|
|
744
765
|
onCancel: () => void;
|
|
745
766
|
}) {
|
|
746
|
-
const chrome = { onOpenSettings, onApprove, onCancel };
|
|
767
|
+
const chrome = { onOpenSettings, onApprove, onCancel, error };
|
|
747
768
|
|
|
748
769
|
// Tier 1: a host (e.g. radar-hub-web) supplied its own copy — use it verbatim.
|
|
749
770
|
if (copy) return <ConsentCardShell {...copy} {...chrome} />;
|
|
@@ -390,9 +390,14 @@ function ProblemsPanel({
|
|
|
390
390
|
<div className="flex items-center gap-1.5">
|
|
391
391
|
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
|
|
392
392
|
<span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
|
|
393
|
-
{(age || timing) && (
|
|
393
|
+
{(age || timing || issue.onset_unknown) && (
|
|
394
394
|
<span className="ml-auto flex shrink-0 items-center gap-1">
|
|
395
395
|
{age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
|
|
396
|
+
{issue.onset_unknown && (
|
|
397
|
+
<Tooltip content="Radar can confirm this issue is active, but current Kubernetes state does not reveal when it began." delay={100}>
|
|
398
|
+
<span className="text-[10px] text-theme-text-tertiary">Onset unknown</span>
|
|
399
|
+
</Tooltip>
|
|
400
|
+
)}
|
|
396
401
|
{timing && (
|
|
397
402
|
<Tooltip content={timing.tooltip} delay={100}>
|
|
398
403
|
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
@@ -248,7 +248,13 @@ interface WorkloadViewProps {
|
|
|
248
248
|
pushTabHistory?: boolean
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
function useActionsBarProps(
|
|
251
|
+
function useActionsBarProps(
|
|
252
|
+
kind: string,
|
|
253
|
+
namespace: string,
|
|
254
|
+
name: string,
|
|
255
|
+
group: string | undefined,
|
|
256
|
+
cascadeEnabled: boolean,
|
|
257
|
+
) {
|
|
252
258
|
const { showCopied } = useToast()
|
|
253
259
|
const openTerminal = useOpenTerminal()
|
|
254
260
|
const openLogs = useOpenLogs()
|
|
@@ -284,11 +290,16 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
|
|
|
284
290
|
const argoSuspendMutation = useArgoSuspend()
|
|
285
291
|
const argoResumeMutation = useArgoResume()
|
|
286
292
|
|
|
287
|
-
const {
|
|
293
|
+
const {
|
|
294
|
+
data: cascadePreview,
|
|
295
|
+
isLoading: cascadeLoading,
|
|
296
|
+
isError: cascadeError,
|
|
297
|
+
} = useCascadeDeletePreview(
|
|
288
298
|
kind,
|
|
289
299
|
namespace,
|
|
290
300
|
name,
|
|
291
|
-
|
|
301
|
+
group,
|
|
302
|
+
cascadeEnabled,
|
|
292
303
|
)
|
|
293
304
|
|
|
294
305
|
const canNodeWrite = useCanNodeWrite()
|
|
@@ -327,6 +338,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
|
|
|
327
338
|
isDeleting: deleteMutation.isPending,
|
|
328
339
|
cascadeDependents: cascadePreview?.dependents,
|
|
329
340
|
cascadeLoading,
|
|
341
|
+
cascadeRootResolved: cascadeError ? false : cascadePreview?.rootResolved,
|
|
330
342
|
onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
|
|
331
343
|
restartWorkloadMutation.mutate(params),
|
|
332
344
|
isRestarting: restartWorkloadMutation.isPending,
|
|
@@ -675,7 +687,13 @@ export function WorkloadView({
|
|
|
675
687
|
)
|
|
676
688
|
const updateResource = useUpdateResource()
|
|
677
689
|
const previewResources = usePreviewResources()
|
|
678
|
-
const baseActionsBarProps = useActionsBarProps(
|
|
690
|
+
const baseActionsBarProps = useActionsBarProps(
|
|
691
|
+
apiKind,
|
|
692
|
+
namespace,
|
|
693
|
+
name,
|
|
694
|
+
effectiveGroup,
|
|
695
|
+
!resourceLoading && Boolean(resource),
|
|
696
|
+
)
|
|
679
697
|
const desktopDownload = useDesktopDownload()
|
|
680
698
|
|
|
681
699
|
// Live Operational Issues for this resource. Fetched here (not inside the lead
|