feat(gateways): 网关列表增加连通性与健康状态探测展示

- GET /api/gateways 并发探测 /healthz(连通性)与 /v1/browsers(健康),失败原因随列表返回
- 前端新增连通性/健康两列,失败原因悬浮提示
- 覆盖健康、令牌被拒、离线三类探测路径的单元测试
This commit is contained in:
2026-09-03 12:44:49 +08:00
parent a321fd8280
commit e5c720e7d5
4 changed files with 581 additions and 140 deletions
+66 -1
View File
@@ -216,6 +216,71 @@ func gatewayUnreachable(err error) error {
return gatewayFailure{status: http.StatusBadGateway, message: fmt.Sprintf("gateway unreachable: %v", err)}
}
// gatewayStatus 在网关基础信息上补充实时探测结果:connectivity 探测 /healthz(无认证,
// 收到任意 HTTP 响应即在线),health 探测 /v1/browsersBearer 认证,200 且容器列表可
// 解析为健康)。失败原因原样返回给页面,不吞掉。
type gatewayStatus struct {
hub.Gateway
Connectivity string `json:"connectivity"` // online | offline
ConnectivityReason string `json:"connectivity_reason,omitempty"` // 仅离线时携带
Health string `json:"health"` // healthy | unhealthy
HealthReason string `json:"health_reason,omitempty"` // 仅异常时携带
}
const gatewayHealthzTimeout = 5 * time.Second
// gatewayStatusList 并发探测全部网关;单个网关失败不影响其余结果与接口返回。
// 结果经由带缓冲 channel 回收,由收集协程独占写入,避免共享写。
func gatewayStatusList(ctx context.Context, gateways []hub.Gateway) []gatewayStatus {
results := make(chan struct {
index int
status gatewayStatus
}, len(gateways))
for index, target := range gateways {
go func(index int, target hub.Gateway) {
results <- struct {
index int
status gatewayStatus
}{index, probeGatewayStatus(ctx, target)}
}(index, target)
}
statuses := make([]gatewayStatus, len(gateways))
for range gateways {
result := <-results
statuses[result.index] = result.status
}
return statuses
}
func probeGatewayStatus(ctx context.Context, target hub.Gateway) gatewayStatus {
status := gatewayStatus{Gateway: target, Connectivity: "offline", Health: "unhealthy"}
probeCtx, cancel := context.WithTimeout(ctx, gatewayHealthzTimeout)
defer cancel()
request, requestErr := http.NewRequestWithContext(probeCtx, http.MethodGet, target.Endpoint+"/healthz", nil)
if requestErr != nil {
status.ConnectivityReason = requestErr.Error()
} else if response, callErr := http.DefaultClient.Do(request); callErr != nil {
status.ConnectivityReason = callErr.Error()
} else {
_ = response.Body.Close()
status.Connectivity = "online"
}
code, body, callErr := gatewayCall(ctx, target, http.MethodGet, "/v1/browsers", nil, 30*time.Second)
switch {
case callErr != nil:
status.HealthReason = callErr.Error()
case code != http.StatusOK:
status.HealthReason = fmt.Sprintf("gateway call failed with status %d", code)
default:
if _, parseErr := parseGatewayBrowserList(body); parseErr != nil {
status.HealthReason = "gateway returned an invalid browser list"
} else {
status.Health = "healthy"
}
}
return status
}
func reconcileGatewayContainer(ctx context.Context, target hub.Gateway, alias string) (containerStatus, bool, error) {
var lastErr error
for attempt := 0; attempt < gatewayReconcileAttempts; attempt++ {
@@ -458,7 +523,7 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro
if err != nil {
return hubError(c, err)
}
return c.JSON(gateways)
return c.JSON(gatewayStatusList(c.Context(), gateways))
})
app.Post("/api/gateways", func(c fiber.Ctx) error {
input := struct {
+61 -1
View File
@@ -156,7 +156,15 @@ func (s *memoryStore) lock(key string) func() {
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
return hub.Gateway{}, nil
}
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) { return nil, nil }
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) {
s.mu.Lock()
defer s.mu.Unlock()
gateways := make([]hub.Gateway, 0, len(s.gateways))
for _, gateway := range s.gateways {
gateways = append(gateways, gateway)
}
return gateways, nil
}
func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -635,6 +643,10 @@ func (probe *sequenceExitProbe) Check(context.Context, hub.NetworkExitAccess) (h
func (g *fakeGateway) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet && request.URL.Path == "/healthz" {
response.WriteHeader(http.StatusNoContent)
return
}
if request.Header.Get("Authorization") != "Bearer "+g.token {
response.WriteHeader(http.StatusUnauthorized)
_, _ = response.Write([]byte(`{"error":"gateway token rejected"}`))
@@ -855,6 +867,54 @@ func do(app *fiber.App, method, path, body string, credentials ...string) *httpt
const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","image_version":"148.0.7778.215",` +
`"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"},"account_id":"account-a","network_exit_id":"exit-1"}`
func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) {
store := newMemoryStore()
// gw-1:正常网关,/healthz 可达且 /v1/browsers 可认证。
gateway := &fakeGateway{token: "unit-test-gateway-token"}
server := httptest.NewServer(gateway.handler(t))
t.Cleanup(server.Close)
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
// gw-auth/healthz 可达但令牌被拒,应区分连通与 API 健康。
authServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet && request.URL.Path == "/healthz" {
response.WriteHeader(http.StatusNoContent)
return
}
response.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(authServer.Close)
store.gateways["gw-auth"] = hub.Gateway{Name: "gw-auth", Endpoint: authServer.URL, Token: "unit-test-gateway-token"}
// gw-offline:端口已关闭,两个探测都应失败并携带原因。
closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
closedURL := closed.URL
closed.Close()
store.gateways["gw-offline"] = hub.Gateway{Name: "gw-offline", Endpoint: closedURL, Token: "offline-token-123456789"}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/gateways", "")
if response.Code != http.StatusOK {
t.Fatalf("list gateways returned %d: %s", response.Code, response.Body.String())
}
var statuses []gatewayStatus
if err := json.Unmarshal(response.Body.Bytes(), &statuses); err != nil || len(statuses) != 3 {
t.Fatalf("decode gateway statuses failed: statuses=%#v err=%v", statuses, err)
}
byName := map[string]gatewayStatus{}
for _, status := range statuses {
byName[status.Name] = status
}
if status := byName["gw-1"]; status.Connectivity != "online" || status.Health != "healthy" || status.ConnectivityReason != "" || status.HealthReason != "" {
t.Fatalf("healthy gateway reported %#v", status)
}
if status := byName["gw-auth"]; status.Connectivity != "online" || status.Health != "unhealthy" || status.HealthReason == "" {
t.Fatalf("auth-rejected gateway reported %#v", status)
}
if status := byName["gw-offline"]; status.Connectivity != "offline" || status.Health != "unhealthy" || status.ConnectivityReason == "" || status.HealthReason == "" {
t.Fatalf("offline gateway reported %#v", status)
}
}
func TestParseGatewayBrowserListStrict(t *testing.T) {
for _, test := range []struct {
name string
+310 -86
View File
@@ -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
View File
@@ -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);
});
});