- Any session operation that hits 404/410 (or finds no open tab) reconciles
tabs; if none survive it re-injects the account cookies (BR-2), opens a new
tab under the same userId/sessionKey, returns to the recorded URL (site home
otherwise), marks old tabs lost and retries the operation once. Failure
returns 503 browser_reconnect_failed with the reason, with a 10s backoff.
- Restart detection: umt plugin GET /umt/instance (fallback /health fields);
a changed instance id marks every open session needsReconnect.
- POST /browser/sessions/{id}/reconnect (idempotent); session view exposes
needsReconnect and reconnect {status, trigger, at, reason, humanNotice}.
- Controller and lease are kept; reconnecting during takeover asks the human
to double-check the reloaded page.
- Web: screenshot and desktop modes show "reconnecting", auto-reconnect once,
pause polling, offer a reconnect button with the reason on failure; desktop
mode rebuilds the Selkies connection after a reconnect.
- Mock camofox restart(), tests, smoke step and runbook section 10.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
119 lines
5.0 KiB
TypeScript
119 lines
5.0 KiB
TypeScript
import type { BrowserSessionView } from "@umt/contracts";
|
|
import { LoaderCircle, RotateCw } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useBrowserReconnect } from "~/api/browser.ts";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
|
|
|
// BR-3 浏览器服务(camofox)重启后的自动重连:截图模式与桌面模式共用。
|
|
// 收到"标签页 / 会话不在了"(404)、"已被回收"(409)或自动重连失败(503),或会话详情标了 needsReconnect 时,
|
|
// 自动调用一次 POST /browser/sessions/{id}/reconnect;成功后继续轮询,失败显示原因与"重新连接"按钮,不再反复刷错误。
|
|
|
|
/** 这个错误是否说明浏览器服务重启过(上游标签页 / 会话不在了、自动重连失败) */
|
|
export function isBrowserGone(err: unknown): boolean {
|
|
if (!(err instanceof ApiRequestError)) return false;
|
|
if (err.code === "not_found" || err.code === "browser_reconnect_failed") return true;
|
|
return err.code === "conflict" && /回收/.test(err.message);
|
|
}
|
|
|
|
export type ReconnectPhase = { phase: "idle" } | { phase: "reconnecting" } | { phase: "failed"; reason: string };
|
|
|
|
/**
|
|
* gone = 当前视图收到了"浏览器不在了"类错误。每次出问题只自动重连一次(问题消失后复位);
|
|
* 失败后等人点"重新连接"(retry)。onReconnected:重连成功(桌面模式据此重建 Selkies 连接)。
|
|
*/
|
|
export function useAutoReconnect(
|
|
siteId: string,
|
|
session: BrowserSessionView | null,
|
|
gone: boolean,
|
|
onReconnected?: () => void,
|
|
) {
|
|
const mutation = useBrowserReconnect(siteId);
|
|
const [state, setState] = useState<ReconnectPhase>({ phase: "idle" });
|
|
const tried = useRef<string | null>(null);
|
|
const logged = useRef(false);
|
|
const done = useRef(onReconnected);
|
|
done.current = onReconnected;
|
|
const id = session && session.controller !== "closed" ? session.id : null;
|
|
const trigger = !!id && (gone || session?.needsReconnect === true);
|
|
const { mutate } = mutation;
|
|
|
|
const run = useCallback(() => {
|
|
if (!id) return;
|
|
setState({ phase: "reconnecting" });
|
|
mutate(id, {
|
|
onSuccess: () => {
|
|
setState({ phase: "idle" });
|
|
done.current?.();
|
|
},
|
|
onError: (e) => setState({ phase: "failed", reason: errorMessage(e, "浏览器服务不可用") }),
|
|
});
|
|
}, [id, mutate]);
|
|
|
|
useEffect(() => {
|
|
if (!trigger) {
|
|
// 问题消失(截图恢复 / 会话不再需要重连):下次再出问题时还能自动重连一次
|
|
if (tried.current === id) tried.current = null;
|
|
return;
|
|
}
|
|
if (tried.current === id) return;
|
|
tried.current = id;
|
|
if (!logged.current) {
|
|
logged.current = true;
|
|
console.debug("[browser] 浏览器服务可能重启过,自动重新连接一次", id);
|
|
}
|
|
run();
|
|
}, [trigger, id, run]);
|
|
|
|
return { state, retry: run, busy: state.phase === "reconnecting" };
|
|
}
|
|
|
|
/** 画面区域:重连中 / 重连失败(原因 + 重新连接按钮) */
|
|
export function ReconnectPanel({
|
|
state,
|
|
error,
|
|
onRetry,
|
|
}: {
|
|
state: ReconnectPhase;
|
|
/** idle 但仍拿到"浏览器不在了"类错误时显示它的消息 */
|
|
error?: unknown;
|
|
onRetry: () => void;
|
|
}) {
|
|
if (state.phase === "reconnecting")
|
|
return (
|
|
<div role="status" className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
|
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
<p className="text-lead font-medium text-foreground">浏览器已重启,正在重新连接…</p>
|
|
<p className="text-caption text-muted-foreground">重新注入账号登录态,并回到原来的页面。</p>
|
|
</div>
|
|
);
|
|
const reason = state.phase === "failed" ? state.reason : errorMessage(error, "浏览器服务不可用");
|
|
return (
|
|
<div role="alert" className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
|
<p className="text-lead font-medium text-foreground">浏览器连接已断开</p>
|
|
<p className="max-w-md text-caption break-words text-muted-foreground">{reason}</p>
|
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
<RotateCw />
|
|
重新连接
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 人工接管中浏览器重连过:页面已重新加载(表单输入、滚动位置都没了),提示核对;点"知道了"收起 */
|
|
export function ReloadNotice({ session }: { session: BrowserSessionView }) {
|
|
const r = session.reconnect;
|
|
const at = r?.status === "ok" && r.humanNotice && session.controller === "human" ? r.at : null;
|
|
const [dismissed, setDismissed] = useState<string | null>(null);
|
|
if (!at || dismissed === at) return null;
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge tone="warning">浏览器重启过,页面已重新加载,请核对</StatusBadge>
|
|
<Button variant="ghost" size="xs" onClick={() => setDismissed(at)}>
|
|
知道了
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|