@skyhook-io/radar-app 1.13.3 → 1.13.5
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 +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +17 -1
- package/src/api/client.ts +28 -10
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
- package/src/components/diagnose/InvestigationView.tsx +227 -5
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
- package/src/components/diagnose/investigationEvidence.ts +2246 -166
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +6 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +537 -1
- package/src/components/diagnose/parts.tsx +486 -42
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +8 -5
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -15,6 +15,12 @@ import {
|
|
|
15
15
|
type ReactNode,
|
|
16
16
|
type SetStateAction,
|
|
17
17
|
} from "react";
|
|
18
|
+
import {
|
|
19
|
+
MemoryRouter,
|
|
20
|
+
useInRouterContext,
|
|
21
|
+
useLocation,
|
|
22
|
+
useNavigate,
|
|
23
|
+
} from "react-router-dom";
|
|
18
24
|
import {
|
|
19
25
|
fetchAgents,
|
|
20
26
|
getRun,
|
|
@@ -86,8 +92,12 @@ interface DiagnoseCtx {
|
|
|
86
92
|
openInvestigation: (t: Target) => void;
|
|
87
93
|
openRun: (id: string) => void;
|
|
88
94
|
openHome: () => void;
|
|
95
|
+
openWorkspace: (runID?: string | null) => void;
|
|
96
|
+
restoreWorkspace: () => void;
|
|
97
|
+
canRestoreWorkspace: boolean;
|
|
89
98
|
goHome: () => void;
|
|
90
99
|
close: () => void;
|
|
100
|
+
dismissForNavigation: () => void;
|
|
91
101
|
approveConsent: () => void;
|
|
92
102
|
cancelConsent: () => void;
|
|
93
103
|
refreshRuns: () => void;
|
|
@@ -102,6 +112,7 @@ interface DiagnoseCtx {
|
|
|
102
112
|
interface DiagnoseLayoutCtx {
|
|
103
113
|
open: boolean;
|
|
104
114
|
close: () => void;
|
|
115
|
+
dismissForNavigation: () => void;
|
|
105
116
|
contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
|
|
106
117
|
maximized: boolean;
|
|
107
118
|
setMaximized: Dispatch<SetStateAction<boolean>>;
|
|
@@ -111,6 +122,7 @@ interface DiagnoseLayoutCtx {
|
|
|
111
122
|
panelBounds: { min: number; max: number };
|
|
112
123
|
panelWidthKey: string;
|
|
113
124
|
runningKeys: ReadonlySet<string>; // resources with a live investigation (see runTargetKey)
|
|
125
|
+
runningCount: number;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
const Ctx = createContext<DiagnoseCtx | null>(null);
|
|
@@ -183,39 +195,114 @@ function writeStored(key: string, value: string) {
|
|
|
183
195
|
}
|
|
184
196
|
}
|
|
185
197
|
|
|
186
|
-
|
|
187
|
-
|
|
198
|
+
const WORKSPACE_RETURN_STATE = "investigationWorkspaceReturn";
|
|
199
|
+
const WORKSPACE_RETURN_HISTORY_STEPS_STATE = "investigationReturnHistorySteps";
|
|
200
|
+
const INVALID_WORKSPACE_RUN_ID = "__invalid_workspace_run__";
|
|
201
|
+
|
|
202
|
+
function runIDFromSearch(search: string): string | null {
|
|
188
203
|
try {
|
|
189
|
-
return new URLSearchParams(
|
|
204
|
+
return new URLSearchParams(search).get("ai-run");
|
|
190
205
|
} catch {
|
|
191
206
|
return null;
|
|
192
207
|
}
|
|
193
208
|
}
|
|
194
209
|
|
|
195
|
-
function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
browserURLState: boolean,
|
|
199
|
-
) {
|
|
200
|
-
if (!diagnoseURLStateEnabled(browserURLState)) return;
|
|
210
|
+
export function workspaceRunIDFromPath(pathname: string): string | null {
|
|
211
|
+
const match = pathname.match(/^\/investigations\/([^/]+)\/?$/);
|
|
212
|
+
if (!match) return null;
|
|
201
213
|
try {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
214
|
+
const id = decodeURIComponent(match[1]);
|
|
215
|
+
return id && !id.includes("/") ? id : null;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function isInvestigationWorkspacePath(pathname: string): boolean {
|
|
222
|
+
return /^\/investigations(?:\/.*)?$/.test(pathname);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function investigationWorkspacePath(runID: string | null): string {
|
|
226
|
+
return runID
|
|
227
|
+
? `/investigations/${encodeURIComponent(runID)}`
|
|
228
|
+
: "/investigations";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function investigationWorkspaceSearch(search: string): string {
|
|
232
|
+
const current = new URLSearchParams(search);
|
|
233
|
+
const next = new URLSearchParams();
|
|
234
|
+
const org = current.get("org");
|
|
235
|
+
if (org) next.set("org", org);
|
|
236
|
+
const namespaces = current.get("namespaces");
|
|
237
|
+
if (namespaces) next.set("namespaces", namespaces);
|
|
238
|
+
const value = next.toString();
|
|
239
|
+
return value ? `?${value}` : "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function safeWorkspaceReturn(
|
|
243
|
+
state: unknown,
|
|
244
|
+
origin = window.location.origin,
|
|
245
|
+
): string | null {
|
|
246
|
+
if (!state || typeof state !== "object") return null;
|
|
247
|
+
const value = (state as Record<string, unknown>)[WORKSPACE_RETURN_STATE];
|
|
248
|
+
if (typeof value !== "string" || !value.startsWith("/")) return null;
|
|
249
|
+
try {
|
|
250
|
+
const url = new URL(value, origin);
|
|
251
|
+
if (url.origin !== origin || url.pathname.startsWith("//"))
|
|
252
|
+
return null;
|
|
253
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
216
254
|
} catch {
|
|
217
|
-
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function investigationWorkspaceRestorePath(
|
|
260
|
+
state: unknown,
|
|
261
|
+
focusedRunID: string | null,
|
|
262
|
+
origin = window.location.origin,
|
|
263
|
+
): string | null {
|
|
264
|
+
const target = safeWorkspaceReturn(state, origin);
|
|
265
|
+
if (!target) return null;
|
|
266
|
+
const url = new URL(target, origin);
|
|
267
|
+
if (focusedRunID && focusedRunID !== INVALID_WORKSPACE_RUN_ID) {
|
|
268
|
+
url.searchParams.set("ai-run", focusedRunID);
|
|
269
|
+
} else {
|
|
270
|
+
url.searchParams.delete("ai-run");
|
|
271
|
+
}
|
|
272
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function shouldExitUnavailableWorkspace(
|
|
276
|
+
pathname: string,
|
|
277
|
+
agentsResolved: boolean,
|
|
278
|
+
eligible: boolean,
|
|
279
|
+
): boolean {
|
|
280
|
+
return agentsResolved && !eligible && isInvestigationWorkspacePath(pathname);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function investigationWorkspaceNavigationState(options?: {
|
|
284
|
+
returnPath?: string | null;
|
|
285
|
+
drawerOrigin?: boolean;
|
|
286
|
+
closeHistorySteps?: number;
|
|
287
|
+
}): Record<string, string> | undefined {
|
|
288
|
+
const state: Record<string, string> = {};
|
|
289
|
+
if (options?.returnPath) state[WORKSPACE_RETURN_STATE] = options.returnPath;
|
|
290
|
+
if (options?.closeHistorySteps && options.closeHistorySteps > 0) {
|
|
291
|
+
state[WORKSPACE_RETURN_HISTORY_STEPS_STATE] = String(
|
|
292
|
+
options.closeHistorySteps,
|
|
293
|
+
);
|
|
218
294
|
}
|
|
295
|
+
return Object.keys(state).length > 0 ? state : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function workspaceReturnHistorySteps(state: unknown): number | null {
|
|
299
|
+
if (!state || typeof state !== "object") return null;
|
|
300
|
+
const raw = (state as Record<string, unknown>)[
|
|
301
|
+
WORKSPACE_RETURN_HISTORY_STEPS_STATE
|
|
302
|
+
];
|
|
303
|
+
if (typeof raw !== "string" || !/^\d+$/.test(raw)) return null;
|
|
304
|
+
const steps = Number(raw);
|
|
305
|
+
return steps > 0 ? steps : null;
|
|
219
306
|
}
|
|
220
307
|
|
|
221
308
|
// Fleet pages host the panel against a cluster-scoped API while remaining on a
|
|
@@ -228,15 +315,29 @@ function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
|
|
|
228
315
|
return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
|
|
229
316
|
}
|
|
230
317
|
|
|
231
|
-
|
|
232
|
-
children,
|
|
233
|
-
browserURLState = true,
|
|
234
|
-
}: {
|
|
318
|
+
interface DiagnoseProviderProps {
|
|
235
319
|
children: ReactNode;
|
|
236
320
|
browserURLState?: boolean;
|
|
237
|
-
|
|
321
|
+
forceRouterURLState?: boolean;
|
|
322
|
+
onFocusedRun?: (runID: string) => void;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function RoutedDiagnoseProvider({
|
|
326
|
+
children,
|
|
327
|
+
browserURLState = true,
|
|
328
|
+
forceRouterURLState = false,
|
|
329
|
+
onFocusedRun,
|
|
330
|
+
}: DiagnoseProviderProps) {
|
|
331
|
+
const location = useLocation();
|
|
332
|
+
const navigate = useNavigate();
|
|
333
|
+
const locationRef = useRef(location);
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
locationRef.current = location;
|
|
336
|
+
}, [location]);
|
|
238
337
|
const [available, setAvailable] = useState(false);
|
|
239
338
|
const [eligible, setEligible] = useState(false);
|
|
339
|
+
const [agentEligibilityResolved, setAgentEligibilityResolved] =
|
|
340
|
+
useState(false);
|
|
240
341
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
241
342
|
const [consented, setConsented] = useState<Record<string, boolean>>({});
|
|
242
343
|
const [selectedAgent, setSelectedAgentState] = useState<string>(
|
|
@@ -264,15 +365,48 @@ export function DiagnoseProvider({
|
|
|
264
365
|
// Tracks whether the panel focus belongs to browser history. Fleet opens the
|
|
265
366
|
// same panel on /issues, where URL state is deliberately disabled; a generic
|
|
266
367
|
// panel launch must never be closed by the deep-link synchronization effect.
|
|
267
|
-
const urlRunIdRef = useRef(
|
|
368
|
+
const urlRunIdRef = useRef<string | null>(null);
|
|
369
|
+
const workspaceRouteRef = useRef(false);
|
|
268
370
|
const writeFocusedRunID = useCallback(
|
|
269
371
|
(id: string | null, push: boolean) => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
372
|
+
if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState))
|
|
373
|
+
return;
|
|
374
|
+
const current = locationRef.current;
|
|
375
|
+
urlRunIdRef.current = id;
|
|
376
|
+
if (isInvestigationWorkspacePath(current.pathname)) {
|
|
377
|
+
const returnSteps = workspaceReturnHistorySteps(current.state);
|
|
378
|
+
const state =
|
|
379
|
+
push && returnSteps
|
|
380
|
+
? {
|
|
381
|
+
...(current.state && typeof current.state === "object"
|
|
382
|
+
? current.state
|
|
383
|
+
: {}),
|
|
384
|
+
[WORKSPACE_RETURN_HISTORY_STEPS_STATE]: String(returnSteps + 1),
|
|
385
|
+
}
|
|
386
|
+
: current.state;
|
|
387
|
+
navigate(
|
|
388
|
+
{
|
|
389
|
+
pathname: investigationWorkspacePath(id),
|
|
390
|
+
search: investigationWorkspaceSearch(current.search),
|
|
391
|
+
hash: current.hash,
|
|
392
|
+
},
|
|
393
|
+
{ replace: !push, state },
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const params = new URLSearchParams(current.search);
|
|
398
|
+
if (id) params.set("ai-run", id);
|
|
399
|
+
else params.delete("ai-run");
|
|
400
|
+
navigate(
|
|
401
|
+
{
|
|
402
|
+
pathname: current.pathname,
|
|
403
|
+
search: params.toString(),
|
|
404
|
+
hash: current.hash,
|
|
405
|
+
},
|
|
406
|
+
{ replace: !push, state: current.state },
|
|
407
|
+
);
|
|
274
408
|
},
|
|
275
|
-
[browserURLState],
|
|
409
|
+
[browserURLState, forceRouterURLState, navigate],
|
|
276
410
|
);
|
|
277
411
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
278
412
|
const [runsLoaded, setRunsLoaded] = useState(false);
|
|
@@ -335,6 +469,7 @@ export function DiagnoseProvider({
|
|
|
335
469
|
setEffortState("");
|
|
336
470
|
writeStored(EFFORT_KEY, "");
|
|
337
471
|
}
|
|
472
|
+
setAgentEligibilityResolved(true);
|
|
338
473
|
})
|
|
339
474
|
.catch(() => {});
|
|
340
475
|
return () => {
|
|
@@ -468,11 +603,30 @@ export function DiagnoseProvider({
|
|
|
468
603
|
);
|
|
469
604
|
}, []);
|
|
470
605
|
|
|
606
|
+
// Hosts may need to bridge the focused run into chrome outside RadarApp's
|
|
607
|
+
// router. Notify only after the server has returned a real run summary — a
|
|
608
|
+
// URL-shaped id alone is not evidence that the run exists or is readable.
|
|
609
|
+
const lastNotifiedRunIDRef = useRef<string | null>(null);
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
if (
|
|
612
|
+
!onFocusedRun ||
|
|
613
|
+
!activeRunId ||
|
|
614
|
+
lastNotifiedRunIDRef.current === activeRunId ||
|
|
615
|
+
!runs.some((run) => run.id === activeRunId)
|
|
616
|
+
) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
lastNotifiedRunIDRef.current = activeRunId;
|
|
620
|
+
onFocusedRun(activeRunId);
|
|
621
|
+
}, [activeRunId, onFocusedRun, runs]);
|
|
622
|
+
|
|
623
|
+
const runningRuns = runs.filter(
|
|
624
|
+
(r) => r.status === "running" || r.status === "stopping",
|
|
625
|
+
);
|
|
471
626
|
// A content-stable signature of the resources with a live investigation,
|
|
472
|
-
// so
|
|
473
|
-
//
|
|
474
|
-
const runningSig =
|
|
475
|
-
.filter((r) => r.status === "running" || r.status === "stopping")
|
|
627
|
+
// so per-resource Investigate buttons only re-render when their live targets
|
|
628
|
+
// change.
|
|
629
|
+
const runningSig = runningRuns
|
|
476
630
|
.map((r) => runTargetKey(r.kind, r.namespace, r.name, r.group))
|
|
477
631
|
.sort()
|
|
478
632
|
// resourceKey itself is pipe-delimited; newlines cannot occur in a
|
|
@@ -482,7 +636,8 @@ export function DiagnoseProvider({
|
|
|
482
636
|
() => new Set(runningSig ? runningSig.split("\n") : []),
|
|
483
637
|
[runningSig],
|
|
484
638
|
);
|
|
485
|
-
const
|
|
639
|
+
const runningCount = runningRuns.length;
|
|
640
|
+
const hasRunning = runningCount > 0;
|
|
486
641
|
|
|
487
642
|
// Keep the run list (statuses, new background runs) fresh while the surface is open
|
|
488
643
|
// OR while any investigation is still running — so the button indicator stays live
|
|
@@ -575,68 +730,217 @@ export function DiagnoseProvider({
|
|
|
575
730
|
[updateRunSummary, writeFocusedRunID],
|
|
576
731
|
);
|
|
577
732
|
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
733
|
+
// Browser navigation is the source of truth for presentation: a dedicated
|
|
734
|
+
// /investigations route is the full workspace, while ?ai-run keeps the
|
|
735
|
+
// contextual drawer on its underlying page. Fetch exact ids rather than
|
|
736
|
+
// assuming the bounded recent list contains them.
|
|
581
737
|
useEffect(() => {
|
|
582
|
-
if (!
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
738
|
+
if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState))
|
|
739
|
+
return;
|
|
740
|
+
const workspace = isInvestigationWorkspacePath(location.pathname);
|
|
741
|
+
// Eligibility is unresolved until the agent probe returns. Once it has
|
|
742
|
+
// definitively resolved to off, workspace routes cannot render anything
|
|
743
|
+
// useful; return to the app instead of leaving an eternal loading panel.
|
|
744
|
+
if (agentEligibilityResolved && !eligible) {
|
|
745
|
+
setActiveRunId(null);
|
|
746
|
+
setView("home");
|
|
747
|
+
setOpen(false);
|
|
748
|
+
setMaximized(false);
|
|
749
|
+
if (
|
|
750
|
+
shouldExitUnavailableWorkspace(
|
|
751
|
+
location.pathname,
|
|
752
|
+
agentEligibilityResolved,
|
|
753
|
+
eligible,
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
navigate(
|
|
757
|
+
{
|
|
758
|
+
pathname: "/",
|
|
759
|
+
search: investigationWorkspaceSearch(location.search),
|
|
760
|
+
},
|
|
761
|
+
{ replace: true },
|
|
762
|
+
);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const id = workspace
|
|
766
|
+
? workspaceRunIDFromPath(location.pathname)
|
|
767
|
+
: runIDFromSearch(location.search);
|
|
768
|
+
const invalidWorkspaceRun =
|
|
769
|
+
workspace &&
|
|
770
|
+
!id &&
|
|
771
|
+
location.pathname.replace(/\/+$/, "") !== "/investigations";
|
|
772
|
+
const previouslyURLFocused = urlRunIdRef.current;
|
|
773
|
+
const wasWorkspace = workspaceRouteRef.current;
|
|
774
|
+
workspaceRouteRef.current = workspace;
|
|
775
|
+
|
|
776
|
+
setMaximized(workspace);
|
|
777
|
+
const workspaceChanged = wasWorkspace !== workspace;
|
|
778
|
+
if (id && id === previouslyURLFocused && !workspaceChanged) return;
|
|
779
|
+
if (!id) {
|
|
780
|
+
urlRunIdRef.current = null;
|
|
781
|
+
if (invalidWorkspaceRun) {
|
|
782
|
+
setActiveRunId(INVALID_WORKSPACE_RUN_ID);
|
|
783
|
+
setView("investigation");
|
|
784
|
+
setOpen(true);
|
|
785
|
+
} else if (workspace) {
|
|
786
|
+
setActiveRunId(null);
|
|
787
|
+
setView("home");
|
|
788
|
+
setOpen(true);
|
|
789
|
+
} else if (previouslyURLFocused || wasWorkspace) {
|
|
790
|
+
setActiveRunId(null);
|
|
791
|
+
setView("home");
|
|
792
|
+
setOpen(false);
|
|
597
793
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (!available) {
|
|
798
|
+
// Preserve the URL-selected detail while agent availability is still
|
|
799
|
+
// resolving. Leaving urlRunIdRef untouched ensures the availability
|
|
800
|
+
// transition reruns this effect and fetches the exact run.
|
|
601
801
|
setActiveRunId(id);
|
|
602
802
|
setView("investigation");
|
|
603
803
|
setOpen(true);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
urlRunIdRef.current = id;
|
|
808
|
+
unavailableRunIDsRef.current.delete(id);
|
|
809
|
+
setStartError(null);
|
|
810
|
+
setActiveRunId(id);
|
|
811
|
+
setView("investigation");
|
|
812
|
+
setOpen(true);
|
|
813
|
+
getRun(id)
|
|
814
|
+
.then(updateRunSummary)
|
|
815
|
+
.catch((error) => {
|
|
816
|
+
if (error instanceof DiagnoseError && error.status === 404) {
|
|
817
|
+
unavailableRunIDsRef.current.add(id);
|
|
818
|
+
}
|
|
819
|
+
// The list load owns the final missing/degraded state and keeps retrying.
|
|
820
|
+
});
|
|
821
|
+
}, [
|
|
822
|
+
available,
|
|
823
|
+
agentEligibilityResolved,
|
|
824
|
+
browserURLState,
|
|
825
|
+
eligible,
|
|
826
|
+
forceRouterURLState,
|
|
827
|
+
location.pathname,
|
|
828
|
+
location.search,
|
|
829
|
+
navigate,
|
|
830
|
+
updateRunSummary,
|
|
831
|
+
writeFocusedRunID,
|
|
832
|
+
]);
|
|
833
|
+
|
|
618
834
|
// Leaving the detail pane drops the failure that belonged to it. startError
|
|
619
835
|
// renders as the entire pane (maximized home still shows `detail`), where a
|
|
620
836
|
// message about a resource you just navigated away from has nothing to attach
|
|
621
837
|
// to and no way to be dismissed.
|
|
622
838
|
const openHome = useCallback(() => {
|
|
623
839
|
unavailableRunIDsRef.current.clear();
|
|
840
|
+
setActiveRunId(null);
|
|
624
841
|
setView("home");
|
|
625
842
|
setStartError(null);
|
|
626
843
|
setOpen(true);
|
|
627
844
|
writeFocusedRunID(null, false);
|
|
628
845
|
}, [writeFocusedRunID]);
|
|
846
|
+
const openWorkspace = useCallback(
|
|
847
|
+
(preferredRunID?: string | null) => {
|
|
848
|
+
setOpen(true);
|
|
849
|
+
setStartError(null);
|
|
850
|
+
if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState)) {
|
|
851
|
+
if (preferredRunID) {
|
|
852
|
+
setActiveRunId(preferredRunID);
|
|
853
|
+
setView("investigation");
|
|
854
|
+
} else {
|
|
855
|
+
setActiveRunId(null);
|
|
856
|
+
setView("home");
|
|
857
|
+
}
|
|
858
|
+
setMaximized(true);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const current = locationRef.current;
|
|
862
|
+
// The global entry remains visible in the workspace. Treat clicking it
|
|
863
|
+
// there as an idempotent reveal rather than pushing another workspace
|
|
864
|
+
// entry and replacing the page that Close should return to.
|
|
865
|
+
if (isInvestigationWorkspacePath(current.pathname)) {
|
|
866
|
+
setMaximized(true);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
// Global entry calls this without a run and always lands on the fresh Home.
|
|
870
|
+
// Expanding a docked detail passes its focused run explicitly so the
|
|
871
|
+
// presentation change does not discard the user's current context.
|
|
872
|
+
const runID = preferredRunID ?? null;
|
|
873
|
+
const state = {
|
|
874
|
+
...(current.state && typeof current.state === "object"
|
|
875
|
+
? current.state
|
|
876
|
+
: {}),
|
|
877
|
+
[WORKSPACE_RETURN_STATE]: `${current.pathname}${current.search}${current.hash}`,
|
|
878
|
+
};
|
|
879
|
+
navigate(
|
|
880
|
+
{
|
|
881
|
+
pathname: investigationWorkspacePath(runID),
|
|
882
|
+
search: investigationWorkspaceSearch(current.search),
|
|
883
|
+
},
|
|
884
|
+
{ state },
|
|
885
|
+
);
|
|
886
|
+
},
|
|
887
|
+
[browserURLState, forceRouterURLState, navigate],
|
|
888
|
+
);
|
|
889
|
+
const routerURLStateEnabled =
|
|
890
|
+
forceRouterURLState || diagnoseURLStateEnabled(browserURLState);
|
|
891
|
+
const canRestoreWorkspace = routerURLStateEnabled
|
|
892
|
+
? isInvestigationWorkspacePath(location.pathname) &&
|
|
893
|
+
!!activeRunId &&
|
|
894
|
+
activeRunId !== INVALID_WORKSPACE_RUN_ID &&
|
|
895
|
+
!!safeWorkspaceReturn(location.state)
|
|
896
|
+
: maximized;
|
|
897
|
+
const restoreWorkspace = useCallback(() => {
|
|
898
|
+
if (!routerURLStateEnabled) {
|
|
899
|
+
setMaximized(false);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const current = locationRef.current;
|
|
903
|
+
const target = investigationWorkspaceRestorePath(
|
|
904
|
+
current.state,
|
|
905
|
+
activeRunIdRef.current,
|
|
906
|
+
);
|
|
907
|
+
if (target) navigate(target, { replace: true });
|
|
908
|
+
}, [navigate, routerURLStateEnabled]);
|
|
629
909
|
const goHome = useCallback(() => {
|
|
630
910
|
unavailableRunIDsRef.current.clear();
|
|
911
|
+
setActiveRunId(null);
|
|
631
912
|
setView("home");
|
|
632
913
|
setStartError(null);
|
|
633
914
|
writeFocusedRunID(null, false);
|
|
634
915
|
}, [writeFocusedRunID]);
|
|
916
|
+
const dismissForNavigation = useCallback(() => {
|
|
917
|
+
unavailableRunIDsRef.current.clear();
|
|
918
|
+
setOpen(false);
|
|
919
|
+
setView("home");
|
|
920
|
+
setStartError(null);
|
|
921
|
+
}, []);
|
|
635
922
|
const close = useCallback(() => {
|
|
636
923
|
unavailableRunIDsRef.current.clear();
|
|
637
924
|
setOpen(false);
|
|
925
|
+
const current = locationRef.current;
|
|
926
|
+
if (isInvestigationWorkspacePath(current.pathname)) {
|
|
927
|
+
const historySteps = workspaceReturnHistorySteps(current.state);
|
|
928
|
+
if (historySteps) {
|
|
929
|
+
navigate(-historySteps);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const returnPath = safeWorkspaceReturn(current.state);
|
|
933
|
+
if (returnPath) {
|
|
934
|
+
const url = new URL(returnPath, window.location.origin);
|
|
935
|
+
url.searchParams.delete("ai-run");
|
|
936
|
+
navigate(`${url.pathname}${url.search}${url.hash}`, { replace: true });
|
|
937
|
+
} else {
|
|
938
|
+
navigate("/", { replace: true });
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
638
942
|
writeFocusedRunID(null, false);
|
|
639
|
-
}, [writeFocusedRunID]);
|
|
943
|
+
}, [navigate, writeFocusedRunID]);
|
|
640
944
|
const consentBusyRef = useRef(false);
|
|
641
945
|
const approveConsent = useCallback(() => {
|
|
642
946
|
if (consentBusyRef.current) return;
|
|
@@ -675,7 +979,8 @@ export function DiagnoseProvider({
|
|
|
675
979
|
const cancelConsent = useCallback(() => {
|
|
676
980
|
setPendingTarget(null);
|
|
677
981
|
setConsentError(null);
|
|
678
|
-
setOpen(
|
|
982
|
+
setOpen(true);
|
|
983
|
+
if (!activeRunIdRef.current) setView("home");
|
|
679
984
|
}, []);
|
|
680
985
|
const dismissError = useCallback(() => setStartError(null), []);
|
|
681
986
|
|
|
@@ -712,8 +1017,12 @@ export function DiagnoseProvider({
|
|
|
712
1017
|
openInvestigation,
|
|
713
1018
|
openRun,
|
|
714
1019
|
openHome,
|
|
1020
|
+
openWorkspace,
|
|
1021
|
+
restoreWorkspace,
|
|
1022
|
+
canRestoreWorkspace,
|
|
715
1023
|
goHome,
|
|
716
1024
|
close,
|
|
1025
|
+
dismissForNavigation,
|
|
717
1026
|
approveConsent,
|
|
718
1027
|
cancelConsent,
|
|
719
1028
|
refreshRuns,
|
|
@@ -729,6 +1038,7 @@ export function DiagnoseProvider({
|
|
|
729
1038
|
() => ({
|
|
730
1039
|
open,
|
|
731
1040
|
close,
|
|
1041
|
+
dismissForNavigation,
|
|
732
1042
|
contentGutter,
|
|
733
1043
|
maximized,
|
|
734
1044
|
setMaximized,
|
|
@@ -738,15 +1048,18 @@ export function DiagnoseProvider({
|
|
|
738
1048
|
panelBounds: PANEL_BOUNDS,
|
|
739
1049
|
panelWidthKey: WIDTH_KEY,
|
|
740
1050
|
runningKeys,
|
|
1051
|
+
runningCount,
|
|
741
1052
|
}),
|
|
742
1053
|
[
|
|
743
1054
|
open,
|
|
744
1055
|
close,
|
|
1056
|
+
dismissForNavigation,
|
|
745
1057
|
contentGutter,
|
|
746
1058
|
maximized,
|
|
747
1059
|
width,
|
|
748
1060
|
narrow,
|
|
749
1061
|
runningKeys,
|
|
1062
|
+
runningCount,
|
|
750
1063
|
setMaximized,
|
|
751
1064
|
setWidth,
|
|
752
1065
|
],
|
|
@@ -758,3 +1071,15 @@ export function DiagnoseProvider({
|
|
|
758
1071
|
</Ctx.Provider>
|
|
759
1072
|
);
|
|
760
1073
|
}
|
|
1074
|
+
|
|
1075
|
+
// Standalone consumers may not have a router, while full-app hosts already do.
|
|
1076
|
+
// Supply one only when there is no surrounding routing context.
|
|
1077
|
+
export function DiagnoseProvider(props: DiagnoseProviderProps) {
|
|
1078
|
+
const inRouter = useInRouterContext();
|
|
1079
|
+
if (inRouter) return <RoutedDiagnoseProvider {...props} />;
|
|
1080
|
+
return (
|
|
1081
|
+
<MemoryRouter>
|
|
1082
|
+
<RoutedDiagnoseProvider {...props} />
|
|
1083
|
+
</MemoryRouter>
|
|
1084
|
+
);
|
|
1085
|
+
}
|