feat(gateways): 网关列表增加连通性与健康状态探测展示
- GET /api/gateways 并发探测 /healthz(连通性)与 /v1/browsers(健康),失败原因随列表返回 - 前端新增连通性/健康两列,失败原因悬浮提示 - 覆盖健康、令牌被拒、离线三类探测路径的单元测试
This commit is contained in:
@@ -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/browsers(Bearer 认证,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 {
|
||||
|
||||
@@ -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
@@ -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