chore: 前端代码风格统一并清理未使用导入;移除过时的 PR 流程规则

This commit is contained in:
2026-09-03 17:16:08 +08:00
parent 934d2c1bc5
commit de91d7a882
7 changed files with 1120 additions and 397 deletions
-2
View File
@@ -90,5 +90,3 @@
- 后端变更必须通过 `go test ./...``go vet ./...`,并完成 `./cmd/control-plane``./cmd/docker-gateway` 双端构建;涉及并发、生命周期或共享状态的变更须运行 `go test -race ./...`。Docker 或 Compose 变更还须通过 `docker compose config --quiet`
- 前端变更必须从 lockfile 安装、通过仓库的非交互式测试命令,并通过 `npm --prefix web run build`。主题、Layout、导航、资源动作或 data provider 的变更需要聚焦的交互覆盖,包括适用的错误与禁用状态。
- 除非 issue 明确批准契约变更,保持既有 API 与 Docker 生命周期行为不变。在 PR 中文档化任何状态码、载荷、配置、迁移、安全或重试方面的影响。
- 每个 PR 限定于单个 issue,标题、正文或分支名中包含 issue 编号,并报告确切的验证命令与结果。除非 issue 明确要求,不使用 close intent。
- 每个代码 PR 均需独立评审。P0/P1 问题必须修复并复审;P2 问题记录为后续工作。实现 agent 不得合并、部署、发布或修改生产配置;将已批准的 PR 移交指定交付角色。
+339 -107
View File
@@ -1,187 +1,419 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { useDataProvider, useList } from '@refinedev/core'
import { useState } from "react";
import { useDataProvider, useList } from "@refinedev/core";
import {
Alert, Button, Card, CardContent, ConfirmDialog, Field, Input, Modal, PageHeader, PageState,
Select, StatusPill, Switch, Table, Textarea, conflictMessage,
} from './lib/ui.jsx'
import { useTitle } from './lib/hooks.js'
Alert,
Button,
ConfirmDialog,
Field,
Input,
Modal,
PageHeader,
PageState,
Switch,
Table,
} from "./lib/ui.jsx";
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 versionPattern = /^\d[A-Za-z0-9._-]{0,63}$/;
const refPattern = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$/;
function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
const [form, setForm] = useState({ version: '', image_ref: '', note: '' })
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
const valid = versionPattern.test(form.version) && refPattern.test(form.image_ref)
const versionInvalid = form.version !== '' && !versionPattern.test(form.version)
const refInvalid = form.image_ref !== '' && !refPattern.test(form.image_ref)
const [form, setForm] = useState({ version: "", image_ref: "", note: "" });
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const valid =
versionPattern.test(form.version) && refPattern.test(form.image_ref);
const versionInvalid =
form.version !== "" && !versionPattern.test(form.version);
const refInvalid = form.image_ref !== "" && !refPattern.test(form.image_ref);
async function submit(event) {
event.preventDefault()
if (!valid) return
const created = await onSubmit({ version: form.version, image_ref: form.image_ref, note: form.note, enabled: true })
event.preventDefault();
if (!valid) return;
const created = await onSubmit({
version: form.version,
image_ref: form.image_ref,
note: form.note,
enabled: true,
});
if (created) {
setForm({ version: '', image_ref: '', note: '' })
onClose()
setForm({ version: "", image_ref: "", note: "" });
onClose();
}
}
return (
<Modal open={open} onClose={onClose} title="添加镜像版本" labelledBy="image-create-title"
footer={<>
<Button onClick={onClose} disabled={busy}>取消</Button>
<Button variant="primary" type="submit" form="image-create-form" busy={busy} busyText="保存中…" disabled={!valid}>添加版本</Button>
</>}>
<Modal
open={open}
onClose={onClose}
title="添加镜像版本"
labelledBy="image-create-title"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="primary"
type="submit"
form="image-create-form"
busy={busy}
busyText="保存中…"
disabled={!valid}
>
添加版本
</Button>
</>
}
>
<form id="image-create-form" onSubmit={submit} noValidate>
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
{error ? (
<Alert variant="destructive" className="mb-4">
{error.message}
</Alert>
) : null}
<div className="space-y-4">
<Field id="image-version" label="版本" required error={versionInvalid ? '数字开头,最长 64 字符' : undefined} helper="镜像版本号,如 148.0.7778.215">
<Input id="image-version" required maxLength={64} value={form.version} onChange={event => update('version', event.target.value)} invalid={versionInvalid} />
<Field
id="image-version"
label="版本"
required
error={versionInvalid ? "数字开头,最长 64 字符" : undefined}
helper="镜像版本号,如 148.0.7778.215"
>
<Input
id="image-version"
required
maxLength={64}
value={form.version}
onChange={(event) => update("version", event.target.value)}
invalid={versionInvalid}
/>
</Field>
<Field id="image-ref" label="镜像引用" required error={refInvalid ? '格式不正确' : undefined} helper="registry/repo:tag 或 @sha256 摘要">
<Input id="image-ref" required maxLength={301} value={form.image_ref} onChange={event => update('image_ref', event.target.value)} placeholder="registry/repo:tag 或 @sha256 摘要" invalid={refInvalid} />
<Field
id="image-ref"
label="镜像引用"
required
error={refInvalid ? "格式不正确" : undefined}
helper="registry/repo:tag 或 @sha256 摘要"
>
<Input
id="image-ref"
required
maxLength={301}
value={form.image_ref}
onChange={(event) => update("image_ref", event.target.value)}
placeholder="registry/repo:tag 或 @sha256 摘要"
invalid={refInvalid}
/>
</Field>
<Field id="image-note" label="备注" helper="可选,最长 200 字符">
<Input id="image-note" maxLength={200} value={form.note} onChange={event => update('note', event.target.value)} />
<Input
id="image-note"
maxLength={200}
value={form.note}
onChange={(event) => update("note", event.target.value)}
/>
</Field>
</div>
</form>
</Modal>
)
);
}
function ImageEditForm({ editing, onChange, onSave, busy }) {
const refInvalid = editing.image_ref !== '' && !refPattern.test(editing.image_ref)
const refInvalid =
editing.image_ref !== "" && !refPattern.test(editing.image_ref);
return (
<div className="space-y-4">
<Field id="edit-image-ref" label="镜像引用" required error={refInvalid ? '格式不正确' : undefined} helper="registry/repo:tag 或 @sha256 摘要">
<Input id="edit-image-ref" value={editing.image_ref} invalid={refInvalid} onChange={event => onChange({ ...editing, image_ref: event.target.value })} />
<Field
id="edit-image-ref"
label="镜像引用"
required
error={refInvalid ? "格式不正确" : undefined}
helper="registry/repo:tag 或 @sha256 摘要"
>
<Input
id="edit-image-ref"
value={editing.image_ref}
invalid={refInvalid}
onChange={(event) =>
onChange({ ...editing, image_ref: event.target.value })
}
/>
</Field>
<Field id="edit-image-note" label="备注" helper="可选,最长 200 字符">
<Input id="edit-image-note" maxLength={200} value={editing.note} onChange={event => onChange({ ...editing, note: event.target.value })} />
<Input
id="edit-image-note"
maxLength={200}
value={editing.note}
onChange={(event) =>
onChange({ ...editing, note: event.target.value })
}
/>
</Field>
<div className="space-y-1.5">
<p className="text-sm font-medium">启用可用于创建与升级</p>
<Switch id="edit-image-enabled" checked={editing.enabled} onCheckedChange={checked => onChange({ ...editing, enabled: checked })} />
<Switch
id="edit-image-enabled"
checked={editing.enabled}
onCheckedChange={(checked) =>
onChange({ ...editing, enabled: checked })
}
/>
</div>
<Button variant="primary" busy={busy} busyText="保存中…" disabled={refInvalid} onClick={onSave}>保存</Button>
<Button
variant="primary"
busy={busy}
busyText="保存中…"
disabled={refInvalid}
onClick={onSave}
>
保存
</Button>
</div>
)
);
}
export function BrowserImageList() {
const dataProvider = useDataProvider()('default')
const { result, query } = useList({ resource: 'browser-images' })
const error = query.error
const isPending = query.isPending
const images = 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 · 镜像版本')
const dataProvider = useDataProvider()("default");
const { result, query } = useList({ resource: "browser-images" });
const error = query.error;
const isPending = query.isPending;
const images = 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 · 镜像版本");
async function createImage(data) {
setBusyAlias('create')
setCreateError(null)
setBusyAlias("create");
setCreateError(null);
try {
await dataProvider.create({ resource: 'browser-images', variables: data })
await query.refetch()
return true
await dataProvider.create({
resource: "browser-images",
variables: data,
});
await query.refetch();
return true;
} catch (reason) {
setCreateError(reason)
return false
setCreateError(reason);
return false;
} finally {
setBusyAlias('')
setBusyAlias("");
}
}
async function toggleEnabled(image) {
setBusyAlias(image.version)
setNotice(null)
setBusyAlias(image.version);
setNotice(null);
try {
await dataProvider.update({ resource: 'browser-images', id: image.version, variables: { image_ref: image.image_ref, note: image.note, enabled: !image.enabled } })
await query.refetch()
await dataProvider.update({
resource: "browser-images",
id: image.version,
variables: {
image_ref: image.image_ref,
note: image.note,
enabled: !image.enabled,
},
});
await query.refetch();
} catch (reason) {
setNotice({ variant: 'destructive', text: reason.message })
setNotice({ variant: "destructive", text: reason.message });
} finally {
setBusyAlias('')
setBusyAlias("");
}
}
async function removeImage(image) {
setBusyAlias(image.version)
setNotice(null)
setBusyAlias(image.version);
setNotice(null);
try {
await dataProvider.deleteOne({ resource: 'browser-images', id: image.version })
await query.refetch()
setDeleting(null)
await dataProvider.deleteOne({
resource: "browser-images",
id: image.version,
});
await query.refetch();
setDeleting(null);
} catch (reason) {
setNotice({ variant: 'destructive', text: reason.message })
setNotice({ variant: "destructive", text: reason.message });
} finally {
setBusyAlias('')
setBusyAlias("");
}
}
async function saveEdit() {
if (!refPattern.test(editing.image_ref)) {
setEditing({ ...editing, error: '镜像引用格式不正确' })
return
setEditing({ ...editing, error: "镜像引用格式不正确" });
return;
}
setBusyAlias(editing.version)
setNotice(null)
setBusyAlias(editing.version);
setNotice(null);
try {
await dataProvider.update({ resource: 'browser-images', id: editing.version, variables: { image_ref: editing.image_ref, note: editing.note, enabled: editing.enabled } })
setEditing(null)
await query.refetch()
await dataProvider.update({
resource: "browser-images",
id: editing.version,
variables: {
image_ref: editing.image_ref,
note: editing.note,
enabled: editing.enabled,
},
});
setEditing(null);
await query.refetch();
} catch (reason) {
setEditing({ ...editing, error: reason.message })
setEditing({ ...editing, error: reason.message });
} finally {
setBusyAlias('')
setBusyAlias("");
}
}
const message = notice?.text || (error ? null : notice?.message) || (notice ? notice.message : null)
const message =
notice?.text ||
(error ? null : notice?.message) ||
(notice ? notice.message : null);
return (
<div>
<PageHeader icon="ri-inbox-line" title="镜像版本" description="维护可用浏览器镜像版本;创建与升级环境时从中选择">
<Button variant="primary" icon="ri-add-line" onClick={() => setCreateOpen(true)}>添加版本</Button>
<PageHeader
icon="ri-inbox-line"
title="镜像版本"
description="维护可用浏览器镜像版本;创建与升级环境时从中选择"
>
<Button
variant="primary"
icon="ri-add-line"
onClick={() => setCreateOpen(true)}
>
添加版本
</Button>
</PageHeader>
{message ? <Alert variant="destructive" className="mb-4">{message}</Alert> : null}
<PageState pending={isPending} error={error} empty={images.length === 0} emptyText="暂无镜像版本;网关会在镜像缺失时按引用自动拉取。">
{message ? (
<Alert variant="destructive" className="mb-4">
{message}
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
empty={images.length === 0}
emptyText="暂无镜像版本;网关会在镜像缺失时按引用自动拉取。"
>
<Table
columns={[
{ header: '版本', width: '22%', render: image => <span className="font-semibold">{image.version}</span> },
{ header: '镜像引用', width: '38%', render: image => <code className="anywhere font-mono text-[13px]">{image.image_ref}</code> },
{ header: '备注', width: '20%', render: image => <span className={image.note ? '' : 'text-muted'}>{image.note || '—'}</span> },
{ header: '启用', width: '8%', render: image => <Switch id={`image-enabled-${image.version}`} aria-label={`启用 ${image.version}`} checked={image.enabled} disabled={busyAlias === image.version} onCheckedChange={() => toggleEnabled(image)} /> },
{ header: '操作', width: '12%', align: 'right', render: image => (
<span className="inline-flex gap-1">
<button type="button" aria-label={`编辑 ${image.version}`} disabled={busyAlias === image.version} onClick={() => setEditing({ ...image, 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)}
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"><i className="ri-delete-bin-line" aria-hidden="true" /></button>
</span>
) },
{
header: "版本",
width: "22%",
render: (image) => (
<span className="font-semibold">{image.version}</span>
),
},
{
header: "镜像引用",
width: "38%",
render: (image) => (
<code className="anywhere font-mono text-[13px]">
{image.image_ref}
</code>
),
},
{
header: "备注",
width: "20%",
render: (image) => (
<span className={image.note ? "" : "text-muted"}>
{image.note || "—"}
</span>
),
},
{
header: "启用",
width: "8%",
render: (image) => (
<Switch
id={`image-enabled-${image.version}`}
aria-label={`启用 ${image.version}`}
checked={image.enabled}
disabled={busyAlias === image.version}
onCheckedChange={() => toggleEnabled(image)}
/>
),
},
{
header: "操作",
width: "12%",
align: "right",
render: (image) => (
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`编辑 ${image.version}`}
disabled={busyAlias === image.version}
onClick={() => setEditing({ ...image, 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)}
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"
>
<i className="ri-delete-bin-line" aria-hidden="true" />
</button>
</span>
),
},
]}
rows={images} rowKey={image => image.version} minWidth="900px"
rows={images}
rowKey={(image) => image.version}
minWidth="900px"
/>
</PageState>
<ImageCreateModal open={createOpen} onClose={() => { setCreateOpen(false); setCreateError(null) }} onSubmit={createImage} busy={busyAlias === 'create'} error={createError} />
<ImageCreateModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
}}
onSubmit={createImage}
busy={busyAlias === "create"}
error={createError}
/>
{editing === null ? null : (
<Modal open onClose={() => setEditing(null)} title={`编辑 ${editing.version}`} labelledBy="image-edit-title"
footer={<Button onClick={() => setEditing(null)}>关闭</Button>}>
{editing.error ? <Alert variant="destructive" className="mb-4">{editing.error}</Alert> : null}
<ImageEditForm editing={editing} onChange={next => setEditing({ ...next, error: editing.error })} onSave={saveEdit} busy={busyAlias === editing.version} />
<Modal
open
onClose={() => setEditing(null)}
title={`编辑 ${editing.version}`}
labelledBy="image-edit-title"
footer={<Button onClick={() => setEditing(null)}>关闭</Button>}
>
{editing.error ? (
<Alert variant="destructive" className="mb-4">
{editing.error}
</Alert>
) : null}
<ImageEditForm
editing={editing}
onChange={(next) => setEditing({ ...next, error: editing.error })}
onSave={saveEdit}
busy={busyAlias === editing.version}
/>
</Modal>
)}
<ConfirmDialog open={deleting !== null} onClose={() => setDeleting(null)} onConfirm={() => removeImage(deleting)}
title="删除镜像版本" confirmLabel="确认删除" busy={busyAlias === deleting?.version}
body={`删除镜像版本 ${deleting?.version}?仍被环境引用时删除会被拒绝。`} />
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={() => removeImage(deleting)}
title="删除镜像版本"
confirmLabel="确认删除"
busy={busyAlias === deleting?.version}
body={`删除镜像版本 ${deleting?.version}?仍被环境引用时删除会被拒绝。`}
/>
</div>
)
);
}
+146 -70
View File
@@ -1,95 +1,171 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Refine } from '@refinedev/core'
import { MemoryRouter } from 'react-router'
import { BrowserImageList } from './BrowserImagesPage'
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Refine } from "@refinedev/core";
import { MemoryRouter } from "react-router";
import { BrowserImageList } from "./BrowserImagesPage";
afterEach(() => { cleanup(); vi.restoreAllMocks() })
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const images = [
{ id: '148.0.0.1', version: '148.0.0.1', image_ref: 'reg/img:148', note: '', enabled: true },
{ id: '144.0.0.1', version: '144.0.0.1', image_ref: 'reg/img:144', note: '旧版', enabled: false },
]
{
id: "148.0.0.1",
version: "148.0.0.1",
image_ref: "reg/img:148",
note: "",
enabled: true,
},
{
id: "144.0.0.1",
version: "144.0.0.1",
image_ref: "reg/img:144",
note: "旧版",
enabled: false,
},
];
function provider(overrides = {}) {
return {
getList: vi.fn(() => Promise.resolve({ data: images, total: images.length })),
getList: vi.fn(() =>
Promise.resolve({ data: images, total: images.length }),
),
create: vi.fn().mockResolvedValue({ data: images[0] }),
update: vi.fn().mockResolvedValue({ data: images[0] }),
deleteOne: vi.fn().mockResolvedValue({ data: { id: '144.0.0.1' } }),
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), browserAction: vi.fn(),
deleteOne: vi.fn().mockResolvedValue({ data: { id: "144.0.0.1" } }),
getOne: vi.fn(),
getMany: vi.fn(),
getManyReference: vi.fn(),
updateMany: vi.fn(),
deleteMany: vi.fn(),
browserAction: vi.fn(),
...overrides,
}
};
}
const renderImages = dataProvider => render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<Refine dataProvider={dataProvider}>
<MemoryRouter><BrowserImageList /></MemoryRouter>
</Refine>
</QueryClientProvider>,
)
const renderImages = (dataProvider) =>
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<Refine dataProvider={dataProvider}>
<MemoryRouter>
<BrowserImageList />
</MemoryRouter>
</Refine>
</QueryClientProvider>,
);
describe('BrowserImageList', () => {
it('adds an image version', async () => {
const dataProvider = provider()
renderImages(dataProvider)
describe("BrowserImageList", () => {
it("adds an image version", async () => {
const dataProvider = provider();
renderImages(dataProvider);
await screen.findAllByText('reg/img:148')
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' } })
fireEvent.click(within(dialog).getByRole('button', { name: '添加版本' }))
await screen.findAllByText("reg/img:148");
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" } },
);
fireEvent.click(within(dialog).getByRole("button", { name: "添加版本" }));
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'browser-images', variables: { version: '150.0.0.1', image_ref: 'reg/img:150', note: '', enabled: true } }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith({
resource: "browser-images",
variables: {
version: "150.0.0.1",
image_ref: "reg/img:150",
note: "",
enabled: true,
},
}),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it('keeps the create modal open after a failure', async () => {
const dataProvider = provider({ create: vi.fn().mockRejectedValue(new Error('镜像创建失败')) })
renderImages(dataProvider)
it("keeps the create modal open after a failure", async () => {
const dataProvider = provider({
create: vi.fn().mockRejectedValue(new Error("镜像创建失败")),
});
renderImages(dataProvider);
await screen.findAllByText('reg/img:148')
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' } })
fireEvent.click(within(dialog).getByRole('button', { name: '添加版本' }))
await screen.findAllByText("reg/img:148");
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" } },
);
fireEvent.click(within(dialog).getByRole("button", { name: "添加版本" }));
expect((await within(dialog).findByRole('alert')).textContent).toContain('镜像创建失败')
expect(screen.getByRole('dialog')).toBeTruthy()
})
expect((await within(dialog).findByRole("alert")).textContent).toContain(
"镜像创建失败",
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it('toggles enabled through update', async () => {
const dataProvider = provider()
renderImages(dataProvider)
it("toggles enabled through update", async () => {
const dataProvider = provider();
renderImages(dataProvider);
fireEvent.click(await screen.findByRole('switch', { name: '启用 144.0.0.1' }))
fireEvent.click(
await screen.findByRole("switch", { name: "启用 144.0.0.1" }),
);
await waitFor(() => expect(dataProvider.update).toHaveBeenCalledWith({ resource: 'browser-images', id: '144.0.0.1', variables: { image_ref: 'reg/img:144', note: '旧版', enabled: true } }))
})
await waitFor(() =>
expect(dataProvider.update).toHaveBeenCalledWith({
resource: "browser-images",
id: "144.0.0.1",
variables: { image_ref: "reg/img:144", note: "旧版", enabled: true },
}),
);
});
it('blocks invalid input before submit', async () => {
const dataProvider = provider()
renderImages(dataProvider)
it("blocks invalid input before submit", async () => {
const dataProvider = provider();
renderImages(dataProvider);
await screen.findAllByText('reg/img:148')
fireEvent.click(screen.getByRole('button', { name: '添加版本' }))
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByRole('textbox', { name: '版本' }), { target: { value: 'v1!' } })
expect(within(dialog).getByRole('button', { name: '添加版本' }).disabled).toBe(true)
expect(dataProvider.create).not.toHaveBeenCalled()
})
await screen.findAllByText("reg/img:148");
fireEvent.click(screen.getByRole("button", { name: "添加版本" }));
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "版本" }), {
target: { value: "v1!" },
});
expect(
within(dialog).getByRole("button", { name: "添加版本" }).disabled,
).toBe(true);
expect(dataProvider.create).not.toHaveBeenCalled();
});
it('surfaces delete rejection (referenced by env)', async () => {
const dataProvider = provider({ deleteOne: vi.fn().mockRejectedValue(new Error('该版本正被环境引用')) })
renderImages(dataProvider)
it("surfaces delete rejection (referenced by env)", async () => {
const dataProvider = provider({
deleteOne: vi.fn().mockRejectedValue(new Error("该版本正被环境引用")),
});
renderImages(dataProvider);
fireEvent.click(await screen.findByLabelText('删除 148.0.0.1'))
fireEvent.click(await screen.findByRole('button', { name: '确认删除' }))
fireEvent.click(await screen.findByLabelText("删除 148.0.0.1"));
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
expect((await screen.findByRole('alert')).textContent).toContain('该版本正被环境引用')
})
})
expect((await screen.findByRole("alert")).textContent).toContain(
"该版本正被环境引用",
);
});
});
+13 -3
View File
@@ -146,7 +146,11 @@ function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
label="名称"
required
error={nameInvalid ? "字母、数字与 . _ -,最长 64 字符" : undefined}
helper={editing ? "改名会同步更新引用该网关的环境" : "网关唯一标识,如 gw-main"}
helper={
editing
? "改名会同步更新引用该网关的环境"
: "网关唯一标识,如 gw-main"
}
>
<Input
id={`gateway-${mode}-name`}
@@ -177,8 +181,14 @@ function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
<Field
id={`gateway-${mode}-token`}
label={editing ? "令牌(留空保持不变)" : "令牌(可选)"}
error={tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined}
helper={editing ? "填写时须与网关 GATEWAY_TOKEN 一致" : "填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"}
error={
tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined
}
helper={
editing
? "填写时须与网关 GATEWAY_TOKEN 一致"
: "填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"
}
>
<Input
id={`gateway-${mode}-token`}
+27 -16
View File
@@ -43,15 +43,13 @@ function provider(overrides = {}) {
getList: vi.fn(() =>
Promise.resolve({ data: gateways, total: gateways.length }),
),
create: vi
.fn()
.mockResolvedValue({
data: {
name: "gw-2",
endpoint: "http://gw2:8081",
token: "generated-token-abcdef",
},
}),
create: vi.fn().mockResolvedValue({
data: {
name: "gw-2",
endpoint: "http://gw2:8081",
token: "generated-token-abcdef",
},
}),
deleteOne: vi.fn().mockResolvedValue({ data: { id: "gw-1" } }),
getOne: vi.fn(),
getMany: vi.fn(),
@@ -109,8 +107,12 @@ describe("GatewayList", () => {
fireEvent.click(screen.getByRole("button", { name: "注册网关" }));
const reopened = screen.getByRole("dialog");
expect(within(reopened).getByRole("textbox", { name: "名称" }).value).toBe("");
expect(within(reopened).getByRole("textbox", { name: "Endpoint" }).value).toBe("");
expect(within(reopened).getByRole("textbox", { name: "名称" }).value).toBe(
"",
);
expect(
within(reopened).getByRole("textbox", { name: "Endpoint" }).value,
).toBe("");
});
it("keeps the registration modal open after a failure", async () => {
@@ -170,16 +172,23 @@ describe("GatewayList", () => {
expect(endpoint.value).toBe("http://docker-gateway:8081");
fireEvent.change(name, { target: { value: "gw-main" } });
fireEvent.change(endpoint, { target: { value: "http://gw-main:8081" } });
fireEvent.change(within(dialog).getByRole("textbox", { name: "令牌(留空保持不变)" }), {
target: { value: "rotated-gateway-token" },
});
fireEvent.change(
within(dialog).getByRole("textbox", { name: "令牌(留空保持不变)" }),
{
target: { value: "rotated-gateway-token" },
},
);
fireEvent.click(within(dialog).getByRole("button", { name: "保存" }));
await waitFor(() =>
expect(update).toHaveBeenCalledWith({
resource: "gateways",
id: "gw-1",
variables: { name: "gw-main", endpoint: "http://gw-main:8081", token: "rotated-gateway-token" },
variables: {
name: "gw-main",
endpoint: "http://gw-main:8081",
token: "rotated-gateway-token",
},
}),
);
expect(dataProvider.getList).toHaveBeenCalledTimes(2);
@@ -193,7 +202,9 @@ describe("GatewayList", () => {
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "保存" }));
expect((await within(dialog).findByRole("alert")).textContent).toContain("网关名称已存在");
expect((await within(dialog).findByRole("alert")).textContent).toContain(
"网关名称已存在",
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
+425 -126
View File
@@ -1,87 +1,242 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { useDataProvider, useList, useOne } from '@refinedev/core'
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader,
PageState, Select, StatusPill, conflictMessage,
} from './lib/ui.jsx'
import { useTitle } from './lib/hooks.js'
Alert,
Button,
Card,
CardContent,
ConfirmDialog,
DetailList,
Field,
Input,
Modal,
PageHeader,
PageState,
Select,
StatusPill,
conflictMessage,
} from "./lib/ui.jsx";
import { useTitle } from "./lib/hooks.js";
const createInitial = { protocol: 'socks5', host: '', port: '', username: '', password: '', expected_public_ip: '', expected_region: '' }
const healthText = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' }
const protocolOptions = ['http', 'https', 'socks4', 'socks5'].map(value => ({ value, label: value }))
const createInitial = {
protocol: "socks5",
host: "",
port: "",
username: "",
password: "",
expected_public_ip: "",
expected_region: "",
};
const healthText = {
unchecked: "未检测",
healthy: "健康",
unhealthy: "不健康",
disabled: "已停用",
};
const protocolOptions = ["http", "https", "socks4", "socks5"].map((value) => ({
value,
label: value,
}));
function ExitHealthPill({ exit }) {
const known = Object.hasOwn(healthText, exit.health_status)
const tone = exit.health_status === 'healthy' ? 'success' : exit.health_status === 'disabled' ? 'neutral' : exit.health_status === 'unchecked' ? 'warning' : 'danger'
return <StatusPill tone={tone} label={known ? healthText[exit.health_status] : '未知状态'} sub={known ? undefined : exit.health_status} />
const known = Object.hasOwn(healthText, exit.health_status);
const tone =
exit.health_status === "healthy"
? "success"
: exit.health_status === "disabled"
? "neutral"
: exit.health_status === "unchecked"
? "warning"
: "danger";
return (
<StatusPill
tone={tone}
label={known ? healthText[exit.health_status] : "未知状态"}
sub={known ? undefined : exit.health_status}
/>
);
}
function ExitActions({ exit, busy, onAction }) {
const disabled = busy || exit.health_status === 'disabled'
const disabled = busy || exit.health_status === "disabled";
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" icon="ri-refresh-line" disabled={disabled} onClick={() => onAction(exit, 'check')}>检测</Button>
<Button size="sm" variant="danger" icon="ri-forbid-line" disabled={disabled} onClick={() => onAction(exit, 'disable')}>停用</Button>
<Button
size="sm"
icon="ri-refresh-line"
disabled={disabled}
onClick={() => onAction(exit, "check")}
>
检测
</Button>
<Button
size="sm"
variant="danger"
icon="ri-forbid-line"
disabled={disabled}
onClick={() => onAction(exit, "disable")}
>
停用
</Button>
</div>
)
);
}
function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
const [form, setForm] = useState(createInitial)
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
const port = Number(form.port)
const credentialsValid = !form.password || !!form.username
const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535 && credentialsValid
const [form, setForm] = useState(createInitial);
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const port = Number(form.port);
const credentialsValid = !form.password || !!form.username;
const valid =
form.host.trim() &&
Number.isInteger(port) &&
port > 0 &&
port <= 65535 &&
credentialsValid;
async function submit(event) {
event.preventDefault()
if (!valid) return
event.preventDefault();
if (!valid) return;
const created = await onSubmit({
protocol: form.protocol, host: form.host.trim(), port,
username: form.username, password: form.password,
expected_public_ip: form.expected_public_ip.trim(), expected_region: form.expected_region.trim(),
})
protocol: form.protocol,
host: form.host.trim(),
port,
username: form.username,
password: form.password,
expected_public_ip: form.expected_public_ip.trim(),
expected_region: form.expected_region.trim(),
});
if (created) {
setForm(createInitial)
onClose()
setForm(createInitial);
onClose();
}
}
return (
<Modal open={open} onClose={onClose} title="创建网络出口" labelledBy="exit-create-title" size="lg"
footer={<>
<Button onClick={onClose} disabled={busy}>取消</Button>
<Button variant="primary" type="submit" form="exit-create-form" busy={busy} busyText="创建中…" disabled={!valid}>创建网络出口</Button>
</>}>
<Modal
open={open}
onClose={onClose}
title="创建网络出口"
labelledBy="exit-create-title"
size="lg"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="primary"
type="submit"
form="exit-create-form"
busy={busy}
busyText="创建中…"
disabled={!valid}
>
创建网络出口
</Button>
</>
}
>
<form id="exit-create-form" onSubmit={submit} noValidate>
{error ? <Alert variant="destructive" className="mb-4">{conflictMessage(error, '出口地址或认证信息与现有资源冲突;表单内容已保留。')}</Alert> : null}
{error ? (
<Alert variant="destructive" className="mb-4">
{conflictMessage(
error,
"出口地址或认证信息与现有资源冲突;表单内容已保留。",
)}
</Alert>
) : null}
<div className="grid gap-4 sm:grid-cols-3">
<Field id="exit-protocol" label="协议" required helper="代理协议">
<Select id="exit-protocol" value={form.protocol} onChange={event => update('protocol', event.target.value)} options={protocolOptions} />
<Select
id="exit-protocol"
value={form.protocol}
onChange={(event) => update("protocol", event.target.value)}
options={protocolOptions}
/>
</Field>
<Field id="exit-host" label="主机" required helper="代理服务器地址">
<Input id="exit-host" required maxLength={253} value={form.host} onChange={event => update('host', event.target.value)} placeholder="如 proxy.example" />
<Input
id="exit-host"
required
maxLength={253}
value={form.host}
onChange={(event) => update("host", event.target.value)}
placeholder="如 proxy.example"
/>
</Field>
<Field id="exit-port" label="端口" required helper="1..65535">
<Input id="exit-port" type="number" min={1} max={65535} required value={form.port} onChange={event => update('port', event.target.value)} />
<Input
id="exit-port"
type="number"
min={1}
max={65535}
required
value={form.port}
onChange={(event) => update("port", event.target.value)}
/>
</Field>
<Field id="exit-username" label="用户名(可选)" helper="直接保存到系统并用于代理认证">
<Input id="exit-username" maxLength={255} value={form.username} onChange={event => update('username', event.target.value)} />
<Field
id="exit-username"
label="用户名(可选)"
helper="直接保存到系统并用于代理认证"
>
<Input
id="exit-username"
maxLength={255}
value={form.username}
onChange={(event) => update("username", event.target.value)}
/>
</Field>
<Field id="exit-password" label="密码(可选)" error={!credentialsValid ? '填写密码时必须同时填写用户名' : undefined} helper="明文保存并直接用于代理认证">
<Input id="exit-password" maxLength={255} value={form.password} onChange={event => update('password', event.target.value)} invalid={!credentialsValid} />
<Field
id="exit-password"
label="密码(可选)"
error={
credentialsValid ? undefined : "填写密码时必须同时填写用户名"
}
helper="明文保存并直接用于代理认证"
>
<Input
id="exit-password"
maxLength={255}
value={form.password}
onChange={(event) => update("password", event.target.value)}
invalid={!credentialsValid}
/>
</Field>
<Field id="exit-ip" label="出口IP(可选)" helper="健康检测时比对的预期公网 IP">
<Input id="exit-ip" value={form.expected_public_ip} onChange={event => update('expected_public_ip', event.target.value)} />
<Field
id="exit-ip"
label="出口IP(可选)"
helper="健康检测时比对的预期公网 IP"
>
<Input
id="exit-ip"
value={form.expected_public_ip}
onChange={(event) =>
update("expected_public_ip", event.target.value)
}
/>
</Field>
<Field id="exit-region" label="出口地区(可选)" helper="如 CN-Shanghai">
<Input id="exit-region" maxLength={64} value={form.expected_region} onChange={event => update('expected_region', event.target.value)} />
<Field
id="exit-region"
label="出口地区(可选)"
helper="如 CN-Shanghai"
>
<Input
id="exit-region"
maxLength={64}
value={form.expected_region}
onChange={(event) =>
update("expected_region", event.target.value)
}
/>
</Field>
</div>
</form>
</Modal>
)
);
}
function ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
@@ -90,137 +245,281 @@ function ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
<CardContent className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<Link to={`/network-exits/${encodeURIComponent(exit.id)}`} className="anywhere font-semibold text-ink hover:text-primary">{exit.protocol}://{exit.host}:{exit.port}</Link>
<p className="anywhere text-xs text-muted">{exit.id} · 用户名 {exit.username || '无'} · 密码 {exit.password || '无'}</p>
<Link
to={`/network-exits/${encodeURIComponent(exit.id)}`}
className="anywhere font-semibold text-ink hover:text-primary"
>
{exit.protocol}://{exit.host}:{exit.port}
</Link>
<p className="anywhere text-xs text-muted">
{exit.id} · 用户名 {exit.username || "无"} · 密码{" "}
{exit.password || "无"}
</p>
</div>
<ExitHealthPill exit={exit} />
</div>
<div className="text-sm"><span className="text-muted">出口IP</span><span className="anywhere">{exit.observed_public_ip || '尚无观测'}</span></div>
<p className="text-xs text-muted">最近检测{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}</p>
<div className="text-sm">
<span className="text-muted">出口IP</span>
<span className="anywhere">
{exit.observed_public_ip || "尚无观测"}
</span>
</div>
<p className="text-xs text-muted">
最近检测
{exit.last_checked_at
? new Date(exit.last_checked_at).toLocaleString("zh-CN")
: "未检测"}
</p>
<div className="text-sm">
<span className="text-muted">绑定账号</span>
{bindingsError ? '状态未知' : (
<span className="anywhere">{boundAccounts?.length ? boundAccounts.join('、') : '未绑定'}</span>
{bindingsError ? (
"状态未知"
) : (
<span className="anywhere">
{boundAccounts?.length ? boundAccounts.join("、") : "未绑定"}
</span>
)}
</div>
<ExitActions exit={exit} busy={busy} onAction={onAction} />
</CardContent>
</Card>
)
);
}
export function NetworkExitList() {
const dataProvider = useDataProvider()('default')
const [createOpen, setCreateOpen] = useState(false)
const [busy, setBusy] = useState('')
const [createError, setCreateError] = useState(null)
const [notice, setNotice] = useState(null)
const [disabling, setDisabling] = useState(null)
const { result, query } = useList({ resource: 'network-exits' })
const error = query.error
const isPending = query.isPending
const exits = result.data ?? []
const { result: bindingResult, query: browsersQuery } = useList({ resource: 'browsers', queryOptions: { retry: false } })
const browsers = bindingResult.data ?? []
const bindingsError = browsersQuery.error
const refetchBrowsers = browsersQuery.refetch
const dataProvider = useDataProvider()("default");
const [createOpen, setCreateOpen] = useState(false);
const [busy, setBusy] = useState("");
const [createError, setCreateError] = useState(null);
const [notice, setNotice] = useState(null);
const [disabling, setDisabling] = useState(null);
const { result, query } = useList({ resource: "network-exits" });
const error = query.error;
const isPending = query.isPending;
const exits = result.data ?? [];
const { result: bindingResult, query: browsersQuery } = useList({
resource: "browsers",
queryOptions: { retry: false },
});
const browsers = bindingResult.data ?? [];
const bindingsError = browsersQuery.error;
const refetchBrowsers = browsersQuery.refetch;
const accountsByExit = useMemo(() => {
const map = new Map
const map = new Map();
for (const runtime of browsers) {
if (!runtime.network_exit_id || !runtime.account_id) continue
map.set(runtime.network_exit_id, [...(map.get(runtime.network_exit_id) || []), runtime.account_id])
if (!runtime.network_exit_id || !runtime.account_id) continue;
map.set(runtime.network_exit_id, [
...(map.get(runtime.network_exit_id) || []),
runtime.account_id,
]);
}
return map
}, [browsers])
useTitle('CreatorHub · 网络出口')
return map;
}, [browsers]);
useTitle("CreatorHub · 网络出口");
async function createExit(data) {
setBusy('create')
setCreateError(null)
setBusy("create");
setCreateError(null);
try {
await dataProvider.create({ resource: 'network-exits', variables: data })
await query.refetch()
setNotice({ variant: 'success', text: '网络出口已创建;检测通过后才可用于账号绑定。' })
return true
await dataProvider.create({ resource: "network-exits", variables: data });
await query.refetch();
setNotice({
variant: "success",
text: "网络出口已创建;检测通过后才可用于账号绑定。",
});
return true;
} catch (reason) {
setCreateError(reason)
return false
setCreateError(reason);
return false;
} finally {
setBusy('')
setBusy("");
}
}
async function runAction(exit, action) {
setBusy(exit.id)
setNotice(null)
setBusy(exit.id);
setNotice(null);
try {
await dataProvider.networkExitAction(exit.id, action)
await Promise.all([query.refetch(), refetchBrowsers()])
setNotice({ variant: 'success', text: action === 'check' ? '出口检测完成。' : '出口已停用;绑定账号不会自动恢复。' })
await dataProvider.networkExitAction(exit.id, action);
await Promise.all([query.refetch(), refetchBrowsers()]);
setNotice({
variant: "success",
text:
action === "check"
? "出口检测完成。"
: "出口已停用;绑定账号不会自动恢复。",
});
} catch (reason) {
setNotice({ variant: 'destructive', text: conflictMessage(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。') })
setNotice({
variant: "destructive",
text: conflictMessage(
reason,
action === "check" ? "已停用的出口不能检测。" : "出口当前不能停用。",
),
});
} finally {
setBusy('')
setBusy("");
}
}
return (
<div>
<PageHeader icon="ri-earth-line" title="网络出口" description="维护固定出口、健康观测与账号绑定">
<Button variant="primary" icon="ri-add-line" onClick={() => setCreateOpen(true)}>创建网络出口</Button>
<PageHeader
icon="ri-earth-line"
title="网络出口"
description="维护固定出口、健康观测与账号绑定"
>
<Button
variant="primary"
icon="ri-add-line"
onClick={() => setCreateOpen(true)}
>
创建网络出口
</Button>
</PageHeader>
{notice ? <Alert variant={notice.variant} className="mb-4">{notice.text}</Alert> : null}
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `${bindingsError.status}` : ''}无法读取账号绑定状态暂时未知
<Button size="sm" onClick={() => refetchBrowsers()}>重试环境状态</Button>
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
<PageState pending={isPending} error={error} empty={exits.length === 0} emptyText="暂无网络出口。创建并检测健康后,才能绑定运行环境。">
{error ? (
<Alert variant="destructive" className="mb-4">
{error.message}
</Alert>
) : null}
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `${bindingsError.status}` : ""}
无法读取账号绑定状态暂时未知
<Button size="sm" onClick={() => refetchBrowsers()}>
重试环境状态
</Button>
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
empty={exits.length === 0}
emptyText="暂无网络出口。创建并检测健康后,才能绑定运行环境。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{exits.map(exit => (
<ExitCard key={exit.id} exit={exit} boundAccounts={accountsByExit.get(exit.id)} bindingsError={!!bindingsError}
busy={busy === exit.id} onAction={(exit, action) => (action === 'disable' ? setDisabling(exit) : runAction(exit, action))} />
{exits.map((exit) => (
<ExitCard
key={exit.id}
exit={exit}
boundAccounts={accountsByExit.get(exit.id)}
bindingsError={!!bindingsError}
busy={busy === exit.id}
onAction={(exit, action) =>
action === "disable"
? setDisabling(exit)
: runAction(exit, action)
}
/>
))}
</div>
</PageState>
<ExitCreateModal open={createOpen} onClose={() => { setCreateOpen(false); setCreateError(null) }} onSubmit={createExit} busy={busy === 'create'} error={createError} />
<ConfirmDialog open={disabling !== null} onClose={() => setDisabling(null)} onConfirm={() => { const exit = disabling; setDisabling(null); runAction(exit, 'disable') }}
title="停用网络出口" confirmLabel="确认停用" busy={busy === disabling?.id}
body={`停用网络出口 ${disabling?.id}?绑定账号将暂停,排队任务进入 hold。`} />
<ExitCreateModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
}}
onSubmit={createExit}
busy={busy === "create"}
error={createError}
/>
<ConfirmDialog
open={disabling !== null}
onClose={() => setDisabling(null)}
onConfirm={() => {
const exit = disabling;
setDisabling(null);
runAction(exit, "disable");
}}
title="停用网络出口"
confirmLabel="确认停用"
busy={busy === disabling?.id}
body={`停用网络出口 ${disabling?.id}?绑定账号将暂停,排队任务进入 hold。`}
/>
</div>
)
);
}
export function NetworkExitDetail() {
const { id } = useParams()
const { result, error, isPending } = useOne({ resource: 'network-exits', id, queryOptions: { retry: false } })
const exit = result
useTitle('CreatorHub · 出口详情')
if (isPending) return <PageState pending />
if (error || !exit) return <Alert variant="destructive">{error?.message || '网络出口不存在'}</Alert>
const { id } = useParams();
const { result, error, isPending } = useOne({
resource: "network-exits",
id,
queryOptions: { retry: false },
});
const exit = result;
useTitle("CreatorHub · 出口详情");
if (isPending) return <PageState pending />;
if (error || !exit)
return (
<Alert variant="destructive">{error?.message || "网络出口不存在"}</Alert>
);
return (
<div>
<PageHeader icon="ri-earth-line" title="网络出口详情" description={<span className="anywhere">{exit.protocol}://{exit.host}:{exit.port}</span>}>
<Button icon="ri-arrow-left-line" as={Link} to="/network-exits">返回出口列表</Button>
<PageHeader
icon="ri-earth-line"
title="网络出口详情"
description={
<span className="anywhere">
{exit.protocol}://{exit.host}:{exit.port}
</span>
}
>
<Button icon="ri-arrow-left-line" as={Link} to="/network-exits">
返回出口列表
</Button>
</PageHeader>
<Card>
<CardContent className="space-y-4">
<ExitHealthPill exit={exit} />
<DetailList rows={[
['ID', <span className="anywhere">{exit.id}</span>],
['健康 / 版本', <span className="anywhere">{healthText[exit.health_status] ?? exit.health_status} · {exit.version}</span>],
['用户名', <span className="anywhere">{exit.username || '无'}</span>],
['密码', <span className="anywhere">{exit.password || '无'}</span>],
['出口IP', <span className="anywhere">{exit.observed_public_ip || '尚无观测'}</span>],
['最近检测', exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'],
]} />
<DetailList
rows={[
["ID", <span className="anywhere">{exit.id}</span>],
[
"健康 / 版本",
<span className="anywhere">
{healthText[exit.health_status] ?? exit.health_status} ·{" "}
{exit.version}
</span>,
],
[
"用户名",
<span className="anywhere">{exit.username || "无"}</span>,
],
[
"密码",
<span className="anywhere">{exit.password || "无"}</span>,
],
[
"出口IP",
<span className="anywhere">
{exit.observed_public_ip || "尚无观测"}
</span>,
],
[
"最近检测",
exit.last_checked_at
? new Date(exit.last_checked_at).toLocaleString("zh-CN")
: "未检测",
],
]}
/>
<div className="flex flex-wrap gap-3">
<Button as={Link} to={`/audit?network_exit_id=${encodeURIComponent(exit.id)}`}>查看审计</Button>
<Button
as={Link}
to={`/audit?network_exit_id=${encodeURIComponent(exit.id)}`}
>
查看审计
</Button>
</div>
</CardContent>
</Card>
</div>
)
);
}
+170 -73
View File
@@ -1,96 +1,193 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Refine } from '@refinedev/core'
import { MemoryRouter } from 'react-router'
import { NetworkExitList } from './NetworkExitsPage'
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Refine } from "@refinedev/core";
import { MemoryRouter } from "react-router";
import { NetworkExitList } from "./NetworkExitsPage";
afterEach(() => { cleanup(); vi.restoreAllMocks() })
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const httpError = (message, status, body) => Object.assign(new Error(message), { status, body })
const httpError = (message, status, body) =>
Object.assign(new Error(message), { status, body });
const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, username: 'proxy-user', password: 'plain-password', health_status: 'healthy', observed_public_ip: '203.0.113.1' }
const networkExit = {
id: "exit-a",
protocol: "socks5",
host: "proxy.example",
port: 1080,
username: "proxy-user",
password: "plain-password",
health_status: "healthy",
observed_public_ip: "203.0.113.1",
};
function provider(exits = [], overrides = {}) {
return {
getList: vi.fn(({ resource }) => Promise.resolve(resource === 'network-exits' ? { data: exits, total: exits.length } : { data: [], total: 0 })),
getList: vi.fn(({ resource }) =>
Promise.resolve(
resource === "network-exits"
? { data: exits, total: exits.length }
: { data: [], total: 0 },
),
),
create: vi.fn().mockResolvedValue({ data: networkExit }),
networkExitAction: vi.fn().mockResolvedValue(networkExit),
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
getOne: vi.fn(),
getMany: vi.fn(),
getManyReference: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
...overrides,
}
};
}
const renderExits = dataProvider => render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<Refine dataProvider={dataProvider}>
<MemoryRouter><NetworkExitList /></MemoryRouter>
</Refine>
</QueryClientProvider>,
)
const renderExits = (dataProvider) =>
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
>
<Refine dataProvider={dataProvider}>
<MemoryRouter>
<NetworkExitList />
</MemoryRouter>
</Refine>
</QueryClientProvider>,
);
describe('NetworkExitList', () => {
it('shows the ordered empty-state instruction', async () => {
renderExits(provider())
expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy()
})
describe("NetworkExitList", () => {
it("shows the ordered empty-state instruction", async () => {
renderExits(provider());
expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy();
});
it('shows stored credentials and runs an explicit health check', async () => {
const dataProvider = provider([networkExit])
renderExits(dataProvider)
it("shows stored credentials and runs an explicit health check", async () => {
const dataProvider = provider([networkExit]);
renderExits(dataProvider);
expect((await screen.findAllByText(/proxy-user/)).length).toBeGreaterThan(0)
expect((await screen.findAllByText(/plain-password/)).length).toBeGreaterThan(0)
fireEvent.click(screen.getAllByRole('button', { name: '检测' })[0])
await waitFor(() => expect(dataProvider.networkExitAction).toHaveBeenCalledWith('exit-a', 'check'))
})
expect((await screen.findAllByText(/proxy-user/)).length).toBeGreaterThan(
0,
);
expect(
(await screen.findAllByText(/plain-password/)).length,
).toBeGreaterThan(0);
fireEvent.click(screen.getAllByRole("button", { name: "检测" })[0]);
await waitFor(() =>
expect(dataProvider.networkExitAction).toHaveBeenCalledWith(
"exit-a",
"check",
),
);
});
it('creates with plain credentials and closes on success', async () => {
const dataProvider = provider()
renderExits(dataProvider)
it("creates with plain credentials and closes on success", async () => {
const dataProvider = provider();
renderExits(dataProvider);
fireEvent.click(await screen.findByRole('button', { name: '创建网络出口' }))
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByRole('textbox', { name: '主机' }), { target: { value: 'proxy.example' } })
fireEvent.change(within(dialog).getByRole('spinbutton', { name: '端口' }), { target: { value: '1080' } })
fireEvent.change(within(dialog).getByRole('textbox', { name: '用户名(可选)' }), { target: { value: 'proxy-user' } })
fireEvent.change(within(dialog).getByRole('textbox', { name: '密码(可选)' }), { target: { value: 'plain-password' } })
fireEvent.click(within(dialog).getByRole('button', { name: '创建网络出口' }))
fireEvent.click(
await screen.findByRole("button", { name: "创建网络出口" }),
);
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "主机" }), {
target: { value: "proxy.example" },
});
fireEvent.change(within(dialog).getByRole("spinbutton", { name: "端口" }), {
target: { value: "1080" },
});
fireEvent.change(
within(dialog).getByRole("textbox", { name: "用户名(可选)" }),
{ target: { value: "proxy-user" } },
);
fireEvent.change(
within(dialog).getByRole("textbox", { name: "密码(可选)" }),
{ target: { value: "plain-password" } },
);
fireEvent.click(
within(dialog).getByRole("button", { name: "创建网络出口" }),
);
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'network-exits', variables: {
protocol: 'socks5', host: 'proxy.example', port: 1080, username: 'proxy-user', password: 'plain-password', expected_public_ip: '', expected_region: '',
} }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith({
resource: "network-exits",
variables: {
protocol: "socks5",
host: "proxy.example",
port: 1080,
username: "proxy-user",
password: "plain-password",
expected_public_ip: "",
expected_region: "",
},
}),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it('keeps the create modal open after a failure', async () => {
const dataProvider = provider([], { create: vi.fn().mockRejectedValue(new Error('创建失败')) })
renderExits(dataProvider)
it("keeps the create modal open after a failure", async () => {
const dataProvider = provider([], {
create: vi.fn().mockRejectedValue(new Error("创建失败")),
});
renderExits(dataProvider);
fireEvent.click(await screen.findByRole('button', { name: '创建网络出口' }))
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByRole('textbox', { name: '主机' }), { target: { value: 'proxy.example' } })
fireEvent.change(within(dialog).getByRole('spinbutton', { name: '端口' }), { target: { value: '1080' } })
fireEvent.click(within(dialog).getByRole('button', { name: '创建网络出口' }))
fireEvent.click(
await screen.findByRole("button", { name: "创建网络出口" }),
);
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByRole("textbox", { name: "主机" }), {
target: { value: "proxy.example" },
});
fireEvent.change(within(dialog).getByRole("spinbutton", { name: "端口" }), {
target: { value: "1080" },
});
fireEvent.click(
within(dialog).getByRole("button", { name: "创建网络出口" }),
);
expect((await within(dialog).findByRole('alert')).textContent).toContain('创建失败')
expect(screen.getByRole('dialog')).toBeTruthy()
})
expect((await within(dialog).findByRole("alert")).textContent).toContain(
"创建失败",
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it('shows unknown bindings and retries when browsers return 502', async () => {
it("shows unknown bindings and retries when browsers return 502", async () => {
const dataProvider = provider([networkExit], {
getList: vi.fn(({ resource }) => resource === 'browsers'
? Promise.reject(httpError('bad gateway', 502))
: Promise.resolve({ data: [networkExit], total: 1 })),
})
renderExits(dataProvider)
getList: vi.fn(({ resource }) =>
resource === "browsers"
? Promise.reject(httpError("bad gateway", 502))
: Promise.resolve({ data: [networkExit], total: 1 }),
),
});
renderExits(dataProvider);
expect(await screen.findByText(/环境不可用/)).toBeTruthy()
expect((await screen.findAllByText(/绑定账号:状态未知|状态未知/)).length).toBeGreaterThan(0)
expect(screen.queryByText(/绑定账号:未绑定/)).toBeNull()
expect(screen.queryByText(/^未绑定$/)).toBeNull()
expect(screen.getAllByRole('button', { name: '检测' })[0].disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([call]) => call.resource === 'browsers').length).toBeGreaterThan(1))
})
})
expect(await screen.findByText(/环境不可用/)).toBeTruthy();
expect(
(await screen.findAllByText(/绑定账号:状态未知|状态未知/)).length,
).toBeGreaterThan(0);
expect(screen.queryByText(/绑定账号:未绑定/)).toBeNull();
expect(screen.queryByText(/^未绑定$/)).toBeNull();
expect(screen.getAllByRole("button", { name: "检测" })[0].disabled).toBe(
false,
);
fireEvent.click(screen.getByRole("button", { name: "重试环境状态" }));
await waitFor(() =>
expect(
dataProvider.getList.mock.calls.filter(
([call]) => call.resource === "browsers",
).length,
).toBeGreaterThan(1),
);
});
});