refactor: migrate browser gateway to native xvfb

This commit is contained in:
2026-09-18 16:50:18 +08:00
parent 7e3808cf4c
commit e8f2c192ba
86 changed files with 6104 additions and 5323 deletions
+1 -1
View File
@@ -240,7 +240,7 @@ export function AccountEditPage() {
variant: "destructive",
text: conflictMessage(
qrError,
"登录二维码获取失败;请检查运行环境、网关和镜像配置",
"登录二维码获取失败;请检查运行环境、网关和浏览器版本配置",
),
});
}
@@ -15,29 +15,30 @@ import {
import { useTitle } from "./lib/hooks.js";
const versionPattern = /^\d[A-Za-z0-9._-]{0,63}$/;
const refPattern = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$/;
const pathPattern = /^\/[A-Za-z0-9._+~/-]{1,4095}$/;
function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
const [form, setForm] = useState({ version: "", image_ref: "", note: "" });
function BrowserVersionCreateModal({ open, onClose, onSubmit, busy, error }) {
const [form, setForm] = useState({ version: "", browser_path: "", note: "" });
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const valid =
versionPattern.test(form.version) && refPattern.test(form.image_ref);
versionPattern.test(form.version) && pathPattern.test(form.browser_path);
const versionInvalid =
form.version !== "" && !versionPattern.test(form.version);
const refInvalid = form.image_ref !== "" && !refPattern.test(form.image_ref);
const pathInvalid =
form.browser_path !== "" && !pathPattern.test(form.browser_path);
async function submit(event) {
event.preventDefault();
if (!valid) return;
const created = await onSubmit({
version: form.version,
image_ref: form.image_ref,
browser_path: form.browser_path,
note: form.note,
enabled: true,
});
if (created) {
setForm({ version: "", image_ref: "", note: "" });
setForm({ version: "", browser_path: "", note: "" });
onClose();
}
}
@@ -46,8 +47,8 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
<Modal
open={open}
onClose={onClose}
title="添加镜像版本"
labelledBy="image-create-title"
title="添加浏览器版本"
labelledBy="browser-version-create-title"
footer={
<>
<Button onClick={onClose} disabled={busy}>
@@ -56,7 +57,7 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
<Button
variant="primary"
type="submit"
form="image-create-form"
form="browser-version-create-form"
busy={busy}
busyText="保存中…"
disabled={!valid}
@@ -66,7 +67,7 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
</>
}
>
<form id="image-create-form" onSubmit={submit} noValidate>
<form id="browser-version-create-form" onSubmit={submit} noValidate>
{error ? (
<Alert variant="destructive" className="mb-4">
{error.message}
@@ -74,14 +75,14 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
) : null}
<div className="space-y-4">
<Field
id="image-version"
id="browser-version"
label="版本"
required
error={versionInvalid ? "数字开头,最长 64 字符" : undefined}
helper="镜像版本号,如 148.0.7778.215"
helper="浏览器版本号,如 148.0.7778.215"
>
<Input
id="image-version"
id="browser-version"
required
maxLength={64}
value={form.version}
@@ -90,25 +91,29 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
/>
</Field>
<Field
id="image-ref"
label="镜像引用"
id="browser-path"
label="浏览器路径"
required
error={refInvalid ? "格式不正确" : undefined}
helper="registry/repo:tag 或 @sha256 摘要"
error={pathInvalid ? "格式不正确" : undefined}
helper="gateway 节点上的预安装浏览器绝对路径"
>
<Input
id="image-ref"
id="browser-path"
required
maxLength={301}
value={form.image_ref}
onChange={(event) => update("image_ref", event.target.value)}
placeholder="registry/repo:tag 或 @sha256 摘要"
invalid={refInvalid}
maxLength={4096}
value={form.browser_path}
onChange={(event) => update("browser_path", event.target.value)}
placeholder="gateway 节点上的预安装浏览器绝对路径"
invalid={pathInvalid}
/>
</Field>
<Field id="image-note" label="备注" helper="可选,最长 200 字符">
<Field
id="browser-version-note"
label="备注"
helper="可选,最长 200 字符"
>
<Input
id="image-note"
id="browser-version-note"
maxLength={200}
value={form.note}
onChange={(event) => update("note", event.target.value)}
@@ -120,30 +125,34 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
);
}
function ImageEditForm({ editing, onChange, onSave, busy }) {
const refInvalid =
editing.image_ref !== "" && !refPattern.test(editing.image_ref);
function BrowserVersionEditForm({ editing, onChange, onSave, busy }) {
const pathInvalid =
editing.browser_path !== "" && !pathPattern.test(editing.browser_path);
return (
<div className="space-y-4">
<Field
id="edit-image-ref"
label="镜像引用"
id="edit-browser-path"
label="浏览器路径"
required
error={refInvalid ? "格式不正确" : undefined}
helper="registry/repo:tag 或 @sha256 摘要"
error={pathInvalid ? "格式不正确" : undefined}
helper="gateway 节点上的预安装浏览器绝对路径"
>
<Input
id="edit-image-ref"
value={editing.image_ref}
invalid={refInvalid}
id="edit-browser-path"
value={editing.browser_path}
invalid={pathInvalid}
onChange={(event) =>
onChange({ ...editing, image_ref: event.target.value })
onChange({ ...editing, browser_path: event.target.value })
}
/>
</Field>
<Field id="edit-image-note" label="备注" helper="可选,最长 200 字符">
<Field
id="edit-browser-version-note"
label="备注"
helper="可选,最长 200 字符"
>
<Input
id="edit-image-note"
id="edit-browser-version-note"
maxLength={200}
value={editing.note}
onChange={(event) =>
@@ -154,7 +163,7 @@ function ImageEditForm({ editing, onChange, onSave, busy }) {
<div className="space-y-1.5">
<p className="text-sm font-medium">启用(可用于创建与升级)</p>
<Switch
id="edit-image-enabled"
id="edit-browser-version-enabled"
checked={editing.enabled}
onCheckedChange={(checked) =>
onChange({ ...editing, enabled: checked })
@@ -165,7 +174,7 @@ function ImageEditForm({ editing, onChange, onSave, busy }) {
variant="primary"
busy={busy}
busyText="保存中…"
disabled={refInvalid}
disabled={pathInvalid}
onClick={onSave}
>
保存
@@ -174,26 +183,26 @@ function ImageEditForm({ editing, onChange, onSave, busy }) {
);
}
export function BrowserImageList() {
export function BrowserVersionList() {
const dataProvider = useDataProvider()("default");
const { result, query } = useList({ resource: "browser-images" });
const { result, query } = useList({ resource: "browser-versions" });
const error = query.error;
const isPending = query.isPending;
const images = result.data ?? [];
const versions = result.data ?? [];
const [createOpen, setCreateOpen] = useState(false);
const [createError, setCreateError] = useState(null);
const [editing, setEditing] = useState(null);
const [busyAlias, setBusyAlias] = useState("");
const [notice, setNotice] = useState(null);
const [deleting, setDeleting] = useState(null);
useTitle("CreatorHub · 镜像版本");
useTitle("CreatorHub · 浏览器版本");
async function createImage(data) {
async function createBrowserVersion(data) {
setBusyAlias("create");
setCreateError(null);
try {
await dataProvider.create({
resource: "browser-images",
resource: "browser-versions",
variables: data,
});
await query.refetch();
@@ -206,17 +215,17 @@ export function BrowserImageList() {
}
}
async function toggleEnabled(image) {
setBusyAlias(image.version);
async function toggleBrowserVersion(version) {
setBusyAlias(version.version);
setNotice(null);
try {
await dataProvider.update({
resource: "browser-images",
id: image.version,
resource: "browser-versions",
id: version.version,
variables: {
image_ref: image.image_ref,
note: image.note,
enabled: !image.enabled,
browser_path: version.browser_path,
note: version.note,
enabled: !version.enabled,
},
});
await query.refetch();
@@ -227,13 +236,13 @@ export function BrowserImageList() {
}
}
async function removeImage(image) {
setBusyAlias(image.version);
async function removeBrowserVersion(version) {
setBusyAlias(version.version);
setNotice(null);
try {
await dataProvider.deleteOne({
resource: "browser-images",
id: image.version,
resource: "browser-versions",
id: version.version,
});
await query.refetch();
setDeleting(null);
@@ -245,18 +254,18 @@ export function BrowserImageList() {
}
async function saveEdit() {
if (!refPattern.test(editing.image_ref)) {
setEditing({ ...editing, error: "镜像引用格式不正确" });
if (!pathPattern.test(editing.browser_path)) {
setEditing({ ...editing, error: "浏览器路径格式不正确" });
return;
}
setBusyAlias(editing.version);
setNotice(null);
try {
await dataProvider.update({
resource: "browser-images",
resource: "browser-versions",
id: editing.version,
variables: {
image_ref: editing.image_ref,
browser_path: editing.browser_path,
note: editing.note,
enabled: editing.enabled,
},
@@ -278,9 +287,9 @@ export function BrowserImageList() {
return (
<div>
<PageHeader
icon="ri-inbox-line"
title="镜像版本"
description="维护可用浏览器镜像版本;创建与升级环境时从中选择"
icon="ri-window-line"
title="浏览器版本"
description="维护可用浏览器版本;创建与升级环境时从中选择"
>
<Button
variant="primary"
@@ -298,46 +307,46 @@ export function BrowserImageList() {
<PageState
pending={isPending}
error={error}
empty={images.length === 0}
emptyText="暂无镜像版本;网关会在镜像缺失时按引用自动拉取。"
empty={versions.length === 0}
emptyText="暂无浏览器版本;请先登记 gateway 节点上的预安装浏览器路径。"
>
<Table
columns={[
{
header: "版本",
width: "22%",
render: (image) => (
<span className="font-semibold">{image.version}</span>
render: (version) => (
<span className="font-semibold">{version.version}</span>
),
},
{
header: "镜像引用",
header: "浏览器路径",
width: "38%",
render: (image) => (
render: (version) => (
<code className="anywhere font-mono text-[13px]">
{image.image_ref}
{version.browser_path}
</code>
),
},
{
header: "备注",
width: "20%",
render: (image) => (
<span className={image.note ? "" : "text-muted"}>
{image.note || "—"}
render: (version) => (
<span className={version.note ? "" : "text-muted"}>
{version.note || "—"}
</span>
),
},
{
header: "启用",
width: "8%",
render: (image) => (
render: (version) => (
<Switch
id={`image-enabled-${image.version}`}
aria-label={`启用 ${image.version}`}
checked={image.enabled}
disabled={busyAlias === image.version}
onCheckedChange={() => toggleEnabled(image)}
id={`browser-version-enabled-${version.version}`}
aria-label={`启用 ${version.version}`}
checked={version.enabled}
disabled={busyAlias === version.version}
onCheckedChange={() => toggleBrowserVersion(version)}
/>
),
},
@@ -345,22 +354,22 @@ export function BrowserImageList() {
header: "操作",
width: "12%",
align: "right",
render: (image) => (
render: (version) => (
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`编辑 ${image.version}`}
disabled={busyAlias === image.version}
onClick={() => setEditing({ ...image, error: "" })}
aria-label={`编辑 ${version.version}`}
disabled={busyAlias === version.version}
onClick={() => setEditing({ ...version, error: "" })}
className="rounded p-1.5 text-muted hover:bg-[#f5f7fa] hover:text-primary disabled:opacity-50"
>
<i className="ri-edit-line" aria-hidden="true" />
</button>
<button
type="button"
aria-label={`删除 ${image.version}`}
disabled={busyAlias === image.version}
onClick={() => setDeleting(image)}
aria-label={`删除 ${version.version}`}
disabled={busyAlias === version.version}
onClick={() => setDeleting(version)}
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"
>
<i className="ri-delete-bin-line" aria-hidden="true" />
@@ -369,18 +378,18 @@ export function BrowserImageList() {
),
},
]}
rows={images}
rowKey={(image) => image.version}
rows={versions}
rowKey={(version) => version.version}
minWidth="900px"
/>
</PageState>
<ImageCreateModal
<BrowserVersionCreateModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
}}
onSubmit={createImage}
onSubmit={createBrowserVersion}
busy={busyAlias === "create"}
error={createError}
/>
@@ -389,7 +398,7 @@ export function BrowserImageList() {
open
onClose={() => setEditing(null)}
title={`编辑 ${editing.version}`}
labelledBy="image-edit-title"
labelledBy="browser-version-edit-title"
footer={<Button onClick={() => setEditing(null)}>关闭</Button>}
>
{editing.error ? (
@@ -397,7 +406,7 @@ export function BrowserImageList() {
{editing.error}
</Alert>
) : null}
<ImageEditForm
<BrowserVersionEditForm
editing={editing}
onChange={(next) => setEditing({ ...next, error: editing.error })}
onSave={saveEdit}
@@ -408,11 +417,11 @@ export function BrowserImageList() {
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={() => removeImage(deleting)}
title="删除镜像版本"
onConfirm={() => removeBrowserVersion(deleting)}
title="删除浏览器版本"
confirmLabel="确认删除"
busy={busyAlias === deleting?.version}
body={`删除镜像版本 ${deleting?.version}?仍被环境引用时删除会被拒绝。`}
body={`删除浏览器版本 ${deleting?.version}?仍被环境引用时删除会被拒绝。`}
/>
</div>
);
@@ -10,25 +10,25 @@ import {
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Refine } from "@refinedev/core";
import { MemoryRouter } from "react-router";
import { BrowserImageList } from "./BrowserImagesPage";
import { BrowserVersionList } from "./BrowserVersionsPage";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const images = [
const versions = [
{
id: "148.0.0.1",
version: "148.0.0.1",
image_ref: "reg/img:148",
browser_path: "/opt/creatorhub/browsers/148/chrome",
note: "",
enabled: true,
},
{
id: "144.0.0.1",
version: "144.0.0.1",
image_ref: "reg/img:144",
browser_path: "/opt/creatorhub/browsers/144/chrome",
note: "旧版",
enabled: false,
},
@@ -37,10 +37,10 @@ const images = [
function provider(overrides = {}) {
return {
getList: vi.fn(() =>
Promise.resolve({ data: images, total: images.length }),
Promise.resolve({ data: versions, total: versions.length }),
),
create: vi.fn().mockResolvedValue({ data: images[0] }),
update: vi.fn().mockResolvedValue({ data: images[0] }),
create: vi.fn().mockResolvedValue({ data: versions[0] }),
update: vi.fn().mockResolvedValue({ data: versions[0] }),
deleteOne: vi.fn().mockResolvedValue({ data: { id: "144.0.0.1" } }),
getOne: vi.fn(),
getMany: vi.fn(),
@@ -52,7 +52,7 @@ function provider(overrides = {}) {
};
}
const renderImages = (dataProvider) =>
const renderVersions = (dataProvider) =>
render(
<QueryClientProvider
client={
@@ -61,35 +61,35 @@ const renderImages = (dataProvider) =>
>
<Refine dataProvider={dataProvider}>
<MemoryRouter>
<BrowserImageList />
<BrowserVersionList />
</MemoryRouter>
</Refine>
</QueryClientProvider>,
);
describe("BrowserImageList", () => {
it("adds an image version", async () => {
describe("BrowserVersionList", () => {
it("adds a browser version", async () => {
const dataProvider = provider();
renderImages(dataProvider);
renderVersions(dataProvider);
await screen.findAllByText("reg/img:148");
await screen.findAllByText("/opt/creatorhub/browsers/148/chrome");
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
target: { value: "150.0.0.1" },
});
fireEvent.change(
within(dialog).getByRole("textbox", { name: "镜像引用" }),
{ target: { value: "reg/img:150" } },
within(dialog).getByRole("textbox", { name: "浏览器路径" }),
{ target: { value: "/opt/creatorhub/browsers/150/chrome" } },
);
fireEvent.click(within(dialog).getByRole("button", { name: "添加版本" }));
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith({
resource: "browser-images",
resource: "browser-versions",
variables: {
version: "150.0.0.1",
image_ref: "reg/img:150",
browser_path: "/opt/creatorhub/browsers/150/chrome",
note: "",
enabled: true,
},
@@ -100,31 +100,31 @@ describe("BrowserImageList", () => {
it("keeps the create modal open after a failure", async () => {
const dataProvider = provider({
create: vi.fn().mockRejectedValue(new Error("镜像创建失败")),
create: vi.fn().mockRejectedValue(new Error("浏览器版本创建失败")),
});
renderImages(dataProvider);
renderVersions(dataProvider);
await screen.findAllByText("reg/img:148");
await screen.findAllByText("/opt/creatorhub/browsers/148/chrome");
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
target: { value: "150.0.0.1" },
});
fireEvent.change(
within(dialog).getByRole("textbox", { name: "镜像引用" }),
{ target: { value: "reg/img:150" } },
within(dialog).getByRole("textbox", { name: "浏览器路径" }),
{ target: { value: "/opt/creatorhub/browsers/150/chrome" } },
);
fireEvent.click(within(dialog).getByRole("button", { name: "添加版本" }));
expect((await within(dialog).findByRole("alert")).textContent).toContain(
"镜像创建失败",
"浏览器版本创建失败",
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("toggles enabled through update", async () => {
const dataProvider = provider();
renderImages(dataProvider);
renderVersions(dataProvider);
fireEvent.click(
await screen.findByRole("switch", { name: "启用 144.0.0.1" }),
@@ -132,18 +132,22 @@ describe("BrowserImageList", () => {
await waitFor(() =>
expect(dataProvider.update).toHaveBeenCalledWith({
resource: "browser-images",
resource: "browser-versions",
id: "144.0.0.1",
variables: { image_ref: "reg/img:144", note: "旧版", enabled: true },
variables: {
browser_path: "/opt/creatorhub/browsers/144/chrome",
note: "旧版",
enabled: true,
},
}),
);
});
it("blocks invalid input before submit", async () => {
const dataProvider = provider();
renderImages(dataProvider);
renderVersions(dataProvider);
await screen.findAllByText("reg/img:148");
await screen.findAllByText("/opt/creatorhub/browsers/148/chrome");
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
@@ -159,7 +163,7 @@ describe("BrowserImageList", () => {
const dataProvider = provider({
deleteOne: vi.fn().mockRejectedValue(new Error("该版本正被环境引用")),
});
renderImages(dataProvider);
renderVersions(dataProvider);
fireEvent.click(await screen.findByLabelText("删除 148.0.0.1"));
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
+74 -51
View File
@@ -34,9 +34,13 @@ const spoofingFields = ["font", "audio", "canvas", "clientrects", "gpu"];
const stateText = {
created: "已创建",
running: "运行中",
exited: "已停止",
restarting: "重启中",
paused: "已暂停",
stopped: "已停止",
degraded: "异常",
failed: "失败待清理",
cleanup_pending: "待清理",
gateway_unreachable: "网关不可达",
runtime_missing: "运行实例缺失",
recorded: "已记录",
missing: "未部署",
};
const blockText = {
@@ -60,7 +64,7 @@ const createInitial = {
name: "",
alias: "",
gateway: "",
image_version: "",
browser_version: "",
account_id: "",
network_exit_id: "",
seed: "",
@@ -114,7 +118,7 @@ function StatePill({ state }) {
tone={
state === "running"
? "success"
: state === "exited"
: state === "stopped"
? "warning"
: "neutral"
}
@@ -126,6 +130,14 @@ function StatePill({ 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"
? ""
@@ -137,7 +149,7 @@ function RuntimeActions({ runtime, busy, onAction }) {
size="sm"
icon="ri-play-line"
aria-label={startLabel}
disabled={busy || running || block !== ""}
disabled={busy || running || gatewayUnavailable || block !== ""}
onClick={() => onAction(runtime, "start")}
>
启动
@@ -146,7 +158,7 @@ function RuntimeActions({ runtime, busy, onAction }) {
size="sm"
icon="ri-stop-line"
aria-label={`停止 ${runtime.name}`}
disabled={busy || !running}
disabled={busy || !stopping || gatewayUnavailable}
onClick={() => onAction(runtime, "stop")}
>
停止
@@ -155,7 +167,7 @@ function RuntimeActions({ runtime, busy, onAction }) {
size="sm"
icon="ri-arrow-up-circle-line"
aria-label={`升级 ${runtime.name}`}
disabled={busy}
disabled={busy || gatewayUnavailable}
onClick={() => onAction(runtime, "upgrade")}
>
升级
@@ -165,7 +177,7 @@ function RuntimeActions({ runtime, busy, onAction }) {
variant="danger"
icon="ri-delete-bin-line"
aria-label={`回收 ${runtime.name}`}
disabled={busy}
disabled={busy || gatewayUnavailable}
onClick={() => onAction(runtime, "recycle")}
>
回收
@@ -184,9 +196,9 @@ function SchedulePill({ runtime }) {
);
}
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
function UpgradeDialog({ target, versions, busy, onClose, onConfirm }) {
const [version, setVersion] = useState("");
const enabled = images.filter((image) => image.enabled);
const enabled = versions.filter((item) => item.enabled);
useEffect(() => {
setVersion("");
}, [target]);
@@ -206,7 +218,7 @@ function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
variant="primary"
busy={busy}
busyText="升级中…"
disabled={version === "" || version === target?.image_version}
disabled={version === "" || version === target?.browser_version}
onClick={() => onConfirm(version)}
>
确认升级
@@ -216,16 +228,16 @@ function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
>
<div className="space-y-4">
<p className="text-sm text-muted">
容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。
环境将按目标浏览器版本重启,Profile 目录与指纹参数保持不变。
</p>
<Field id="upgrade-version" label="目标镜像版本" required>
<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 ? "(当前)" : ""}`,
options={enabled.map((item) => ({
value: item.version,
label: `${item.version}${item.version === target?.browser_version ? "(当前)" : ""}`,
}))}
placeholder="选择版本"
/>
@@ -270,7 +282,7 @@ function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
<p className="text-sm">{runtime.account_id || "未绑定账号"}</p>
<p className="anywhere text-xs text-muted">
{runtime.network_exit_id || "当前机器直连"} ·{" "}
{runtime.image_version}
{runtime.browser_version}
</p>
<div className="mt-1">
<SchedulePill runtime={runtime} />
@@ -307,7 +319,7 @@ function RuntimeCards({ runtimes, busy, onAction }) {
<div>
<p className="font-semibold">{runtime.name}</p>
<p className="anywhere text-xs text-muted">
{runtime.alias} · {runtime.image_version} · seed{" "}
{runtime.alias} · {runtime.browser_version} · seed{" "}
{runtime.fingerprint?.seed}
</p>
</div>
@@ -340,17 +352,17 @@ export function BrowserList() {
const error = query.error;
const isPending = query.isPending;
const runtimes = result.data ?? [];
const { result: imageResult } = useList({
resource: "browser-images",
const { result: versionsResult } = useList({
resource: "browser-versions",
});
const images = imageResult.data ?? [];
const versions = versionsResult.data ?? [];
useTitle("CreatorHub · 运行环境");
async function handleAction(runtime, action) {
if (
action === "recycle" &&
!window.confirm(
`回收 ${runtime.name}(${runtime.alias})?容器将被删除,Profile 数据卷保留。`,
`回收 ${runtime.name}(${runtime.alias})?运行资源将被回收,Profile 目录保留。`,
)
)
return;
@@ -384,7 +396,7 @@ export function BrowserList() {
<PageHeader
icon="ri-server-line"
title="运行环境"
description="启动、停止、升级并回收隔离的指纹浏览器"
description="启动、停止、升级并回收隔离的指纹浏览器运行环境"
>
<Button
variant="primary"
@@ -420,7 +432,7 @@ export function BrowserList() {
</PageState>
<UpgradeDialog
target={upgradeTarget}
images={images}
versions={versions}
busy={busy === upgradeTarget?.alias}
onClose={() => setUpgradeTarget(null)}
onConfirm={upgrade}
@@ -442,15 +454,15 @@ export function BrowserCreatePage() {
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: 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 enabledImages = images.filter((image) => image.enabled);
const enabledVersions = versions.filter((item) => item.enabled);
const pausedAccounts = accounts.filter(
(account) =>
account.authorization_status === "authorized" &&
@@ -475,9 +487,9 @@ export function BrowserCreatePage() {
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]);
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);
@@ -495,7 +507,7 @@ export function BrowserCreatePage() {
form.name.trim() !== "" &&
aliasPattern.test(form.alias) &&
form.gateway !== "" &&
form.image_version !== "" &&
form.browser_version !== "" &&
form.account_id !== "" &&
Number.isInteger(seed) &&
seed >= 1 &&
@@ -513,7 +525,7 @@ export function BrowserCreatePage() {
alias: form.alias,
name: form.name.trim(),
gateway: form.gateway,
image_version: form.image_version,
browser_version: form.browser_version,
account_id: form.account_id,
network_exit_id: form.network_exit_id,
fingerprint: fingerprintPayload(form),
@@ -539,7 +551,7 @@ export function BrowserCreatePage() {
<div className="max-w-[960px]">
<PageHeader
title="创建运行环境"
description="为已授权且暂停的账号绑定网关、镜像、指纹与网络出口"
description="为已授权且暂停的账号绑定网关、浏览器版本、指纹与网络出口"
>
<Button icon="ri-arrow-left-line" onClick={() => navigate("/browsers")}>
返回运行环境
@@ -570,14 +582,14 @@ export function BrowserCreatePage() {
</Field>
<Field
id="env-alias"
label="别名(容器名)"
label="别名"
required
error={
form.alias !== "" && !aliasPattern.test(form.alias)
? "小写字母、数字与中划线,最长 32 字符"
: undefined
}
helper="用于容器名、数据卷与 API 路径"
helper="用于 Profile 标识与 API 路径"
>
<Input
id="env-alias"
@@ -597,7 +609,7 @@ export function BrowserCreatePage() {
? "请先在「网关管理」注册网关"
: undefined
}
helper="容器调度到该网关"
helper="环境由该 native gateway 管理"
>
<Select
id="env-gateway"
@@ -613,27 +625,27 @@ export function BrowserCreatePage() {
</Field>
<Field
id="env-version"
label="镜像版本"
label="浏览器版本"
required
error={
enabledImages.length === 0
? "请先在「镜像版本」添加并启用"
enabledVersions.length === 0
? "请先在「浏览器版本」添加并启用"
: undefined
}
helper="环境使用的浏览器镜像"
helper="环境使用的预安装浏览器版本"
>
<Select
id="env-version"
value={form.image_version}
value={form.browser_version}
onChange={(event) =>
update("image_version", event.target.value)
update("browser_version", event.target.value)
}
options={enabledImages.map((item) => ({
options={enabledVersions.map((item) => ({
value: item.version,
label: item.version,
}))}
placeholder="选择版本"
disabled={enabledImages.length === 0}
disabled={enabledVersions.length === 0}
/>
</Field>
<Field
@@ -884,16 +896,16 @@ export function BrowserDetail() {
<DetailList
rows={[
[
"账号与镜像",
"账号与浏览器版本",
<span className="anywhere">
{runtime.account_id} · {runtime.image_version}
{runtime.account_id} · {runtime.browser_version}
</span>,
],
[
"固定出口",
<span className="anywhere">
{runtime.network_exit?.id || "未绑定"} · 健康:
{runtime.network_exit?.health_status || "未知"}
{runtime.network_exit_id || "未绑定"} · 健康:
{runtime.network_exit_health || "未知"}
</span>,
],
[
@@ -905,6 +917,17 @@ export function BrowserDetail() {
],
]}
/>
<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>
@@ -920,10 +943,10 @@ export function BrowserDetail() {
>
查看账号
</Button>
{runtime.network_exit?.id ? (
{runtime.network_exit_id ? (
<Button
as={Link}
to={`/network-exits/${encodeURIComponent(runtime.network_exit.id)}`}
to={`/network-exits/${encodeURIComponent(runtime.network_exit_id)}`}
>
查看出口
</Button>
+8 -8
View File
@@ -27,7 +27,7 @@ const runtimes = [
name: "店铺一号",
state: "running",
status: "Up",
image_version: "148.0.0.1",
browser_version: "148.0.0.1",
fingerprint: { seed: 1000 },
endpoint: "http://account-a:9222",
account_id: "social-a",
@@ -41,7 +41,7 @@ const runtimes = [
name: "店铺二号",
state: "exited",
status: "Exited",
image_version: "148.0.0.1",
browser_version: "148.0.0.1",
fingerprint: { seed: 2000 },
endpoint: "http://account-b:9222",
},
@@ -53,19 +53,19 @@ const images = [
{
id: "148.0.0.1",
version: "148.0.0.1",
image_ref: "reg/img:148",
browser_path: "reg/img:148",
enabled: true,
},
{
id: "144.0.0.1",
version: "144.0.0.1",
image_ref: "reg/img:144",
browser_path: "reg/img:144",
enabled: true,
},
{
id: "139.0.0.1",
version: "139.0.0.1",
image_ref: "reg/img:139",
browser_path: "reg/img:139",
enabled: false,
},
];
@@ -94,7 +94,7 @@ function provider(overrides = {}) {
return Promise.resolve({ data: runtimes, total: runtimes.length });
if (resource === "gateways")
return Promise.resolve({ data: gateways, total: gateways.length });
if (resource === "browser-images")
if (resource === "browser-versions")
return Promise.resolve({ data: images, total: images.length });
if (resource === "accounts")
return Promise.resolve({ data: accounts, total: accounts.length });
@@ -373,7 +373,7 @@ describe("BrowserList", () => {
alias: "shop-03",
name: "店铺三号",
gateway: "gw-1",
image_version: "148.0.0.1",
browser_version: "148.0.0.1",
account_id: "social-a",
network_exit_id: "exit-1",
fingerprint: { seed: 1000 },
@@ -446,7 +446,7 @@ describe("BrowserList", () => {
fireEvent.click(screen.getAllByLabelText("升级 店铺一号")[0]);
const dialog = await screen.findByRole("dialog");
fireEvent.click(
within(dialog).getByRole("combobox", { name: /目标镜像版本/ }),
within(dialog).getByRole("combobox", { name: /目标浏览器版本/ }),
);
fireEvent.click(
await within(document.body).findByRole("option", { name: "144.0.0.1" }),
+2 -2
View File
@@ -150,7 +150,7 @@ function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
required
value={form.endpoint}
onChange={(event) => update("endpoint", event.target.value)}
placeholder="http://docker-gateway:8081"
placeholder="http://127.0.0.1:8081"
invalid={endpointInvalid}
/>
</Field>
@@ -253,7 +253,7 @@ export function GatewayList() {
<PageHeader
icon="ri-git-branch-line"
title="网关管理"
description="注册 Docker 网关;令牌须与网关进程的 GATEWAY_TOKEN 一致"
description="注册宿主机 native browser gateway;令牌须与 gateway 进程的 GATEWAY_TOKEN 一致"
>
<Button
variant="primary"
+2 -2
View File
@@ -21,7 +21,7 @@ const gateways = [
{
id: "gw-1",
name: "gw-1",
endpoint: "http://docker-gateway:8081",
endpoint: "http://127.0.0.1:8081",
token: "unit-test-gateway-token",
},
{
@@ -163,7 +163,7 @@ describe("GatewayList", () => {
const name = within(dialog).getByRole("textbox", { name: "名称" });
const endpoint = within(dialog).getByRole("textbox", { name: "Endpoint" });
expect(name.value).toBe("gw-1");
expect(endpoint.value).toBe("http://docker-gateway:8081");
expect(endpoint.value).toBe("http://127.0.0.1:8081");
fireEvent.change(name, { target: { value: "gw-main" } });
fireEvent.change(endpoint, { target: { value: "http://gw-main:8081" } });
fireEvent.change(
+11 -3
View File
@@ -10,9 +10,17 @@ const menu = [
section: "运营",
items: [
{ to: "/accounts", icon: "ri-account-circle-line", label: "账号管理" },
{ to: "/creator/competitors", icon: "ri-line-chart-line", label: "竞品分析" },
{
to: "/creator/competitors",
icon: "ri-line-chart-line",
label: "竞品分析",
},
{ to: "/creator/workbench", icon: "ri-chat-3-line", label: "运营工作台" },
{ to: "/creator/settings", icon: "ri-settings-3-line", label: "采集设置" },
{
to: "/creator/settings",
icon: "ri-settings-3-line",
label: "采集设置",
},
{ to: "/tasks", icon: "ri-task-line", label: "任务中心" },
{ to: "/audit", icon: "ri-history-line", label: "审计记录" },
],
@@ -22,7 +30,7 @@ const menu = [
items: [
{ to: "/browsers", icon: "ri-hard-drive-3-line", label: "运行环境" },
{ to: "/network-exits", icon: "ri-earth-line", label: "网络出口" },
{ to: "/browser-images", icon: "ri-inbox-line", label: "镜像版本" },
{ to: "/browser-versions", icon: "ri-window-line", label: "浏览器版本" },
{ to: "/gateways", icon: "ri-router-line", label: "网关管理" },
],
},
+3 -3
View File
@@ -46,7 +46,7 @@ const unsupported = (resource, operation) =>
const resourcePaths = {
browsers: "/browsers",
"browser-images": "/browser-images",
"browser-versions": "/browser-versions",
gateways: "/gateways",
accounts: "/phase-a/accounts",
drafts: "/phase-a/drafts",
@@ -152,7 +152,7 @@ export const dataProvider = {
const path = resourcePaths[resource];
if (!path || resource === "browsers")
return unsupported(resource, "update");
// browser-images 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
// browser-versions 的 PUT 不接受 version 字段(路径已携带),透传其余字段。
const { version: _ignored, ...rest } = variables ?? {};
await request(
`${path}/${encodeURIComponent(id)}`,
@@ -164,7 +164,7 @@ export const dataProvider = {
const path = resourcePaths[resource];
if (
!path ||
!["browser-images", "accounts", "creator-competitors"].includes(resource)
!["browser-versions", "accounts", "creator-competitors"].includes(resource)
)
return unsupported(resource, "deleteOne");
await request(`${path}/${encodeURIComponent(id)}`, { method: "DELETE" });
+7 -11
View File
@@ -5,14 +5,10 @@ import { Refine } from "@refinedev/core";
import { dataProvider } from "./dataProvider";
import { CreatorHubLayout } from "./Layout";
import { LoginPage } from "./LoginPage";
import {
AccountCreatePage,
AccountDetail,
AccountList,
} from "./AccountsPage";
import { AccountCreatePage, AccountDetail, AccountList } from "./AccountsPage";
import { AuditPage } from "./AuditPage";
import { BrowserCreatePage, BrowserDetail, BrowserList } from "./BrowsersPage";
import { BrowserImageList } from "./BrowserImagesPage";
import { BrowserVersionList } from "./BrowserVersionsPage";
import { DraftPage } from "./DraftPage";
import { GatewayList } from "./GatewaysPage";
import { NetworkExitDetail, NetworkExitList } from "./NetworkExitsPage";
@@ -52,10 +48,7 @@ createRoot(document.getElementById("root")).render(
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/new" element={<AccountCreatePage />} />
<Route path="/accounts/:id" element={<AccountDetail />} />
<Route
path="/accounts/:id/edit"
element={<AccountEditPage />}
/>
<Route path="/accounts/:id/edit" element={<AccountEditPage />} />
<Route
path="/creator/competitors"
element={<CreatorCompetitorsPage />}
@@ -89,7 +82,10 @@ createRoot(document.getElementById("root")).render(
<Route path="/browsers" element={<BrowserList />} />
<Route path="/browsers/new" element={<BrowserCreatePage />} />
<Route path="/browsers/:id" element={<BrowserDetail />} />
<Route path="/browser-images" element={<BrowserImageList />} />
<Route
path="/browser-versions"
element={<BrowserVersionList />}
/>
<Route path="/gateways" element={<GatewayList />} />
<Route path="/drafts/:id" element={<DraftPage />} />
<Route path="/attempts/:id" element={<AttemptDetail />} />