feat(gateways): 网关列表增加连通性与健康状态探测展示
- GET /api/gateways 并发探测 /healthz(连通性)与 /v1/browsers(健康),失败原因随列表返回 - 前端新增连通性/健康两列,失败原因悬浮提示 - 覆盖健康、令牌被拒、离线三类探测路径的单元测试
This commit is contained in:
+310
-86
@@ -1,146 +1,370 @@
|
||||
import { useState } from 'react'
|
||||
import { useDataProvider, useList } from '@refinedev/core'
|
||||
import { useState } from "react";
|
||||
import { useDataProvider, useList } from "@refinedev/core";
|
||||
import {
|
||||
Alert, Button, ConfirmDialog, Copyable, Field, Input, Modal, PageHeader, PageState, Table,
|
||||
} from './lib/ui.jsx'
|
||||
import { useTitle } from './lib/hooks.js'
|
||||
Alert,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Copyable,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
PageState,
|
||||
StatusPill,
|
||||
Table,
|
||||
} from "./lib/ui.jsx";
|
||||
import { useTitle } from "./lib/hooks.js";
|
||||
|
||||
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
||||
const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/
|
||||
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/;
|
||||
const connectivityLabels = { online: "在线", offline: "离线" };
|
||||
const healthLabels = { healthy: "健康", unhealthy: "异常" };
|
||||
|
||||
// 后端对每个网关实时探测 /healthz(连通性)与 /v1/browsers(健康);
|
||||
// 失败原因通过 title 悬浮展示,不占用列宽。
|
||||
function GatewayStatePill({ value, reason, labels, healthyValue }) {
|
||||
const known = Object.hasOwn(labels, value);
|
||||
const tone = known
|
||||
? value === healthyValue
|
||||
? "success"
|
||||
: "danger"
|
||||
: "neutral";
|
||||
return (
|
||||
<span title={reason || undefined}>
|
||||
<StatusPill
|
||||
tone={tone}
|
||||
label={known ? labels[value] : "未知"}
|
||||
sub={known ? undefined : value}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenCell({ token, name }) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<code className="anywhere font-mono text-[13px]">{visible ? token : '••••••••••••'}</code>
|
||||
<button type="button" aria-label={visible ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`} onClick={() => setVisible(current => !current)}
|
||||
className="rounded p-1 text-muted hover:text-primary"><i className={visible ? 'ri-eye-off-line' : 'ri-eye-line'} aria-hidden="true" /></button>
|
||||
<code className="anywhere font-mono text-[13px]">
|
||||
{visible ? token : "••••••••••••"}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
className="rounded p-1 text-muted hover:text-primary"
|
||||
>
|
||||
<i
|
||||
className={visible ? "ri-eye-off-line" : "ri-eye-line"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{visible ? (
|
||||
<button type="button" aria-label={`复制 ${token}`} onClick={() => navigator.clipboard?.writeText(token)}
|
||||
className="rounded p-1 text-muted hover:text-primary"><i className="ri-file-copy-line" aria-hidden="true" /></button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`复制 ${token}`}
|
||||
onClick={() => navigator.clipboard?.writeText(token)}
|
||||
className="rounded p-1 text-muted hover:text-primary"
|
||||
>
|
||||
<i className="ri-file-copy-line" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
const [form, setForm] = useState({ name: '', endpoint: '', token: '' })
|
||||
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
const token = form.token.trim()
|
||||
const valid = namePattern.test(form.name) && /^https?:\/\/\S+$/.test(form.endpoint) && (token === '' || tokenPattern.test(token))
|
||||
const nameInvalid = form.name !== '' && !namePattern.test(form.name)
|
||||
const endpointInvalid = form.endpoint !== '' && !/^https?:\/\/\S+$/.test(form.endpoint)
|
||||
const tokenInvalid = token !== '' && !tokenPattern.test(token)
|
||||
const [form, setForm] = useState({ name: "", endpoint: "", token: "" });
|
||||
const update = (key, value) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
const token = form.token.trim();
|
||||
const valid =
|
||||
namePattern.test(form.name) &&
|
||||
/^https?:\/\/\S+$/.test(form.endpoint) &&
|
||||
(token === "" || tokenPattern.test(token));
|
||||
const nameInvalid = form.name !== "" && !namePattern.test(form.name);
|
||||
const endpointInvalid =
|
||||
form.endpoint !== "" && !/^https?:\/\/\S+$/.test(form.endpoint);
|
||||
const tokenInvalid = token !== "" && !tokenPattern.test(token);
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (!valid) return
|
||||
const created = await onSubmit({ name: form.name, endpoint: form.endpoint, token })
|
||||
event.preventDefault();
|
||||
if (!valid) return;
|
||||
const created = await onSubmit({
|
||||
name: form.name,
|
||||
endpoint: form.endpoint,
|
||||
token,
|
||||
});
|
||||
if (created) {
|
||||
setForm({ name: '', endpoint: '', token: '' })
|
||||
onClose()
|
||||
setForm({ name: "", endpoint: "", token: "" });
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="注册网关" labelledBy="gateway-create-title"
|
||||
footer={<>
|
||||
<Button onClick={onClose} disabled={busy}>取消</Button>
|
||||
<Button variant="primary" type="submit" form="gateway-create-form" busy={busy} busyText="注册中…" disabled={!valid}>注册网关</Button>
|
||||
</>}>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="注册网关"
|
||||
labelledBy="gateway-create-title"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form="gateway-create-form"
|
||||
busy={busy}
|
||||
busyText="注册中…"
|
||||
disabled={!valid}
|
||||
>
|
||||
注册网关
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="gateway-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="gateway-name" label="名称" required error={nameInvalid ? '字母、数字与 . _ -,最长 64 字符' : undefined} helper="网关唯一标识,如 gw-main">
|
||||
<Input id="gateway-name" required maxLength={64} value={form.name} onChange={event => update('name', event.target.value)} placeholder="如 gw-main" invalid={nameInvalid} />
|
||||
<Field
|
||||
id="gateway-name"
|
||||
label="名称"
|
||||
required
|
||||
error={nameInvalid ? "字母、数字与 . _ -,最长 64 字符" : undefined}
|
||||
helper="网关唯一标识,如 gw-main"
|
||||
>
|
||||
<Input
|
||||
id="gateway-name"
|
||||
required
|
||||
maxLength={64}
|
||||
value={form.name}
|
||||
onChange={(event) => update("name", event.target.value)}
|
||||
placeholder="如 gw-main"
|
||||
invalid={nameInvalid}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="gateway-endpoint" label="Endpoint" required error={endpointInvalid ? '须为 http(s) URL' : undefined} helper="网关进程的可访问地址">
|
||||
<Input id="gateway-endpoint" required value={form.endpoint} onChange={event => update('endpoint', event.target.value)} placeholder="http://docker-gateway:8081" invalid={endpointInvalid} />
|
||||
<Field
|
||||
id="gateway-endpoint"
|
||||
label="Endpoint"
|
||||
required
|
||||
error={endpointInvalid ? "须为 http(s) URL" : undefined}
|
||||
helper="网关进程的可访问地址"
|
||||
>
|
||||
<Input
|
||||
id="gateway-endpoint"
|
||||
required
|
||||
value={form.endpoint}
|
||||
onChange={(event) => update("endpoint", event.target.value)}
|
||||
placeholder="http://docker-gateway:8081"
|
||||
invalid={endpointInvalid}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="gateway-token" label="令牌(可选)" error={tokenInvalid ? '至少 16 个字符,字母、数字与 . _ -' : undefined} helper="填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成">
|
||||
<Input id="gateway-token" maxLength={128} value={form.token} onChange={event => update('token', event.target.value)} placeholder="留空由平台生成" invalid={tokenInvalid} />
|
||||
<Field
|
||||
id="gateway-token"
|
||||
label="令牌(可选)"
|
||||
error={
|
||||
tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined
|
||||
}
|
||||
helper="填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"
|
||||
>
|
||||
<Input
|
||||
id="gateway-token"
|
||||
maxLength={128}
|
||||
value={form.token}
|
||||
onChange={(event) => update("token", event.target.value)}
|
||||
placeholder="留空由平台生成"
|
||||
invalid={tokenInvalid}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function GatewayList() {
|
||||
const dataProvider = useDataProvider()('default')
|
||||
const { result, query } = useList({ resource: 'gateways' })
|
||||
const error = query.error
|
||||
const isPending = query.isPending
|
||||
const gateways = result.data ?? []
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [createError, setCreateError] = useState(null)
|
||||
const [created, setCreated] = useState(null)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
useTitle('CreatorHub · 网关管理')
|
||||
const dataProvider = useDataProvider()("default");
|
||||
const { result, query } = useList({ resource: "gateways" });
|
||||
const error = query.error;
|
||||
const isPending = query.isPending;
|
||||
const gateways = result.data ?? [];
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [createError, setCreateError] = useState(null);
|
||||
const [created, setCreated] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
useTitle("CreatorHub · 网关管理");
|
||||
|
||||
async function createGateway(data) {
|
||||
setBusy('create')
|
||||
setCreateError(null)
|
||||
setBusy("create");
|
||||
setCreateError(null);
|
||||
try {
|
||||
const { data: record } = await dataProvider.create({ resource: 'gateways', variables: data })
|
||||
setCreated(record)
|
||||
await query.refetch()
|
||||
return true
|
||||
const { data: record } = await dataProvider.create({
|
||||
resource: "gateways",
|
||||
variables: data,
|
||||
});
|
||||
setCreated(record);
|
||||
await query.refetch();
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setCreateError(reason)
|
||||
return false
|
||||
setCreateError(reason);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy('')
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGateway() {
|
||||
const gateway = deleting
|
||||
setBusy(gateway.name)
|
||||
const gateway = deleting;
|
||||
setBusy(gateway.name);
|
||||
try {
|
||||
await dataProvider.deleteOne({ resource: 'gateways', id: gateway.name })
|
||||
await query.refetch()
|
||||
setDeleting(null)
|
||||
await dataProvider.deleteOne({ resource: "gateways", id: gateway.name });
|
||||
await query.refetch();
|
||||
setDeleting(null);
|
||||
} finally {
|
||||
setBusy('')
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader icon="ri-git-branch-line" title="网关管理" description="注册 Docker 网关;令牌须与网关进程的 GATEWAY_TOKEN 一致">
|
||||
<Button variant="primary" icon="ri-add-line" onClick={() => setCreateOpen(true)}>注册网关</Button>
|
||||
<PageHeader
|
||||
icon="ri-git-branch-line"
|
||||
title="网关管理"
|
||||
description="注册 Docker 网关;令牌须与网关进程的 GATEWAY_TOKEN 一致"
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="ri-add-line"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
注册网关
|
||||
</Button>
|
||||
</PageHeader>
|
||||
{created ? (
|
||||
<Alert variant="success" className="mb-4">
|
||||
网关 {created.name} 已注册。
|
||||
<span className="ml-1 inline-flex items-center gap-1">令牌 <code className="font-mono">{created.token}</code>
|
||||
<button type="button" aria-label={`复制 ${created.token}`} onClick={() => navigator.clipboard?.writeText(created.token)}
|
||||
className="rounded p-1 text-muted hover:text-primary"><i className="ri-file-copy-line" aria-hidden="true" /></button>
|
||||
<span className="ml-1 inline-flex items-center gap-1">
|
||||
令牌 <code className="font-mono">{created.token}</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`复制 ${created.token}`}
|
||||
onClick={() => navigator.clipboard?.writeText(created.token)}
|
||||
className="rounded p-1 text-muted hover:text-primary"
|
||||
>
|
||||
<i className="ri-file-copy-line" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setCreated(null)}>知道了</Button>
|
||||
<Button size="sm" onClick={() => setCreated(null)}>
|
||||
知道了
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
{error ? <Alert variant="destructive" className="mb-4">{error.message}</Alert> : null}
|
||||
<PageState pending={isPending} error={error} empty={gateways.length === 0} emptyText="暂无网关;注册后环境即可调度到该网关。">
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
{error.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
<PageState
|
||||
pending={isPending}
|
||||
error={error}
|
||||
empty={gateways.length === 0}
|
||||
emptyText="暂无网关;注册后环境即可调度到该网关。"
|
||||
>
|
||||
<Table
|
||||
columns={[
|
||||
{ header: '名称', width: '18%', render: gateway => <span className="font-semibold">{gateway.name}</span> },
|
||||
{ header: 'Endpoint', width: '34%', render: gateway => <code className="anywhere font-mono text-[13px]">{gateway.endpoint}</code> },
|
||||
{ header: '令牌', width: '34%', render: gateway => <TokenCell token={gateway.token} name={gateway.name} /> },
|
||||
{ header: '操作', width: '14%', align: 'right', render: gateway => (
|
||||
<button type="button" aria-label={`删除 ${gateway.name}`} disabled={busy === gateway.name} onClick={() => setDeleting(gateway)}
|
||||
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"><i className="ri-delete-bin-line" aria-hidden="true" /></button>
|
||||
) },
|
||||
{
|
||||
header: "名称",
|
||||
width: "16%",
|
||||
render: (gateway) => (
|
||||
<span className="font-semibold">{gateway.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Endpoint",
|
||||
width: "28%",
|
||||
render: (gateway) => (
|
||||
<code className="anywhere font-mono text-[13px]">
|
||||
{gateway.endpoint}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "连通性",
|
||||
width: "9%",
|
||||
render: (gateway) => (
|
||||
<GatewayStatePill
|
||||
value={gateway.connectivity}
|
||||
reason={gateway.connectivity_reason}
|
||||
labels={connectivityLabels}
|
||||
healthyValue="online"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "健康",
|
||||
width: "9%",
|
||||
render: (gateway) => (
|
||||
<GatewayStatePill
|
||||
value={gateway.health}
|
||||
reason={gateway.health_reason}
|
||||
labels={healthLabels}
|
||||
healthyValue="healthy"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "令牌",
|
||||
width: "26%",
|
||||
render: (gateway) => (
|
||||
<TokenCell token={gateway.token} name={gateway.name} />
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "操作",
|
||||
width: "12%",
|
||||
align: "right",
|
||||
render: (gateway) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${gateway.name}`}
|
||||
disabled={busy === gateway.name}
|
||||
onClick={() => setDeleting(gateway)}
|
||||
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"
|
||||
>
|
||||
<i className="ri-delete-bin-line" aria-hidden="true" />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rows={gateways} rowKey={gateway => gateway.name}
|
||||
rows={gateways}
|
||||
rowKey={(gateway) => gateway.name}
|
||||
/>
|
||||
</PageState>
|
||||
<GatewayCreateModal open={createOpen} onClose={() => { setCreateOpen(false); setCreateError(null) }} onSubmit={createGateway} busy={busy === 'create'} error={createError} />
|
||||
<ConfirmDialog open={deleting !== null} onClose={() => setDeleting(null)} onConfirm={removeGateway}
|
||||
title="删除网关" confirmLabel="确认删除" busy={busy === deleting?.name}
|
||||
body={`删除网关 ${deleting?.name}?仍被环境引用时删除会被拒绝。`} />
|
||||
<GatewayCreateModal
|
||||
open={createOpen}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
onSubmit={createGateway}
|
||||
busy={busy === "create"}
|
||||
error={createError}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={removeGateway}
|
||||
title="删除网关"
|
||||
confirmLabel="确认删除"
|
||||
busy={busy === deleting?.name}
|
||||
body={`删除网关 ${deleting?.name}?仍被环境引用时删除会被拒绝。`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+144
-52
@@ -1,68 +1,160 @@
|
||||
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 { GatewayList } from './GatewaysPage'
|
||||
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 { GatewayList } from "./GatewaysPage";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://docker-gateway:8081', token: 'unit-test-gateway-token' }]
|
||||
const gateways = [
|
||||
{
|
||||
id: "gw-1",
|
||||
name: "gw-1",
|
||||
endpoint: "http://docker-gateway:8081",
|
||||
token: "unit-test-gateway-token",
|
||||
connectivity: "online",
|
||||
health: "healthy",
|
||||
},
|
||||
{
|
||||
id: "gw-2",
|
||||
name: "gw-2",
|
||||
endpoint: "http://gw2:8081",
|
||||
token: "offline-token-123456",
|
||||
connectivity: "offline",
|
||||
connectivity_reason: "connection refused",
|
||||
health: "unhealthy",
|
||||
health_reason: "gateway unreachable",
|
||||
},
|
||||
];
|
||||
|
||||
function provider(overrides = {}) {
|
||||
return {
|
||||
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' } }),
|
||||
deleteOne: vi.fn().mockResolvedValue({ data: { id: 'gw-1' } }),
|
||||
getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn(), browserAction: vi.fn(),
|
||||
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",
|
||||
},
|
||||
}),
|
||||
deleteOne: vi.fn().mockResolvedValue({ data: { id: "gw-1" } }),
|
||||
getOne: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
getManyReference: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
browserAction: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const renderGateways = dataProvider => render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<Refine dataProvider={dataProvider}>
|
||||
<MemoryRouter><GatewayList /></MemoryRouter>
|
||||
</Refine>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const renderGateways = (dataProvider) =>
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
}
|
||||
>
|
||||
<Refine dataProvider={dataProvider}>
|
||||
<MemoryRouter>
|
||||
<GatewayList />
|
||||
</MemoryRouter>
|
||||
</Refine>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe('GatewayList', () => {
|
||||
it('registers a gateway and reveals the generated token', async () => {
|
||||
const dataProvider = provider()
|
||||
renderGateways(dataProvider)
|
||||
await screen.findAllByText('gw-1')
|
||||
describe("GatewayList", () => {
|
||||
it("registers a gateway and reveals the generated token", async () => {
|
||||
const dataProvider = provider();
|
||||
renderGateways(dataProvider);
|
||||
await screen.findAllByText("gw-1");
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '注册网关' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '名称' }), { target: { value: 'gw-2' } })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Endpoint' }), { target: { value: 'http://gw2:8081' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '注册网关' }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "注册网关" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "名称" }), {
|
||||
target: { value: "gw-2" },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(dialog).getByRole("textbox", { name: "Endpoint" }),
|
||||
{ target: { value: "http://gw2:8081" } },
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "注册网关" }));
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'gateways', variables: { name: 'gw-2', endpoint: 'http://gw2:8081', token: '' } }))
|
||||
expect((await screen.findByRole('alert')).textContent).toContain('generated-token-abcdef')
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(dataProvider.create).toHaveBeenCalledWith({
|
||||
resource: "gateways",
|
||||
variables: { name: "gw-2", endpoint: "http://gw2:8081", token: "" },
|
||||
}),
|
||||
);
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"generated-token-abcdef",
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid endpoint before submit', async () => {
|
||||
const dataProvider = provider()
|
||||
renderGateways(dataProvider)
|
||||
await screen.findAllByText('gw-1')
|
||||
it("rejects an invalid endpoint before submit", async () => {
|
||||
const dataProvider = provider();
|
||||
renderGateways(dataProvider);
|
||||
await screen.findAllByText("gw-1");
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '注册网关' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '名称' }), { target: { value: 'gw-2' } })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Endpoint' }), { target: { value: 'ftp://gw2:8081' } })
|
||||
expect(within(dialog).getByRole('button', { name: '注册网关' }).disabled).toBe(true)
|
||||
expect(dataProvider.create).not.toHaveBeenCalled()
|
||||
})
|
||||
fireEvent.click(screen.getByRole("button", { name: "注册网关" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "名称" }), {
|
||||
target: { value: "gw-2" },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(dialog).getByRole("textbox", { name: "Endpoint" }),
|
||||
{ target: { value: "ftp://gw2:8081" } },
|
||||
);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "注册网关" }).disabled,
|
||||
).toBe(true);
|
||||
expect(dataProvider.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a gateway after confirm', async () => {
|
||||
const dataProvider = provider()
|
||||
renderGateways(dataProvider)
|
||||
it("deletes a gateway after confirm", async () => {
|
||||
const dataProvider = provider();
|
||||
renderGateways(dataProvider);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('删除 gw-1'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确认删除' }))
|
||||
fireEvent.click(await screen.findByLabelText("删除 gw-1"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
|
||||
|
||||
await waitFor(() => expect(dataProvider.deleteOne).toHaveBeenCalledWith({ resource: 'gateways', id: 'gw-1' }))
|
||||
})
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(dataProvider.deleteOne).toHaveBeenCalledWith({
|
||||
resource: "gateways",
|
||||
id: "gw-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows connectivity and health with reasons for failing gateways", async () => {
|
||||
renderGateways(provider());
|
||||
|
||||
const online = await screen.findByText("在线");
|
||||
expect((await screen.findAllByText("健康")).length).toBeGreaterThan(0);
|
||||
const offline = screen.getByText("离线");
|
||||
const unhealthy = screen.getByText("异常");
|
||||
expect(offline.closest("[title]")?.getAttribute("title")).toBe(
|
||||
"connection refused",
|
||||
);
|
||||
expect(unhealthy.closest("[title]")?.getAttribute("title")).toBe(
|
||||
"gateway unreachable",
|
||||
);
|
||||
expect(online.closest("[title]")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user