diff --git a/README.md b/README.md index 6d4f2ae..3779697 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/apps/web-antdv-next/src/api/core/menu.spec.ts b/apps/web-antdv-next/src/api/core/menu.spec.ts index ce57825..9297ba6 100644 --- a/apps/web-antdv-next/src/api/core/menu.spec.ts +++ b/apps/web-antdv-next/src/api/core/menu.spec.ts @@ -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', + }, + ], + }, + ], + }, + ]); + }); }); diff --git a/apps/web-antdv-next/src/api/core/menu.ts b/apps/web-antdv-next/src/api/core/menu.ts index 8f1795f..67cc7e0 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -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', diff --git a/apps/web-antdv-next/src/api/system/index.ts b/apps/web-antdv-next/src/api/system/index.ts index 0f44e97..f37fc91 100644 --- a/apps/web-antdv-next/src/api/system/index.ts +++ b/apps/web-antdv-next/src/api/system/index.ts @@ -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'; diff --git a/apps/web-antdv-next/src/api/system/network.spec.ts b/apps/web-antdv-next/src/api/system/network.spec.ts new file mode 100644 index 0000000..7ee038e --- /dev/null +++ b/apps/web-antdv-next/src/api/system/network.spec.ts @@ -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 } }, + ); + }); +}); diff --git a/apps/web-antdv-next/src/api/system/network.ts b/apps/web-antdv-next/src/api/system/network.ts new file mode 100644 index 0000000..b49b244 --- /dev/null +++ b/apps/web-antdv-next/src/api/system/network.ts @@ -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 { + 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 { + 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 + >('/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( + '/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( + `/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( + `/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( + `/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( + `/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( + `/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( + `/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 + >(`/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( + '/system/network/agent/status', + ); +} diff --git a/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.spec.tsx b/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.spec.tsx new file mode 100644 index 0000000..12150da --- /dev/null +++ b/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.spec.tsx @@ -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(); + }); +}); diff --git a/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.tsx b/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.tsx index 587c2d1..a398d96 100644 --- a/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.tsx +++ b/apps/web-antdv-next/src/components/ktTable/hooks/useKtTableActions.tsx @@ -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, @@ -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 = ( confirmRowAction(action, row)} + onClick={disabled ? undefined : () => confirmRowAction(action, row)} type={action.type || 'link'} > {renderIcon(action.icon)} {action.label} ); + + if (!disabledReason) { + return {button}; + } + + return ( + + {button} + + ); }; + /** + * 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, diff --git a/apps/web-antdv-next/src/components/ktTable/styles/actions.scss b/apps/web-antdv-next/src/components/ktTable/styles/actions.scss index 16fa462..de6e99f 100644 --- a/apps/web-antdv-next/src/components/ktTable/styles/actions.scss +++ b/apps/web-antdv-next/src/components/ktTable/styles/actions.scss @@ -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 { diff --git a/apps/web-antdv-next/src/components/ktTable/types.ts b/apps/web-antdv-next/src/components/ktTable/types.ts index dcc68db..7947c50 100644 --- a/apps/web-antdv-next/src/components/ktTable/types.ts +++ b/apps/web-antdv-next/src/components/ktTable/types.ts @@ -100,6 +100,12 @@ export interface KtTableRowAction< disabled?: | ((row: Row, context: KtTableContext) => boolean) | boolean; + disabledReason?: + | (( + row: Row, + context: KtTableContext, + ) => string | undefined) + | string; onClick?: ( row: Row, context: KtTableContext, diff --git a/apps/web-antdv-next/src/locales/langs/en-US/system.json b/apps/web-antdv-next/src/locales/langs/en-US/system.json index 6dcbfa3..77fc64a 100644 --- a/apps/web-antdv-next/src/locales/langs/en-US/system.json +++ b/apps/web-antdv-next/src/locales/langs/en-US/system.json @@ -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", diff --git a/apps/web-antdv-next/src/locales/langs/zh-CN/system.json b/apps/web-antdv-next/src/locales/langs/zh-CN/system.json index d618ddd..e22ac63 100644 --- a/apps/web-antdv-next/src/locales/langs/zh-CN/system.json +++ b/apps/web-antdv-next/src/locales/langs/zh-CN/system.json @@ -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": "取消置顶成功", diff --git a/apps/web-antdv-next/src/router/routes/modules/system.ts b/apps/web-antdv-next/src/router/routes/modules/system.ts index 742e7f8..09b2b03 100644 --- a/apps/web-antdv-next/src/router/routes/modules/system.ts +++ b/apps/web-antdv-next/src/router/routes/modules/system.ts @@ -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', diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx new file mode 100644 index 0000000..cd98cb2 --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.spec.tsx @@ -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, + }); + }); +}); diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.tsx new file mode 100644 index 0000000..78ab97f --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkEndpointHistoryDrawer.tsx @@ -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 = { + 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(); + const columns: Array< + TableColumnType + > = [ + { + 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 = { + /** 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({ + 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 () => ( + + { + const item = record as SystemNetworkApi.EndpointHistoryItem; + if (column.key === 'eventType') { + return ( + + {item.eventType} + + ); + } + if (column.key === 'publicEndpoint') { + return item.publicIpv4 && item.publicPort + ? `${item.publicIpv4}:${item.publicPort}` + : '-'; + } + return undefined; + }, + }} + /> + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx new file mode 100644 index 0000000..40d71e2 --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx @@ -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: '' }), + ); + }); +}); diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx new file mode 100644 index 0000000..b0952db --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx @@ -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; + openEdit: (row: SystemNetworkApi.PortForward) => Promise; +} + +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(); + 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, + ) { + 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(); + 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 () => ( + + + + + ); + }, +}); + +/** + * 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('')), + }, + ]; +} diff --git a/apps/web-antdv-next/src/views/system/network/list.spec.tsx b/apps/web-antdv-next/src/views/system/network/list.spec.tsx new file mode 100644 index 0000000..95f15a1 --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/list.spec.tsx @@ -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 { + 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((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(); + }); +}); diff --git a/apps/web-antdv-next/src/views/system/network/list.tsx b/apps/web-antdv-next/src/views/system/network/list.tsx new file mode 100644 index 0000000..b1805ab --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/list.tsx @@ -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 = { + conflict: 'warning', + deleting: 'default', + failed: 'error', + pending: 'processing', + synced: 'success', + syncing: 'processing', +}; +const keeperStatusColors: Record = { + active: 'success', + disabled: 'default', + failed: 'error', + stale: 'warning', + starting: 'processing', +}; +const syncStatusLabels: Record = { + 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 = { + 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(); + const agentStatusUnknown = ref(true); + const busyRowIds = ref>(new Set()); + const modalRef = ref(); + const historyDrawerRef = ref(); + let pollActive = false; + let pollTimer: ReturnType | undefined; + let refreshInFlight: Promise | undefined; + let refreshQueued = false; + + const columns: Array> = [ + { + 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 = { + /** Loads the latest API fact-source page for KtTable. */ + list: async (params) => await getNetworkPortForwardList(params), + }; + const buttons: Array> = [ + { + icon: , + key: 'create', + label: $t('system.network.createAction'), + onClick: openCreate, + permissionCodes: ['System:Network:PortForward:Create'], + type: 'primary', + }, + ]; + const rowActions: Array> = [ + { + 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({ + 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, + 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 { + 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 ( + + + {$t('system.network.agentLabel')}:{' '} + {getAgentStatusLabel(status, agentStatusUnknown.value)} + + + {$t('system.network.revisionLabel')}{' '} + {String(status?.appliedRevision ?? '-')}/ + {String(status?.desiredRevision ?? '-')} + + + {$t('system.network.targetLabel')} {status?.targetIpv4 || '-'} + + + ); + } + + /** Renders cells without collapsing independent synchronization dimensions. */ + function renderBodyCell({ column, record }: any) { + const row = record as SystemNetworkApi.PortForward; + if (column.key === 'protocol') { + return ( + + {row.protocol.toUpperCase()} + + ); + } + if (column.key === 'internalTarget') { + return `${row.targetIpv4}:${row.internalPort}`; + } + if (column.key === 'syncStatus') { + return ( + + {syncStatusLabels[row.syncStatus]} + + ); + } + if (column.key === 'keeper') { + return ( + + + {row.keeperDesiredEnabled + ? $t('system.network.desiredOn') + : $t('system.network.desiredOff')} + + + {keeperStatusLabels[row.keeperStatus]} + + + ); + } + 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 () => ( + + + + + + ); + }, +}); + +/** + * 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; +}