Files
UHD_Media_Tool/apps/web/app/api/browser.ts
T
rootandClaude Opus 5.5 838aea3ebd feat(browser): self-heal sessions after camofox restarts (BR-3)
- 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]>
2026-09-29 17:29:11 +08:00

201 lines
7.2 KiB
TypeScript

import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
BrowserActionResult,
BrowserControlResult,
BrowserNavigation,
BrowserProviderCreate,
BrowserProviderUpdate,
BrowserProviderView,
BrowserScreenshotView,
BrowserSessionView,
BrowserTarget,
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
// C1-E 原站视图:浏览器服务、站点会话、定时截图与人工输入。输入类动作要先接管(服务端校验租约)。
const AREA = "browser";
export function useBrowserProviders() {
return useQuery({
queryKey: qk.list(AREA, { kind: "providers" }),
queryFn: ({ signal }) => apiData<{ items: BrowserProviderView[] }>("/browser/providers", { signal }),
staleTime: 60_000,
});
}
// ── 设置页:浏览器服务登记 / 修改 / 删除 / 测试连通 ──
function useInvalidateProviders() {
const qc = useQueryClient();
return () => qc.invalidateQueries({ queryKey: qk.list(AREA, { kind: "providers" }) });
}
export function useCreateBrowserProvider() {
const invalidate = useInvalidateProviders();
return useMutation({
mutationFn: (body: BrowserProviderCreate) =>
apiData<BrowserProviderView>("/browser/providers", { method: "POST", body }),
onSuccess: invalidate,
});
}
export function useUpdateBrowserProvider() {
const invalidate = useInvalidateProviders();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BrowserProviderUpdate }) =>
apiData<BrowserProviderView>("/browser/providers/{id}", { method: "PATCH", pathParams: { id }, body }),
onSuccess: invalidate,
});
}
export function useDeleteBrowserProvider() {
const invalidate = useInvalidateProviders();
return useMutation({
mutationFn: (id: string) =>
apiData<{ id: string }>("/browser/providers/{id}", { method: "DELETE", pathParams: { id } }),
onSuccess: invalidate,
});
}
/** 测试连通:探测 /health 与 /openapi.json 并写回能力 */
export function useProbeBrowserProvider() {
const invalidate = useInvalidateProviders();
return useMutation({
mutationFn: (id: string) =>
apiData<BrowserProviderView>("/browser/providers/{id}/probe", { method: "POST", pathParams: { id }, body: {} }),
onSuccess: invalidate,
});
}
/** 站点的未关闭会话(最新的一个) */
export function useSiteBrowserSession(siteId: string, enabled = true) {
return useQuery({
queryKey: qk.sub(AREA, "site", siteId),
queryFn: async ({ signal }) => {
const r = await apiData<{ items: BrowserSessionView[] }>("/browser/sessions", { params: { siteId }, signal });
return r.items[0] ?? null;
},
enabled: !!siteId && enabled,
refetchInterval: 5_000,
});
}
/**
* 定时截图(1.5 秒,可暂停)。上一张保留到新图到达,避免闪烁;失败时保留旧图并在界面标"连接中断"。
*/
export function useBrowserScreenshot(sessionId: string | null, opts: { paused: boolean; intervalMs?: number }) {
return useQuery({
queryKey: qk.sub(AREA, "screenshot", sessionId ?? ""),
queryFn: ({ signal }) =>
apiData<BrowserScreenshotView>("/browser/sessions/{id}/screenshot", {
pathParams: { id: sessionId ?? "" },
signal,
}),
enabled: !!sessionId,
refetchInterval: opts.paused ? false : (opts.intervalMs ?? 1_500),
refetchIntervalInBackground: false,
placeholderData: keepPreviousData,
retry: false,
gcTime: 0,
});
}
function useRefresh(siteId: string) {
const qc = useQueryClient();
return (session?: BrowserSessionView | null) => {
if (session !== undefined) qc.setQueryData(qk.sub(AREA, "site", siteId), session);
void qc.invalidateQueries({ queryKey: qk.sub(AREA, "site", siteId) });
void qc.invalidateQueries({ queryKey: qk.sub(AREA, "screenshot") });
};
}
export function useOpenBrowserSession(siteId: string) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: () => apiData<BrowserSessionView>("/browser/sessions", { method: "POST", body: { siteId } }),
onSuccess: (s) => refresh(s),
});
}
export function useCloseBrowserSession(siteId: string) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: (id: string) =>
apiData<BrowserSessionView>("/browser/sessions/{id}", { method: "DELETE", pathParams: { id } }),
onSuccess: () => refresh(null),
});
}
/** BR-3:浏览器服务重启后重新连接(幂等;上游上下文还在时服务端什么都不做) */
export function useBrowserReconnect(siteId: string) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: (id: string) =>
apiData<BrowserSessionView>("/browser/sessions/{id}/reconnect", { method: "POST", pathParams: { id }, body: {} }),
onSuccess: (s) => refresh(s),
});
}
export function useBrowserControl(siteId: string) {
const refresh = useRefresh(siteId);
const acquire = useMutation({
mutationFn: (id: string) =>
apiData<BrowserControlResult>("/browser/sessions/{id}/control/acquire", {
method: "POST",
pathParams: { id },
body: { reason: "原站视图接管" },
}),
onSuccess: (r) => refresh(r.session),
});
const release = useMutation({
mutationFn: (id: string) =>
apiData<BrowserControlResult>("/browser/sessions/{id}/control/release", {
method: "POST",
pathParams: { id },
body: { reason: "原站视图交还" },
}),
onSuccess: (r) => refresh(r.session),
});
return { acquire, release };
}
export type BrowserInput =
| { kind: "click"; target: BrowserTarget }
| { kind: "text"; text: string; submit?: boolean }
| { kind: "credential"; credential: "username" | "password" | "totp" }
| { kind: "key"; key: string }
| { kind: "scroll"; direction: "up" | "down" }
| { kind: "navigate"; url: string }
| { kind: "history"; action: "back" | "forward" | "reload" };
/** 人工输入:点击 / 文本 / 凭证 / 按键 / 滚动 / 导航;成功后立即刷新截图 */
export function useBrowserInput(siteId: string, sessionId: string | null) {
const refresh = useRefresh(siteId);
return useMutation({
mutationFn: async (input: BrowserInput) => {
const id = sessionId ?? "";
const post = <T>(path: string, body: unknown) =>
apiData<T>(`/browser/sessions/{id}/${path}`, { method: "POST", pathParams: { id }, body });
switch (input.kind) {
case "click":
return (await post<BrowserActionResult>("click", { target: input.target })).session;
case "text":
return (await post<BrowserActionResult>("type", { text: input.text, submit: input.submit })).session;
case "credential":
return (await post<BrowserActionResult>("type", { credential: input.credential })).session;
case "key":
return (await post<BrowserActionResult>("type", { key: input.key })).session;
case "scroll":
return (await post<BrowserActionResult>("scroll", { direction: input.direction, amount: 600 })).session;
case "navigate":
return (await post<BrowserNavigation>("navigate", { url: input.url })).session;
case "history":
return (await post<BrowserNavigation>("navigate", { action: input.action })).session;
}
},
onSuccess: (s) => refresh(s),
});
}