Files
creator-hub/web/src/features/environments/BrowsersPage.jsx
T

959 lines
31 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "../../shared/router.jsx";
import { useDataProvider, useList, useOne } from "../../shared/hooks/dataHooks.js";
import {
Alert,
Button,
Card,
CardContent,
Copyable,
DetailList,
Field,
Input,
Modal,
PageHeader,
PageState,
Select,
StatusPill,
conflictMessage,
} from "../../shared/ui/ui.jsx";
import { useTitle, useUnsavedChanges } from "../../shared/hooks/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: "运行中",
stopped: "已停止",
degraded: "异常",
failed: "失败待清理",
cleanup_pending: "待清理",
gateway_unreachable: "网关不可达",
runtime_missing: "运行实例缺失",
recorded: "已记录",
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: "",
browser_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 (
<StatusPill
tone={
state === "running"
? "success"
: state === "stopped"
? "warning"
: "neutral"
}
label={known ? stateText[state] : "未知状态"}
sub={known ? undefined : state}
/>
);
}
function RuntimeActions({ runtime, busy, onAction }) {
const running = runtime.state === "running";
const stopping = [
"running",
"degraded",
"starting",
"failed",
"cleanup_pending",
].includes(runtime.state);
const gatewayUnavailable = runtime.gateway_reachable === false;
const block =
runtime.schedule_block_reason === "runtime_missing"
? ""
: scheduleBlock(runtime);
const startLabel = `启动 ${runtime.name}${block ? `(${block})` : ""}`;
return (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
icon="ri-play-line"
aria-label={startLabel}
disabled={busy || running || gatewayUnavailable || block !== ""}
onClick={() => onAction(runtime, "start")}
>
启动
</Button>
<Button
size="sm"
icon="ri-stop-line"
aria-label={`停止 ${runtime.name}`}
disabled={busy || !stopping || gatewayUnavailable}
onClick={() => onAction(runtime, "stop")}
>
停止
</Button>
<Button
size="sm"
icon="ri-arrow-up-circle-line"
aria-label={`升级 ${runtime.name}`}
disabled={busy || gatewayUnavailable}
onClick={() => onAction(runtime, "upgrade")}
>
升级
</Button>
<Button
size="sm"
variant="danger"
icon="ri-delete-bin-line"
aria-label={`回收 ${runtime.name}`}
disabled={busy || gatewayUnavailable}
onClick={() => onAction(runtime, "recycle")}
>
回收
</Button>
</div>
);
}
function SchedulePill({ runtime }) {
const block = scheduleBlock(runtime);
return (
<StatusPill
tone={block ? "warning" : "success"}
label={block || "可调度"}
/>
);
}
function UpgradeDialog({ target, versions, busy, onClose, onConfirm }) {
const [version, setVersion] = useState("");
const enabled = versions.filter((item) => item.enabled);
useEffect(() => {
setVersion("");
}, [target]);
return (
<Modal
open={target !== null}
onClose={busy ? undefined : onClose}
title={`升级 ${target ? `${target.name}(${target.alias})` : ""}`}
size="sm"
labelledBy="upgrade-title"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="primary"
busy={busy}
busyText="升级中…"
disabled={version === "" || version === target?.browser_version}
onClick={() => onConfirm(version)}
>
确认升级
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted">
环境将按目标浏览器版本重启,Profile 目录与指纹参数保持不变。
</p>
<Field id="upgrade-version" label="目标浏览器版本" required>
<Select
id="upgrade-version"
value={version}
onChange={(event) => setVersion(event.target.value)}
options={enabled.map((item) => ({
value: item.version,
label: `${item.version}${item.version === target?.browser_version ? "(当前)" : ""}`,
}))}
placeholder="选择版本"
/>
</Field>
</div>
</Modal>
);
}
function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
return (
<div className="hidden overflow-x-auto rounded-lg border border-hairline bg-white md:block">
<table className="w-full min-w-[860px] table-fixed text-sm">
<thead>
<tr className="border-b border-hairline text-left text-xs text-muted">
<th className="w-[19%] px-4 py-2.5 font-medium">环境</th>
<th className="w-[12%] px-4 py-2.5 font-medium">状态</th>
<th className="w-[23%] px-4 py-2.5 font-medium">固定绑定</th>
<th className="w-[16%] px-4 py-2.5 font-medium">CDP</th>
<th className="w-[30%] px-4 py-2.5 font-medium">操作</th>
</tr>
</thead>
<tbody>
{runtimes.map((runtime) => (
<tr
key={runtime.alias}
className="border-b border-hairline last:border-0"
>
<td
className="cursor-pointer px-4 py-3"
onClick={() => onRowClick(runtime)}
>
<p className="font-semibold">{runtime.name}</p>
<p className="anywhere text-xs text-muted">
{runtime.alias} · seed {runtime.fingerprint?.seed}
</p>
</td>
<td className="px-4 py-3">
<StatePill state={runtime.state} />
</td>
<td className="px-4 py-3">
<p className="text-sm">{runtime.account_id || "未绑定账号"}</p>
<p className="anywhere text-xs text-muted">
{runtime.network_exit_id || "当前机器直连"} ·{" "}
{runtime.browser_version}
</p>
<div className="mt-1">
<SchedulePill runtime={runtime} />
</div>
</td>
<td className="px-4 py-3">
{runtime.endpoint ? (
<Copyable value={runtime.endpoint} />
) : (
<span className="text-sm text-muted">—</span>
)}
</td>
<td className="px-4 py-3">
<RuntimeActions
runtime={runtime}
busy={busy === runtime.alias}
onAction={onAction}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function RuntimeCards({ runtimes, busy, onAction }) {
return (
<div className="space-y-4 md:hidden">
{runtimes.map((runtime) => (
<Card key={runtime.alias}>
<CardContent className="space-y-3">
<div>
<p className="font-semibold">{runtime.name}</p>
<p className="anywhere text-xs text-muted">
{runtime.alias} · {runtime.browser_version} · seed{" "}
{runtime.fingerprint?.seed}
</p>
</div>
<StatePill state={runtime.state} />
<p className="text-sm">
账号:{runtime.account_id || "未绑定"} · 网络出口:
{runtime.network_exit_id || "当前机器直连"}
</p>
<SchedulePill runtime={runtime} />
{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}
<RuntimeActions
runtime={runtime}
busy={busy === runtime.alias}
onAction={onAction}
/>
</CardContent>
</Card>
))}
</div>
);
}
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: versionsResult } = useList({
resource: "browser-versions",
});
const versions = versionsResult.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 (
<div>
<PageHeader
icon="ri-server-line"
title="运行环境"
description="启动、停止、升级并回收隔离的指纹浏览器运行环境"
>
<Button
variant="primary"
icon="ri-add-line"
onClick={() => navigate("/browsers/new")}
>
创建环境
</Button>
</PageHeader>
{actionError ? (
<Alert variant="destructive" className="mb-4">
{actionError}
<Button size="sm" onClick={() => query.refetch()}>
重试
</Button>
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
empty={runtimes.length === 0}
emptyText="暂无运行环境,创建第一个隔离 Profile。"
>
<RuntimeTable
runtimes={runtimes}
busy={busy}
onAction={handleAction}
onRowClick={(runtime) =>
navigate(`/browsers/${encodeURIComponent(runtime.alias)}`)
}
/>
<RuntimeCards runtimes={runtimes} busy={busy} onAction={handleAction} />
</PageState>
<UpgradeDialog
target={upgradeTarget}
versions={versions}
busy={busy === upgradeTarget?.alias}
onClose={() => setUpgradeTarget(null)}
onConfirm={upgrade}
/>
</div>
);
}
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: versionsResult } = useList({ resource: "browser-versions" });
const versions = versionsResult.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 enabledVersions = versions.filter((item) => item.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.browser_version === "" && enabledVersions.length > 0)
update("browser_version", enabledVersions[0].version);
}, [enabledVersions, form.browser_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.browser_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,
browser_version: form.browser_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 (
<div className="max-w-[960px]">
<PageHeader
title="创建运行环境"
description="为已授权且暂停的账号绑定网关、浏览器版本、指纹与网络出口"
>
<Button icon="ri-arrow-left-line" onClick={() => navigate("/browsers")}>
返回运行环境
</Button>
</PageHeader>
{submitError ? (
<Alert variant="destructive" className="mb-4">
{submitError}
</Alert>
) : null}
<Card>
<CardContent>
<form onSubmit={submit} noValidate className="space-y-6">
<div className="grid gap-5 sm:grid-cols-2">
<Field
id="env-name"
label="环境名称"
required
helper="展示名称,支持中文,如:店铺一号"
>
<Input
id="env-name"
required
maxLength={64}
value={form.name}
onChange={(event) => update("name", event.target.value)}
/>
</Field>
<Field
id="env-alias"
label="别名"
required
error={
form.alias !== "" && !aliasPattern.test(form.alias)
? "小写字母、数字与中划线,最长 32 字符"
: undefined
}
helper="用于 Profile 标识与 API 路径"
>
<Input
id="env-alias"
required
pattern="[a-z0-9][a-z0-9-]{0,31}"
value={form.alias}
onChange={(event) => update("alias", event.target.value)}
placeholder="如:shop-01"
/>
</Field>
<Field
id="env-gateway"
label="网关"
required
error={
gateways.length === 0
? "请先在「网关管理」注册网关"
: undefined
}
helper="环境由该 native gateway 管理"
>
<Select
id="env-gateway"
value={form.gateway}
onChange={(event) => update("gateway", event.target.value)}
options={gateways.map((item) => ({
value: item.name,
label: item.name,
}))}
placeholder="选择网关"
disabled={gateways.length === 0}
/>
</Field>
<Field
id="env-version"
label="浏览器版本"
required
error={
enabledVersions.length === 0
? "请先在「浏览器版本」添加并启用"
: undefined
}
helper="环境使用的预安装浏览器版本"
>
<Select
id="env-version"
value={form.browser_version}
onChange={(event) =>
update("browser_version", event.target.value)
}
options={enabledVersions.map((item) => ({
value: item.version,
label: item.version,
}))}
placeholder="选择版本"
disabled={enabledVersions.length === 0}
/>
</Field>
<Field
id="env-seed"
label="Fingerprint Seed"
required
helper="1..2147483647,派生指纹参数"
>
<Input
id="env-seed"
type="number"
min={1}
max={2147483647}
required
value={form.seed}
onChange={(event) => update("seed", event.target.value)}
/>
</Field>
<Field
id="env-account"
label="社媒账号"
required
error={
pausedAccounts.length === 0
? "请先准备已授权且暂停的账号"
: undefined
}
helper="绑定后该账号固定使用此环境"
>
<Select
id="env-account"
value={form.account_id}
onChange={(event) => update("account_id", event.target.value)}
options={pausedAccounts.map((item) => ({
value: item.id,
label: `${item.id} · ${item.platform}`,
}))}
placeholder="选择账号"
disabled={pausedAccounts.length === 0}
/>
</Field>
<Field
id="env-exit"
label="网络出口"
helper="留空时使用当前机器网络出口直连"
>
<Select
id="env-exit"
value={form.network_exit_id}
onChange={(event) =>
update("network_exit_id", event.target.value)
}
options={healthyExits.map((item) => ({
value: item.id,
label: `${item.id} · ${item.protocol}://${item.host}:${item.port}`,
}))}
placeholder="当前机器直连"
emptyOption={{ value: "", label: "当前机器直连" }}
/>
</Field>
</div>
<div>
<button
type="button"
onClick={() => setAdvancedOpen((current) => !current)}
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
高级指纹参数{advancedOpen ? "(收起)" : "(展开)"}
<i
className={
advancedOpen ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"
}
aria-hidden="true"
/>
</button>
{advancedOpen ? (
<div className="mt-4 grid gap-5 border-t border-hairline pt-4 sm:grid-cols-3">
<Field id="env-platform" label="操作系统 platform">
<Select
id="env-platform"
value={form.platform}
onChange={(event) =>
update("platform", event.target.value)
}
options={platformOptions}
emptyOption={{ value: "", label: "默认" }}
placeholder="默认"
/>
</Field>
<Field
id="env-platform-version"
label="系统版本 platform-version"
helper="如 11.0.0"
>
<Input
id="env-platform-version"
value={form.platform_version}
onChange={(event) =>
update("platform_version", event.target.value)
}
/>
</Field>
<Field id="env-brand" label="浏览器品牌 brand">
<Select
id="env-brand"
value={form.brand}
onChange={(event) => update("brand", event.target.value)}
options={brandOptions}
emptyOption={{ value: "", label: "默认 Chromium" }}
placeholder="默认 Chromium"
/>
</Field>
<Field
id="env-brand-version"
label="品牌版本 brand-version"
helper="如 132.0.6834.159"
>
<Input
id="env-brand-version"
value={form.brand_version}
onChange={(event) =>
update("brand_version", event.target.value)
}
/>
</Field>
<Field
id="env-concurrency"
label="CPU 核心数 hardware-concurrency"
helper="留空由种子生成"
>
<Input
id="env-concurrency"
type="number"
min={1}
max={128}
value={form.hardware_concurrency}
onChange={(event) =>
update("hardware_concurrency", event.target.value)
}
/>
</Field>
<Field id="env-lang" label="语言 lang" helper="如 zh-CN">
<Input
id="env-lang"
value={form.lang}
onChange={(event) => update("lang", event.target.value)}
/>
</Field>
<Field
id="env-accept-lang"
label="接受语言 accept-lang"
helper="如 zh-CN,en-US"
>
<Input
id="env-accept-lang"
value={form.accept_lang}
onChange={(event) =>
update("accept_lang", event.target.value)
}
/>
</Field>
<Field
id="env-timezone"
label="时区 timezone"
helper="如 Asia/Shanghai"
>
<Input
id="env-timezone"
value={form.timezone}
onChange={(event) =>
update("timezone", event.target.value)
}
/>
</Field>
<div className="space-y-1.5">
<p className="text-sm font-medium">
禁用指纹伪装 disable-spoofing
</p>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{spoofingFields.map((field) => (
<label
key={field}
className="flex cursor-pointer items-center gap-1.5 text-sm"
>
<input
type="checkbox"
checked={form.disable_spoofing.includes(field)}
onChange={() => toggleSpoofing(field)}
aria-label={`禁用伪装 ${field}`}
className="size-4 accent-primary"
/>
{field}
</label>
))}
</div>
</div>
</div>
) : null}
</div>
<div className="flex gap-3">
<Button
variant="primary"
type="submit"
busy={submitBusy}
busyText="创建中…"
disabled={!valid}
>
创建环境
</Button>
<Button type="button" onClick={() => navigate("/browsers")}>
取消
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
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 <PageState pending />;
if (error || !runtime)
return (
<Alert variant="destructive">{error?.message || "运行环境不存在"}</Alert>
);
return (
<div>
<PageHeader
title="运行环境详情"
description={`${runtime.name}(${runtime.alias})`}
>
<Button icon="ri-arrow-left-line" as={Link} to="/browsers">
返回运行环境
</Button>
</PageHeader>
<Card>
<CardContent className="space-y-4">
<StatePill state={runtime.state} />
<DetailList
rows={[
[
"账号与浏览器版本",
<span className="anywhere">
{runtime.account_id} · {runtime.browser_version}
</span>,
],
[
"固定出口",
<span className="anywhere">
{runtime.network_exit_id || "未绑定"} · 健康:
{runtime.network_exit_health || "未知"}
</span>,
],
[
"绑定版本 / 运行实例",
<span className="anywhere">
{runtime.binding_version} ·{" "}
{runtime.runtime_instance_id || "无"}
</span>,
],
]}
/>
<div className="space-y-1 text-sm text-muted">
<p>网关:{runtime.gateway_reachable ? "可达" : "不可达"}</p>
{runtime.runtime_node_id ? (
<p>节点:{runtime.runtime_node_id}</p>
) : null}
{runtime.runtime_cleanup_state === "pending" ? (
<p className="text-danger">
资源待清理:{runtime.runtime_cleanup_error || "原因未返回"}
</p>
) : null}
</div>
{runtime.endpoint ? (
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
<dt className="text-muted">CDP</dt>
<dd>
<Copyable value={runtime.endpoint} />
</dd>
</div>
) : null}
<div className="flex flex-wrap gap-3">
<Button
as={Link}
to={`/accounts/${encodeURIComponent(runtime.account_id)}`}
>
查看账号
</Button>
{runtime.network_exit_id ? (
<Button
as={Link}
to={`/network-exits/${encodeURIComponent(runtime.network_exit_id)}`}
>
查看出口
</Button>
) : null}
</div>
</CardContent>
</Card>
</div>
);
}