import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
Alert,
Button,
Card,
CardContent,
Copyable,
DetailList,
Field,
Input,
Modal,
PageHeader,
PageState,
Select,
StatusPill,
conflictMessage,
useUnsavedChanges,
} from "./lib/ui.jsx";
import { useTitle } from "./lib/hooks.js";
const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/;
const platformOptions = ["windows", "linux", "macos"].map((value) => ({
value,
label: value,
}));
const brandOptions = ["Chrome", "Edge", "Opera", "Vivaldi"].map((value) => ({
value,
label: value,
}));
const spoofingFields = ["font", "audio", "canvas", "clientrects", "gpu"];
const stateText = {
created: "已创建",
running: "运行中",
exited: "已停止",
restarting: "重启中",
paused: "已暂停",
missing: "未部署",
};
const blockText = {
account_revoked: "授权已撤销",
account_paused: "账号已暂停",
binding_missing: "绑定缺失",
network_exit_missing: "固定出口缺失",
network_exit_unhealthy: "固定出口不健康",
runtime_stop_pending: "停止结果待确认",
runtime_missing: "运行实例缺失",
};
const scheduleBlock = (runtime) =>
runtime.schedule_status === "ready"
? ""
: blockText[runtime.schedule_block_reason] ||
runtime.schedule_block_reason ||
"不可调度";
const createInitial = {
name: "",
alias: "",
gateway: "",
image_version: "",
account_id: "",
network_exit_id: "",
seed: "",
platform: "",
platform_version: "",
brand: "",
brand_version: "",
hardware_concurrency: "",
lang: "",
accept_lang: "",
timezone: "",
disable_spoofing: [],
};
// 高级字段空值不提交,收敛到 seed 驱动。
function nextFingerprintSeed(runtimes) {
const used = new Set(
runtimes
.map((runtime) => Number(runtime.fingerprint?.seed))
.filter((seed) => Number.isInteger(seed) && seed > 0),
);
let seed = 1000;
while (used.has(seed)) seed += 1;
return String(seed);
}
function fingerprintPayload(form) {
const payload = { seed: Number(form.seed) };
const advanced = {
platform: form.platform,
platform_version: form.platform_version,
brand: form.brand,
brand_version: form.brand_version,
lang: form.lang,
accept_lang: form.accept_lang,
timezone: form.timezone,
};
for (const [key, value] of Object.entries(advanced))
if (value !== "") payload[key] = value;
if (form.hardware_concurrency !== "")
payload.hardware_concurrency = Number(form.hardware_concurrency);
if (form.disable_spoofing.length > 0)
payload.disable_spoofing = form.disable_spoofing.join(",");
return payload;
}
function StatePill({ state }) {
const known = Object.hasOwn(stateText, state);
return (
);
}
function RuntimeActions({ runtime, busy, onAction }) {
const running = runtime.state === "running";
const block =
runtime.schedule_block_reason === "runtime_missing"
? ""
: scheduleBlock(runtime);
const startLabel = `启动 ${runtime.name}${block ? `(${block})` : ""}`;
return (
);
}
function SchedulePill({ runtime }) {
const block = scheduleBlock(runtime);
return (
);
}
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
const [version, setVersion] = useState("");
const enabled = images.filter((image) => image.enabled);
useEffect(() => {
setVersion("");
}, [target]);
return (
>
}
>
容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。
);
}
function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
return (
| 环境 |
状态 |
固定绑定 |
CDP |
操作 |
{runtimes.map((runtime) => (
| onRowClick(runtime)}
>
{runtime.name}
{runtime.alias} · seed {runtime.fingerprint?.seed}
|
|
{runtime.account_id || "未绑定账号"}
{runtime.network_exit_id || "当前机器直连"} ·{" "}
{runtime.image_version}
|
{runtime.endpoint ? (
) : (
—
)}
|
|
))}
);
}
function RuntimeCards({ runtimes, busy, onAction }) {
return (
{runtimes.map((runtime) => (
{runtime.name}
{runtime.alias} · {runtime.image_version} · seed{" "}
{runtime.fingerprint?.seed}
账号:{runtime.account_id || "未绑定"} · 网络出口:
{runtime.network_exit_id || "当前机器直连"}
{runtime.endpoint ? : null}
))}
);
}
export function BrowserList() {
const navigate = useNavigate();
const dataProvider = useDataProvider()("default");
const [busy, setBusy] = useState("");
const [actionError, setActionError] = useState("");
const [upgradeTarget, setUpgradeTarget] = useState(null);
const { result, query } = useList({ resource: "browsers" });
const error = query.error;
const isPending = query.isPending;
const runtimes = result.data ?? [];
const { result: imageResult } = useList({
resource: "browser-images",
});
const images = imageResult.data ?? [];
useTitle("CreatorHub · 运行环境");
async function handleAction(runtime, action) {
if (
action === "recycle" &&
!window.confirm(
`回收 ${runtime.name}(${runtime.alias})?容器将被删除,Profile 数据卷保留。`,
)
)
return;
if (action === "upgrade") {
setUpgradeTarget(runtime);
return;
}
await runAction(runtime, action);
}
async function runAction(runtime, action, data) {
setBusy(runtime.alias);
setActionError("");
try {
await dataProvider.browserAction(runtime.alias, action, data);
await query.refetch();
} catch (reason) {
setActionError(reason.message);
} finally {
setBusy("");
}
}
async function upgrade(version) {
await runAction(upgradeTarget, "upgrade", { version });
setUpgradeTarget(null);
}
return (
{actionError ? (
{actionError}
) : null}
navigate(`/browsers/${encodeURIComponent(runtime.alias)}`)
}
/>
setUpgradeTarget(null)}
onConfirm={upgrade}
/>
);
}
export function BrowserCreatePage() {
const navigate = useNavigate();
const dataProvider = useDataProvider()("default");
const [form, setForm] = useState(createInitial);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [submitBusy, setSubmitBusy] = useState(false);
const [submitError, setSubmitError] = useState("");
const { result: runtimesResult, query: runtimesQuery } = useList({
resource: "browsers",
});
const { result: gatewayResult } = useList({ resource: "gateways" });
const gateways = gatewayResult.data ?? [];
const runtimes = runtimesResult.data ?? [];
const { result: imageResult } = useList({ resource: "browser-images" });
const images = imageResult.data ?? [];
const { result: accountResult } = useList({ resource: "accounts" });
const accounts = accountResult.data ?? [];
const { result: exitResult } = useList({ resource: "network-exits" });
const networkExits = exitResult.data ?? [];
useTitle("CreatorHub · 创建运行环境");
const enabledImages = images.filter((image) => image.enabled);
const pausedAccounts = accounts.filter(
(account) =>
account.authorization_status === "authorized" &&
account.runtime_status === "paused",
);
const healthyExits = networkExits.filter(
(item) => item.health_status === "healthy",
);
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const toggleSpoofing = (field) =>
setForm((current) => ({
...current,
disable_spoofing: current.disable_spoofing.includes(field)
? current.disable_spoofing.filter((item) => item !== field)
: [...current.disable_spoofing, field],
}));
// 选中项为空且列表就绪时,默认选中第一个可选项,减少必填噪音。
useEffect(() => {
if (form.gateway === "" && gateways.length > 0)
update("gateway", gateways[0].name);
}, [gateways, form.gateway]);
useEffect(() => {
if (form.image_version === "" && enabledImages.length > 0)
update("image_version", enabledImages[0].version);
}, [enabledImages, form.image_version]);
useEffect(() => {
if (form.account_id === "" && pausedAccounts[0]?.id)
update("account_id", pausedAccounts[0].id);
}, [pausedAccounts, form.account_id]);
useEffect(() => {
if (form.seed === "" && runtimesResult.data !== undefined)
update("seed", nextFingerprintSeed(runtimes));
}, [form.seed, runtimes, runtimesResult.data]);
useUnsavedChanges(
Boolean(form.name || form.alias || form.seed || advancedOpen),
);
const seed = Number(form.seed);
const valid =
form.name.trim() !== "" &&
aliasPattern.test(form.alias) &&
form.gateway !== "" &&
form.image_version !== "" &&
form.account_id !== "" &&
Number.isInteger(seed) &&
seed >= 1 &&
seed <= 2147483647;
async function submit(event) {
event.preventDefault();
if (!valid) return;
setSubmitBusy(true);
setSubmitError("");
try {
await dataProvider.create({
resource: "browsers",
variables: {
alias: form.alias,
name: form.name.trim(),
gateway: form.gateway,
image_version: form.image_version,
account_id: form.account_id,
network_exit_id: form.network_exit_id,
fingerprint: fingerprintPayload(form),
},
});
await runtimesQuery.refetch();
navigate("/browsers");
} catch (reason) {
setSubmitError(
reason.status === 503
? `环境不可用(503):${reason.message}`
: conflictMessage(
reason,
"账号或运行环境已有活动绑定;表单内容已保留。",
),
);
} finally {
setSubmitBusy(false);
}
}
return (
{submitError ? (
{submitError}
) : null}
);
}
export function BrowserDetail() {
const { id } = useParams();
const { result, error, isPending } = useOne({
resource: "browsers",
id,
queryOptions: { retry: false },
});
const runtime = result;
useTitle("CreatorHub · 环境详情");
if (isPending) return ;
if (error || !runtime)
return (
{error?.message || "运行环境不存在"}
);
return (
{runtime.account_id} · {runtime.image_version}
,
],
[
"固定出口",
{runtime.network_exit?.id || "未绑定"} · 健康:
{runtime.network_exit?.health_status || "未知"}
,
],
[
"绑定版本 / 运行实例",
{runtime.binding_version} ·{" "}
{runtime.runtime_instance_id || "无"}
,
],
]}
/>
{runtime.endpoint ? (
CDP
) : null}
{runtime.network_exit?.id ? (
) : null}
);
}