feat(browser): ship camofox v1.17.0 in compose, verified on a real instance
- compose: camofox service on by default (internal only, volume umt-camofox, healthcheck, CAMOFOX_REPLICAS=0 to skip); app depends on it with service_started/required=false and auto-registers camofox-env from BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED - docker/camofox/Dockerfile: builds upstream tag v1.17.0 (389c996); fixes the upstream Dockerfile (missing postinstall.js before npm ci, sh -c not forwarding SIGTERM so logins were never persisted on restart) - adapter fixes from the real container: coordinate click via evaluate + selector, focus typing with mode=keyboard, password values redacted from ARIA snapshots/extract, downloads field names, 410 after browser restart treated as lost tab; navigate reopens a tab in the same session - verified-capability table keyed by upstream version (1.17.0 all ten); other versions stay unverified; probe reports auth failures - settings: browser providers block (create/edit/delete/probe, capability table); API POST/PATCH/DELETE /browser/providers, POST .../probe - scripts/browser-verify: umtbr stack + mock NexusPHP run of REQ 4.7.4 items - docs: runbook browser §1/§7 (real-instance record), deploy §2.1 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4c9b1ead2
commit
40dd0e69e4
29 files changed
+1661
-73
No files matched your search
@@ -45,7 +45,8 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
|
||||
const item = p.body.data.items[0];
|
||||
expect(item.health.status).toBe("ok");
|
||||
expect(item.healthStatus).toBe("healthy");
|
||||
expect(item.capabilities.upstreamVersion).toBe("1.17.0");
|
||||
// mock 的版本号不在真机核验表里:verified 只来自配置
|
||||
expect(item.capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
expect(item.capabilities.snapshot).toMatchObject({ supported: true, verified: true, source: "probed" });
|
||||
expect(item.capabilities.interact).toMatchObject({ supported: true, verified: false });
|
||||
expect(item.capabilities.takeover).toMatchObject({ supported: true, verified: false });
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { BrowserService, BrowserServiceError, ENV_PROVIDER_NAME } from "../service.ts";
|
||||
import { ACCESS_KEY, type BrowserHarness, createBrowserHarness, type Loose, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-1:浏览器服务登记 —— 环境变量(BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → camofox-env)
|
||||
// 与设置页端点(POST / PATCH /browser/providers、POST /browser/providers/{id}/probe)。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("浏览器服务登记", () => {
|
||||
let h: BrowserHarness;
|
||||
const withEnv = (env: Record<string, unknown>) =>
|
||||
new BrowserService({ ...h.ctx, config: { ...(h.ctx.config as object), ...env } } as ServerContext, {});
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("provcfg");
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("BROWSER_PROVIDER_URL:登记 camofox-env,凭据只在进程内;探测带 Bearer;地址/凭据不能在设置页改", async () => {
|
||||
const svc = withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ACCESS_KEY: ACCESS_KEY, BROWSER_ENABLED: true });
|
||||
const list = await svc.listProviders(true);
|
||||
const env = list.find((p) => p.name === ENV_PROVIDER_NAME)!;
|
||||
expect(env).toMatchObject({ managedBy: "env", hasAccessKey: true, secretRef: null, enabled: true });
|
||||
expect(env.health?.status).toBe("ok");
|
||||
const row = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(JSON.stringify(row)).not.toContain(ACCESS_KEY);
|
||||
expect(h.mock.calls.filter((c) => c.path === "/openapi.json").at(-1)?.headers.authorization).toBe(
|
||||
`Bearer ${ACCESS_KEY}`,
|
||||
);
|
||||
await expect(svc.updateProvider(env.id, { baseUrl: "http://x:1" })).rejects.toBeInstanceOf(BrowserServiceError);
|
||||
// 启用可以改
|
||||
expect((await svc.updateProvider(env.id, { enabled: false })).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("BROWSER_ENABLED=false / 去掉地址:camofox-env 停用,其它服务不受影响", async () => {
|
||||
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: false }).syncEnvProvider();
|
||||
const off = await h.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(off.enabled).toBe(false);
|
||||
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: true }).syncEnvProvider();
|
||||
await withEnv({}).syncEnvProvider();
|
||||
const rows = await h.db.selectFrom("browser_provider").select(["name", "enabled"]).orderBy("name").execute();
|
||||
expect(rows).toEqual([
|
||||
{ name: "camofox", enabled: true },
|
||||
{ name: ENV_PROVIDER_NAME, enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("设置页:登记 / 改凭据 / 测试连通;凭据明文不出 API,名称 camofox-env 保留", async () => {
|
||||
const bad = await h.req("/browser/providers", { body: { name: ENV_PROVIDER_NAME, baseUrl: h.mock.url } });
|
||||
expect(bad.status).toBe(400);
|
||||
const c = await h.req("/browser/providers", {
|
||||
body: { name: "my-camofox", baseUrl: h.mock.url, accessKey: "wrong-key", enabled: true },
|
||||
});
|
||||
expect(c.status).toBe(200);
|
||||
const id = c.body.data.id as string;
|
||||
expect(c.body.data).toMatchObject({ managedBy: null, hasAccessKey: true, enabled: true });
|
||||
expect(JSON.stringify(c.body)).not.toContain("wrong-key");
|
||||
const dup = await h.req("/browser/providers", { body: { name: "my-camofox", baseUrl: h.mock.url } });
|
||||
expect(dup.status).toBe(409);
|
||||
// 错误凭据:/health 免认证是好的,但 /openapi.json 401 → 标离线并注明 auth
|
||||
const p1 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p1.body.data).toMatchObject({
|
||||
healthStatus: "offline",
|
||||
health: { status: "down", detail: { error: "auth" } },
|
||||
});
|
||||
const u = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: ACCESS_KEY } });
|
||||
expect(u.status).toBe(200);
|
||||
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p2.status).toBe(200);
|
||||
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
|
||||
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
|
||||
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
|
||||
const env = (await h.req("/browser/providers")).body.data.items.find((p: Loose) => p.name === ENV_PROVIDER_NAME);
|
||||
expect((await h.req(`/browser/providers/${env.id}`, { method: "DELETE" })).status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,17 @@ export function registerBrowserCapabilities(registry: CapabilityRegistry, svc: (
|
||||
agent(ctx.actorId),
|
||||
async (p, ref, x) => {
|
||||
await svc().limited(x.site);
|
||||
// 标签页全被上游回收(重启 / 空闲超时,已对账为 lost):在同一会话(同一 profile)里开新标签页,不重放旧引用
|
||||
if (!input.tabId && !p.sessionInfo(sid)?.tabs.some((t) => t.status === "open")) {
|
||||
const tab = await p.openTab(sid, { url: input.url, budget: x.budget, as: "automation" });
|
||||
return {
|
||||
url: tab.url ?? "",
|
||||
httpStatus: null,
|
||||
ok: true,
|
||||
attempts: 1,
|
||||
loginRequired: p.sessionInfo(sid)?.loginPage ?? false,
|
||||
};
|
||||
}
|
||||
const r = await p.navigate(ref, input.url, x.budget);
|
||||
return { ...r, loginRequired: p.sessionInfo(sid)?.loginPage ?? false };
|
||||
},
|
||||
|
||||
@@ -87,6 +87,7 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
|
||||
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
|
||||
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
|
||||
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));
|
||||
route(app, B.deleteProvider, async ({ params }) => svc().deleteProvider(params.id));
|
||||
route(app, B.probeProvider, async ({ params }) => {
|
||||
const [v] = await svc().listProviders(true, params.id);
|
||||
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BrowserError,
|
||||
type BrowserProvider,
|
||||
type BrowserSecretResolver,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
createCamofoxBrowserProvider,
|
||||
type DownloadSink,
|
||||
type ElementTarget,
|
||||
@@ -224,14 +225,21 @@ export class BrowserService {
|
||||
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
|
||||
*/
|
||||
syncEnvProvider(): Promise<void> {
|
||||
this.envSync ??= this.doSyncEnvProvider().catch((e) => {
|
||||
this.envSync = undefined;
|
||||
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
|
||||
});
|
||||
this.envSync ??= this.doSyncEnvProvider().then(
|
||||
(probe) => {
|
||||
// 首次登记后在后台探测一次,写回能力与上游版本(不阻塞当前请求;camofox 未就绪时下次"测试连通"再写)
|
||||
if (probe) void this.listProviders(true).catch(() => {});
|
||||
},
|
||||
(e) => {
|
||||
this.envSync = undefined;
|
||||
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
|
||||
},
|
||||
);
|
||||
return this.envSync;
|
||||
}
|
||||
|
||||
private async doSyncEnvProvider(): Promise<void> {
|
||||
/** 返回是否需要后台探测(已启用且还没有探测过能力) */
|
||||
private async doSyncEnvProvider(): Promise<boolean> {
|
||||
const c = this.ctx.config;
|
||||
const url = c?.BROWSER_PROVIDER_URL;
|
||||
const enabled = !!url && c.BROWSER_ENABLED !== false;
|
||||
@@ -244,7 +252,7 @@ export class BrowserService {
|
||||
if (!url) {
|
||||
if (row?.enabled)
|
||||
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
|
||||
if (row)
|
||||
@@ -259,6 +267,7 @@ export class BrowserService {
|
||||
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
|
||||
.onConflict((oc) => oc.column("name").doNothing())
|
||||
.execute();
|
||||
return enabled && !Object.keys(obj(row?.capabilities)).length;
|
||||
}
|
||||
|
||||
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
|
||||
@@ -300,7 +309,8 @@ export class BrowserService {
|
||||
): Promise<BrowserProviderView> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (body.baseUrl !== undefined || body.accessKey !== undefined))
|
||||
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (changesAddr || body.accessKey !== undefined))
|
||||
throw new BrowserServiceError(
|
||||
"conflict",
|
||||
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
|
||||
@@ -331,6 +341,25 @@ export class BrowserService {
|
||||
return this.providerView(id);
|
||||
}
|
||||
|
||||
/** 删除设置页登记的服务:有未关闭会话时拒绝(先关会话);环境变量登记的只能停用 */
|
||||
async deleteProvider(id: string): Promise<{ id: string }> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED)
|
||||
throw new BrowserServiceError("conflict", "该浏览器服务由环境变量登记,只能停用(BROWSER_ENABLED=false)");
|
||||
const open = await this.ctx.db
|
||||
.selectFrom("browser_session")
|
||||
.select("id")
|
||||
.where("provider_id", "=", id)
|
||||
.where("controller", "<>", "closed")
|
||||
.executeTakeFirst();
|
||||
if (open) throw new BrowserServiceError("conflict", "该浏览器服务还有未关闭的会话,先关闭会话");
|
||||
await this.ctx.db.deleteFrom("browser_provider").where("id", "=", id).execute();
|
||||
if (row.secret_ref) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
|
||||
this.providers.delete(id);
|
||||
return { id };
|
||||
}
|
||||
|
||||
private async providerView(id: string): Promise<BrowserProviderView> {
|
||||
const v = (await this.listProviders(false, id))[0];
|
||||
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
@@ -364,13 +393,20 @@ export class BrowserService {
|
||||
let caps: BrowserCapabilities | null = null;
|
||||
try {
|
||||
caps = await p.capabilities({ probe: health.status !== "down" });
|
||||
} catch {
|
||||
} catch (e) {
|
||||
caps = await p.capabilities();
|
||||
// /health 免认证,只看它会把"凭据错了"显示成健康:探测被 401/403 拒绝时标为离线并注明
|
||||
if (e instanceof BrowserError && e.kind === "auth")
|
||||
health = { ...health, status: "down", detail: { ...health.detail, error: "auth" } };
|
||||
}
|
||||
row = await this.ctx.db
|
||||
.updateTable("browser_provider")
|
||||
.set({
|
||||
capabilities: JSON.stringify(caps),
|
||||
// 上游版本有真机核验记录时,verified_at 取核验日期(不是探测时间)
|
||||
...(caps.upstreamVersion && CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]
|
||||
? { verified_at: new Date(`${CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]!.verifiedAt}T00:00:00Z`) }
|
||||
: {}),
|
||||
health_status: HEALTH[health.status],
|
||||
last_health_at: new Date(health.checkedAt),
|
||||
})
|
||||
@@ -934,7 +970,7 @@ export class BrowserService {
|
||||
try {
|
||||
out = await fn(loaded.provider, ref, { row, site, budget });
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found" && (e.httpStatus === 404 || e.httpStatus === 410)) {
|
||||
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
|
||||
await this.persist(id, loaded.provider, { needsSnapshot: true });
|
||||
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
BrowserActionResult,
|
||||
BrowserControlResult,
|
||||
BrowserNavigation,
|
||||
BrowserProviderCreate,
|
||||
BrowserProviderUpdate,
|
||||
BrowserProviderView,
|
||||
BrowserScreenshotView,
|
||||
BrowserSessionView,
|
||||
@@ -23,6 +25,50 @@ export function useBrowserProviders() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 设置页:浏览器服务登记 / 修改 / 删除 / 测试连通 ──
|
||||
|
||||
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({
|
||||
|
||||
@@ -103,7 +103,9 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
);
|
||||
|
||||
const caps = provider.capabilities;
|
||||
const unverified = !caps?.takeover.verified || !caps?.screenshot.verified;
|
||||
// 上游缺端点 → 不可用;有端点但没有真机核验记录 → 未核验(REQ §4.7.4)
|
||||
const unsupported = !!caps && (!caps.takeover.supported || !caps.screenshot.supported);
|
||||
const unverified = !unsupported && (!caps?.takeover.verified || !caps?.screenshot.verified);
|
||||
const conn: { tone: StatusTone; text: string } = paused
|
||||
? { tone: "neutral", text: "已暂停刷新" }
|
||||
: shot.isError
|
||||
@@ -147,6 +149,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
{human && leaseHolder ? `(${leaseHolder})` : ""}
|
||||
</StatusBadge>
|
||||
{waiting ? <StatusBadge tone="warning">{waiting}</StatusBadge> : null}
|
||||
{unsupported ? <StatusBadge tone="destructive">该浏览器服务不支持画面与输入</StatusBadge> : null}
|
||||
{unverified ? <StatusBadge tone="warning">画面与输入未对真实实例核验</StatusBadge> : null}
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{human ? (
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
BROWSER_CAPABILITY_KEYS,
|
||||
type BrowserCapabilityKey,
|
||||
BrowserProviderCreate,
|
||||
BrowserProviderUpdate,
|
||||
type BrowserProviderView,
|
||||
} from "@umt/contracts";
|
||||
import {
|
||||
useBrowserProviders,
|
||||
useCreateBrowserProvider,
|
||||
useDeleteBrowserProvider,
|
||||
useProbeBrowserProvider,
|
||||
useUpdateBrowserProvider,
|
||||
} from "~/api/browser.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Health, TestButton } from "./instances.tsx";
|
||||
import { type FormValues, optStr, ResourceSection, str } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 浏览器服务(camofox-browser):地址、访问凭据(只写,存凭证库)、启用、测试连通、能力核验状态。
|
||||
// 部署自带的 camofox 由环境变量登记为 camofox-env,地址与凭据只能改部署配置。
|
||||
|
||||
const CAP_LABEL: Record<BrowserCapabilityKey, string> = {
|
||||
sessions: "会话",
|
||||
tabs: "标签页",
|
||||
navigate: "导航",
|
||||
snapshot: "页面快照",
|
||||
screenshot: "截图",
|
||||
interact: "点击与输入",
|
||||
extract: "字段提取",
|
||||
downloadCapture: "下载捕获",
|
||||
takeover: "人工接管",
|
||||
persistentProfile: "登录保持",
|
||||
};
|
||||
|
||||
function Verified({ p }: { p: BrowserProviderView }) {
|
||||
const caps = p.capabilities;
|
||||
if (!caps) return <StatusBadge>未探测</StatusBadge>;
|
||||
const keys = BROWSER_CAPABILITY_KEYS.filter((k) => caps[k].supported);
|
||||
const ok = keys.filter((k) => caps[k].verified).length;
|
||||
if (ok === 0) return <StatusBadge tone="warning">未核验</StatusBadge>;
|
||||
return (
|
||||
<StatusBadge tone={ok === keys.length ? "success" : "warning"}>
|
||||
{ok === keys.length ? "已核验" : `已核验 ${ok}/${keys.length}`}
|
||||
{caps.upstreamVersion ? ` · ${caps.upstreamVersion}` : ""}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityTable({ p }: { p: BrowserProviderView }) {
|
||||
const caps = p.capabilities;
|
||||
if (!caps) return <p className="mt-4 text-caption text-muted-foreground">还没有探测过。点"测试连通"读取能力。</p>;
|
||||
return (
|
||||
<div className="mt-5 space-y-2">
|
||||
<h3 className="text-caption font-semibold">
|
||||
能力
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
上游版本 {caps.upstreamVersion ?? "未知"}
|
||||
{p.verifiedAt ? ` · 真机核验 ${p.verifiedAt.slice(0, 10)}` : ""}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{BROWSER_CAPABILITY_KEYS.map((k) => {
|
||||
const f = caps[k];
|
||||
return (
|
||||
<li key={k} className="flex items-start justify-between gap-3 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption font-medium">{CAP_LABEL[k]}</p>
|
||||
{f.note ? <p className="text-meta text-muted-foreground">{f.note}</p> : null}
|
||||
</div>
|
||||
{!f.supported ? (
|
||||
<StatusBadge tone="destructive">不可用</StatusBadge>
|
||||
) : f.verified ? (
|
||||
<StatusBadge tone="success">已核验</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="warning">未核验</StatusBadge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
"已核验"只来自对真实实例的逐项核验记录(见运行手册),探测只能判断接口在不在。未核验的能力在原站视图里标出。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserProvidersBlock() {
|
||||
const list = useBrowserProviders();
|
||||
const create = useCreateBrowserProvider();
|
||||
const update = useUpdateBrowserProvider();
|
||||
const remove = useDeleteBrowserProvider();
|
||||
const probe = useProbeBrowserProvider();
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="sites"
|
||||
anchor="browser"
|
||||
title="浏览器服务"
|
||||
description="原站视图和 Agent 打开站点网页用的 camofox-browser。部署自带的一台由环境变量登记(camofox-env)。"
|
||||
plain
|
||||
>
|
||||
<ResourceSection<BrowserProviderView>
|
||||
hooks={{ list, create, update, remove } as never}
|
||||
config={{
|
||||
noun: "浏览器服务",
|
||||
emptyHint: "没有浏览器服务时,原站视图不可用。",
|
||||
deleteHint: "有未关闭会话时不能删除。环境变量登记的只能停用。",
|
||||
drawerExtra: (r) => <CapabilityTable p={r} />,
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
header: "名称",
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{r.name}
|
||||
{r.managedBy === "env" ? <StatusBadge>环境变量</StatusBadge> : null}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "key",
|
||||
header: "访问凭据",
|
||||
hideBelow: "md",
|
||||
cell: (r) => (r.hasAccessKey ? "已设置" : "未设置"),
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
header: "状态",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<StatusBadge tone={r.enabled ? "success" : "neutral"}>{r.enabled ? "启用" : "停用"}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
|
||||
{ key: "verified", header: "核验", hideBelow: "lg", cell: (r) => <Verified p={r} /> },
|
||||
],
|
||||
rowActions: (r) => (
|
||||
<TestButton
|
||||
label="测试连通"
|
||||
pending={probe.isPending}
|
||||
run={async () => {
|
||||
const v = await probe.mutateAsync(r.id);
|
||||
const auth = (v.health?.detail as { error?: string } | undefined)?.error === "auth";
|
||||
return {
|
||||
ok: v.health?.status === "ok",
|
||||
message: auth ? "访问凭据不对" : v.health?.status === "degraded" ? "浏览器服务正在恢复" : "连不上",
|
||||
version: v.capabilities?.upstreamVersion ?? null,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
),
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "名称",
|
||||
kind: "text",
|
||||
required: true,
|
||||
createOnly: true,
|
||||
placeholder: "例如:家里的 camofox",
|
||||
},
|
||||
{
|
||||
name: "baseUrl",
|
||||
label: "服务地址",
|
||||
kind: "url",
|
||||
required: true,
|
||||
placeholder: "http://camofox:9377",
|
||||
hint: "环境变量登记的服务改部署配置里的 BROWSER_PROVIDER_URL",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "accessKey",
|
||||
label: "访问凭据",
|
||||
kind: "password",
|
||||
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
|
||||
editHint: "留空不修改",
|
||||
},
|
||||
{ name: "enabled", label: "启用", kind: "switch" },
|
||||
],
|
||||
initial: { name: "", baseUrl: "", accessKey: "", enabled: true },
|
||||
fromRow: (r) => ({ name: r.name, baseUrl: r.baseUrl ?? "", accessKey: "", enabled: r.enabled }),
|
||||
toBody: (v: FormValues, mode) =>
|
||||
mode === "create"
|
||||
? {
|
||||
name: str(v.name),
|
||||
baseUrl: str(v.baseUrl),
|
||||
accessKey: optStr(v.accessKey),
|
||||
enabled: v.enabled === true,
|
||||
}
|
||||
: { baseUrl: str(v.baseUrl), accessKey: optStr(v.accessKey), enabled: v.enabled === true },
|
||||
createSchema: BrowserProviderCreate,
|
||||
updateSchema: BrowserProviderUpdate,
|
||||
}}
|
||||
/>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { ArrowRight } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { BrowserProvidersBlock } from "./browser-providers.tsx";
|
||||
import { num, str } from "./resource-section.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理。
|
||||
// 站点默认(site-center §8):站点没单独设置时用的限流、快照周期、UA、代理;浏览器服务(BR-1)。
|
||||
// 站点、凭证、账号在「站点」页管理;站点级覆盖在站点的"编辑"里。
|
||||
|
||||
const DEFAULTS_CARD: CardSpec = {
|
||||
@@ -91,6 +92,7 @@ export function SitesSection() {
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsGate>{(s) => <SettingsCard section="sites" spec={DEFAULTS_CARD} settings={s} />}</SettingsGate>
|
||||
<BrowserProvidersBlock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user