feat: 增加网络端口转发管理页面
This commit is contained in:
parent
d75c271d80
commit
bba71812b8
@ -50,6 +50,7 @@ pnpm run build:antdv-next
|
||||
- Blog 左栏“管理”入口通过 `/#/auth/login?sso=1&redirect=%2Fblog%2Farticle` 进入 Admin SSO bootstrap。Admin 只在自身域内调用 `/api/auth/refresh` 恢复 HttpOnly refresh Cookie;成功后进入文章管理,失败则移除 `sso` 并显示登录表单,且只保留固定内部回跳 `/blog/article`。该链路不接受外部 return URL,也不把 access token 放进地址栏。
|
||||
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
|
||||
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
||||
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。
|
||||
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
||||
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
||||
|
||||
@ -224,4 +224,121 @@ describe('core menu api', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps generic network management under System with scoped actions', async () => {
|
||||
requestClientGet.mockResolvedValue([
|
||||
{
|
||||
name: 'System',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
name: 'SystemNetwork',
|
||||
path: '/system/network',
|
||||
component: '/system/network/list',
|
||||
children: [
|
||||
{
|
||||
name: 'SystemNetworkPortForwardList',
|
||||
authCode: 'System:Network:PortForward:List',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardCreate',
|
||||
authCode: 'System:Network:PortForward:Create',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardUpdate',
|
||||
authCode: 'System:Network:PortForward:Update',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardDelete',
|
||||
authCode: 'System:Network:PortForward:Delete',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardRetry',
|
||||
authCode: 'System:Network:PortForward:Retry',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardKeeper',
|
||||
authCode: 'System:Network:PortForward:Keeper',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardProbe',
|
||||
authCode: 'System:Network:PortForward:Probe',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardHistory',
|
||||
authCode: 'System:Network:PortForward:History',
|
||||
type: 'button',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const { getAllMenusApi } = await import('./menu');
|
||||
const menus = await getAllMenusApi();
|
||||
|
||||
expect(menus).toEqual([
|
||||
{
|
||||
name: 'System',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
name: 'SystemNetwork',
|
||||
path: '/system/network',
|
||||
component: '/system/network/list',
|
||||
children: [
|
||||
{
|
||||
name: 'SystemNetworkPortForwardList',
|
||||
authCode: 'System:Network:PortForward:List',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardCreate',
|
||||
authCode: 'System:Network:PortForward:Create',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardUpdate',
|
||||
authCode: 'System:Network:PortForward:Update',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardDelete',
|
||||
authCode: 'System:Network:PortForward:Delete',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardRetry',
|
||||
authCode: 'System:Network:PortForward:Retry',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardKeeper',
|
||||
authCode: 'System:Network:PortForward:Keeper',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardProbe',
|
||||
authCode: 'System:Network:PortForward:Probe',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkPortForwardHistory',
|
||||
authCode: 'System:Network:PortForward:History',
|
||||
type: 'button',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -82,6 +82,15 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
|
||||
'SystemMenuCreate',
|
||||
'SystemMenuDelete',
|
||||
'SystemMenuEdit',
|
||||
'SystemNetwork',
|
||||
'SystemNetworkPortForwardCreate',
|
||||
'SystemNetworkPortForwardDelete',
|
||||
'SystemNetworkPortForwardHistory',
|
||||
'SystemNetworkPortForwardKeeper',
|
||||
'SystemNetworkPortForwardList',
|
||||
'SystemNetworkPortForwardProbe',
|
||||
'SystemNetworkPortForwardRetry',
|
||||
'SystemNetworkPortForwardUpdate',
|
||||
'SystemNotice',
|
||||
'SystemNoticeDelete',
|
||||
'SystemNoticeEdit',
|
||||
|
||||
@ -2,6 +2,7 @@ export * from './dept';
|
||||
export * from './dict';
|
||||
export * from './log';
|
||||
export * from './menu';
|
||||
export * from './network';
|
||||
export * from './notice';
|
||||
export * from './role';
|
||||
export * from './user';
|
||||
|
||||
105
apps/web-antdv-next/src/api/system/network.spec.ts
Normal file
105
apps/web-antdv-next/src/api/system/network.spec.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import {
|
||||
createNetworkPortForward,
|
||||
deleteNetworkPortForward,
|
||||
disableNetworkPortForwardKeeper,
|
||||
enableNetworkPortForwardKeeper,
|
||||
getNetworkAgentStatus,
|
||||
getNetworkPortForwardEndpointHistory,
|
||||
getNetworkPortForwardList,
|
||||
probeNetworkPortForward,
|
||||
retryNetworkPortForward,
|
||||
updateNetworkPortForward,
|
||||
} from './network';
|
||||
|
||||
vi.mock('#/api/request', () => ({
|
||||
requestClient: {
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const input = {
|
||||
externalPort: 45_678,
|
||||
internalPort: 45_678,
|
||||
name: 'Game UDP',
|
||||
protocol: 'udp' as const,
|
||||
remark: 'managed mapping',
|
||||
};
|
||||
|
||||
describe('system network api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses the persisted CRUD endpoints without any router secret', async () => {
|
||||
await getNetworkPortForwardList({ pageNo: 1, pageSize: 20 });
|
||||
await createNetworkPortForward(input);
|
||||
await updateNetworkPortForward('mapping-1', input);
|
||||
await deleteNetworkPortForward('mapping-1');
|
||||
|
||||
expect(requestClient.get).toHaveBeenCalledWith(
|
||||
'/system/network/port-forward/list',
|
||||
{ params: { pageNo: 1, pageSize: 20 } },
|
||||
);
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
'/system/network/port-forward',
|
||||
input,
|
||||
);
|
||||
expect(requestClient.put).toHaveBeenCalledWith(
|
||||
'/system/network/port-forward/mapping-1',
|
||||
input,
|
||||
);
|
||||
expect(requestClient.delete).toHaveBeenCalledWith(
|
||||
'/system/network/port-forward/mapping-1',
|
||||
);
|
||||
expect(JSON.stringify(input)).not.toContain('password');
|
||||
});
|
||||
|
||||
it('uses distinct reconciliation and Keeper action endpoints', async () => {
|
||||
await retryNetworkPortForward('mapping-1');
|
||||
await enableNetworkPortForwardKeeper('mapping-1');
|
||||
await disableNetworkPortForwardKeeper('mapping-1');
|
||||
await probeNetworkPortForward('mapping-1');
|
||||
|
||||
expect(requestClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/system/network/port-forward/mapping-1/retry',
|
||||
);
|
||||
expect(requestClient.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/system/network/port-forward/mapping-1/keeper/enable',
|
||||
);
|
||||
expect(requestClient.post).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/system/network/port-forward/mapping-1/keeper/disable',
|
||||
);
|
||||
expect(requestClient.post).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/system/network/port-forward/mapping-1/probe',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads Agent status and paged endpoint history independently', async () => {
|
||||
await getNetworkAgentStatus();
|
||||
await getNetworkPortForwardEndpointHistory('mapping-1', {
|
||||
pageNo: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/system/network/agent/status',
|
||||
);
|
||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/system/network/port-forward/mapping-1/endpoint-history',
|
||||
{ params: { pageNo: 2, pageSize: 10 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
235
apps/web-antdv-next/src/api/system/network.ts
Normal file
235
apps/web-antdv-next/src/api/system/network.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemNetworkApi {
|
||||
export type DesiredPresence = 'absent' | 'present';
|
||||
export type EndpointEventType =
|
||||
| 'changed'
|
||||
| 'published'
|
||||
| 'restored'
|
||||
| 'withdrawn';
|
||||
export type KeeperStatus =
|
||||
| 'active'
|
||||
| 'disabled'
|
||||
| 'failed'
|
||||
| 'stale'
|
||||
| 'starting';
|
||||
export type Protocol = 'tcp' | 'udp';
|
||||
export type Revision = string;
|
||||
export type SyncStatus =
|
||||
| 'conflict'
|
||||
| 'deleting'
|
||||
| 'failed'
|
||||
| 'pending'
|
||||
| 'synced'
|
||||
| 'syncing';
|
||||
|
||||
export interface AgentStatus {
|
||||
agentId: string;
|
||||
appliedRevision: Revision;
|
||||
desiredRevision: Revision;
|
||||
lastErrorCode?: null | string;
|
||||
lastErrorMessage?: null | string;
|
||||
lastHeartbeatAt?: null | string;
|
||||
online: boolean;
|
||||
publishedRevision: Revision;
|
||||
startedAt?: null | string;
|
||||
targetIpv4: string;
|
||||
version?: null | string;
|
||||
}
|
||||
|
||||
export interface EndpointHistoryItem {
|
||||
eventId: string;
|
||||
eventType: EndpointEventType;
|
||||
firstObservedAt?: null | string;
|
||||
id: string;
|
||||
lastObservedAt?: null | string;
|
||||
occurredAt: string;
|
||||
portForwardId: string;
|
||||
publicIpv4?: null | string;
|
||||
publicPort?: null | number;
|
||||
withdrawalReason?: null | string;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
list?: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PortForward {
|
||||
activeKey?: null | string;
|
||||
createTime?: string;
|
||||
currentObservedAt?: null | string;
|
||||
currentPublicIpv4?: null | string;
|
||||
currentPublicPort?: null | number;
|
||||
currentValidUntil?: null | string;
|
||||
desiredPresence: DesiredPresence;
|
||||
desiredRevision: Revision;
|
||||
externalPort: number;
|
||||
id: string;
|
||||
internalPort: number;
|
||||
isDeleted: boolean;
|
||||
keeperDesiredEnabled: boolean;
|
||||
keeperStatus: KeeperStatus;
|
||||
lastErrorCode?: null | string;
|
||||
lastErrorMessage?: null | string;
|
||||
lastObservedAt?: null | string;
|
||||
lastObservedIpv4?: null | string;
|
||||
lastObservedPort?: null | number;
|
||||
name: string;
|
||||
probeRequestId?: null | string;
|
||||
protocol: Protocol;
|
||||
remark?: null | string;
|
||||
reportedRevision?: null | Revision;
|
||||
syncStatus: SyncStatus;
|
||||
targetIpv4: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface PortForwardInput {
|
||||
externalPort: number;
|
||||
internalPort: number;
|
||||
name: string;
|
||||
protocol: Protocol;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type PortForwardItem = PortForward;
|
||||
export type PortForwardPayload = PortForwardInput;
|
||||
|
||||
export interface PortForwardQuery extends Recordable<any> {
|
||||
keeperStatus?: KeeperStatus;
|
||||
name?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
protocol?: Protocol;
|
||||
syncStatus?: SyncStatus;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the persisted port-forward resource page from the API fact source.
|
||||
* @param params - Pagination and independent protocol/synchronization filters.
|
||||
* @returns Port-forward rows and the total record count.
|
||||
*/
|
||||
export function getNetworkPortForwardList(
|
||||
params: SystemNetworkApi.PortForwardQuery,
|
||||
) {
|
||||
return requestClient.get<
|
||||
SystemNetworkApi.PageResult<SystemNetworkApi.PortForward>
|
||||
>('/system/network/port-forward/list', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a desired router mapping without exposing router credentials.
|
||||
* @param data - User-editable mapping fields.
|
||||
* @returns The persisted pending desired record.
|
||||
*/
|
||||
export function createNetworkPortForward(
|
||||
data: SystemNetworkApi.PortForwardInput,
|
||||
) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
'/system/network/port-forward',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the editable desired fields of one active mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @param data - Complete editable mapping fields.
|
||||
* @returns The updated pending desired record.
|
||||
*/
|
||||
export function updateNetworkPortForward(
|
||||
id: string,
|
||||
data: SystemNetworkApi.PortForwardInput,
|
||||
) {
|
||||
return requestClient.put<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests confirmed asynchronous deletion of one managed mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @returns The deleting tombstone retained until Agent acknowledgement.
|
||||
*/
|
||||
export function deleteNetworkPortForward(id: string) {
|
||||
return requestClient.delete<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a new reconciliation revision for a failed or conflicted mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @returns The latest pending desired record.
|
||||
*/
|
||||
export function retryNetworkPortForward(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/retry`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables persistent UDP STUN observation and triggers an immediate probe.
|
||||
* @param id - Eligible UDP same-port mapping ID.
|
||||
* @returns The latest pending desired record.
|
||||
*/
|
||||
export function enableNetworkPortForwardKeeper(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/keeper/enable`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops future STUN renewals without deleting the router mapping.
|
||||
* @param id - UDP mapping ID whose Keeper is currently desired.
|
||||
* @returns The latest pending desired record.
|
||||
*/
|
||||
export function disableNetworkPortForwardKeeper(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/keeper/disable`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an idempotent request ID for one immediate UDP STUN cycle.
|
||||
* @param id - Enabled UDP same-port mapping ID.
|
||||
* @returns The latest desired record carrying the new request ID.
|
||||
*/
|
||||
export function probeNetworkPortForward(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.PortForward>(
|
||||
`/system/network/port-forward/${id}/probe`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads append-only endpoint transition history for one mapping.
|
||||
* @param id - Stable port-forward record ID.
|
||||
* @param params - History pagination values.
|
||||
* @param params.pageNo - One-based history page number.
|
||||
* @param params.pageSize - Maximum rows requested for the page.
|
||||
* @returns Endpoint transition records and their total count.
|
||||
*/
|
||||
export function getNetworkPortForwardEndpointHistory(
|
||||
id: string,
|
||||
params: { pageNo?: number; pageSize?: number },
|
||||
) {
|
||||
return requestClient.get<
|
||||
SystemNetworkApi.PageResult<SystemNetworkApi.EndpointHistoryItem>
|
||||
>(`/system/network/port-forward/${id}/endpoint-history`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads independent Agent connectivity and revision convergence state.
|
||||
* @returns Current Agent status without inferring per-record failures.
|
||||
*/
|
||||
export function getNetworkAgentStatus() {
|
||||
return requestClient.get<SystemNetworkApi.AgentStatus>(
|
||||
'/system/network/agent/status',
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useKtTableActions } from './useKtTableActions';
|
||||
|
||||
vi.mock('antdv-next', () => ({
|
||||
Button: defineComponent({
|
||||
name: 'MockButton',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
disabled: attrs.disabled,
|
||||
onClick: attrs.onClick,
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
Modal: { confirm: vi.fn() },
|
||||
Tooltip: defineComponent({
|
||||
name: 'MockTooltip',
|
||||
props: { title: String },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h('span', { 'data-disabled-reason': props.title }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Creates the smallest action runtime needed to exercise row rendering. */
|
||||
function createActionRuntime() {
|
||||
const context = {
|
||||
formApi: {},
|
||||
getRows: vi.fn(() => []),
|
||||
getSearchValues: vi.fn(async () => ({})),
|
||||
registerHook: vi.fn(),
|
||||
reload: vi.fn(async () => {}),
|
||||
reset: vi.fn(async () => {}),
|
||||
search: vi.fn(async () => {}),
|
||||
selectedRowKeys: vi.fn(() => []),
|
||||
selectedRows: vi.fn(() => []),
|
||||
setSearchValues: vi.fn(async () => {}),
|
||||
unregisterHook: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return useKtTableActions({
|
||||
context,
|
||||
permissions: {
|
||||
filterVisibleActions: (actions: any[]) => actions,
|
||||
filterVisibleButtons: (buttons: any[]) => buttons,
|
||||
resolveBoolean: (value: unknown, fallback: boolean) =>
|
||||
typeof value === 'boolean' ? value : fallback,
|
||||
},
|
||||
props: {
|
||||
buttons: [],
|
||||
modules: [],
|
||||
rowActions: [],
|
||||
showDefaultButtons: false,
|
||||
} as any,
|
||||
reload: context.reload,
|
||||
reset: context.reset,
|
||||
runHook: vi.fn(async () => {}),
|
||||
search: context.search,
|
||||
});
|
||||
}
|
||||
|
||||
describe('ktTable disabled row action reason', () => {
|
||||
it('keeps a disabled action visible and exposes its readable reason', async () => {
|
||||
const onClick = vi.fn();
|
||||
const runtime = createActionRuntime();
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
runtime.renderRowAction(
|
||||
{
|
||||
disabled: true,
|
||||
disabledReason: () => 'TCP 仅支持端口转发 CRUD',
|
||||
key: 'keeper',
|
||||
label: '启用 Keeper',
|
||||
onClick,
|
||||
},
|
||||
{ id: 'tcp-1' },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Harness);
|
||||
expect(wrapper.text()).toContain('启用 Keeper');
|
||||
expect(wrapper.get('[data-disabled-reason]').attributes()).toMatchObject({
|
||||
'data-disabled-reason': 'TCP 仅支持端口转发 CRUD',
|
||||
});
|
||||
await wrapper.get('button').trigger('click');
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes an enabled row action without a disabled tooltip', async () => {
|
||||
const onClick = vi.fn();
|
||||
const runtime = createActionRuntime();
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
runtime.renderRowAction(
|
||||
{
|
||||
disabled: false,
|
||||
disabledReason: 'not rendered',
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
onClick,
|
||||
},
|
||||
{ id: 'udp-1' },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(Harness);
|
||||
expect(wrapper.find('[data-disabled-reason]').exists()).toBe(false);
|
||||
await wrapper.get('button').trigger('click');
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@ -14,9 +14,10 @@ import { computed } from 'vue';
|
||||
|
||||
import { Search, SearchX } from '@vben/icons';
|
||||
|
||||
import { Button, Modal } from 'antdv-next';
|
||||
import { Button, Modal, Tooltip } from 'antdv-next';
|
||||
|
||||
const AButton = Button as any;
|
||||
const ATooltip = Tooltip as any;
|
||||
|
||||
type PermissionHelpers = Pick<
|
||||
ReturnType<typeof useKtTablePermission>,
|
||||
@ -236,21 +237,50 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
|
||||
typeof action.disabled === 'function'
|
||||
? action.disabled(row, context)
|
||||
: resolveBoolean(action.disabled, false);
|
||||
const disabledReason = resolveDisabledReason(action, row, disabled);
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<AButton
|
||||
danger={action.danger}
|
||||
disabled={disabled}
|
||||
key={action.key}
|
||||
onClick={() => confirmRowAction(action, row)}
|
||||
onClick={disabled ? undefined : () => confirmRowAction(action, row)}
|
||||
type={action.type || 'link'}
|
||||
>
|
||||
{renderIcon(action.icon)}
|
||||
{action.label}
|
||||
</AButton>
|
||||
);
|
||||
|
||||
if (!disabledReason) {
|
||||
return <span key={action.key}>{button}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ATooltip key={action.key} title={disabledReason}>
|
||||
<span class="kt-table__disabled-action">{button}</span>
|
||||
</ATooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a readable disabled reason only while a row action is disabled.
|
||||
*
|
||||
* @param action Current row action configuration.
|
||||
* @param row Current table row.
|
||||
* @param disabled Resolved disabled state for this row.
|
||||
* @returns Tooltip text, or undefined when no explanation should render.
|
||||
*/
|
||||
function resolveDisabledReason(
|
||||
action: KtTableRowAction,
|
||||
row: KtTableRecord,
|
||||
disabled: boolean,
|
||||
): string | undefined {
|
||||
if (!disabled || !action.disabledReason) return undefined;
|
||||
return typeof action.disabledReason === 'function'
|
||||
? action.disabledReason(row, context)
|
||||
: action.disabledReason;
|
||||
}
|
||||
|
||||
return {
|
||||
formButtons,
|
||||
getVisibleRowActions,
|
||||
|
||||
@ -37,6 +37,11 @@
|
||||
&__row-action-more-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__disabled-action {
|
||||
display: inline-flex;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
#{kt.$block}__row-action-popover-content {
|
||||
|
||||
@ -100,6 +100,12 @@ export interface KtTableRowAction<
|
||||
disabled?:
|
||||
| ((row: Row, context: KtTableContext<Row, SearchValues>) => boolean)
|
||||
| boolean;
|
||||
disabledReason?:
|
||||
| ((
|
||||
row: Row,
|
||||
context: KtTableContext<Row, SearchValues>,
|
||||
) => string | undefined)
|
||||
| string;
|
||||
onClick?: (
|
||||
row: Row,
|
||||
context: KtTableContext<Row, SearchValues>,
|
||||
|
||||
@ -112,6 +112,78 @@
|
||||
"total": "Total",
|
||||
"unconfigured": "Loki Not Configured"
|
||||
},
|
||||
"network": {
|
||||
"agentLabel": "Agent",
|
||||
"agentOffline": "Offline",
|
||||
"agentOnline": "Online",
|
||||
"agentUnknown": "Unknown",
|
||||
"copyEndpointAction": "Copy Endpoint",
|
||||
"createAction": "Create Port Forward",
|
||||
"createTitle": "Create Port Forward",
|
||||
"deleteAction": "Delete",
|
||||
"deleteConfirm": "Submit deletion for \"{0}\"?",
|
||||
"deleteSubmitted": "Deletion submitted; waiting for Agent confirmation",
|
||||
"deletingImmutable": "The mapping is immutable while deletion is pending",
|
||||
"desiredSaved": "Desired configuration saved; waiting for Agent",
|
||||
"disableKeeperBeforeEdit": "Disable Keeper before changing protocol or port relationship",
|
||||
"disableKeeperAction": "Disable Keeper",
|
||||
"desiredOff": "Desired: Off",
|
||||
"desiredOn": "Desired: On",
|
||||
"editAction": "Edit",
|
||||
"editTitle": "Edit Port Forward",
|
||||
"enableKeeperFirst": "Enable persistent keepalive first",
|
||||
"enableKeeperAction": "Enable Keeper",
|
||||
"endpointCopied": "Public endpoint copied",
|
||||
"endpointHistory": "Endpoint History",
|
||||
"externalPort": "External Port",
|
||||
"firstObservedAt": "First Observed",
|
||||
"historyEvent": "Event",
|
||||
"historyAction": "View History",
|
||||
"internalTarget": "Target IPv4:Port",
|
||||
"internalPort": "Internal Port",
|
||||
"keeperDisableSubmitted": "Stop-renewal request submitted",
|
||||
"keeperEnableSubmitted": "Keepalive and immediate probe request submitted",
|
||||
"keeperActive": "Active",
|
||||
"keeperDisabled": "Disabled",
|
||||
"keeperFailed": "Failed",
|
||||
"keeperStale": "Stale",
|
||||
"keeperStarting": "Starting",
|
||||
"keeperState": "Keeper Desired / Actual",
|
||||
"lastObservedAt": "Last Observed",
|
||||
"lastObserved": "Last Successful Observation",
|
||||
"name": "Name",
|
||||
"nameRequired": "Enter a mapping name",
|
||||
"noCurrentEndpoint": "No valid public endpoint is currently available",
|
||||
"operationInProgress": "Another action is already in progress for this row",
|
||||
"portForwardTitle": "Port Forwards",
|
||||
"portRange": "The port must be an integer from 1 to 65535",
|
||||
"probeSubmitted": "Immediate STUN probe request submitted",
|
||||
"probeAction": "Refresh Now",
|
||||
"protocol": "Protocol",
|
||||
"publicEndpoint": "Public Endpoint",
|
||||
"remark": "Remark",
|
||||
"retryAction": "Retry",
|
||||
"retryNotRequired": "The current state does not require retry",
|
||||
"retrySubmitted": "Reconciliation retry submitted",
|
||||
"revisionLabel": "Revision",
|
||||
"summary": "Pending / Error Summary",
|
||||
"syncConflict": "Conflict",
|
||||
"syncDeleting": "Deleting",
|
||||
"syncFailed": "Failed",
|
||||
"syncPending": "Pending",
|
||||
"syncStatus": "Sync Status",
|
||||
"syncSynced": "Synced",
|
||||
"syncSyncing": "Syncing",
|
||||
"targetLabel": "Target",
|
||||
"targetIpv4": "NAS Target IPv4",
|
||||
"tcpKeeperUnsupported": "TCP supports port-forward CRUD only",
|
||||
"title": "Network Management",
|
||||
"updateTime": "Updated At",
|
||||
"udpSamePortRequired": "UDP STUN requires equal external and internal ports",
|
||||
"waitingForAgent": "Waiting for the Agent to reconnect",
|
||||
"waitingForSync": "Waiting for Agent acknowledgement",
|
||||
"withdrawalReason": "Withdrawal Reason"
|
||||
},
|
||||
"notice": {
|
||||
"cancelTop": "Unpin",
|
||||
"cancelTopSuccess": "Unpinned",
|
||||
|
||||
@ -114,6 +114,78 @@
|
||||
"total": "总量",
|
||||
"unconfigured": "Loki 未配置"
|
||||
},
|
||||
"network": {
|
||||
"agentLabel": "Agent",
|
||||
"agentOffline": "离线",
|
||||
"agentOnline": "在线",
|
||||
"agentUnknown": "未知",
|
||||
"copyEndpointAction": "复制端点",
|
||||
"createAction": "新增端口转发",
|
||||
"createTitle": "新增端口转发",
|
||||
"deleteAction": "删除",
|
||||
"deleteConfirm": "确认提交「{0}」的删除请求吗?",
|
||||
"deleteSubmitted": "删除请求已提交,等待 Agent 确认",
|
||||
"deletingImmutable": "删除确认期间不能修改或重复删除",
|
||||
"desiredSaved": "期望配置已保存,等待 Agent 同步",
|
||||
"disableKeeperBeforeEdit": "请先停用 Keeper,再修改协议或端口关系",
|
||||
"disableKeeperAction": "停用 Keeper",
|
||||
"desiredOff": "期望:关闭",
|
||||
"desiredOn": "期望:开启",
|
||||
"editAction": "编辑",
|
||||
"editTitle": "编辑端口转发",
|
||||
"enableKeeperFirst": "请先启用持续保活",
|
||||
"enableKeeperAction": "启用 Keeper",
|
||||
"endpointCopied": "公网端点已复制",
|
||||
"endpointHistory": "端点历史",
|
||||
"externalPort": "外部端口",
|
||||
"firstObservedAt": "首次观测",
|
||||
"historyEvent": "事件类型",
|
||||
"historyAction": "查看历史",
|
||||
"internalTarget": "目标 IPv4:端口",
|
||||
"internalPort": "内网端口",
|
||||
"keeperDisableSubmitted": "已提交停止续约请求",
|
||||
"keeperEnableSubmitted": "已提交持续保活与立即探测请求",
|
||||
"keeperActive": "运行中",
|
||||
"keeperDisabled": "已停用",
|
||||
"keeperFailed": "失败",
|
||||
"keeperStale": "已过期",
|
||||
"keeperStarting": "启动中",
|
||||
"keeperState": "Keeper 期望 / 实际",
|
||||
"lastObservedAt": "最近观测",
|
||||
"lastObserved": "最近成功观测",
|
||||
"name": "名称",
|
||||
"nameRequired": "请输入规则名称",
|
||||
"noCurrentEndpoint": "当前没有可复制的有效公网端点",
|
||||
"operationInProgress": "该记录已有操作正在提交",
|
||||
"portForwardTitle": "端口转发",
|
||||
"portRange": "端口必须是 1 到 65535 的整数",
|
||||
"probeSubmitted": "已提交立即 STUN 刷新请求",
|
||||
"probeAction": "立即刷新",
|
||||
"protocol": "协议",
|
||||
"publicEndpoint": "公网端点",
|
||||
"remark": "备注",
|
||||
"retryAction": "重试",
|
||||
"retryNotRequired": "当前状态不需要重试",
|
||||
"retrySubmitted": "已提交重新协调请求",
|
||||
"revisionLabel": "版本",
|
||||
"summary": "等待 / 错误摘要",
|
||||
"syncConflict": "冲突",
|
||||
"syncDeleting": "删除中",
|
||||
"syncFailed": "失败",
|
||||
"syncPending": "等待同步",
|
||||
"syncStatus": "同步状态",
|
||||
"syncSynced": "已同步",
|
||||
"syncSyncing": "同步中",
|
||||
"targetLabel": "目标",
|
||||
"targetIpv4": "NAS 目标 IPv4",
|
||||
"tcpKeeperUnsupported": "TCP 仅支持端口转发 CRUD",
|
||||
"title": "网络管理",
|
||||
"updateTime": "更新时间",
|
||||
"udpSamePortRequired": "UDP STUN 要求外部端口与内部端口相同",
|
||||
"waitingForAgent": "等待 Agent 上线后协调",
|
||||
"waitingForSync": "等待 Agent 确认期望状态",
|
||||
"withdrawalReason": "撤下原因"
|
||||
},
|
||||
"notice": {
|
||||
"cancelTop": "取消置顶",
|
||||
"cancelTopSuccess": "取消置顶成功",
|
||||
|
||||
@ -75,6 +75,15 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('#/views/system/notice/list.vue'),
|
||||
},
|
||||
{
|
||||
path: '/system/network',
|
||||
name: 'SystemNetwork',
|
||||
meta: {
|
||||
icon: 'lucide:router',
|
||||
title: $t('system.network.title'),
|
||||
},
|
||||
component: () => import('#/views/system/network/list'),
|
||||
},
|
||||
{
|
||||
path: '/system/ktTableDemo',
|
||||
name: 'SystemKtTableDemo',
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type { SystemNetworkApi } from '#/api/system/network';
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import NetworkEndpointHistoryDrawer from './NetworkEndpointHistoryDrawer';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
drawerApi: { open: vi.fn() },
|
||||
drawerOptions: undefined as any,
|
||||
getHistory: vi.fn(),
|
||||
tableApi: { reload: vi.fn() },
|
||||
tableOptions: undefined as any,
|
||||
}));
|
||||
|
||||
vi.mock('@vben/common-ui', () => ({
|
||||
useVbenDrawer: vi.fn((options) => {
|
||||
mocks.drawerOptions = options;
|
||||
const Drawer = defineComponent({
|
||||
name: 'MockDrawer',
|
||||
setup(_, { slots }) {
|
||||
return () => h('aside', slots.default?.());
|
||||
},
|
||||
});
|
||||
return [Drawer, mocks.drawerApi];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antdv-next', () => ({
|
||||
Tag: defineComponent({
|
||||
name: 'MockTag',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockKtTable',
|
||||
setup() {
|
||||
return () => h('section');
|
||||
},
|
||||
}),
|
||||
useKtTable: vi.fn((options) => {
|
||||
mocks.tableOptions = options;
|
||||
return [vi.fn(), mocks.tableApi];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/system/network', () => ({
|
||||
getNetworkPortForwardEndpointHistory: mocks.getHistory,
|
||||
}));
|
||||
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
/** Creates the minimal row needed to scope a history request. */
|
||||
function createRow(id: string): SystemNetworkApi.PortForward {
|
||||
return {
|
||||
desiredPresence: 'present',
|
||||
desiredRevision: '1',
|
||||
externalPort: 40_000,
|
||||
id,
|
||||
internalPort: 40_000,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: true,
|
||||
keeperStatus: 'active',
|
||||
name: `mapping-${id}`,
|
||||
protocol: 'udp',
|
||||
syncStatus: 'synced',
|
||||
targetIpv4: '192.168.31.224',
|
||||
};
|
||||
}
|
||||
|
||||
describe('network endpoint history drawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getHistory.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
|
||||
it('stays lazy until opened and uses a disposable Vben drawer', () => {
|
||||
mount(NetworkEndpointHistoryDrawer);
|
||||
expect(mocks.tableOptions.immediate).toBe(false);
|
||||
expect(mocks.drawerOptions.destroyOnClose).toBe(true);
|
||||
expect(mocks.getHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the selected record ID and does not reuse a prior selection', async () => {
|
||||
const wrapper = mount(NetworkEndpointHistoryDrawer);
|
||||
(wrapper.vm as any).open(createRow('mapping-1'));
|
||||
mocks.drawerOptions.onOpened();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||
await mocks.tableOptions.api.list({ pageNo: 2, pageSize: 10 });
|
||||
expect(mocks.getHistory).toHaveBeenLastCalledWith('mapping-1', {
|
||||
pageNo: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
(wrapper.vm as any).open(createRow('mapping-2'));
|
||||
await mocks.tableOptions.api.list({ pageNo: 1, pageSize: 20 });
|
||||
expect(mocks.getHistory).toHaveBeenLastCalledWith('mapping-2', {
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,148 @@
|
||||
import type { TableColumnType } from 'antdv-next';
|
||||
|
||||
import type { SystemNetworkApi } from '#/api/system/network';
|
||||
import type { KtTableApi } from '#/components/ktTable';
|
||||
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Tag } from 'antdv-next';
|
||||
|
||||
import { getNetworkPortForwardEndpointHistory } from '#/api/system/network';
|
||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export interface NetworkEndpointHistoryDrawerExposed {
|
||||
open: (row: SystemNetworkApi.PortForward) => void;
|
||||
}
|
||||
|
||||
const AKtTable = KtTable as any;
|
||||
const eventColors: Record<SystemNetworkApi.EndpointEventType, string> = {
|
||||
changed: 'warning',
|
||||
published: 'success',
|
||||
restored: 'processing',
|
||||
withdrawn: 'default',
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NetworkEndpointHistoryDrawer',
|
||||
/** Creates a lazy, record-scoped history table inside a disposable drawer. */
|
||||
setup(_, { expose }) {
|
||||
const selectedRow = ref<SystemNetworkApi.PortForward>();
|
||||
const columns: Array<
|
||||
TableColumnType<SystemNetworkApi.EndpointHistoryItem>
|
||||
> = [
|
||||
{
|
||||
dataIndex: 'eventType',
|
||||
key: 'eventType',
|
||||
title: $t('system.network.historyEvent'),
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
key: 'publicEndpoint',
|
||||
title: $t('system.network.publicEndpoint'),
|
||||
width: 190,
|
||||
},
|
||||
{
|
||||
dataIndex: 'firstObservedAt',
|
||||
key: 'firstObservedAt',
|
||||
title: $t('system.network.firstObservedAt'),
|
||||
width: 190,
|
||||
},
|
||||
{
|
||||
dataIndex: 'lastObservedAt',
|
||||
key: 'lastObservedAt',
|
||||
title: $t('system.network.lastObservedAt'),
|
||||
width: 190,
|
||||
},
|
||||
{
|
||||
dataIndex: 'withdrawalReason',
|
||||
key: 'withdrawalReason',
|
||||
title: $t('system.network.withdrawalReason'),
|
||||
width: 220,
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<SystemNetworkApi.EndpointHistoryItem> = {
|
||||
/** Loads only the selected mapping's append-only endpoint transitions. */
|
||||
list: async (params) => {
|
||||
if (!selectedRow.value) return { items: [], total: 0 };
|
||||
return await getNetworkPortForwardEndpointHistory(
|
||||
selectedRow.value.id,
|
||||
{
|
||||
pageNo: Number(params.pageNo || params.page || 1),
|
||||
pageSize: Number(params.pageSize || 10),
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
const [registerTable, tableApi] =
|
||||
useKtTable<SystemNetworkApi.EndpointHistoryItem>({
|
||||
api,
|
||||
columns,
|
||||
immediate: false,
|
||||
pageSize: 10,
|
||||
rowKey: 'eventId',
|
||||
showDefaultButtons: false,
|
||||
showIndex: false,
|
||||
tableSettings: {
|
||||
column: true,
|
||||
density: true,
|
||||
fullscreen: false,
|
||||
reload: true,
|
||||
showSearch: false,
|
||||
},
|
||||
tableTitle: $t('system.network.endpointHistory'),
|
||||
});
|
||||
const drawerTitle = computed(() =>
|
||||
selectedRow.value
|
||||
? `${$t('system.network.endpointHistory')} · ${selectedRow.value.name}`
|
||||
: $t('system.network.endpointHistory'),
|
||||
);
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
class: 'w-[860px]',
|
||||
destroyOnClose: true,
|
||||
footer: false,
|
||||
onOpened() {
|
||||
void tableApi.reload();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens history for one mapping and discards rows from the prior selection.
|
||||
* @param row - Mapping whose append-only history should be queried.
|
||||
*/
|
||||
function open(row: SystemNetworkApi.PortForward) {
|
||||
selectedRow.value = row;
|
||||
drawerApi.open();
|
||||
}
|
||||
|
||||
expose({ open } satisfies NetworkEndpointHistoryDrawerExposed);
|
||||
|
||||
return () => (
|
||||
<Drawer title={drawerTitle.value}>
|
||||
<AKtTable
|
||||
onRegister={registerTable}
|
||||
v-slots={{
|
||||
bodyCell: ({ column, record }: any) => {
|
||||
const item = record as SystemNetworkApi.EndpointHistoryItem;
|
||||
if (column.key === 'eventType') {
|
||||
return (
|
||||
<Tag color={eventColors[item.eventType]}>
|
||||
{item.eventType}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (column.key === 'publicEndpoint') {
|
||||
return item.publicIpv4 && item.publicPort
|
||||
? `${item.publicIpv4}:${item.publicPort}`
|
||||
: '-';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,197 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import NetworkPortForwardModal from './NetworkPortForwardModal';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const modalApi = {
|
||||
close: vi.fn(async () => {}),
|
||||
lock: vi.fn(),
|
||||
open: vi.fn(),
|
||||
unlock: vi.fn(),
|
||||
};
|
||||
const formApi = {
|
||||
getValues: vi.fn(),
|
||||
resetForm: vi.fn(async () => {}),
|
||||
resetValidate: vi.fn(async () => {}),
|
||||
setValues: vi.fn(async () => {}),
|
||||
validate: vi.fn(async () => ({ valid: true })),
|
||||
};
|
||||
return {
|
||||
create: vi.fn(),
|
||||
formApi,
|
||||
formOptions: undefined as any,
|
||||
modalApi,
|
||||
modalOptions: undefined as any,
|
||||
update: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
/** Creates a fluent no-op validation rule for the form-schema fixture. */
|
||||
function createRule(): any {
|
||||
const rule: any = {};
|
||||
for (const method of ['int', 'max', 'min', 'optional', 'or', 'trim']) {
|
||||
rule[method] = vi.fn(() => rule);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
vi.mock('#/adapter/form', () => ({
|
||||
useVbenForm: vi.fn((options) => {
|
||||
mocks.formOptions = options;
|
||||
const Form = defineComponent({
|
||||
name: 'MockForm',
|
||||
setup() {
|
||||
return () => h('form');
|
||||
},
|
||||
});
|
||||
return [Form, mocks.formApi];
|
||||
}),
|
||||
z: {
|
||||
enum: vi.fn(createRule),
|
||||
literal: vi.fn(createRule),
|
||||
number: vi.fn(createRule),
|
||||
string: vi.fn(createRule),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@vben/common-ui', () => ({
|
||||
useVbenModal: vi.fn((options) => {
|
||||
mocks.modalOptions = options;
|
||||
const Modal = defineComponent({
|
||||
name: 'MockModal',
|
||||
setup(_, { slots }) {
|
||||
return () => h('section', slots.default?.());
|
||||
},
|
||||
});
|
||||
return [Modal, mocks.modalApi];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antdv-next', () => ({
|
||||
Alert: defineComponent({
|
||||
name: 'MockAlert',
|
||||
props: { message: String },
|
||||
setup(props) {
|
||||
return () => h('p', props.message);
|
||||
},
|
||||
}),
|
||||
message: { success: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('#/api/system/network', () => ({
|
||||
createNetworkPortForward: mocks.create,
|
||||
updateNetworkPortForward: mocks.update,
|
||||
}));
|
||||
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('network port-forward modal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.update.mockResolvedValue({});
|
||||
mocks.formApi.getValues.mockResolvedValue({
|
||||
externalPort: 45_678,
|
||||
internalPort: 45_678,
|
||||
name: ' Game UDP ',
|
||||
protocol: 'udp',
|
||||
remark: ' managed ',
|
||||
});
|
||||
});
|
||||
|
||||
it('contains only approved editable fields and shows target IPv4 read-only', async () => {
|
||||
const wrapper = mount(NetworkPortForwardModal);
|
||||
await (wrapper.vm as any).openCreate('192.168.31.224');
|
||||
|
||||
expect(
|
||||
mocks.formOptions.schema.map((field: any) => field.fieldName),
|
||||
).toEqual(['name', 'protocol', 'externalPort', 'internalPort', 'remark']);
|
||||
expect(JSON.stringify(mocks.formOptions.schema)).not.toMatch(
|
||||
/password|routerUrl|token/i,
|
||||
);
|
||||
expect(wrapper.text()).toContain('192.168.31.224');
|
||||
expect(mocks.formApi.setValues).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
keeperDesiredEnabled: expect.anything(),
|
||||
targetIpv4: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the API name and remark length contract', () => {
|
||||
mount(NetworkPortForwardModal);
|
||||
const schema = mocks.formOptions.schema as Array<{
|
||||
componentProps?: { maxlength?: number };
|
||||
fieldName: string;
|
||||
}>;
|
||||
|
||||
expect(
|
||||
schema.find((field) => field.fieldName === 'name')?.componentProps
|
||||
?.maxlength,
|
||||
).toBe(100);
|
||||
expect(
|
||||
schema.find((field) => field.fieldName === 'remark')?.componentProps
|
||||
?.maxlength,
|
||||
).toBe(500);
|
||||
});
|
||||
|
||||
it('creates a trimmed desired payload without runtime or secret fields', async () => {
|
||||
const wrapper = mount(NetworkPortForwardModal);
|
||||
await (wrapper.vm as any).openCreate('192.168.31.224');
|
||||
await mocks.modalOptions.onConfirm();
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith({
|
||||
externalPort: 45_678,
|
||||
internalPort: 45_678,
|
||||
name: 'Game UDP',
|
||||
protocol: 'udp',
|
||||
remark: 'managed',
|
||||
});
|
||||
expect(JSON.stringify(mocks.create.mock.calls[0]?.[0])).not.toMatch(
|
||||
/password|targetIpv4|keeper|token/i,
|
||||
);
|
||||
expect(mocks.modalApi.lock).toHaveBeenCalledOnce();
|
||||
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sends an explicit empty remark when an existing value is cleared', async () => {
|
||||
mocks.formApi.getValues.mockResolvedValue({
|
||||
externalPort: 45_678,
|
||||
internalPort: 45_678,
|
||||
name: 'Game UDP',
|
||||
protocol: 'udp',
|
||||
remark: ' ',
|
||||
});
|
||||
const wrapper = mount(NetworkPortForwardModal);
|
||||
await (wrapper.vm as any).openEdit({
|
||||
desiredPresence: 'present',
|
||||
desiredRevision: '7',
|
||||
externalPort: 45_678,
|
||||
id: '42',
|
||||
internalPort: 45_678,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: false,
|
||||
keeperStatus: 'disabled',
|
||||
name: 'Game UDP',
|
||||
protocol: 'udp',
|
||||
remark: 'old remark',
|
||||
syncStatus: 'synced',
|
||||
targetIpv4: '192.168.31.224',
|
||||
});
|
||||
await mocks.modalOptions.onConfirm();
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledWith(
|
||||
'42',
|
||||
expect.objectContaining({ remark: '' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,218 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { SystemNetworkApi } from '#/api/system/network';
|
||||
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Alert, message } from 'antdv-next';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import {
|
||||
createNetworkPortForward,
|
||||
updateNetworkPortForward,
|
||||
} from '#/api/system/network';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export interface NetworkPortForwardModalExposed {
|
||||
openCreate: (targetIpv4: string) => Promise<void>;
|
||||
openEdit: (row: SystemNetworkApi.PortForward) => Promise<void>;
|
||||
}
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NetworkPortForwardModal',
|
||||
emits: ['saved'],
|
||||
/**
|
||||
* Owns the CRUD form while keeping the NAS target and Keeper state read-only.
|
||||
*/
|
||||
setup(_, { emit, expose }) {
|
||||
const editingRow = ref<SystemNetworkApi.PortForward>();
|
||||
const targetIpv4 = ref('');
|
||||
const [PortForwardForm, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
labelClass: 'w-24',
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: createFormSchema(),
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
const modalTitle = computed(() =>
|
||||
editingRow.value
|
||||
? $t('system.network.editTitle')
|
||||
: $t('system.network.createTitle'),
|
||||
);
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-[640px]',
|
||||
fullscreenButton: false,
|
||||
async onConfirm() {
|
||||
await submit();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a blank form for one new desired mapping.
|
||||
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
|
||||
*/
|
||||
async function openCreate(fixedTargetIpv4: string) {
|
||||
editingRow.value = undefined;
|
||||
targetIpv4.value = fixedTargetIpv4;
|
||||
await resetForm({
|
||||
externalPort: undefined,
|
||||
internalPort: undefined,
|
||||
name: '',
|
||||
protocol: 'udp',
|
||||
remark: '',
|
||||
});
|
||||
modalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an existing row for editing without copying reported/runtime fields.
|
||||
* @param row - Persisted desired record selected from KtTable.
|
||||
*/
|
||||
async function openEdit(row: SystemNetworkApi.PortForward) {
|
||||
editingRow.value = row;
|
||||
targetIpv4.value = row.targetIpv4;
|
||||
await resetForm({
|
||||
externalPort: row.externalPort,
|
||||
internalPort: row.internalPort,
|
||||
name: row.name,
|
||||
protocol: row.protocol,
|
||||
remark: row.remark || '',
|
||||
});
|
||||
modalApi.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets validation and installs only user-editable values.
|
||||
* @param values - Form values excluding target IP, Keeper and secrets.
|
||||
*/
|
||||
async function resetForm(
|
||||
values: Partial<SystemNetworkApi.PortForwardInput>,
|
||||
) {
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues(values);
|
||||
await formApi.resetValidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the desired mapping and leaves all runtime effects asynchronous.
|
||||
*/
|
||||
async function submit() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const values =
|
||||
await formApi.getValues<SystemNetworkApi.PortForwardInput>();
|
||||
const payload: SystemNetworkApi.PortForwardInput = {
|
||||
externalPort: Number(values.externalPort),
|
||||
internalPort: Number(values.internalPort),
|
||||
name: values.name.trim(),
|
||||
protocol: values.protocol,
|
||||
remark: values.remark?.trim() || '',
|
||||
};
|
||||
if (
|
||||
editingRow.value?.keeperDesiredEnabled &&
|
||||
(payload.protocol !== 'udp' ||
|
||||
payload.externalPort !== payload.internalPort)
|
||||
) {
|
||||
message.warning($t('system.network.disableKeeperBeforeEdit'));
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
await (editingRow.value
|
||||
? updateNetworkPortForward(editingRow.value.id, payload)
|
||||
: createNetworkPortForward(payload));
|
||||
message.success($t('system.network.desiredSaved'));
|
||||
await modalApi.close();
|
||||
emit('saved');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
expose({ openCreate, openEdit } satisfies NetworkPortForwardModalExposed);
|
||||
|
||||
return () => (
|
||||
<Modal title={modalTitle.value}>
|
||||
<Alert
|
||||
class="mb-4"
|
||||
message={`${$t('system.network.targetIpv4')}: ${
|
||||
targetIpv4.value || '-'
|
||||
}`}
|
||||
showIcon
|
||||
type="info"
|
||||
/>
|
||||
<PortForwardForm class="mx-2" />
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the modal's approved editable schema without any credential field.
|
||||
* @returns Vben form fields for name, protocol, ports and optional remark.
|
||||
*/
|
||||
function createFormSchema(): VbenFormSchema[] {
|
||||
const portRule = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, $t('system.network.portRange'))
|
||||
.max(65_535, $t('system.network.portRange'));
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: { allowClear: true, maxlength: 100 },
|
||||
fieldName: 'name',
|
||||
label: $t('system.network.name'),
|
||||
rules: z.string().trim().min(1).max(100),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: { options: protocolOptions },
|
||||
defaultValue: 'udp',
|
||||
fieldName: 'protocol',
|
||||
label: $t('system.network.protocol'),
|
||||
rules: z.enum(['tcp', 'udp']),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
max: 65_535,
|
||||
min: 1,
|
||||
precision: 0,
|
||||
},
|
||||
fieldName: 'externalPort',
|
||||
label: $t('system.network.externalPort'),
|
||||
rules: portRule,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
max: 65_535,
|
||||
min: 1,
|
||||
precision: 0,
|
||||
},
|
||||
fieldName: 'internalPort',
|
||||
label: $t('system.network.internalPort'),
|
||||
rules: portRule,
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
componentProps: { allowClear: true, maxlength: 500, rows: 3 },
|
||||
fieldName: 'remark',
|
||||
label: $t('system.network.remark'),
|
||||
rules: z.string().max(500).optional().or(z.literal('')),
|
||||
},
|
||||
];
|
||||
}
|
||||
358
apps/web-antdv-next/src/views/system/network/list.spec.tsx
Normal file
358
apps/web-antdv-next/src/views/system/network/list.spec.tsx
Normal file
@ -0,0 +1,358 @@
|
||||
/* @vitest-environment happy-dom */
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type { SystemNetworkApi } from '#/api/system/network';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import routes from '#/router/routes/modules/system';
|
||||
|
||||
import NetworkList, {
|
||||
getCurrentEndpoint,
|
||||
getKeeperDisabledReason,
|
||||
isDeleting,
|
||||
} from './list';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
deleteMapping: vi.fn(),
|
||||
disableKeeper: vi.fn(),
|
||||
enableKeeper: vi.fn(),
|
||||
getAgentStatus: vi.fn(),
|
||||
getList: vi.fn(),
|
||||
probe: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
},
|
||||
messageSuccess: vi.fn(),
|
||||
tableApi: {
|
||||
getRows: vi.fn(() => []),
|
||||
reload: vi.fn(),
|
||||
},
|
||||
tableOptions: undefined as any,
|
||||
}));
|
||||
|
||||
vi.mock('@vben/common-ui', () => ({
|
||||
Page: defineComponent({
|
||||
name: 'MockPage',
|
||||
setup(_, { slots }) {
|
||||
return () => h('main', { 'data-testid': 'page-root' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antdv-next', () => ({
|
||||
message: { success: mocks.messageSuccess },
|
||||
Space: defineComponent({
|
||||
name: 'MockSpace',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
}),
|
||||
Tag: defineComponent({
|
||||
name: 'MockTag',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
}),
|
||||
Typography: {
|
||||
Text: defineComponent({
|
||||
name: 'MockText',
|
||||
setup(_, { slots }) {
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('#/components/ktTable', () => ({
|
||||
KtTable: defineComponent({
|
||||
name: 'MockKtTable',
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('section', { 'data-testid': 'network-table' }, [
|
||||
slots.headerControls?.(),
|
||||
slots.bodyCell?.({
|
||||
column: { key: 'syncStatus' },
|
||||
record: { syncStatus: 'pending' },
|
||||
}),
|
||||
slots.bodyCell?.({
|
||||
column: { key: 'keeper' },
|
||||
record: {
|
||||
keeperDesiredEnabled: true,
|
||||
keeperStatus: 'active',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
useKtTable: vi.fn((options) => {
|
||||
mocks.tableOptions = options;
|
||||
return [vi.fn(), mocks.tableApi];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./components/NetworkPortForwardModal', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockNetworkPortForwardModal',
|
||||
setup(_, { expose }) {
|
||||
expose({ openCreate: vi.fn(), openEdit: vi.fn() });
|
||||
return () => h('div');
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockNetworkEndpointHistoryDrawer',
|
||||
setup(_, { expose }) {
|
||||
expose({ open: vi.fn() });
|
||||
return () => h('div');
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/system/network', () => ({
|
||||
deleteNetworkPortForward: mocks.api.deleteMapping,
|
||||
disableNetworkPortForwardKeeper: mocks.api.disableKeeper,
|
||||
enableNetworkPortForwardKeeper: mocks.api.enableKeeper,
|
||||
getNetworkAgentStatus: mocks.api.getAgentStatus,
|
||||
getNetworkPortForwardList: mocks.api.getList,
|
||||
probeNetworkPortForward: mocks.api.probe,
|
||||
retryNetworkPortForward: mocks.api.retry,
|
||||
}));
|
||||
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
/** Creates a complete UDP row fixture with string revisions. */
|
||||
function createRow(
|
||||
overrides: Partial<SystemNetworkApi.PortForward> = {},
|
||||
): SystemNetworkApi.PortForward {
|
||||
return {
|
||||
currentObservedAt: null,
|
||||
currentPublicIpv4: null,
|
||||
currentPublicPort: null,
|
||||
currentValidUntil: null,
|
||||
desiredPresence: 'present',
|
||||
desiredRevision: '42',
|
||||
externalPort: 45_678,
|
||||
id: '90071992547409930',
|
||||
internalPort: 45_678,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: false,
|
||||
keeperStatus: 'disabled',
|
||||
lastObservedAt: null,
|
||||
lastObservedIpv4: null,
|
||||
lastObservedPort: null,
|
||||
name: 'Game UDP',
|
||||
protocol: 'udp',
|
||||
reportedRevision: '41',
|
||||
syncStatus: 'synced',
|
||||
targetIpv4: '192.168.31.224',
|
||||
updateTime: '2026-07-22T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('system network persisted list', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
mocks.tableOptions = undefined;
|
||||
mocks.tableApi.getRows.mockReturnValue([]);
|
||||
mocks.tableApi.reload.mockResolvedValue(undefined);
|
||||
mocks.api.getAgentStatus.mockResolvedValue({
|
||||
agentId: 'nas-main',
|
||||
appliedRevision: '41',
|
||||
desiredRevision: '42',
|
||||
online: false,
|
||||
publishedRevision: '42',
|
||||
targetIpv4: '192.168.31.224',
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders one Page root and the standard system list route', async () => {
|
||||
const wrapper = mount(NetworkList);
|
||||
await flushPromises();
|
||||
const system = routes.find((route) => route.name === 'System');
|
||||
const network = system?.children?.find(
|
||||
(route) => route.name === 'SystemNetwork',
|
||||
);
|
||||
|
||||
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
|
||||
expect(wrapper.find('[data-testid="network-table"]').exists()).toBe(true);
|
||||
expect(network).toMatchObject({ path: '/system/network' });
|
||||
expect(String(network?.component)).toContain('network/list');
|
||||
expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([
|
||||
'name',
|
||||
'protocol',
|
||||
'externalPort',
|
||||
'internalTarget',
|
||||
'syncStatus',
|
||||
'keeper',
|
||||
'publicEndpoint',
|
||||
'lastObserved',
|
||||
'summary',
|
||||
'updateTime',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes list labels and status values through the network locale namespace', () => {
|
||||
const wrapper = mount(NetworkList);
|
||||
|
||||
expect(mocks.tableOptions.tableTitle).toBe(
|
||||
'system.network.portForwardTitle',
|
||||
);
|
||||
expect(mocks.tableOptions.buttons[0].label).toBe(
|
||||
'system.network.createAction',
|
||||
);
|
||||
expect(
|
||||
mocks.tableOptions.columns.map((column: any) => column.title),
|
||||
).toEqual([
|
||||
'system.network.name',
|
||||
'system.network.protocol',
|
||||
'system.network.externalPort',
|
||||
'system.network.internalTarget',
|
||||
'system.network.syncStatus',
|
||||
'system.network.keeperState',
|
||||
'system.network.publicEndpoint',
|
||||
'system.network.lastObserved',
|
||||
'system.network.summary',
|
||||
'system.network.updateTime',
|
||||
]);
|
||||
expect(
|
||||
mocks.tableOptions.rowActions.map((action: any) => action.label),
|
||||
).toEqual([
|
||||
'system.network.editAction',
|
||||
'system.network.retryAction',
|
||||
'system.network.enableKeeperAction',
|
||||
'system.network.disableKeeperAction',
|
||||
'system.network.probeAction',
|
||||
'system.network.copyEndpointAction',
|
||||
'system.network.historyAction',
|
||||
'system.network.deleteAction',
|
||||
]);
|
||||
expect(wrapper.text()).toContain('system.network.syncPending');
|
||||
expect(wrapper.text()).toContain('system.network.desiredOn');
|
||||
expect(wrapper.text()).toContain('system.network.keeperActive');
|
||||
});
|
||||
|
||||
it('keeps unsupported Keeper actions visible-disabled with exact reasons', () => {
|
||||
mount(NetworkList);
|
||||
const actions = mocks.tableOptions.rowActions;
|
||||
const enable = actions.find((item: any) => item.key === 'keeper-enable');
|
||||
const probe = actions.find((item: any) => item.key === 'probe');
|
||||
const tcp = createRow({ protocol: 'tcp' });
|
||||
const splitPort = createRow({ internalPort: 32_000 });
|
||||
const disabledKeeper = createRow({ keeperDesiredEnabled: false });
|
||||
|
||||
expect(enable.rowVisible(tcp)).toBe(true);
|
||||
expect(enable.disabled(tcp)).toBe(true);
|
||||
expect(enable.disabledReason(tcp)).toBe(
|
||||
'system.network.tcpKeeperUnsupported',
|
||||
);
|
||||
expect(probe.disabledReason(splitPort)).toBe(
|
||||
'system.network.udpSamePortRequired',
|
||||
);
|
||||
expect(probe.disabledReason(disabledKeeper)).toBe(
|
||||
'system.network.enableKeeperFirst',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use Agent offline state to disable valid desired mutations', () => {
|
||||
mount(NetworkList);
|
||||
const actions = mocks.tableOptions.rowActions;
|
||||
const row = createRow({ keeperDesiredEnabled: false });
|
||||
const edit = actions.find((item: any) => item.key === 'edit');
|
||||
const enable = actions.find((item: any) => item.key === 'keeper-enable');
|
||||
|
||||
expect(edit.disabled(row)).toBe(false);
|
||||
expect(enable.disabled(row)).toBe(false);
|
||||
});
|
||||
|
||||
it('makes deleting rows immutable while preserving retry', () => {
|
||||
mount(NetworkList);
|
||||
const actions = mocks.tableOptions.rowActions;
|
||||
const row = createRow({
|
||||
desiredPresence: 'absent',
|
||||
syncStatus: 'deleting',
|
||||
});
|
||||
|
||||
expect(isDeleting(row)).toBe(true);
|
||||
expect(actions.find((item: any) => item.key === 'edit').disabled(row)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
actions.find((item: any) => item.key === 'delete').disabled(row),
|
||||
).toBe(true);
|
||||
expect(
|
||||
actions.find((item: any) => item.key === 'retry').disabled(row),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('copies only the exact API-approved current endpoint', async () => {
|
||||
mount(NetworkList);
|
||||
const action = mocks.tableOptions.rowActions.find(
|
||||
(item: any) => item.key === 'copy-endpoint',
|
||||
);
|
||||
const row = createRow({
|
||||
currentPublicIpv4: '123.45.67.89',
|
||||
currentPublicPort: 45_678,
|
||||
});
|
||||
|
||||
expect(getCurrentEndpoint(row)).toBe('123.45.67.89:45678');
|
||||
expect(action.disabled(row)).toBe(false);
|
||||
await action.onClick(row);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
'123.45.67.89:45678',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes polling and clears its timer when the page unmounts', async () => {
|
||||
let finishReload: (() => void) | undefined;
|
||||
mocks.tableApi.reload.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishReload = resolve;
|
||||
}),
|
||||
);
|
||||
const wrapper = mount(NetworkList);
|
||||
await flushPromises();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||
finishReload?.();
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
finishReload?.();
|
||||
await flushPromises();
|
||||
wrapper.unmount();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network action constraints', () => {
|
||||
it('keeps Snowflake IDs and revisions as strings in row fixtures', () => {
|
||||
const row = createRow();
|
||||
expect(typeof row.id).toBe('string');
|
||||
expect(typeof row.desiredRevision).toBe('string');
|
||||
expect(getKeeperDisabledReason(row, false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
667
apps/web-antdv-next/src/views/system/network/list.tsx
Normal file
667
apps/web-antdv-next/src/views/system/network/list.tsx
Normal file
@ -0,0 +1,667 @@
|
||||
import type { TableColumnType } from 'antdv-next';
|
||||
|
||||
import type { NetworkEndpointHistoryDrawerExposed } from './components/NetworkEndpointHistoryDrawer';
|
||||
import type { NetworkPortForwardModalExposed } from './components/NetworkPortForwardModal';
|
||||
|
||||
import type { SystemNetworkApi } from '#/api/system/network';
|
||||
import type {
|
||||
KtTableApi,
|
||||
KtTableButton,
|
||||
KtTableRowAction,
|
||||
} from '#/components/ktTable';
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { message, Space, Tag, Typography } from 'antdv-next';
|
||||
|
||||
import {
|
||||
deleteNetworkPortForward,
|
||||
disableNetworkPortForwardKeeper,
|
||||
enableNetworkPortForwardKeeper,
|
||||
getNetworkAgentStatus,
|
||||
getNetworkPortForwardList,
|
||||
probeNetworkPortForward,
|
||||
retryNetworkPortForward,
|
||||
} from '#/api/system/network';
|
||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
||||
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
||||
|
||||
const AKtTable = KtTable as any;
|
||||
const ATypographyText = Typography.Text as any;
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
];
|
||||
const syncStatusOptions = [
|
||||
{ label: $t('system.network.syncPending'), value: 'pending' },
|
||||
{ label: $t('system.network.syncSyncing'), value: 'syncing' },
|
||||
{ label: $t('system.network.syncSynced'), value: 'synced' },
|
||||
{ label: $t('system.network.syncConflict'), value: 'conflict' },
|
||||
{ label: $t('system.network.syncFailed'), value: 'failed' },
|
||||
{ label: $t('system.network.syncDeleting'), value: 'deleting' },
|
||||
];
|
||||
const syncStatusColors: Record<SystemNetworkApi.SyncStatus, string> = {
|
||||
conflict: 'warning',
|
||||
deleting: 'default',
|
||||
failed: 'error',
|
||||
pending: 'processing',
|
||||
synced: 'success',
|
||||
syncing: 'processing',
|
||||
};
|
||||
const keeperStatusColors: Record<SystemNetworkApi.KeeperStatus, string> = {
|
||||
active: 'success',
|
||||
disabled: 'default',
|
||||
failed: 'error',
|
||||
stale: 'warning',
|
||||
starting: 'processing',
|
||||
};
|
||||
const syncStatusLabels: Record<SystemNetworkApi.SyncStatus, string> = {
|
||||
conflict: $t('system.network.syncConflict'),
|
||||
deleting: $t('system.network.syncDeleting'),
|
||||
failed: $t('system.network.syncFailed'),
|
||||
pending: $t('system.network.syncPending'),
|
||||
synced: $t('system.network.syncSynced'),
|
||||
syncing: $t('system.network.syncSyncing'),
|
||||
};
|
||||
const keeperStatusLabels: Record<SystemNetworkApi.KeeperStatus, string> = {
|
||||
active: $t('system.network.keeperActive'),
|
||||
disabled: $t('system.network.keeperDisabled'),
|
||||
failed: $t('system.network.keeperFailed'),
|
||||
stale: $t('system.network.keeperStale'),
|
||||
starting: $t('system.network.keeperStarting'),
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SystemNetworkList',
|
||||
/**
|
||||
* Builds the generic persisted network-resource table and serialized poller.
|
||||
*/
|
||||
setup() {
|
||||
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
|
||||
const agentStatusUnknown = ref(true);
|
||||
const busyRowIds = ref<Set<string>>(new Set());
|
||||
const modalRef = ref<NetworkPortForwardModalExposed>();
|
||||
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
|
||||
let pollActive = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let refreshInFlight: Promise<void> | undefined;
|
||||
let refreshQueued = false;
|
||||
|
||||
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
|
||||
{
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
title: $t('system.network.name'),
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'protocol',
|
||||
key: 'protocol',
|
||||
title: $t('system.network.protocol'),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
dataIndex: 'externalPort',
|
||||
key: 'externalPort',
|
||||
title: $t('system.network.externalPort'),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
key: 'internalTarget',
|
||||
title: $t('system.network.internalTarget'),
|
||||
width: 190,
|
||||
},
|
||||
{
|
||||
dataIndex: 'syncStatus',
|
||||
key: 'syncStatus',
|
||||
title: $t('system.network.syncStatus'),
|
||||
width: 105,
|
||||
},
|
||||
{
|
||||
key: 'keeper',
|
||||
title: $t('system.network.keeperState'),
|
||||
width: 190,
|
||||
},
|
||||
{
|
||||
key: 'publicEndpoint',
|
||||
title: $t('system.network.publicEndpoint'),
|
||||
width: 185,
|
||||
},
|
||||
{
|
||||
key: 'lastObserved',
|
||||
title: $t('system.network.lastObserved'),
|
||||
width: 210,
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
title: $t('system.network.summary'),
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
dataIndex: 'updateTime',
|
||||
key: 'updateTime',
|
||||
title: $t('system.network.updateTime'),
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
const api: KtTableApi<SystemNetworkApi.PortForward> = {
|
||||
/** Loads the latest API fact-source page for KtTable. */
|
||||
list: async (params) => await getNetworkPortForwardList(params),
|
||||
};
|
||||
const buttons: Array<KtTableButton<SystemNetworkApi.PortForward>> = [
|
||||
{
|
||||
icon: <Plus class="kt-table__button-icon" />,
|
||||
key: 'create',
|
||||
label: $t('system.network.createAction'),
|
||||
onClick: openCreate,
|
||||
permissionCodes: ['System:Network:PortForward:Create'],
|
||||
type: 'primary',
|
||||
},
|
||||
];
|
||||
const rowActions: Array<KtTableRowAction<SystemNetworkApi.PortForward>> = [
|
||||
{
|
||||
disabled: (row) => isRowBusy(row) || isDeleting(row),
|
||||
disabledReason: (row) =>
|
||||
isRowBusy(row)
|
||||
? $t('system.network.operationInProgress')
|
||||
: getWriteDisabledReason(row),
|
||||
key: 'edit',
|
||||
label: $t('system.network.editAction'),
|
||||
onClick: openEdit,
|
||||
permissionCodes: ['System:Network:PortForward:Update'],
|
||||
},
|
||||
{
|
||||
disabled: (row) =>
|
||||
isRowBusy(row) ||
|
||||
!['conflict', 'deleting', 'failed'].includes(row.syncStatus),
|
||||
disabledReason: (row) =>
|
||||
isRowBusy(row)
|
||||
? $t('system.network.operationInProgress')
|
||||
: $t('system.network.retryNotRequired'),
|
||||
key: 'retry',
|
||||
label: $t('system.network.retryAction'),
|
||||
onClick: async (row) => {
|
||||
await runRowMutation(
|
||||
row,
|
||||
retryNetworkPortForward,
|
||||
$t('system.network.retrySubmitted'),
|
||||
);
|
||||
},
|
||||
permissionCodes: ['System:Network:PortForward:Retry'],
|
||||
},
|
||||
{
|
||||
disabled: (row) =>
|
||||
isRowBusy(row) || !!getKeeperDisabledReason(row, false),
|
||||
disabledReason: (row) =>
|
||||
isRowBusy(row)
|
||||
? $t('system.network.operationInProgress')
|
||||
: getKeeperDisabledReason(row, false),
|
||||
key: 'keeper-enable',
|
||||
label: $t('system.network.enableKeeperAction'),
|
||||
onClick: async (row) => {
|
||||
await runRowMutation(
|
||||
row,
|
||||
enableNetworkPortForwardKeeper,
|
||||
$t('system.network.keeperEnableSubmitted'),
|
||||
);
|
||||
},
|
||||
permissionCodes: ['System:Network:PortForward:Keeper'],
|
||||
rowVisible: (row) => !row.keeperDesiredEnabled,
|
||||
},
|
||||
{
|
||||
disabled: (row) => isRowBusy(row) || isDeleting(row),
|
||||
disabledReason: (row) =>
|
||||
isRowBusy(row)
|
||||
? $t('system.network.operationInProgress')
|
||||
: getWriteDisabledReason(row),
|
||||
key: 'keeper-disable',
|
||||
label: $t('system.network.disableKeeperAction'),
|
||||
onClick: async (row) => {
|
||||
await runRowMutation(
|
||||
row,
|
||||
disableNetworkPortForwardKeeper,
|
||||
$t('system.network.keeperDisableSubmitted'),
|
||||
);
|
||||
},
|
||||
permissionCodes: ['System:Network:PortForward:Keeper'],
|
||||
rowVisible: (row) => row.keeperDesiredEnabled,
|
||||
},
|
||||
{
|
||||
disabled: (row) =>
|
||||
isRowBusy(row) || !!getKeeperDisabledReason(row, true),
|
||||
disabledReason: (row) =>
|
||||
isRowBusy(row)
|
||||
? $t('system.network.operationInProgress')
|
||||
: getKeeperDisabledReason(row, true),
|
||||
key: 'probe',
|
||||
label: $t('system.network.probeAction'),
|
||||
onClick: async (row) => {
|
||||
await runRowMutation(
|
||||
row,
|
||||
probeNetworkPortForward,
|
||||
$t('system.network.probeSubmitted'),
|
||||
);
|
||||
},
|
||||
permissionCodes: ['System:Network:PortForward:Probe'],
|
||||
},
|
||||
{
|
||||
disabled: (row) => !getCurrentEndpoint(row),
|
||||
disabledReason: $t('system.network.noCurrentEndpoint'),
|
||||
key: 'copy-endpoint',
|
||||
label: $t('system.network.copyEndpointAction'),
|
||||
onClick: copyEndpoint,
|
||||
permissionCodes: ['System:Network:PortForward:List'],
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: $t('system.network.historyAction'),
|
||||
onClick: openHistory,
|
||||
permissionCodes: ['System:Network:PortForward:History'],
|
||||
},
|
||||
{
|
||||
confirm: (row) => $t('system.network.deleteConfirm', [row.name]),
|
||||
danger: true,
|
||||
disabled: (row) => isRowBusy(row) || isDeleting(row),
|
||||
disabledReason: (row) =>
|
||||
isRowBusy(row)
|
||||
? $t('system.network.operationInProgress')
|
||||
: getWriteDisabledReason(row),
|
||||
key: 'delete',
|
||||
label: $t('system.network.deleteAction'),
|
||||
onClick: async (row) => {
|
||||
await runRowMutation(
|
||||
row,
|
||||
deleteNetworkPortForward,
|
||||
$t('system.network.deleteSubmitted'),
|
||||
);
|
||||
},
|
||||
permissionCodes: ['System:Network:PortForward:Delete'],
|
||||
},
|
||||
];
|
||||
const [registerTable, tableApi] = useKtTable<SystemNetworkApi.PortForward>({
|
||||
api,
|
||||
buttons,
|
||||
columns,
|
||||
formOptions: {
|
||||
schema: [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: { allowClear: true },
|
||||
fieldName: 'name',
|
||||
label: $t('system.network.name'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: protocolOptions,
|
||||
},
|
||||
fieldName: 'protocol',
|
||||
label: $t('system.network.protocol'),
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: syncStatusOptions,
|
||||
},
|
||||
fieldName: 'syncStatus',
|
||||
label: $t('system.network.syncStatus'),
|
||||
},
|
||||
],
|
||||
},
|
||||
immediate: false,
|
||||
rowActions,
|
||||
rowActionVisibleCount: 2,
|
||||
rowKey: 'id',
|
||||
tableTitle: $t('system.network.portForwardTitle'),
|
||||
});
|
||||
|
||||
/** Opens the create modal using the server-reported fixed target address. */
|
||||
function openCreate() {
|
||||
const rowTarget = tableApi.getRows()[0]?.targetIpv4 || '';
|
||||
void modalRef.value?.openCreate(
|
||||
agentStatus.value?.targetIpv4 || rowTarget,
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens one mutable row in the shared CRUD modal. */
|
||||
function openEdit(row: SystemNetworkApi.PortForward) {
|
||||
if (!isRowBusy(row) && !isDeleting(row)) {
|
||||
void modalRef.value?.openEdit(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the append-only endpoint history drawer for one row. */
|
||||
function openHistory(row: SystemNetworkApi.PortForward) {
|
||||
historyDrawerRef.value?.open(row);
|
||||
}
|
||||
|
||||
/** Reloads after a modal save while preserving serialized request order. */
|
||||
function handleModalSaved() {
|
||||
void requestRefresh();
|
||||
}
|
||||
|
||||
/** Copies an API-approved current endpoint without logging it. */
|
||||
async function copyEndpoint(row: SystemNetworkApi.PortForward) {
|
||||
const endpoint = getCurrentEndpoint(row);
|
||||
if (!endpoint) return;
|
||||
await navigator.clipboard.writeText(endpoint);
|
||||
message.success($t('system.network.endpointCopied'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one row mutation with per-row duplicate suppression and fact-source reload.
|
||||
* @param row - Selected persisted mapping.
|
||||
* @param mutation - API operation accepting the stable record ID.
|
||||
* @param successMessage - Accurate asynchronous-success wording.
|
||||
*/
|
||||
async function runRowMutation(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
mutation: (id: string) => Promise<unknown>,
|
||||
successMessage: string,
|
||||
) {
|
||||
if (isRowBusy(row)) return;
|
||||
setRowBusy(row.id, true);
|
||||
try {
|
||||
await mutation(row.id);
|
||||
message.success(successMessage);
|
||||
await requestRefresh();
|
||||
} finally {
|
||||
setRowBusy(row.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Updates the reactive per-row in-flight set without mutating it in place. */
|
||||
function setRowBusy(id: string, busy: boolean) {
|
||||
const next = new Set(busyRowIds.value);
|
||||
if (busy) next.add(id);
|
||||
else next.delete(id);
|
||||
busyRowIds.value = next;
|
||||
}
|
||||
|
||||
/** Returns whether one row already owns an in-flight write request. */
|
||||
function isRowBusy(row: SystemNetworkApi.PortForward): boolean {
|
||||
return busyRowIds.value.has(row.id);
|
||||
}
|
||||
|
||||
/** Loads Agent connectivity without translating refresh failure into offline. */
|
||||
async function loadAgentStatus() {
|
||||
try {
|
||||
agentStatus.value = await getNetworkAgentStatus();
|
||||
agentStatusUnknown.value = false;
|
||||
} catch {
|
||||
agentStatusUnknown.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes table and Agent polling so stale responses cannot overtake writes.
|
||||
*/
|
||||
async function requestRefresh(): Promise<void> {
|
||||
clearPollTimer();
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true;
|
||||
await refreshInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInFlight = Promise.allSettled([
|
||||
tableApi.reload(),
|
||||
loadAgentStatus(),
|
||||
]).then(() => undefined);
|
||||
try {
|
||||
await refreshInFlight;
|
||||
} finally {
|
||||
refreshInFlight = undefined;
|
||||
}
|
||||
if (refreshQueued) {
|
||||
refreshQueued = false;
|
||||
await requestRefresh();
|
||||
return;
|
||||
}
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
/** Schedules the next non-overlapping refresh only while the page is active. */
|
||||
function schedulePoll() {
|
||||
clearPollTimer();
|
||||
if (!pollActive) return;
|
||||
pollTimer = setTimeout(() => {
|
||||
void requestRefresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Starts polling and immediately requests fresh table and Agent facts. */
|
||||
function startPolling() {
|
||||
if (pollActive) return;
|
||||
pollActive = true;
|
||||
void requestRefresh();
|
||||
}
|
||||
|
||||
/** Stops future polling without changing any locally displayed fact. */
|
||||
function stopPolling() {
|
||||
pollActive = false;
|
||||
clearPollTimer();
|
||||
}
|
||||
|
||||
/** Clears the current one-shot polling timer when present. */
|
||||
function clearPollTimer() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders independent Agent connectivity and revision convergence controls. */
|
||||
function renderAgentControls() {
|
||||
const status = agentStatus.value;
|
||||
return (
|
||||
<Space wrap>
|
||||
<Tag color={getAgentStatusColor(status, agentStatusUnknown.value)}>
|
||||
{$t('system.network.agentLabel')}:{' '}
|
||||
{getAgentStatusLabel(status, agentStatusUnknown.value)}
|
||||
</Tag>
|
||||
<ATypographyText type="secondary">
|
||||
{$t('system.network.revisionLabel')}{' '}
|
||||
{String(status?.appliedRevision ?? '-')}/
|
||||
{String(status?.desiredRevision ?? '-')}
|
||||
</ATypographyText>
|
||||
<ATypographyText type="secondary">
|
||||
{$t('system.network.targetLabel')} {status?.targetIpv4 || '-'}
|
||||
</ATypographyText>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders cells without collapsing independent synchronization dimensions. */
|
||||
function renderBodyCell({ column, record }: any) {
|
||||
const row = record as SystemNetworkApi.PortForward;
|
||||
if (column.key === 'protocol') {
|
||||
return (
|
||||
<Tag color={row.protocol === 'udp' ? 'blue' : 'purple'}>
|
||||
{row.protocol.toUpperCase()}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (column.key === 'internalTarget') {
|
||||
return `${row.targetIpv4}:${row.internalPort}`;
|
||||
}
|
||||
if (column.key === 'syncStatus') {
|
||||
return (
|
||||
<Tag color={syncStatusColors[row.syncStatus]}>
|
||||
{syncStatusLabels[row.syncStatus]}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (column.key === 'keeper') {
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Tag color={row.keeperDesiredEnabled ? 'blue' : 'default'}>
|
||||
{row.keeperDesiredEnabled
|
||||
? $t('system.network.desiredOn')
|
||||
: $t('system.network.desiredOff')}
|
||||
</Tag>
|
||||
<Tag color={keeperStatusColors[row.keeperStatus]}>
|
||||
{keeperStatusLabels[row.keeperStatus]}
|
||||
</Tag>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (column.key === 'publicEndpoint') {
|
||||
return getCurrentEndpoint(row) || '-';
|
||||
}
|
||||
if (column.key === 'lastObserved') {
|
||||
const lastEndpoint =
|
||||
row.lastObservedIpv4 && row.lastObservedPort
|
||||
? `${row.lastObservedIpv4}:${row.lastObservedPort}`
|
||||
: '-';
|
||||
return `${lastEndpoint} · ${row.lastObservedAt || '-'}`;
|
||||
}
|
||||
if (column.key === 'summary') {
|
||||
return getWaitingOrErrorSummary(row, agentStatus.value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onMounted(startPolling);
|
||||
onActivated(startPolling);
|
||||
onDeactivated(stopPolling);
|
||||
onBeforeUnmount(stopPolling);
|
||||
|
||||
return () => (
|
||||
<Page autoContentHeight>
|
||||
<AKtTable
|
||||
onRegister={registerTable}
|
||||
v-slots={{
|
||||
bodyCell: renderBodyCell,
|
||||
headerControls: renderAgentControls,
|
||||
}}
|
||||
/>
|
||||
<NetworkPortForwardModal onSaved={handleModalSaved} ref={modalRef} />
|
||||
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
|
||||
</Page>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns whether a mapping is immutable pending confirmed asynchronous deletion.
|
||||
* @param row - Persisted port-forward row.
|
||||
* @returns True for absent tombstones, deleting state, or confirmed deletion.
|
||||
*/
|
||||
export function isDeleting(row: SystemNetworkApi.PortForward): boolean {
|
||||
return (
|
||||
row.isDeleted ||
|
||||
row.desiredPresence === 'absent' ||
|
||||
row.syncStatus === 'deleting'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Keeper or probe disabled reason without consulting Agent online state.
|
||||
* @param row - Persisted port-forward row.
|
||||
* @param requireEnabled - Whether immediate probe additionally requires desired Keeper.
|
||||
* @returns Readable reason, or undefined when the action is valid.
|
||||
*/
|
||||
export function getKeeperDisabledReason(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
requireEnabled: boolean,
|
||||
): string | undefined {
|
||||
if (isDeleting(row)) return $t('system.network.deletingImmutable');
|
||||
if (row.protocol === 'tcp') {
|
||||
return $t('system.network.tcpKeeperUnsupported');
|
||||
}
|
||||
if (row.externalPort !== row.internalPort) {
|
||||
return $t('system.network.udpSamePortRequired');
|
||||
}
|
||||
if (requireEnabled && !row.keeperDesiredEnabled) {
|
||||
return $t('system.network.enableKeeperFirst');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the current endpoint already lease-filtered by the API.
|
||||
* @param row - Persisted port-forward row.
|
||||
* @returns Exact public IPv4 and port, or undefined when the API withdrew it.
|
||||
*/
|
||||
export function getCurrentEndpoint(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
): string | undefined {
|
||||
return row.currentPublicIpv4 && row.currentPublicPort
|
||||
? `${row.currentPublicIpv4}:${row.currentPublicPort}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the row summary without treating status-refresh failure as Agent offline.
|
||||
* @param row - Persisted mapping with redacted stable error fields.
|
||||
* @param status - Last successfully loaded Agent status, when available.
|
||||
* @returns Error, offline wait, pending wait, or an empty marker.
|
||||
*/
|
||||
export function getWaitingOrErrorSummary(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
status?: SystemNetworkApi.AgentStatus,
|
||||
): string {
|
||||
if (row.lastErrorMessage) return row.lastErrorMessage;
|
||||
if (status && !status.online && row.syncStatus !== 'synced') {
|
||||
return $t('system.network.waitingForAgent');
|
||||
}
|
||||
if (row.syncStatus === 'pending' || row.syncStatus === 'syncing') {
|
||||
return $t('system.network.waitingForSync');
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the independent Agent status color without treating refresh failure as offline.
|
||||
* @param status - Last successfully loaded Agent status.
|
||||
* @param unknown - Whether the latest status request failed.
|
||||
* @returns Ant Design semantic color.
|
||||
*/
|
||||
function getAgentStatusColor(
|
||||
status: SystemNetworkApi.AgentStatus | undefined,
|
||||
unknown: boolean,
|
||||
): string {
|
||||
if (unknown) return 'default';
|
||||
return status?.online ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the independent Agent status label shown in the table header.
|
||||
* @param status - Last successfully loaded Agent status.
|
||||
* @param unknown - Whether the latest status request failed.
|
||||
* @returns Localized unknown, online, or offline label.
|
||||
*/
|
||||
function getAgentStatusLabel(
|
||||
status: SystemNetworkApi.AgentStatus | undefined,
|
||||
unknown: boolean,
|
||||
): string {
|
||||
if (unknown) return $t('system.network.agentUnknown');
|
||||
return status?.online
|
||||
? $t('system.network.agentOnline')
|
||||
: $t('system.network.agentOffline');
|
||||
}
|
||||
|
||||
/** Returns the immutable-row explanation used by CRUD write actions. */
|
||||
function getWriteDisabledReason(
|
||||
row: SystemNetworkApi.PortForward,
|
||||
): string | undefined {
|
||||
return isDeleting(row) ? $t('system.network.deletingImmutable') : undefined;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user