diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go
index 622d89d..83c0643 100644
--- a/cmd/control-plane/hub.go
+++ b/cmd/control-plane/hub.go
@@ -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 {
diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go
index 986c17c..801e7cd 100644
--- a/cmd/control-plane/hub_test.go
+++ b/cmd/control-plane/hub_test.go
@@ -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
diff --git a/web/src/GatewaysPage.jsx b/web/src/GatewaysPage.jsx
index 7c3a74e..27fbd1e 100644
--- a/web/src/GatewaysPage.jsx
+++ b/web/src/GatewaysPage.jsx
@@ -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 (
+
+
+
+ );
+}
function TokenCell({ token, name }) {
- const [visible, setVisible] = useState(false)
+ const [visible, setVisible] = useState(false);
return (
- {visible ? token : '••••••••••••'}
-
+
+ {visible ? token : "••••••••••••"}
+
+
{visible ? (
-
+
) : null}
- )
+ );
}
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 (
-
-
-
- >}>
+
+
+
+ >
+ }
+ >
- )
+ );
}
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 (
-
-
+
+
{created ? (
网关 {created.name} 已注册。
- 令牌 {created.token}
-
+
+ 令牌 {created.token}
+
-
+
) : null}
- {error ? {error.message} : null}
-
+ {error ? (
+
+ {error.message}
+
+ ) : null}
+
{gateway.name} },
- { header: 'Endpoint', width: '34%', render: gateway => {gateway.endpoint} },
- { header: '令牌', width: '34%', render: gateway => },
- { header: '操作', width: '14%', align: 'right', render: gateway => (
-
- ) },
+ {
+ header: "名称",
+ width: "16%",
+ render: (gateway) => (
+ {gateway.name}
+ ),
+ },
+ {
+ header: "Endpoint",
+ width: "28%",
+ render: (gateway) => (
+
+ {gateway.endpoint}
+
+ ),
+ },
+ {
+ header: "连通性",
+ width: "9%",
+ render: (gateway) => (
+
+ ),
+ },
+ {
+ header: "健康",
+ width: "9%",
+ render: (gateway) => (
+
+ ),
+ },
+ {
+ header: "令牌",
+ width: "26%",
+ render: (gateway) => (
+
+ ),
+ },
+ {
+ header: "操作",
+ width: "12%",
+ align: "right",
+ render: (gateway) => (
+
+ ),
+ },
]}
- rows={gateways} rowKey={gateway => gateway.name}
+ rows={gateways}
+ rowKey={(gateway) => gateway.name}
/>
- { setCreateOpen(false); setCreateError(null) }} onSubmit={createGateway} busy={busy === 'create'} error={createError} />
- setDeleting(null)} onConfirm={removeGateway}
- title="删除网关" confirmLabel="确认删除" busy={busy === deleting?.name}
- body={`删除网关 ${deleting?.name}?仍被环境引用时删除会被拒绝。`} />
+ {
+ setCreateOpen(false);
+ setCreateError(null);
+ }}
+ onSubmit={createGateway}
+ busy={busy === "create"}
+ error={createError}
+ />
+ setDeleting(null)}
+ onConfirm={removeGateway}
+ title="删除网关"
+ confirmLabel="确认删除"
+ busy={busy === deleting?.name}
+ body={`删除网关 ${deleting?.name}?仍被环境引用时删除会被拒绝。`}
+ />
- )
+ );
}
diff --git a/web/src/GatewaysPage.test.jsx b/web/src/GatewaysPage.test.jsx
index fafc2a9..2247570 100644
--- a/web/src/GatewaysPage.test.jsx
+++ b/web/src/GatewaysPage.test.jsx
@@ -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(
-
-
-
-
- ,
-)
+const renderGateways = (dataProvider) =>
+ render(
+
+
+
+
+
+
+ ,
+ );
-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);
+ });
+});