feat(web): gateway management for outbound access keys
- create/edit by name only; platform-issued key echoed once with gateway config guide (CREATOR_PLATFORM_URL + CREATOR_GATEWAY_KEY) - online/offline/never-connected tag from channel session state, version column, pending task count linking to per-gateway task history drawer (paginated, duration, status tag, error) - reset-key action with confirm; api layer adds gatewayAction and gatewayTasks; delete whitelist includes gateways - tests updated for the outbound model; events ellipsis checklist entry removed (field no longer truncated); table header test picks the sticky table
This commit is contained in:
+209
-118
@@ -1,53 +1,101 @@
|
||||
// 网关管理:语义对齐 web.archived GatewaysPage.jsx(注册/编辑 Modal、令牌显隐复制、删除)。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, App, Button, Card, Flex, Form, Grid, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
// 网关管理:平台为每个网关签发 access key,网关以出站 WebSocket 连回 /v1/agent;
|
||||
// 页面维护名称与 key 的生命周期(创建回显、重置回显),在线状态由通道会话维护。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, App, Button, Card, Drawer, Flex, Form, Grid, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { create, getList, remove, update } from '@/services/api';
|
||||
import { create, getList, gatewayAction, gatewayTasks, remove, update } from '@/services/api';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
import { fixedLeft, fixedRight, useTablePagination, useTableSticky } from '@/utils/table';
|
||||
|
||||
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/;
|
||||
|
||||
// 健康状态列:只展示后台定时探活的最近一次结果,不保留历史。
|
||||
function HealthCell({ status, reason, checkedAt }: { status?: string; reason?: string; checkedAt?: string }) {
|
||||
const label = status === 'healthy' ? '在线' : status === 'unhealthy' ? '离线' : '未检查';
|
||||
const color = status === 'healthy' ? 'success' : status === 'unhealthy' ? 'error' : 'default';
|
||||
const details = [
|
||||
checkedAt ? `最近检查:${dateTime(checkedAt)}` : null,
|
||||
reason || null,
|
||||
].filter(Boolean).join(';');
|
||||
// 在线状态:会话维护 online/last_seen_at;从未连接不冒充离线。
|
||||
function OnlineCell({ online, lastSeenAt }: { online: boolean; lastSeenAt?: string | null }) {
|
||||
const [label, color] = online
|
||||
? ['在线', 'success']
|
||||
: lastSeenAt
|
||||
? ['离线', 'error']
|
||||
: ['从未连接', 'default'];
|
||||
return (
|
||||
<Tooltip title={details || '尚未执行健康检查'}>
|
||||
<Tooltip title={online ? undefined : lastSeenAt ? `最近在线:${dateTime(lastSeenAt)}` : '网关尚未建立出站连接'}>
|
||||
<Tag color={color}>{label}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessKeyCell({ accessKey, name }: { accessKey: string; name: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Space size={0}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis={{ tooltip: true }}>
|
||||
{visible ? accessKey : '••••••••••••'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={visible ? `隐藏 ${name} 的 access key` : `显示 ${name} 的 access key`}
|
||||
icon={visible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
/>
|
||||
{visible ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={`复制 ${name} 的 access key`}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => navigator.clipboard?.writeText(accessKey)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyGuideAlert({ name, accessKey, onClose }: { name: string; accessKey: string; onClose: () => void }) {
|
||||
const guide = [
|
||||
`CREATOR_PLATFORM_URL=<平台地址>`,
|
||||
`CREATOR_GATEWAY_KEY=${accessKey}`,
|
||||
].join('\n');
|
||||
return (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
closable={{ onClose }}
|
||||
style={{ marginBottom: 16 }}
|
||||
title={`网关 ${name} 的 access key 已生成,只在本次显示`}
|
||||
description={
|
||||
<Flex vertical gap={4}>
|
||||
<Typography.Text>在网关主机的环境变量中配置后启动网关,网关会主动连入平台:</Typography.Text>
|
||||
<Typography.Paragraph code copyable={{ text: guide, tooltips: false }} style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{guide}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">旧 key 立即失效;网关重启后以新 key 重新连入。</Typography.Text>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayFormModal({
|
||||
open,
|
||||
initial,
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit }: {
|
||||
open: boolean;
|
||||
initial: any | null;
|
||||
busy: boolean;
|
||||
error: any;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: { name: string; endpoint: string; token?: string }) => Promise<boolean>;
|
||||
onSubmit: (values: { name: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const editing = initial !== null;
|
||||
const [form] = Form.useForm<{ name: string; endpoint: string; token?: string }>();
|
||||
const [form] = Form.useForm<{ name: string }>();
|
||||
useEffect(() => {
|
||||
if (open) form.setFieldsValue(editing ? { name: initial.name, endpoint: initial.endpoint, token: '' } : { name: '', endpoint: '', token: '' });
|
||||
if (open) form.setFieldsValue(editing ? { name: initial.name } : { name: '' });
|
||||
}, [open, initial, editing, form]);
|
||||
|
||||
return (
|
||||
<Modal open={open} title={editing ? '编辑网关' : '注册网关'} onCancel={onCancel} onOk={() => form.submit()} confirmLoading={busy} okText={busy ? (editing ? '保存中…' : '注册中…') : editing ? '保存' : '注册网关'}>
|
||||
{error ? <Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} /> : null}
|
||||
<Modal open={open} title={editing ? '编辑网关' : '创建网关'} onCancel={onCancel} onOk={() => form.submit()} confirmLoading={busy} okText={busy ? (editing ? '保存中…' : '创建中…') : editing ? '保存' : '创建网关'}>
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
@@ -61,31 +109,91 @@ function GatewayFormModal({
|
||||
>
|
||||
<Input maxLength={64} placeholder="如 gw-main" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
label="Endpoint"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Endpoint' },
|
||||
{ pattern: /^https?:\/\/\S+$/, message: '须为 http(s) URL' },
|
||||
]}
|
||||
extra="网关进程的可访问地址"
|
||||
>
|
||||
<Input placeholder="http://127.0.0.1:8081" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="token"
|
||||
label={editing ? '令牌(留空保持不变)' : '令牌(可选)'}
|
||||
rules={[{ pattern: tokenPattern, message: '至少 16 个字符,字母、数字与 . _ -' }]}
|
||||
extra={editing ? '填写时须与网关 GATEWAY_TOKEN 一致' : '填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成'}
|
||||
>
|
||||
<Input maxLength={128} placeholder={editing ? '留空保持现有令牌' : '留空由平台生成'} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const taskStatusMeta: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待下发', color: 'default' },
|
||||
dispatched: { label: '已下发', color: 'processing' },
|
||||
succeeded: { label: '成功', color: 'success' },
|
||||
failed: { label: '失败', color: 'error' },
|
||||
};
|
||||
|
||||
function TaskHistoryDrawer({ gateway, onClose }: { gateway: any | null; onClose: () => void }) {
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
const load = useCallback(async (targetPage: number) => {
|
||||
if (!gateway) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await gatewayTasks(gateway.name, targetPage, pageSize);
|
||||
setTasks(result.data ?? []);
|
||||
setTotal(result.total ?? 0);
|
||||
setPage(targetPage);
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [gateway]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gateway) load(1);
|
||||
}, [gateway, load]);
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{ title: '时间', dataIndex: 'created_at', width: 160, render: (value: string) => dateTime(value) },
|
||||
{ title: '方法', dataIndex: 'method', width: 80, render: (value: string) => <Typography.Text code>{value}</Typography.Text> },
|
||||
{ title: '路径', dataIndex: 'path', ellipsis: true, render: (value: string) => <Typography.Text code style={{ fontSize: 12 }}>{value}</Typography.Text> },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (value: string, task) => {
|
||||
const meta = taskStatusMeta[value] ?? { label: value, color: 'default' };
|
||||
return <Tooltip title={task.error || undefined}><Tag color={meta.color}>{meta.label}</Tag></Tooltip>;
|
||||
},
|
||||
},
|
||||
{ title: '响应码', dataIndex: 'response_status', width: 80, render: (value: number) => (value ? value : '—') },
|
||||
{
|
||||
title: '耗时',
|
||||
key: 'duration',
|
||||
width: 100,
|
||||
render: (_, task) => {
|
||||
if (!task.dispatched_at || !task.finished_at) return '—';
|
||||
const ms = new Date(task.finished_at).getTime() - new Date(task.dispatched_at).getTime();
|
||||
return Number.isFinite(ms) && ms >= 0 ? `${ms} ms` : '—';
|
||||
},
|
||||
},
|
||||
{ title: '错误', dataIndex: 'error', ellipsis: true, render: (value: string) => value || '—' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer open={gateway !== null} size="large" onClose={onClose} title={`网关 ${gateway?.name ?? ''} 任务记录`}>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={() => load(page)}>重试</Button>} />
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
loading={pending}
|
||||
pagination={{ current: page, pageSize, total, onChange: (next) => load(next), showSizeChanger: false }}
|
||||
locale={{ emptyText: '暂无任务记录' }}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [gateways, setGateways] = useState<any[]>([]);
|
||||
const [pending, setPending] = useState(true);
|
||||
@@ -94,11 +202,12 @@ export default function Page() {
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [created, setCreated] = useState<any>(null);
|
||||
const [resetKey, setResetKey] = useState<any>(null);
|
||||
const [tasksGateway, setTasksGateway] = useState<any | null>(null);
|
||||
const { message: messageApi } = App.useApp();
|
||||
const horizontal = !Grid.useBreakpoint().xl;
|
||||
const sticky = useTableSticky();
|
||||
const pagination = useTablePagination();
|
||||
const healthReloadTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
@@ -113,41 +222,32 @@ export default function Page() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 创建/编辑后的立即探活在后台异步进行:延迟刷新一次让健康状态列尽快显示最新结果。
|
||||
const scheduleHealthReload = useCallback(() => {
|
||||
clearTimeout(healthReloadTimer.current);
|
||||
healthReloadTimer.current = setTimeout(load, 2500);
|
||||
}, [load]);
|
||||
useEffect(() => () => clearTimeout(healthReloadTimer.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function createGateway(values: { name: string; endpoint: string; token?: string }): Promise<boolean> {
|
||||
async function createGateway(values: { name: string }): Promise<boolean> {
|
||||
setBusy('create');
|
||||
try {
|
||||
const record = await create('gateways', values);
|
||||
setCreated(record?.data ?? record);
|
||||
await load();
|
||||
scheduleHealthReload();
|
||||
setCreateOpen(false);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '网关注册失败;表单内容已保留。'));
|
||||
messageApi.error(conflictMessage(reason, '网关创建失败;表单内容已保留。'));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGateway(values: { name: string; endpoint: string; token?: string }): Promise<boolean> {
|
||||
async function updateGateway(values: { name: string }): Promise<boolean> {
|
||||
if (!editing) return false;
|
||||
setBusy('edit');
|
||||
try {
|
||||
await update('gateways', editing.name, values);
|
||||
await load();
|
||||
scheduleHealthReload();
|
||||
setEditing(null);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
@@ -158,6 +258,19 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetGatewayKey(gateway: any) {
|
||||
setBusy(gateway.name);
|
||||
try {
|
||||
const record = await gatewayAction(gateway.name, 'reset-key');
|
||||
setResetKey(record?.data ?? record);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '网关 key 重置失败'));
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGateway(gateway: any) {
|
||||
setBusy(gateway.name);
|
||||
try {
|
||||
@@ -172,17 +285,31 @@ export default function Page() {
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{ title: '名称', dataIndex: 'name', ...fixedLeft<any>({}), render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
|
||||
{ title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => <Typography.Text code style={{ fontSize: 12 }} ellipsis={{ tooltip: true }}>{value}</Typography.Text> },
|
||||
{
|
||||
title: '健康状态',
|
||||
dataIndex: 'health_status',
|
||||
title: '在线状态',
|
||||
key: 'online',
|
||||
width: 110,
|
||||
render: (_, gateway) => <HealthCell status={gateway.health_status} reason={gateway.last_check_reason} checkedAt={gateway.last_checked_at} />,
|
||||
render: (_, gateway) => <OnlineCell online={Boolean(gateway.online)} lastSeenAt={gateway.last_seen_at} />,
|
||||
},
|
||||
{ title: '版本', dataIndex: 'version', width: 140, render: (value: string) => (value ? <Typography.Text code style={{ fontSize: 12 }}>{value}</Typography.Text> : '—') },
|
||||
{
|
||||
title: '任务队列',
|
||||
dataIndex: 'pending_tasks',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (value: number, gateway) => (
|
||||
<Tooltip title="等待下发给网关的异步任务数">
|
||||
<Button type="link" size="small" style={{ paddingInline: 0 }} disabled={busy === gateway.name} onClick={() => setTasksGateway(gateway)}>
|
||||
{value}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '令牌',
|
||||
dataIndex: 'token',
|
||||
render: (token: string, gateway) => <TokenCell token={token} name={gateway.name} /> },
|
||||
title: 'Access Key',
|
||||
dataIndex: 'access_key',
|
||||
render: (accessKey: string, gateway) => <AccessKeyCell accessKey={accessKey} name={gateway.name} />,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
@@ -190,6 +317,14 @@ export default function Page() {
|
||||
...fixedRight<any>({}),
|
||||
render: (_, gateway) => (
|
||||
<Space>
|
||||
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => setTasksGateway(gateway)}>
|
||||
任务记录
|
||||
</Button>
|
||||
<Popconfirm title={`重置 ${gateway.name} 的 access key?`} description="旧 key 立即失效,网关需重新配置。">
|
||||
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => resetGatewayKey(gateway)}>
|
||||
重置 key
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => setEditing(gateway)}>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -206,42 +341,25 @@ export default function Page() {
|
||||
<Card variant="outlined">
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {gateways.length} 个网关</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
注册网关
|
||||
</Button>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建网关
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
{created ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={
|
||||
<Space size={4}>
|
||||
<span>网关 {created.name} 已注册。令牌</span>
|
||||
<Typography.Text code>{created.token}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label={`复制 ${created.token}`}
|
||||
onClick={() => navigator.clipboard?.writeText(created.token)}
|
||||
/>
|
||||
<Button size="small" onClick={() => setCreated(null)}>
|
||||
知道了
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{created ? <KeyGuideAlert name={created.name} accessKey={created.access_key} onClose={() => setCreated(null)} /> : null}
|
||||
{resetKey ? <KeyGuideAlert name={resetKey.name} accessKey={resetKey.access_key} onClose={() => setResetKey(null)} /> : null}
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky={sticky} pagination={pagination} scroll={{ x: horizontal ? 960 : undefined }} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
|
||||
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky={sticky} pagination={pagination} scroll={{ x: horizontal ? 1024 : undefined }} locale={{ emptyText: '暂无网关;创建后把签发的 key 配置到网关主机即可连入。' }} />
|
||||
<GatewayFormModal
|
||||
open={createOpen}
|
||||
initial={null}
|
||||
busy={busy === 'create'}
|
||||
error={null}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
@@ -251,37 +369,10 @@ export default function Page() {
|
||||
open={editing !== null}
|
||||
initial={editing}
|
||||
busy={busy === 'edit'}
|
||||
error={null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={updateGateway}
|
||||
/>
|
||||
<TaskHistoryDrawer gateway={tasksGateway} onClose={() => setTasksGateway(null)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenCell({ token, name }: { token: string; name: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Space size={0}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis={{ tooltip: true }}>
|
||||
{visible ? token : '••••••••••••'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={visible ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`}
|
||||
icon={visible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
/>
|
||||
{visible ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={`复制 ${name} 的令牌`}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => navigator.clipboard?.writeText(token)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-1
@@ -73,7 +73,7 @@ export async function update(resource: Resource, id: string | number, variables:
|
||||
}
|
||||
|
||||
export async function remove(resource: Resource, id: string | number): Promise<void> {
|
||||
if (!['accounts', 'creator-competitors'].includes(resource)) throw new Error(`${resource} 不支持 deleteOne`);
|
||||
if (!['accounts', 'creator-competitors', 'gateways'].includes(resource)) throw new Error(`${resource} 不支持 deleteOne`);
|
||||
await request(`${resourcePaths[resource]}/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -89,6 +89,17 @@ export async function networkExitAction(id: string, action: 'check' | 'disable'
|
||||
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// 网关领域动作:重置 access key(旧 key 立即失效);任务记录为独立分页查询。
|
||||
export async function gatewayAction(name: string, action: 'reset-key'): Promise<any> {
|
||||
if (action !== 'reset-key') throw new Error(`未知网关操作: ${action}`);
|
||||
return request(`/gateways/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function gatewayTasks(name: string, page = 1, pageSize = 10): Promise<{ data: any[]; total: number }> {
|
||||
const query = new URLSearchParams({ page: `${page}`, page_size: `${pageSize}` });
|
||||
return request(`/gateways/${encodeURIComponent(name)}/tasks?${query}`);
|
||||
}
|
||||
|
||||
// creator-* 领域的透传请求(路径由调用方给出,含列表页/actions)。
|
||||
export function creatorRequest(path: string, options?: RequestInit) {
|
||||
return request(path, options);
|
||||
|
||||
@@ -15,7 +15,6 @@ const cases = [
|
||||
'pages/network-exits/index.tsx',
|
||||
'pages/network-exits/$id/index.tsx',
|
||||
'pages/accounts/import.tsx',
|
||||
'pages/creator/events/index.tsx',
|
||||
'pages/creator/messages/index.tsx',
|
||||
];
|
||||
|
||||
@@ -99,10 +98,10 @@ function renderField(file, index, bindings) {
|
||||
}
|
||||
|
||||
for (const visible of [false, true]) {
|
||||
test(`gateway token hover respects visible=${visible}`, () => {
|
||||
const token = 'complete-gateway-token'.repeat(20);
|
||||
const field = renderField('pages/gateways/index.tsx', 1, { token, visible });
|
||||
assert.equal(field.props.children, visible ? token : '••••••••••••');
|
||||
test(`gateway access key respects visible=${visible}`, () => {
|
||||
const accessKey = 'complete-gateway-key'.repeat(20);
|
||||
const field = renderField('pages/gateways/index.tsx', 0, { accessKey, visible });
|
||||
assert.equal(field.props.children, visible ? accessKey : '••••••••••••');
|
||||
assert.equal(field.props.ellipsis.tooltip, true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const ts = require('typescript');
|
||||
|
||||
// 网关健康状态列:后台定时探活只落最近一次结果,列表直接展示,不保留历史。
|
||||
// 网关在线状态列:出站 WS 会话维护 online/last_seen_at;从未连接与离线不得混用。
|
||||
const source = ts.createSourceFile(
|
||||
'gateways-page',
|
||||
readFileSync(`${__dirname}/../src/pages/gateways/index.tsx`, 'utf8'),
|
||||
@@ -13,28 +13,24 @@ const source = ts.createSourceFile(
|
||||
);
|
||||
const code = source.getFullText();
|
||||
|
||||
test('网关列表展示最近一次健康检查结果', () => {
|
||||
assert.match(code, /健康状态/, '应有健康状态列');
|
||||
assert.match(code, /dataIndex: 'health_status'/, '应绑定 health_status 字段');
|
||||
assert.match(code, /gateway\.last_check_reason/, '应展示最近一次检查原因');
|
||||
assert.match(code, /gateway\.last_checked_at/, '应展示最近一次检查时间');
|
||||
assert.match(code, /online|在线/, '健康应展示为在线');
|
||||
assert.match(code, /offline|离线/, '不健康应展示为离线');
|
||||
test('网关列表展示会话维护的在线状态', () => {
|
||||
assert.match(code, /在线状态/, '应有在线状态列');
|
||||
assert.match(code, /online: boolean; lastSeenAt/, '应绑定 online 与 last_seen_at');
|
||||
assert.match(code, /\['在线', 'success'\]/, '在线展示 success');
|
||||
assert.match(code, /\['离线', 'error'\]/, '离线展示 error');
|
||||
assert.match(code, /最近在线:/, '离线时在 Tooltip 展示最近在线时间');
|
||||
});
|
||||
|
||||
test('健康状态未检查时不误报异常', () => {
|
||||
// 未检查(unchecked)必须落在独立的静默分支,不允许与离线混用同一个颜色/文案。
|
||||
const render = code.match(/function HealthCell[\s\S]*?\n}/);
|
||||
assert.ok(render, '应有独立的 HealthCell 组件');
|
||||
assert.match(render[0], /未检查/, '应存在未检查文案');
|
||||
const branches = render[0].match(/status === '[a-z]+'/g) ?? [];
|
||||
assert.ok(branches.includes("status === 'healthy'"), '应判断 healthy');
|
||||
assert.ok(branches.includes("status === 'unhealthy'"), '应判断 unhealthy');
|
||||
test('从未连接不冒充离线', () => {
|
||||
const render = code.match(/function OnlineCell[\s\S]*?\n}/);
|
||||
assert.ok(render, '应有独立的 OnlineCell 组件');
|
||||
assert.match(render[0], /从未连接/, '应存在从未连接文案');
|
||||
assert.match(render[0], /lastSeenAt\s*\?\s*\['离线', 'error'\]\s*:\s*\['从未连接', 'default'\]/, '无 last_seen_at 时落在从未连接静默分支');
|
||||
});
|
||||
|
||||
test('检查原因与时间收进 Tooltip,不挤占表格密度', () => {
|
||||
const render = code.match(/function HealthCell[\s\S]*?\n}/);
|
||||
assert.ok(render, '应有独立的 HealthCell 组件');
|
||||
assert.match(render[0], /Tooltip/, '原因与时间应放 Tooltip');
|
||||
test('在线细节收进 Tooltip,不挤占表格密度', () => {
|
||||
const render = code.match(/function OnlineCell[\s\S]*?\n}/);
|
||||
assert.ok(render, '应有独立的 OnlineCell 组件');
|
||||
assert.match(render[0], /Tooltip/, '细节应放 Tooltip');
|
||||
assert.doesNotMatch(render[0], /Typography\.Text type="secondary"/, '不应在单元格内平铺长文本');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const ts = require('typescript');
|
||||
|
||||
const source = ts.createSourceFile(
|
||||
'gateways.tsx',
|
||||
readFileSync(`${__dirname}/../src/pages/gateways/index.tsx`, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
const code = source.getFullText();
|
||||
|
||||
test('创建网关只需名称,access key 由平台生成', () => {
|
||||
assert.ok(!code.includes('Endpoint'), '出站模型不再登记 endpoint');
|
||||
assert.ok(!code.includes('GATEWAY_TOKEN'), '旧令牌语义已删除');
|
||||
assert.ok(!code.includes('token'), '不应残留 token 字段');
|
||||
const formItems = [...code.matchAll(/<Form\.Item[\s\S]*?name="(\w+)"/g)].map(match => match[1]);
|
||||
assert.deepEqual(formItems, ['name'], '创建/编辑表单只含名称');
|
||||
assert.match(code, /const record = await create\('gateways', values\);/);
|
||||
assert.match(code, /setCreated\(record\?\.data \?\? record\);/);
|
||||
});
|
||||
|
||||
test('key 生成后回显配置指引且只在本次显示', () => {
|
||||
assert.match(code, /KeyGuideAlert/);
|
||||
assert.match(code, /CREATOR_PLATFORM_URL=/);
|
||||
assert.match(code, /CREATOR_GATEWAY_KEY=\$\{accessKey\}/);
|
||||
assert.match(code, /只在本次显示/);
|
||||
assert.match(code, /旧 key 立即失效/);
|
||||
assert.match(code, /copyable=\{\{ text: guide/);
|
||||
});
|
||||
|
||||
test('在线状态区分在线、离线与从未连接', () => {
|
||||
assert.match(code, /\['在线', 'success'\]/);
|
||||
assert.match(code, /\['离线', 'error'\]/);
|
||||
assert.match(code, /\['从未连接', 'default'\]/);
|
||||
assert.match(code, /online: boolean; lastSeenAt\?/);
|
||||
assert.match(code, /最近在线:/);
|
||||
});
|
||||
|
||||
test('任务记录 Drawer 展示方法路径状态与耗时,分页请求真实接口', () => {
|
||||
assert.match(code, /TaskHistoryDrawer/);
|
||||
assert.match(code, /await gatewayTasks\(gateway\.name, targetPage, pageSize\);/);
|
||||
assert.match(code, /taskStatusMeta/);
|
||||
assert.ok(taskStatusMetaSrc.includes('pending') && taskStatusMetaSrc.includes('dispatched') && taskStatusMetaSrc.includes('succeeded') && taskStatusMetaSrc.includes('failed'));
|
||||
assert.match(code, /dataIndex: 'response_status'/);
|
||||
assert.match(code, /dispatched_at/);
|
||||
assert.match(code, /finished_at/);
|
||||
});
|
||||
|
||||
test('任务队列列与重置 key 动作接入真实接口', () => {
|
||||
assert.match(code, /dataIndex: 'pending_tasks'/);
|
||||
assert.match(code, /await gatewayAction\(gateway\.name, 'reset-key'\);/);
|
||||
assert.match(code, /旧 key 立即失效,网关需重新配置/);
|
||||
assert.match(code, /setTasksGateway\(gateway\)/);
|
||||
});
|
||||
|
||||
test('弃用 API 不回潮', () => {
|
||||
assert.ok(!code.includes('Space direction'), '纵向堆叠用 Flex vertical');
|
||||
assert.ok(!code.includes('Alert message='), 'Alert 用 title/description');
|
||||
assert.match(code, /<Flex vertical gap=\{4\}>/);
|
||||
});
|
||||
|
||||
const taskStatusMetaSrc = code.slice(
|
||||
Math.max(0, code.indexOf('const taskStatusMeta')),
|
||||
code.indexOf('function TaskHistoryDrawer'),
|
||||
);
|
||||
@@ -10,7 +10,7 @@ const files = [
|
||||
['src/components/accounts/AccountManagementList.tsx', 'max-content'],
|
||||
['src/pages/accounts/import.tsx', 1000],
|
||||
['src/pages/network-exits/index.tsx', 1100],
|
||||
['src/pages/gateways/index.tsx', 960],
|
||||
['src/pages/gateways/index.tsx', 1024],
|
||||
];
|
||||
|
||||
for (const [file, width] of files) {
|
||||
@@ -24,7 +24,7 @@ for (const [file, width] of files) {
|
||||
const code = source.getFullText();
|
||||
const tables = [];
|
||||
function visit(node) {
|
||||
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === 'Table') tables.push(node);
|
||||
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === 'Table' && node.attributes.properties.some(prop => prop.name?.getText(source) === 'sticky')) tables.push(node);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
|
||||
Reference in New Issue
Block a user