chore: 前端代码风格统一并清理未使用导入;移除过时的 PR 流程规则
This commit is contained in:
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"该版本正被环境引用",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user