@skyhook-io/radar-app 1.13.0 → 1.13.1
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 +36 -8
- package/src/RadarApp.tsx +1 -1
- package/src/api/diagnose.ts +45 -5
- package/src/components/diagnose/AISettings.tsx +9 -4
- package/src/components/diagnose/DiagnoseContext.tsx +216 -48
- package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
- package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
- package/src/components/diagnose/Home.tsx +93 -64
- package/src/components/diagnose/InvestigationView.tsx +190 -124
- package/src/components/diagnose/parts.tsx +10 -2
- package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
- package/src/components/execution/BatchExecutionView.tsx +2 -3
- package/src/components/execution/execution-definition.test.ts +19 -0
- package/src/components/execution/execution-definition.ts +2 -0
- package/src/components/gitops/GitOpsView.tsx +25 -3
- package/src/components/nav/navigation.test.ts +26 -0
- package/src/components/nav/navigation.ts +10 -0
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
|
|
|
37
37
|
import { useNavCustomization } from './context/NavCustomization'
|
|
38
38
|
import type { FleetTakeoverTarget } from './context/NavCustomization'
|
|
39
39
|
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
40
|
+
import { navigateFromPrimaryRail } from './components/nav/navigation'
|
|
40
41
|
import { useNavRailPinned } from './hooks/useNavRailPinned'
|
|
41
42
|
import { useMediaQuery } from './hooks/useMediaQuery'
|
|
42
43
|
import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
|
|
@@ -322,7 +323,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
322
323
|
// The AI panel is an absolute slot in the body frame (the column under the header):
|
|
323
324
|
// it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
|
|
324
325
|
// static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
|
|
325
|
-
const {
|
|
326
|
+
const {
|
|
327
|
+
open: diagnoseOpen,
|
|
328
|
+
close: closeDiagnose,
|
|
329
|
+
contentGutter,
|
|
330
|
+
maximized: diagnoseMaximized,
|
|
331
|
+
} = useDiagnoseLayout()
|
|
326
332
|
// Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
|
|
327
333
|
// cross-tree swap) navigates same-document so the chrome morphs instead of
|
|
328
334
|
// cold-booting; without it we fall back to a hard `window.location` nav.
|
|
@@ -468,12 +474,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
468
474
|
|
|
469
475
|
const path = view === 'home' ? '/' : `/${view}`
|
|
470
476
|
|
|
471
|
-
// Start fresh — keep only cross-view params
|
|
477
|
+
// Start fresh — keep only cross-view params, discard view-specific ones.
|
|
478
|
+
// Read the live location because Diagnose owns `ai-run` through the History
|
|
479
|
+
// API; React Router's searchParams snapshot does not update for that write.
|
|
472
480
|
const newParams = new URLSearchParams()
|
|
473
|
-
const
|
|
481
|
+
const currentParams = new URLSearchParams(window.location.search)
|
|
482
|
+
const globalNamespaces = currentParams.get('namespaces')
|
|
474
483
|
if (globalNamespaces) {
|
|
475
484
|
newParams.set('namespaces', globalNamespaces)
|
|
476
485
|
}
|
|
486
|
+
const diagnoseRun = currentParams.get('ai-run')
|
|
487
|
+
if (diagnoseRun) {
|
|
488
|
+
newParams.set('ai-run', diagnoseRun)
|
|
489
|
+
}
|
|
477
490
|
|
|
478
491
|
// Add any new params
|
|
479
492
|
if (params) {
|
|
@@ -483,7 +496,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
483
496
|
}
|
|
484
497
|
|
|
485
498
|
navigate({ pathname: path, search: newParams.toString() })
|
|
486
|
-
}, [navigate,
|
|
499
|
+
}, [navigate, takeover, goHost])
|
|
500
|
+
|
|
501
|
+
// The standalone rail expresses intent to leave the full-width investigation
|
|
502
|
+
// workspace. Close it before routing so the destination is immediately visible;
|
|
503
|
+
// docked investigations stay open across views as a persistent side panel.
|
|
504
|
+
const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
|
|
505
|
+
navigateFromPrimaryRail(
|
|
506
|
+
diagnoseOpen && diagnoseMaximized,
|
|
507
|
+
closeDiagnose,
|
|
508
|
+
() => setMainView(view),
|
|
509
|
+
)
|
|
510
|
+
}, [diagnoseOpen, diagnoseMaximized, closeDiagnose, setMainView])
|
|
487
511
|
|
|
488
512
|
// Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
|
|
489
513
|
// own fleet pages scoped to this cluster — owned by the host's left rail — so
|
|
@@ -1135,9 +1159,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1135
1159
|
setSelectedResource(null)
|
|
1136
1160
|
setSelectedHelmRelease(null)
|
|
1137
1161
|
|
|
1138
|
-
// Reset
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1162
|
+
// Reset resource-specific params while retaining the durable investigation
|
|
1163
|
+
// focus. Diagnose resolves runs by id and owns whether the focused run is
|
|
1164
|
+
// still readable after the context switch.
|
|
1165
|
+
const nextParams = new URLSearchParams()
|
|
1166
|
+
const diagnoseRun = new URLSearchParams(window.location.search).get('ai-run')
|
|
1167
|
+
if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
|
|
1168
|
+
navigate({ pathname: location.pathname, search: nextParams.toString() }, { replace: true })
|
|
1141
1169
|
|
|
1142
1170
|
// Auto-unpause so the new cluster's topology loads immediately
|
|
1143
1171
|
setTopologyPaused(false)
|
|
@@ -1658,7 +1686,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1658
1686
|
{showNavRail && (
|
|
1659
1687
|
<PrimaryNavRail
|
|
1660
1688
|
activeView={navActiveView}
|
|
1661
|
-
onNavigate={
|
|
1689
|
+
onNavigate={handlePrimaryNavigate}
|
|
1662
1690
|
pinned={navRailEffectivePinned}
|
|
1663
1691
|
onTogglePinned={toggleNavRailPinned}
|
|
1664
1692
|
showPinToggle={!railForcedSlim}
|
package/src/RadarApp.tsx
CHANGED
|
@@ -228,7 +228,7 @@ export function RadarApp({
|
|
|
228
228
|
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
229
229
|
consentCopy={diagnoseConsent}
|
|
230
230
|
>
|
|
231
|
-
<DiagnoseProvider>
|
|
231
|
+
<DiagnoseProvider browserURLState={router !== "memory"}>
|
|
232
232
|
<App
|
|
233
233
|
manageDocumentTitle={manageDocumentTitle}
|
|
234
234
|
documentTitleSuffix={documentTitleSuffix}
|
package/src/api/diagnose.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
|
|
2
2
|
// on the user's own machine/subscription against Radar's MCP; this just starts
|
|
3
3
|
// the investigation and consumes its SSE event stream.
|
|
4
|
-
import { getApiBase, getCredentialsMode } from "./config";
|
|
4
|
+
import { getApiBase, getAuthHeaders, getCredentialsMode } from "./config";
|
|
5
5
|
|
|
6
6
|
export interface AgentInfo {
|
|
7
7
|
name: string;
|
|
@@ -81,6 +81,7 @@ export interface DiagnoseStreamEvent {
|
|
|
81
81
|
error?: string;
|
|
82
82
|
question?: string; // on "turn"
|
|
83
83
|
apply?: boolean; // on "turn"
|
|
84
|
+
actor?: string; // human author on shared hosted transcripts
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// A run is a durable, server-owned investigation. Its lifetime is independent of
|
|
@@ -101,11 +102,17 @@ export interface RunSummary {
|
|
|
101
102
|
effort?: string;
|
|
102
103
|
managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
|
|
103
104
|
health?: ResourceHealthSignal;
|
|
104
|
-
status: "running" | "done" | "error" | "stopped" | "stale";
|
|
105
|
+
status: "running" | "stopping" | "done" | "error" | "stopped" | "stale";
|
|
105
106
|
sessionId?: string;
|
|
106
107
|
preview?: string;
|
|
107
108
|
createdAt: string;
|
|
108
109
|
updatedAt: string;
|
|
110
|
+
visibility?: "private" | "organization";
|
|
111
|
+
ownedByMe?: boolean;
|
|
112
|
+
canManageVisibility?: boolean;
|
|
113
|
+
canContinue?: boolean;
|
|
114
|
+
trigger?: "interactive" | "background";
|
|
115
|
+
radarUrl?: string;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
export async function fetchAgents(
|
|
@@ -113,6 +120,7 @@ export async function fetchAgents(
|
|
|
113
120
|
): Promise<AgentsResponse> {
|
|
114
121
|
const res = await fetch(`${getApiBase()}/agents`, {
|
|
115
122
|
credentials: getCredentialsMode(),
|
|
123
|
+
headers: getAuthHeaders(),
|
|
116
124
|
signal,
|
|
117
125
|
});
|
|
118
126
|
if (!res.ok) throw new Error(`agents: ${res.status}`);
|
|
@@ -167,7 +175,7 @@ export async function createRun(
|
|
|
167
175
|
const res = await fetch(RUNS(), {
|
|
168
176
|
method: "POST",
|
|
169
177
|
credentials: getCredentialsMode(),
|
|
170
|
-
headers: { "Content-Type": "application/json" },
|
|
178
|
+
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
171
179
|
body: JSON.stringify({ ...target, ...opts }),
|
|
172
180
|
});
|
|
173
181
|
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
@@ -186,6 +194,7 @@ export interface RunsResponse {
|
|
|
186
194
|
export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
|
|
187
195
|
const res = await fetch(RUNS(), {
|
|
188
196
|
credentials: getCredentialsMode(),
|
|
197
|
+
headers: getAuthHeaders(),
|
|
189
198
|
signal,
|
|
190
199
|
});
|
|
191
200
|
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
@@ -193,12 +202,41 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
|
|
|
193
202
|
return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
|
|
194
203
|
}
|
|
195
204
|
|
|
205
|
+
// getRun resolves a stable run id directly. Deep links use this rather than
|
|
206
|
+
// relying on a bounded history page to happen to contain the target.
|
|
207
|
+
export async function getRun(
|
|
208
|
+
id: string,
|
|
209
|
+
signal?: AbortSignal,
|
|
210
|
+
): Promise<RunSummary> {
|
|
211
|
+
const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
|
|
212
|
+
credentials: getCredentialsMode(),
|
|
213
|
+
headers: getAuthHeaders(),
|
|
214
|
+
signal,
|
|
215
|
+
});
|
|
216
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
217
|
+
return res.json();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function updateRunVisibility(
|
|
221
|
+
id: string,
|
|
222
|
+
visibility: "private" | "organization",
|
|
223
|
+
): Promise<RunSummary> {
|
|
224
|
+
const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
|
|
225
|
+
method: "PATCH",
|
|
226
|
+
credentials: getCredentialsMode(),
|
|
227
|
+
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
228
|
+
body: JSON.stringify({ visibility }),
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
231
|
+
return res.json();
|
|
232
|
+
}
|
|
233
|
+
|
|
196
234
|
// recordConsent acknowledges the current disclosure for an execution profile, server-side.
|
|
197
235
|
export async function recordConsent(surface: string): Promise<void> {
|
|
198
236
|
const res = await fetch(`${getApiBase()}/diagnose/consent`, {
|
|
199
237
|
method: "POST",
|
|
200
238
|
credentials: getCredentialsMode(),
|
|
201
|
-
headers: { "Content-Type": "application/json" },
|
|
239
|
+
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
202
240
|
body: JSON.stringify({ surface }),
|
|
203
241
|
});
|
|
204
242
|
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
@@ -210,6 +248,7 @@ export async function clearHistory(): Promise<void> {
|
|
|
210
248
|
const res = await fetch(`${getApiBase()}/diagnose/history/clear`, {
|
|
211
249
|
method: "POST",
|
|
212
250
|
credentials: getCredentialsMode(),
|
|
251
|
+
headers: getAuthHeaders(),
|
|
213
252
|
});
|
|
214
253
|
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
215
254
|
}
|
|
@@ -222,7 +261,7 @@ export async function addTurn(
|
|
|
222
261
|
const res = await fetch(`${RUNS()}/${id}/turns`, {
|
|
223
262
|
method: "POST",
|
|
224
263
|
credentials: getCredentialsMode(),
|
|
225
|
-
headers: { "Content-Type": "application/json" },
|
|
264
|
+
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
226
265
|
body: JSON.stringify(body),
|
|
227
266
|
});
|
|
228
267
|
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
@@ -233,6 +272,7 @@ export async function stopRun(id: string): Promise<void> {
|
|
|
233
272
|
await fetch(`${RUNS()}/${id}/stop`, {
|
|
234
273
|
method: "POST",
|
|
235
274
|
credentials: getCredentialsMode(),
|
|
275
|
+
headers: getAuthHeaders(),
|
|
236
276
|
}).catch(() => {});
|
|
237
277
|
}
|
|
238
278
|
|
|
@@ -49,7 +49,7 @@ function ClearHistoryRow({
|
|
|
49
49
|
<div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
|
|
50
50
|
<p className="text-[11px] leading-snug text-theme-text-tertiary">
|
|
51
51
|
{hosted ? (
|
|
52
|
-
|
|
52
|
+
`${agentLabel} stores private, organization-shared, and automatic investigation transcripts so history survives restarts.`
|
|
53
53
|
) : (
|
|
54
54
|
<>
|
|
55
55
|
Investigation transcripts are kept on this machine (
|
|
@@ -67,6 +67,12 @@ function ClearHistoryRow({
|
|
|
67
67
|
Couldn't clear history.
|
|
68
68
|
</span>
|
|
69
69
|
)}
|
|
70
|
+
{hosted && confirming && state === "idle" && (
|
|
71
|
+
<span className="ml-1 font-medium text-red-400">
|
|
72
|
+
This permanently deletes every member's investigations for this
|
|
73
|
+
cluster — private, organization-shared, and automatic.
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
70
76
|
</p>
|
|
71
77
|
{confirming ? (
|
|
72
78
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
@@ -133,9 +139,8 @@ export function AISettingsSection({
|
|
|
133
139
|
selectedAgent={draft.agent}
|
|
134
140
|
// Model + effort are agent-specific; reset them when the agent changes.
|
|
135
141
|
onSelectAgent={(a) => {
|
|
136
|
-
const nextProfile = agents.find(
|
|
137
|
-
|
|
138
|
-
)?.profiles?.[0];
|
|
142
|
+
const nextProfile = agents.find((agent) => agent.name === a)
|
|
143
|
+
?.profiles?.[0];
|
|
139
144
|
onChange({
|
|
140
145
|
agent: a,
|
|
141
146
|
profile: nextProfile ?? draft.profile,
|
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
} from "react";
|
|
18
18
|
import {
|
|
19
19
|
fetchAgents,
|
|
20
|
+
getRun,
|
|
20
21
|
listRuns,
|
|
21
22
|
createRun,
|
|
22
23
|
recordConsent,
|
|
23
24
|
DiagnoseError,
|
|
24
25
|
} from "../../api/diagnose";
|
|
26
|
+
import { getApiBase } from "../../api/config";
|
|
25
27
|
import {
|
|
26
28
|
type RunSummary,
|
|
27
29
|
type AgentInfo,
|
|
@@ -86,6 +88,7 @@ interface DiagnoseCtx {
|
|
|
86
88
|
approveConsent: () => void;
|
|
87
89
|
cancelConsent: () => void;
|
|
88
90
|
refreshRuns: () => void;
|
|
91
|
+
updateRunSummary: (run: RunSummary) => void;
|
|
89
92
|
dismissError: () => void;
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -95,6 +98,7 @@ interface DiagnoseCtx {
|
|
|
95
98
|
// churns the business context) doesn't re-render the whole shell.
|
|
96
99
|
interface DiagnoseLayoutCtx {
|
|
97
100
|
open: boolean;
|
|
101
|
+
close: () => void;
|
|
98
102
|
contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
|
|
99
103
|
maximized: boolean;
|
|
100
104
|
setMaximized: Dispatch<SetStateAction<boolean>>;
|
|
@@ -186,7 +190,58 @@ function writeStored(key: string, value: string) {
|
|
|
186
190
|
}
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
|
|
193
|
+
function runIDFromLocation(browserURLState: boolean): string | null {
|
|
194
|
+
if (!diagnoseURLStateEnabled(browserURLState)) return null;
|
|
195
|
+
try {
|
|
196
|
+
return new URLSearchParams(window.location.search).get("ai-run");
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function writeRunIDToLocation(
|
|
203
|
+
id: string | null,
|
|
204
|
+
push: boolean,
|
|
205
|
+
browserURLState: boolean,
|
|
206
|
+
) {
|
|
207
|
+
if (!diagnoseURLStateEnabled(browserURLState)) return;
|
|
208
|
+
try {
|
|
209
|
+
const url = new URL(window.location.href);
|
|
210
|
+
if (id) url.searchParams.set("ai-run", id);
|
|
211
|
+
else url.searchParams.delete("ai-run");
|
|
212
|
+
window.history[push ? "pushState" : "replaceState"](
|
|
213
|
+
window.history.state,
|
|
214
|
+
"",
|
|
215
|
+
`${url.pathname}${url.search}${url.hash}`,
|
|
216
|
+
);
|
|
217
|
+
// History API writes do not notify BrowserRouter. Replaying the new state as
|
|
218
|
+
// popstate keeps every later navigate/setSearchParams call on the same live
|
|
219
|
+
// query string instead of letting a stale router snapshot erase ai-run.
|
|
220
|
+
window.dispatchEvent(
|
|
221
|
+
new PopStateEvent("popstate", { state: window.history.state }),
|
|
222
|
+
);
|
|
223
|
+
} catch {
|
|
224
|
+
/* URL APIs unavailable — panel state still works for this session */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fleet pages host the panel against a cluster-scoped API while remaining on a
|
|
229
|
+
// hub route such as /issues. Writing ?ai-run there would create a non-reloadable
|
|
230
|
+
// pseudo-link. The embedded /c/:id Radar tree and local Radar own their route and
|
|
231
|
+
// therefore keep the query as navigation state; Fleet copies run.radarUrl instead.
|
|
232
|
+
function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
|
|
233
|
+
if (!browserURLState) return false;
|
|
234
|
+
const clusterScopedAPI = /\/c\/[^/]+\/api\/?$/.test(getApiBase());
|
|
235
|
+
return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function DiagnoseProvider({
|
|
239
|
+
children,
|
|
240
|
+
browserURLState = true,
|
|
241
|
+
}: {
|
|
242
|
+
children: ReactNode;
|
|
243
|
+
browserURLState?: boolean;
|
|
244
|
+
}) {
|
|
190
245
|
const [available, setAvailable] = useState(false);
|
|
191
246
|
const [eligible, setEligible] = useState(false);
|
|
192
247
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
@@ -206,6 +261,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
206
261
|
const [open, setOpen] = useState(false);
|
|
207
262
|
const [view, setView] = useState<DiagnoseView>("home");
|
|
208
263
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
|
264
|
+
const activeRunIdRef = useRef(activeRunId);
|
|
265
|
+
activeRunIdRef.current = activeRunId;
|
|
266
|
+
// A missing/revoked durable link is terminal until the user explicitly
|
|
267
|
+
// navigates to it again. Keep polling the bounded history list (so a newly
|
|
268
|
+
// shared run can reappear), but do not hammer the exact 404 endpoint every
|
|
269
|
+
// four seconds while the unavailable state is already on screen.
|
|
270
|
+
const unavailableRunIDsRef = useRef(new Set<string>());
|
|
271
|
+
// Tracks whether the panel focus belongs to browser history. Fleet opens the
|
|
272
|
+
// same panel on /issues, where URL state is deliberately disabled; a generic
|
|
273
|
+
// panel launch must never be closed by the deep-link synchronization effect.
|
|
274
|
+
const urlRunIdRef = useRef(runIDFromLocation(browserURLState));
|
|
275
|
+
const writeFocusedRunID = useCallback(
|
|
276
|
+
(id: string | null, push: boolean) => {
|
|
277
|
+
// Set this before writeRunIDToLocation's synthetic popstate so our own
|
|
278
|
+
// listener can distinguish a programmatic close/home write from Back.
|
|
279
|
+
if (diagnoseURLStateEnabled(browserURLState)) urlRunIdRef.current = id;
|
|
280
|
+
writeRunIDToLocation(id, push, browserURLState);
|
|
281
|
+
},
|
|
282
|
+
[browserURLState],
|
|
283
|
+
);
|
|
209
284
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
|
210
285
|
const [runsLoaded, setRunsLoaded] = useState(false);
|
|
211
286
|
const [runsLoadFailed, setRunsLoadFailed] = useState(false);
|
|
@@ -332,28 +407,79 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
332
407
|
return () => window.removeEventListener("resize", onResize);
|
|
333
408
|
}, []);
|
|
334
409
|
|
|
335
|
-
const refreshRuns = useCallback(() => {
|
|
410
|
+
const refreshRuns = useCallback(async () => {
|
|
336
411
|
if (!available) return;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
412
|
+
try {
|
|
413
|
+
const r = await listRuns();
|
|
414
|
+
const focusedID = activeRunIdRef.current;
|
|
415
|
+
let focusedRun: RunSummary | null = null;
|
|
416
|
+
let retainFocusedSnapshot = false;
|
|
417
|
+
const listedFocusedRun = focusedID
|
|
418
|
+
? r.runs.find((run) => run.id === focusedID)
|
|
419
|
+
: undefined;
|
|
420
|
+
if (focusedID && listedFocusedRun) {
|
|
421
|
+
unavailableRunIDsRef.current.delete(focusedID);
|
|
422
|
+
} else if (focusedID && !unavailableRunIDsRef.current.has(focusedID)) {
|
|
423
|
+
try {
|
|
424
|
+
// A stable deep link can target a retained run older than the bounded
|
|
425
|
+
// history page. Refresh it directly too: its status and capabilities
|
|
426
|
+
// must not freeze at the first snapshot while the panel stays open.
|
|
427
|
+
focusedRun = await getRun(focusedID);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
// The bounded list is still authoritative for everything else. A
|
|
430
|
+
// missing/revoked focused run falls out and renders unavailable;
|
|
431
|
+
// transient direct-fetch failures keep the last useful snapshot.
|
|
432
|
+
const unavailable =
|
|
433
|
+
error instanceof DiagnoseError && error.status === 404;
|
|
434
|
+
if (unavailable) unavailableRunIDsRef.current.add(focusedID);
|
|
435
|
+
retainFocusedSnapshot = !unavailable;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
setRuns((prev) => {
|
|
439
|
+
let nextRuns = r.runs;
|
|
440
|
+
if (focusedRun) nextRuns = [focusedRun, ...nextRuns];
|
|
441
|
+
if (focusedID && retainFocusedSnapshot) {
|
|
442
|
+
const previous = prev.find((run) => run.id === focusedID);
|
|
443
|
+
if (previous) nextRuns = [previous, ...nextRuns];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// openRun/start can focus and insert a run while the direct fetch above
|
|
447
|
+
// is in flight. Preserve that newer focus instead of replacing it with
|
|
448
|
+
// the snapshot for the run that was active when this refresh started.
|
|
449
|
+
const currentFocusedID = activeRunIdRef.current;
|
|
450
|
+
if (
|
|
451
|
+
currentFocusedID &&
|
|
452
|
+
currentFocusedID !== focusedID &&
|
|
453
|
+
!nextRuns.some((run) => run.id === currentFocusedID)
|
|
454
|
+
) {
|
|
455
|
+
const current = prev.find((run) => run.id === currentFocusedID);
|
|
456
|
+
if (current) nextRuns = [current, ...nextRuns];
|
|
457
|
+
}
|
|
458
|
+
return nextRuns;
|
|
349
459
|
});
|
|
460
|
+
setRunsLoaded(true);
|
|
461
|
+
setRunsLoadFailed(false);
|
|
462
|
+
setHistoryDegraded(!!r.historyDegraded);
|
|
463
|
+
} catch {
|
|
464
|
+
// Leave runsLoaded false (a missing-run verdict needs a real list) but
|
|
465
|
+
// record the failure so the panel can say "retrying" instead of
|
|
466
|
+
// pretending nothing happened. The 4s poll keeps retrying while open.
|
|
467
|
+
setRunsLoadFailed(true);
|
|
468
|
+
}
|
|
350
469
|
}, [available]);
|
|
470
|
+
const updateRunSummary = useCallback((run: RunSummary) => {
|
|
471
|
+
setRuns((prev) =>
|
|
472
|
+
prev.some((item) => item.id === run.id)
|
|
473
|
+
? prev.map((item) => (item.id === run.id ? run : item))
|
|
474
|
+
: [run, ...prev],
|
|
475
|
+
);
|
|
476
|
+
}, []);
|
|
351
477
|
|
|
352
|
-
// A content-stable signature of the resources with a live
|
|
478
|
+
// A content-stable signature of the resources with a live investigation,
|
|
353
479
|
// so the per-resource Diagnose buttons can show a "running" indicator even with the
|
|
354
480
|
// panel closed — and only re-render when the set actually changes, not every poll.
|
|
355
481
|
const runningSig = runs
|
|
356
|
-
.filter((r) => r.status === "running")
|
|
482
|
+
.filter((r) => r.status === "running" || r.status === "stopping")
|
|
357
483
|
.map((r) => runTargetKey(r.kind, r.namespace, r.name))
|
|
358
484
|
.sort()
|
|
359
485
|
.join("|");
|
|
@@ -397,6 +523,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
397
523
|
if (seq !== startSeqRef.current) return;
|
|
398
524
|
setActiveRunId(run.id);
|
|
399
525
|
setView("investigation");
|
|
526
|
+
writeFocusedRunID(run.id, true);
|
|
400
527
|
})
|
|
401
528
|
.catch((e) => {
|
|
402
529
|
if (seq !== startSeqRef.current) return;
|
|
@@ -431,52 +558,90 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
431
558
|
},
|
|
432
559
|
[consentSurface, hosted],
|
|
433
560
|
);
|
|
434
|
-
const openRun = useCallback(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
561
|
+
const openRun = useCallback(
|
|
562
|
+
(id: string) => {
|
|
563
|
+
unavailableRunIDsRef.current.delete(id);
|
|
564
|
+
setStartError(null);
|
|
565
|
+
setActiveRunId(id);
|
|
566
|
+
setView("investigation");
|
|
567
|
+
setOpen(true);
|
|
568
|
+
writeFocusedRunID(id, true);
|
|
569
|
+
// A Fleet issue can point at a retained automatic run older than the
|
|
570
|
+
// bounded history page. Resolve the exact id instead of waiting for list.
|
|
571
|
+
getRun(id)
|
|
572
|
+
.then(updateRunSummary)
|
|
573
|
+
.catch((error) => {
|
|
574
|
+
if (error instanceof DiagnoseError && error.status === 404) {
|
|
575
|
+
unavailableRunIDsRef.current.add(id);
|
|
576
|
+
}
|
|
577
|
+
// The loaded-list state renders the durable unavailable message.
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
[updateRunSummary, writeFocusedRunID],
|
|
581
|
+
);
|
|
440
582
|
|
|
441
|
-
//
|
|
442
|
-
// the
|
|
443
|
-
//
|
|
583
|
+
// The run id is durable navigation state: direct loads and popstate focus the
|
|
584
|
+
// exact run, and the query remains in place so the address bar is copyable.
|
|
585
|
+
// Fetch by id rather than assuming the bounded recent list contains it.
|
|
444
586
|
useEffect(() => {
|
|
445
|
-
if (!available) return;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
null
|
|
455
|
-
""
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
587
|
+
if (!available || !diagnoseURLStateEnabled(browserURLState)) return;
|
|
588
|
+
const focusFromLocation = (fromPopState: boolean) => {
|
|
589
|
+
const id = runIDFromLocation(browserURLState);
|
|
590
|
+
if (!id) {
|
|
591
|
+
// A missing id on first mount is ordinary. On popstate it means "back
|
|
592
|
+
// out of this investigation" only when the focused run was itself
|
|
593
|
+
// installed by URL history; unrelated synthetic popstate events must
|
|
594
|
+
// not tear down a panel opened by an in-app action.
|
|
595
|
+
if (fromPopState && urlRunIdRef.current) {
|
|
596
|
+
setActiveRunId(null);
|
|
597
|
+
setView("home");
|
|
598
|
+
setOpen(false);
|
|
599
|
+
}
|
|
600
|
+
urlRunIdRef.current = null;
|
|
601
|
+
return;
|
|
460
602
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
603
|
+
urlRunIdRef.current = id;
|
|
604
|
+
unavailableRunIDsRef.current.delete(id);
|
|
605
|
+
setStartError(null);
|
|
606
|
+
setActiveRunId(id);
|
|
607
|
+
setView("investigation");
|
|
608
|
+
setOpen(true);
|
|
609
|
+
getRun(id)
|
|
610
|
+
.then(updateRunSummary)
|
|
611
|
+
.catch((error) => {
|
|
612
|
+
if (error instanceof DiagnoseError && error.status === 404) {
|
|
613
|
+
unavailableRunIDsRef.current.add(id);
|
|
614
|
+
}
|
|
615
|
+
// The list load owns the final missing/degraded state and keeps retrying.
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
focusFromLocation(false);
|
|
619
|
+
const onPopState = () => focusFromLocation(true);
|
|
620
|
+
window.addEventListener("popstate", onPopState);
|
|
621
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
622
|
+
}, [available, browserURLState, updateRunSummary]);
|
|
466
623
|
// Leaving the detail pane drops the failure that belonged to it. startError
|
|
467
624
|
// renders as the entire pane (maximized home still shows `detail`), where a
|
|
468
625
|
// message about a resource you just navigated away from has nothing to attach
|
|
469
626
|
// to and no way to be dismissed.
|
|
470
627
|
const openHome = useCallback(() => {
|
|
628
|
+
unavailableRunIDsRef.current.clear();
|
|
471
629
|
setView("home");
|
|
472
630
|
setStartError(null);
|
|
473
631
|
setOpen(true);
|
|
474
|
-
|
|
632
|
+
writeFocusedRunID(null, false);
|
|
633
|
+
}, [writeFocusedRunID]);
|
|
475
634
|
const goHome = useCallback(() => {
|
|
635
|
+
unavailableRunIDsRef.current.clear();
|
|
476
636
|
setView("home");
|
|
477
637
|
setStartError(null);
|
|
478
|
-
|
|
479
|
-
|
|
638
|
+
writeFocusedRunID(null, false);
|
|
639
|
+
}, [writeFocusedRunID]);
|
|
640
|
+
const close = useCallback(() => {
|
|
641
|
+
unavailableRunIDsRef.current.clear();
|
|
642
|
+
setOpen(false);
|
|
643
|
+
writeFocusedRunID(null, false);
|
|
644
|
+
}, [writeFocusedRunID]);
|
|
480
645
|
const consentBusyRef = useRef(false);
|
|
481
646
|
const approveConsent = useCallback(() => {
|
|
482
647
|
if (consentBusyRef.current) return;
|
|
@@ -557,6 +722,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
557
722
|
approveConsent,
|
|
558
723
|
cancelConsent,
|
|
559
724
|
refreshRuns,
|
|
725
|
+
updateRunSummary,
|
|
560
726
|
dismissError,
|
|
561
727
|
};
|
|
562
728
|
|
|
@@ -567,6 +733,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
567
733
|
const layout = useMemo<DiagnoseLayoutCtx>(
|
|
568
734
|
() => ({
|
|
569
735
|
open,
|
|
736
|
+
close,
|
|
570
737
|
contentGutter,
|
|
571
738
|
maximized,
|
|
572
739
|
setMaximized,
|
|
@@ -579,6 +746,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
579
746
|
}),
|
|
580
747
|
[
|
|
581
748
|
open,
|
|
749
|
+
close,
|
|
582
750
|
contentGutter,
|
|
583
751
|
maximized,
|
|
584
752
|
width,
|