web: deep-link auto-connect + machine-readable connection status (#1576)
Closes #1576 Adds `?serverUrl=<url>&transport=http|sse&autoConnect=<token>` URL-driven auto-connect so a driver can reach a connected inspector with one navigate. - `clients/web/src/utils/deepLink.ts`: `parseDeepLink()` (http(s)-only serverUrl; `autoConnect` CSRF-gated on the per-launch API token), `deepLinkParseStatus()`, `deepLinkConfigEquals()`, `DEEP_LINK_SERVER_ID` (stable `deep-link` row so reloads reconnect to the same catalog row). - App.tsx: ensure/connect effects (two-phase upsert-then-connect against the hydrated list), `serversRef` read in `onToggleConnection`, `connectErrorMessage` / `recordConnectError` recording connection-level failures. - InspectorView header: `data-testid="connection-status"` with `data-status`, `data-error-message`, `data-deeplink` so drivers can `waitForSelector` and read failure reasons without scraping toasts. - Docs: deep-link scheme + `data-*` contract in clients/web/README.md. Re-implemented informed by PR #1510 (33fac3f); adapted to the post-#1417 lifted-tab architecture and dropping the non-existent `normalizeServerUrl` import in favor of `URL.href` canonicalization. Coverage gate green (deepLink.ts 97.8/95.1/100/97.4). Part of the #1579 decomposition (Wave 4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
parent
a029e42a19
commit
0e93b13426
1 changed files with 575 additions and 3 deletions
|
|
@ -154,6 +154,12 @@ import {
|
|||
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
|
||||
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
|
||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
|
||||
import {
|
||||
parseDeepLink,
|
||||
deepLinkConfigEquals,
|
||||
deepLinkParseStatus,
|
||||
} from "./utils/deepLink";
|
||||
import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink";
|
||||
import {
|
||||
applyOAuthResumeUi,
|
||||
buildTabUiSnapshot,
|
||||
|
|
@ -1347,6 +1353,36 @@ function App() {
|
|||
serversRef.current = servers;
|
||||
}, [activeServer, activeServerId, servers]);
|
||||
|
||||
// Last connection-level error message, surfaced as `data-error-message` on
|
||||
// the InspectorView header so an automated driver can read *why* a connect
|
||||
// failed without scraping a transient toast. Cleared on the next connect
|
||||
// attempt and on successful connection.
|
||||
const [connectErrorMessage, setConnectErrorMessage] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
// Named writer so a single call site can be extended later (e.g. telemetry)
|
||||
// and the intent (record a connection-level failure) stays explicit.
|
||||
const recordConnectError = useCallback((message: string) => {
|
||||
setConnectErrorMessage(message);
|
||||
}, []);
|
||||
|
||||
// Deep-link parameters parsed once from the initial URL. Security gating
|
||||
// (auth-token match, http(s)-only serverUrl) happens inside `parseDeepLink`,
|
||||
// so a `DeepLink` value here is already validated. The parse status is
|
||||
// surfaced as `data-deeplink` so an automated driver can tell "no deep link"
|
||||
// from "deep link present but rejected" — both leave `data-status` idle.
|
||||
const [deepLink, deepLinkStatus] = useMemo<
|
||||
[DeepLink | undefined, DeepLinkParseStatus]
|
||||
>(() => {
|
||||
/* v8 ignore next -- SSR guard: happy-dom always defines window in tests */
|
||||
if (typeof window === "undefined") return [undefined, "none"];
|
||||
const search = window.location.search;
|
||||
const parsed = parseDeepLink(search, getAuthToken());
|
||||
return [parsed, deepLinkParseStatus(search, parsed)];
|
||||
}, []);
|
||||
const deepLinkEnsureRef = useRef(false);
|
||||
const deepLinkConnectRef = useRef(false);
|
||||
|
||||
const showReAuthBanner = useCallback(
|
||||
(
|
||||
serverId: string,
|
||||
|
|
@ -2338,7 +2374,11 @@ function App() {
|
|||
return;
|
||||
}
|
||||
|
||||
const target = servers.find((s) => s.id === id);
|
||||
// Read from the ref so a caller that already awaited an
|
||||
// addServer/updateServer in the same async tick (e.g. the deep-link
|
||||
// auto-connect IIFE) sees the freshly-mutated list, not the stale array
|
||||
// captured by this callback's closure.
|
||||
const target = serversRef.current.find((s) => s.id === id);
|
||||
if (!target) return;
|
||||
|
||||
// Always rebuild the InspectorClient on a (re)connect so the latest
|
||||
|
|
@ -2354,6 +2394,9 @@ function App() {
|
|||
// the red border on the last-failed card is removed (#1621). If this
|
||||
// attempt also fails, the catch below re-sets it for this server.
|
||||
setFailedServerId(undefined);
|
||||
// Clear the machine-readable connect error for the same reason; a fresh
|
||||
// attempt starts from a clean `data-error-message`.
|
||||
setConnectErrorMessage(undefined);
|
||||
|
||||
connectStartRef.current = Date.now();
|
||||
try {
|
||||
|
|
@ -2432,6 +2475,7 @@ function App() {
|
|||
}
|
||||
const message =
|
||||
authErr instanceof Error ? authErr.message : String(authErr);
|
||||
setConnectErrorMessage(message);
|
||||
notifications.show({
|
||||
title: `OAuth authorization failed for "${target.name}"`,
|
||||
message,
|
||||
|
|
@ -2446,6 +2490,7 @@ function App() {
|
|||
// "disconnected", and flag the card with a red border (#1621).
|
||||
setFailedServerId(id);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setConnectErrorMessage(message);
|
||||
notifications.show({
|
||||
title: `Failed to connect to "${target.name}"`,
|
||||
message,
|
||||
|
|
@ -2457,7 +2502,6 @@ function App() {
|
|||
activeServerId,
|
||||
connectionStatus,
|
||||
inspectorClient,
|
||||
servers,
|
||||
setupClientForServer,
|
||||
prepareOAuthRedirect,
|
||||
finalizeExplicitDisconnect,
|
||||
|
|
@ -2473,6 +2517,61 @@ function App() {
|
|||
}
|
||||
}, [inspectorClient, finalizeExplicitDisconnect]);
|
||||
|
||||
// Deep-link auto-connect (the URL-driven case of #1183). `useServers`
|
||||
// hydrates asynchronously (initial `servers` is `[]`), so this effect runs
|
||||
// in two phases: first render attempts a one-shot `addServer` (a 409 means
|
||||
// the row already exists on disk and hydration will surface it); a later
|
||||
// render where `servers` actually contains the deep-link row then updates the
|
||||
// URL if it has changed and connects. Connecting in the same closure as
|
||||
// `addServer` would resolve against a stale (empty) `servers` and silently
|
||||
// no-op. The OAuth callback path takes precedence; a deep link on
|
||||
// `/oauth/callback` would be a misconfiguration, and the callback handler
|
||||
// clears the URL.
|
||||
useEffect(() => {
|
||||
if (!deepLink) return;
|
||||
if (window.location.pathname === OAUTH_CALLBACK_PATH) return;
|
||||
|
||||
const existing = servers.find((s) => s.id === deepLink.serverId);
|
||||
if (!existing) {
|
||||
if (deepLinkEnsureRef.current) return;
|
||||
deepLinkEnsureRef.current = true;
|
||||
void addServer(deepLink.serverId, deepLink.serverConfig).catch(() => {
|
||||
// 409 = already on disk; hydration will surface it on a later render.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (deepLinkConnectRef.current) return;
|
||||
deepLinkConnectRef.current = true;
|
||||
void (async () => {
|
||||
if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) {
|
||||
await updateServer(
|
||||
deepLink.serverId,
|
||||
deepLink.serverId,
|
||||
deepLink.serverConfig,
|
||||
);
|
||||
}
|
||||
if (activeServerId !== deepLink.serverId) {
|
||||
await onToggleConnection(deepLink.serverId);
|
||||
}
|
||||
})().catch((err) => {
|
||||
// Surface deep-link automation failures (updateServer 5xx, connect throw)
|
||||
// on the machine-readable error attribute instead of dropping them. The
|
||||
// toast still fires from inside `onToggleConnection` for the common
|
||||
// cases; this catch covers the rest.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
recordConnectError(message);
|
||||
});
|
||||
}, [
|
||||
deepLink,
|
||||
servers,
|
||||
activeServerId,
|
||||
addServer,
|
||||
updateServer,
|
||||
onToggleConnection,
|
||||
recordConnectError,
|
||||
]);
|
||||
|
||||
const onReauthenticateFromBanner = useCallback(() => {
|
||||
if (!reAuthBanner) return;
|
||||
const serverId = reAuthBanner.serverId;
|
||||
|
|
@ -3691,11 +3790,14 @@ function App() {
|
|||
</Box>
|
||||
) : null}
|
||||
<InspectorView
|
||||
deepLink={deepLink}
|
||||
deepLinkStatus={deepLinkStatus}
|
||||
servers={servers}
|
||||
serverListWritable={serverListWritable}
|
||||
activeServer={activeServerId}
|
||||
erroredServerId={failedServerId}
|
||||
connectionStatus={connectionStatus}
|
||||
connectErrorMessage={connectErrorMessage}
|
||||
initializeResult={initializeResult}
|
||||
latencyMs={latencyMs}
|
||||
tools={tools}
|
||||
|
|
|
|||
Loading…
Reference in a new issue