937 lines
30 KiB
React
937 lines
30 KiB
React
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 (
|
||
<StatusPill
|
||
tone={
|
||
state === "running"
|
||
? "success"
|
||
: state === "exited"
|
||
? "warning"
|
||
: "neutral"
|
||
}
|
||
label={known ? stateText[state] : "未知状态"}
|
||
sub={known ? undefined : state}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
icon="ri-play-line"
|
||
aria-label={startLabel}
|
||
disabled={busy || running || block !== ""}
|
||
onClick={() => onAction(runtime, "start")}
|
||
>
|
||
启动
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
icon="ri-stop-line"
|
||
aria-label={`停止 ${runtime.name}`}
|
||
disabled={busy || !running}
|
||
onClick={() => onAction(runtime, "stop")}
|
||
>
|
||
停止
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
icon="ri-arrow-up-circle-line"
|
||
aria-label={`升级 ${runtime.name}`}
|
||
disabled={busy}
|
||
onClick={() => onAction(runtime, "upgrade")}
|
||
>
|
||
升级
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
icon="ri-delete-bin-line"
|
||
aria-label={`回收 ${runtime.name}`}
|
||
disabled={busy}
|
||
onClick={() => onAction(runtime, "recycle")}
|
||
>
|
||
回收
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SchedulePill({ runtime }) {
|
||
const block = scheduleBlock(runtime);
|
||
return (
|
||
<StatusPill
|
||
tone={block ? "warning" : "success"}
|
||
label={block || "可调度"}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
|
||
const [version, setVersion] = useState("");
|
||
const enabled = images.filter((image) => image.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?.image_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((image) => ({
|
||
value: image.version,
|
||
label: `${image.version}${image.version === target?.image_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.image_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.image_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: 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 (
|
||
<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}
|
||
images={images}
|
||
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: 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 (
|
||
<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="用于容器名、数据卷与 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="容器调度到该网关"
|
||
>
|
||
<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={
|
||
enabledImages.length === 0
|
||
? "请先在「镜像版本」添加并启用"
|
||
: undefined
|
||
}
|
||
helper="环境使用的浏览器镜像"
|
||
>
|
||
<Select
|
||
id="env-version"
|
||
value={form.image_version}
|
||
onChange={(event) =>
|
||
update("image_version", event.target.value)
|
||
}
|
||
options={enabledImages.map((item) => ({
|
||
value: item.version,
|
||
label: item.version,
|
||
}))}
|
||
placeholder="选择版本"
|
||
disabled={enabledImages.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.image_version}
|
||
</span>,
|
||
],
|
||
[
|
||
"固定出口",
|
||
<span className="anywhere">
|
||
{runtime.network_exit?.id || "未绑定"} · 健康:
|
||
{runtime.network_exit?.health_status || "未知"}
|
||
</span>,
|
||
],
|
||
[
|
||
"绑定版本 / 运行实例",
|
||
<span className="anywhere">
|
||
{runtime.binding_version} ·{" "}
|
||
{runtime.runtime_instance_id || "无"}
|
||
</span>,
|
||
],
|
||
]}
|
||
/>
|
||
{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>
|
||
);
|
||
}
|