Merge pull request 'fix(gateways): 支持编辑名称地址和令牌' (#44) from feat/gateway-edit into main
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
type hubStore interface {
|
||||
LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error)
|
||||
CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error)
|
||||
UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (hub.Gateway, error)
|
||||
ListGateways(ctx context.Context) ([]hub.Gateway, error)
|
||||
GetGateway(ctx context.Context, name string) (hub.Gateway, error)
|
||||
DeleteGateway(ctx context.Context, name string) error
|
||||
@@ -540,6 +541,21 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(gateway)
|
||||
})
|
||||
app.Put("/api/gateways/:name", func(c fiber.Ctx) error {
|
||||
input := struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Token string `json:"token"`
|
||||
}{}
|
||||
if err := decodeHubJSON(c, &input); err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
gateway, err := store.UpdateGateway(c.Context(), c.Params("name"), input.Name, input.Endpoint, input.Token)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
return c.JSON(gateway)
|
||||
})
|
||||
app.Delete("/api/gateways/:name", func(c fiber.Ctx) error {
|
||||
if err := store.DeleteGateway(c.Context(), c.Params("name")); err != nil {
|
||||
return hubError(c, err)
|
||||
|
||||
@@ -156,6 +156,32 @@ func (s *memoryStore) lock(key string) func() {
|
||||
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
|
||||
return hub.Gateway{}, nil
|
||||
}
|
||||
func (s *memoryStore) UpdateGateway(_ context.Context, currentName, name, endpoint, token string) (hub.Gateway, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
gateway, exists := s.gateways[currentName]
|
||||
if !exists {
|
||||
return hub.Gateway{}, hub.ErrNotFound
|
||||
}
|
||||
if currentName != name {
|
||||
if _, exists := s.gateways[name]; exists {
|
||||
return hub.Gateway{}, hub.ErrConflict
|
||||
}
|
||||
delete(s.gateways, currentName)
|
||||
for alias, env := range s.envs {
|
||||
if env.Gateway == currentName {
|
||||
env.Gateway = name
|
||||
s.envs[alias] = env
|
||||
}
|
||||
}
|
||||
}
|
||||
gateway.Name, gateway.Endpoint = name, endpoint
|
||||
if token != "" {
|
||||
gateway.Token = token
|
||||
}
|
||||
s.gateways[name] = gateway
|
||||
return gateway, nil
|
||||
}
|
||||
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -867,6 +893,39 @@ 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 TestUpdateGatewayRenamesAndPreservesReferences(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: "http://gw-1:8081", Token: "unit-test-gateway-token"}
|
||||
store.gateways["gw-existing"] = hub.Gateway{Name: "gw-existing", Endpoint: "http://gw-existing:8081", Token: "existing-gateway-token"}
|
||||
store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"}
|
||||
app := fiber.New()
|
||||
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
|
||||
|
||||
response := do(app, http.MethodPut, "/api/gateways/gw-1", `{"name":"gw-main","endpoint":"http://gw-main:8081","token":""}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("update gateway returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var updated hub.Gateway
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Name != "gw-main" || updated.Endpoint != "http://gw-main:8081" || updated.Token != "unit-test-gateway-token" {
|
||||
t.Fatalf("gateway update lost fields: %#v", updated)
|
||||
}
|
||||
if store.envs["account-a"].Gateway != "gw-main" {
|
||||
t.Fatalf("environment gateway reference was not renamed: %#v", store.envs["account-a"])
|
||||
}
|
||||
|
||||
response = do(app, http.MethodPut, "/api/gateways/gw-main", `{"name":"gw-existing","endpoint":"http://gw-main:8081","token":""}`)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("rename conflict returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
response = do(app, http.MethodPut, "/api/gateways/missing", `{"name":"gw-new","endpoint":"http://gw-new:8081","token":""}`)
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing gateway update returned %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
// gw-1:正常网关,/healthz 可达且 /v1/browsers 可认证。
|
||||
|
||||
@@ -338,6 +338,7 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase {
|
||||
|
||||
{http.MethodGet, "/api/gateways", "/api/gateways", "", http.StatusOK},
|
||||
{http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest},
|
||||
{http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound},
|
||||
{http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound},
|
||||
|
||||
{http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest},
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 14`, 14)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0)
|
||||
@@ -42,7 +42,7 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
|
||||
store = openFullyMigratedHub(t, ctx, testURL)
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 14`, 14)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15)
|
||||
})
|
||||
|
||||
t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 网关名称是 browser_env 的引用键;改名时由数据库原子级联,避免环境引用悬空。
|
||||
ALTER TABLE browser_env DROP CONSTRAINT browser_env_gateway_name_fkey;
|
||||
ALTER TABLE browser_env ADD CONSTRAINT browser_env_gateway_name_fkey
|
||||
FOREIGN KEY (gateway_name) REFERENCES gateway(name) ON UPDATE CASCADE;
|
||||
+26
-1
@@ -60,6 +60,9 @@ var migration013 string
|
||||
//go:embed migrations/014_account_creation_compatibility.sql
|
||||
var migration014 string
|
||||
|
||||
//go:embed migrations/015_gateway_rename_cascade.sql
|
||||
var migration015 string
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("resource conflicts with existing state")
|
||||
ErrInvalid = errors.New("invalid hub input")
|
||||
@@ -216,7 +219,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
for _, migration := range []struct {
|
||||
version int
|
||||
sql string
|
||||
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}} {
|
||||
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}} {
|
||||
var applied bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
|
||||
return errors.New("read hub schema migration state")
|
||||
@@ -261,6 +264,28 @@ func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string)
|
||||
return gateway, nil
|
||||
}
|
||||
|
||||
// UpdateGateway 修改网关名称、Endpoint 和令牌。名称变更由数据库外键 ON UPDATE CASCADE
|
||||
// 原子同步 browser_env 引用;空令牌表示保留当前令牌,避免只改地址时意外轮换凭证。
|
||||
func (s *Store) UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (Gateway, error) {
|
||||
currentName, name = strings.TrimSpace(currentName), strings.TrimSpace(name)
|
||||
endpoint, token = strings.TrimSpace(endpoint), strings.TrimSpace(token)
|
||||
if !gatewayNamePattern.MatchString(currentName) || !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) ||
|
||||
(token != "" && !tokenPattern.MatchString(token)) {
|
||||
return Gateway{}, ErrInvalid
|
||||
}
|
||||
var gateway Gateway
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE gateway SET name = $1, endpoint = $2,
|
||||
token = CASE WHEN $3 = '' THEN token ELSE $3 END, updated_at = now()
|
||||
WHERE name = $4
|
||||
RETURNING name, endpoint, token, created_at, updated_at`, name, endpoint, token, currentName).
|
||||
Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt)
|
||||
if err != nil {
|
||||
return Gateway{}, rowError(err)
|
||||
}
|
||||
return gateway, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT name, endpoint, token, created_at, updated_at FROM gateway ORDER BY created_at, name`)
|
||||
|
||||
@@ -220,6 +220,22 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
||||
if _, err := store.CreateGateway(ctx, "gw-1", "http://gw:8081", "short-token"); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid gateway token, got %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
currentName string
|
||||
newName string
|
||||
endpoint string
|
||||
token string
|
||||
}{
|
||||
{name: "current name", currentName: "bad name!", newName: "gw-2", endpoint: "http://gw:8081"},
|
||||
{name: "new name", currentName: "gw-1", newName: "bad name!", endpoint: "http://gw:8081"},
|
||||
{name: "endpoint", currentName: "gw-1", newName: "gw-2", endpoint: "ftp://gw:8081"},
|
||||
{name: "token", currentName: "gw-1", newName: "gw-2", endpoint: "http://gw:8081", token: "short-token"},
|
||||
} {
|
||||
if _, err := store.UpdateGateway(ctx, test.currentName, test.newName, test.endpoint, test.token); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid gateway update %s, got %v", test.name, err)
|
||||
}
|
||||
}
|
||||
if err := store.CreateImage(ctx, Image{Version: "v1", ImageRef: "registry/img:1"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid image version, got %v", err)
|
||||
}
|
||||
@@ -315,6 +331,23 @@ func TestHubWorkflow(t *testing.T) {
|
||||
if listed[0].Name != "店铺一号" || listed[0].Fingerprint.Seed != 1000 || listed[0].Fingerprint.Timezone != "Asia/Shanghai" {
|
||||
t.Fatalf("fingerprint must round trip through jsonb: %#v", listed[0])
|
||||
}
|
||||
updatedGateway, err := store.UpdateGateway(ctx, "gw-main", "gw-renamed", "http://127.0.0.4:8081", "")
|
||||
if err != nil || updatedGateway.Name != "gw-renamed" || updatedGateway.Endpoint != "http://127.0.0.4:8081" || updatedGateway.Token != gateway.Token {
|
||||
t.Fatalf("gateway update did not preserve the token: %#v err=%v", updatedGateway, err)
|
||||
}
|
||||
renamedEnv, err := store.GetEnv(ctx, "shop-01")
|
||||
if err != nil || renamedEnv.Gateway != "gw-renamed" {
|
||||
t.Fatalf("gateway rename did not cascade to environment: %#v err=%v", renamedEnv, err)
|
||||
}
|
||||
if _, err := store.UpdateGateway(ctx, "gw-renamed", "gw-custom", "http://127.0.0.4:8081", ""); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected gateway rename conflict, got %v", err)
|
||||
}
|
||||
if _, err := store.UpdateGateway(ctx, "missing", "gw-missing", "http://127.0.0.5:8081", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected missing gateway on update, got %v", err)
|
||||
}
|
||||
if _, err := store.UpdateGateway(ctx, "gw-renamed", "gw-main", "http://127.0.0.1:8081", ""); err != nil {
|
||||
t.Fatalf("restore gateway name after cascade check: %v", err)
|
||||
}
|
||||
if _, err := store.GetEnv(ctx, "ghost"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected missing env, got %v", err)
|
||||
}
|
||||
|
||||
+95
-39
@@ -1,10 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDataProvider, useList } from "@refinedev/core";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Copyable,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -72,8 +71,17 @@ function TokenCell({ token, name }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
|
||||
const editing = initial !== null;
|
||||
const [form, setForm] = useState({ name: "", endpoint: "", token: "" });
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setForm(
|
||||
editing
|
||||
? { name: initial.name, endpoint: initial.endpoint, token: "" }
|
||||
: { name: "", endpoint: "", token: "" },
|
||||
);
|
||||
}, [editing, initial, open]);
|
||||
const update = (key, value) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
const token = form.token.trim();
|
||||
@@ -85,17 +93,18 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
const endpointInvalid =
|
||||
form.endpoint !== "" && !/^https?:\/\/\S+$/.test(form.endpoint);
|
||||
const tokenInvalid = token !== "" && !tokenPattern.test(token);
|
||||
const mode = editing ? "edit" : "create";
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!valid) return;
|
||||
const created = await onSubmit({
|
||||
name: form.name,
|
||||
endpoint: form.endpoint,
|
||||
token,
|
||||
});
|
||||
if (created) {
|
||||
setForm({ name: "", endpoint: "", token: "" });
|
||||
if (
|
||||
await onSubmit({
|
||||
name: form.name,
|
||||
endpoint: form.endpoint,
|
||||
token,
|
||||
})
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
@@ -104,8 +113,8 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="注册网关"
|
||||
labelledBy="gateway-create-title"
|
||||
title={editing ? "编辑网关" : "注册网关"}
|
||||
labelledBy={`gateway-${mode}-title`}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
@@ -114,17 +123,17 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form="gateway-create-form"
|
||||
form={`gateway-${mode}-form`}
|
||||
busy={busy}
|
||||
busyText="注册中…"
|
||||
busyText={editing ? "保存中…" : "注册中…"}
|
||||
disabled={!valid}
|
||||
>
|
||||
注册网关
|
||||
{editing ? "保存" : "注册网关"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="gateway-create-form" onSubmit={submit} noValidate>
|
||||
<form id={`gateway-${mode}-form`} onSubmit={submit} noValidate>
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
{error.message}
|
||||
@@ -132,14 +141,14 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
id="gateway-name"
|
||||
id={`gateway-${mode}-name`}
|
||||
label="名称"
|
||||
required
|
||||
error={nameInvalid ? "字母、数字与 . _ -,最长 64 字符" : undefined}
|
||||
helper="网关唯一标识,如 gw-main"
|
||||
helper={editing ? "改名会同步更新引用该网关的环境" : "网关唯一标识,如 gw-main"}
|
||||
>
|
||||
<Input
|
||||
id="gateway-name"
|
||||
id={`gateway-${mode}-name`}
|
||||
required
|
||||
maxLength={64}
|
||||
value={form.name}
|
||||
@@ -149,14 +158,14 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="gateway-endpoint"
|
||||
id={`gateway-${mode}-endpoint`}
|
||||
label="Endpoint"
|
||||
required
|
||||
error={endpointInvalid ? "须为 http(s) URL" : undefined}
|
||||
helper="网关进程的可访问地址"
|
||||
>
|
||||
<Input
|
||||
id="gateway-endpoint"
|
||||
id={`gateway-${mode}-endpoint`}
|
||||
required
|
||||
value={form.endpoint}
|
||||
onChange={(event) => update("endpoint", event.target.value)}
|
||||
@@ -165,19 +174,17 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="gateway-token"
|
||||
label="令牌(可选)"
|
||||
error={
|
||||
tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined
|
||||
}
|
||||
helper="填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"
|
||||
id={`gateway-${mode}-token`}
|
||||
label={editing ? "令牌(留空保持不变)" : "令牌(可选)"}
|
||||
error={tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined}
|
||||
helper={editing ? "填写时须与网关 GATEWAY_TOKEN 一致" : "填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"}
|
||||
>
|
||||
<Input
|
||||
id="gateway-token"
|
||||
id={`gateway-${mode}-token`}
|
||||
maxLength={128}
|
||||
value={form.token}
|
||||
onChange={(event) => update("token", event.target.value)}
|
||||
placeholder="留空由平台生成"
|
||||
placeholder={editing ? "留空保持现有令牌" : "留空由平台生成"}
|
||||
invalid={tokenInvalid}
|
||||
/>
|
||||
</Field>
|
||||
@@ -197,6 +204,8 @@ export function GatewayList() {
|
||||
const [busy, setBusy] = useState("");
|
||||
const [createError, setCreateError] = useState(null);
|
||||
const [created, setCreated] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [editError, setEditError] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
useTitle("CreatorHub · 网关管理");
|
||||
|
||||
@@ -219,6 +228,27 @@ export function GatewayList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGateway(data) {
|
||||
const currentName = editing.name;
|
||||
setBusy("edit");
|
||||
setEditError(null);
|
||||
try {
|
||||
await dataProvider.update({
|
||||
resource: "gateways",
|
||||
id: currentName,
|
||||
variables: data,
|
||||
});
|
||||
await query.refetch();
|
||||
setEditing(null);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setEditError(reason);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGateway() {
|
||||
const gateway = deleting;
|
||||
setBusy(gateway.name);
|
||||
@@ -330,15 +360,29 @@ export function GatewayList() {
|
||||
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>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`编辑 ${gateway.name}`}
|
||||
disabled={busy === gateway.name || busy === "edit"}
|
||||
onClick={() => {
|
||||
setEditing(gateway);
|
||||
setEditError(null);
|
||||
}}
|
||||
className="rounded p-1.5 text-muted hover:bg-[#f5f7fa] hover:text-primary disabled:opacity-50"
|
||||
>
|
||||
<i className="ri-edit-line" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除 ${gateway.name}`}
|
||||
disabled={busy === gateway.name || busy === "edit"}
|
||||
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>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -346,8 +390,9 @@ export function GatewayList() {
|
||||
rowKey={(gateway) => gateway.name}
|
||||
/>
|
||||
</PageState>
|
||||
<GatewayCreateModal
|
||||
<GatewayFormModal
|
||||
open={createOpen}
|
||||
initial={null}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateError(null);
|
||||
@@ -356,6 +401,17 @@ export function GatewayList() {
|
||||
busy={busy === "create"}
|
||||
error={createError}
|
||||
/>
|
||||
<GatewayFormModal
|
||||
open={editing !== null}
|
||||
initial={editing}
|
||||
onClose={() => {
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
}}
|
||||
onSubmit={updateGateway}
|
||||
busy={busy === "edit"}
|
||||
error={editError}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onClose={() => setDeleting(null)}
|
||||
|
||||
@@ -127,6 +127,46 @@ describe("GatewayList", () => {
|
||||
expect(dataProvider.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits and renames a gateway", async () => {
|
||||
const update = vi.fn().mockResolvedValue({ data: { id: "gw-main" } });
|
||||
const dataProvider = provider({ update });
|
||||
renderGateways(dataProvider);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("编辑 gw-1"));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const name = within(dialog).getByRole("textbox", { name: "名称" });
|
||||
const endpoint = within(dialog).getByRole("textbox", { name: "Endpoint" });
|
||||
expect(name.value).toBe("gw-1");
|
||||
expect(endpoint.value).toBe("http://docker-gateway:8081");
|
||||
fireEvent.change(name, { target: { value: "gw-main" } });
|
||||
fireEvent.change(endpoint, { target: { value: "http://gw-main:8081" } });
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "令牌(留空保持不变)" }), {
|
||||
target: { value: "rotated-gateway-token" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
resource: "gateways",
|
||||
id: "gw-1",
|
||||
variables: { name: "gw-main", endpoint: "http://gw-main:8081", token: "rotated-gateway-token" },
|
||||
}),
|
||||
);
|
||||
expect(dataProvider.getList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the edit failure without closing the form", async () => {
|
||||
const update = vi.fn().mockRejectedValue(new Error("网关名称已存在"));
|
||||
renderGateways(provider({ update }));
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("编辑 gw-1"));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "保存" }));
|
||||
|
||||
expect((await within(dialog).findByRole("alert")).textContent).toContain("网关名称已存在");
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deletes a gateway after confirm", async () => {
|
||||
const dataProvider = provider();
|
||||
renderGateways(dataProvider);
|
||||
|
||||
Reference in New Issue
Block a user