From f94a98c09ce434077adb0f43629d2e0c9e9cf66d Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 23 Jul 2026 13:00:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=8C=E6=A0=88?= =?UTF-8?q?=E8=87=AA=E5=8A=A8DDNS=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- apps/web-antdv-next/src/api/core/menu.spec.ts | 50 +++ apps/web-antdv-next/src/api/core/menu.ts | 5 + .../src/api/system/network.spec.ts | 83 ++++ apps/web-antdv-next/src/api/system/network.ts | 166 ++++++- .../src/locales/langs/en-US/system.json | 43 ++ .../src/locales/langs/zh-CN/system.json | 43 ++ .../NetworkDdnsRecordModal.spec.tsx | 370 ++++++++++++++++ .../components/NetworkDdnsRecordModal.tsx | 396 +++++++++++++++++ .../components/NetworkDdnsTable.spec.tsx | 276 ++++++++++++ .../network/components/NetworkDdnsTable.tsx | 415 ++++++++++++++++++ .../composables/useNetworkManagementStream.ts | 2 +- .../src/views/system/network/list.spec.tsx | 136 ++++++ .../src/views/system/network/list.tsx | 158 +++++-- 14 files changed, 2114 insertions(+), 31 deletions(-) create mode 100644 apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx create mode 100644 apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.tsx create mode 100644 apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx create mode 100644 apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.tsx diff --git a/README.md b/README.md index 28e0536..6239538 100644 --- a/README.md +++ b/README.md @@ -50,7 +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 刷新及端点历史。首屏读取一次 HTTP 快照,后续只在 API SSE 状态事件或主动操作后刷新;心跳不刷新页面,不使用定时轮询。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。 +- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态,并通过独立页签管理腾讯云云解析 DNS 的 A/AAAA 自动更新绑定。页面展示 Agent、路由同步、UDP Keeper、公网端点租约和 DDNS 同步状态,支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史;A 只使用合格 UDP Keeper 的公网 IPv4,AAAA 只使用 Agent 全局 IPv6,DNS 值不包含端口。首屏读取一次 HTTP 快照,后续只按 API SSE 的资源来源刷新当前活动页签;心跳和其他页签事件不刷新,不使用定时轮询。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 9297ba6..55a363e 100644 --- a/apps/web-antdv-next/src/api/core/menu.spec.ts +++ b/apps/web-antdv-next/src/api/core/menu.spec.ts @@ -276,6 +276,31 @@ describe('core menu api', () => { authCode: 'System:Network:PortForward:History', type: 'button', }, + { + name: 'SystemNetworkDdnsList', + authCode: 'System:Network:Ddns:List', + type: 'button', + }, + { + name: 'SystemNetworkDdnsCreate', + authCode: 'System:Network:Ddns:Create', + type: 'button', + }, + { + name: 'SystemNetworkDdnsUpdate', + authCode: 'System:Network:Ddns:Update', + type: 'button', + }, + { + name: 'SystemNetworkDdnsDelete', + authCode: 'System:Network:Ddns:Delete', + type: 'button', + }, + { + name: 'SystemNetworkDdnsRetry', + authCode: 'System:Network:Ddns:Retry', + type: 'button', + }, ], }, ], @@ -335,6 +360,31 @@ describe('core menu api', () => { authCode: 'System:Network:PortForward:History', type: 'button', }, + { + name: 'SystemNetworkDdnsList', + authCode: 'System:Network:Ddns:List', + type: 'button', + }, + { + name: 'SystemNetworkDdnsCreate', + authCode: 'System:Network:Ddns:Create', + type: 'button', + }, + { + name: 'SystemNetworkDdnsUpdate', + authCode: 'System:Network:Ddns:Update', + type: 'button', + }, + { + name: 'SystemNetworkDdnsDelete', + authCode: 'System:Network:Ddns:Delete', + type: 'button', + }, + { + name: 'SystemNetworkDdnsRetry', + authCode: 'System:Network:Ddns:Retry', + 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 67cc7e0..1d3d278 100644 --- a/apps/web-antdv-next/src/api/core/menu.ts +++ b/apps/web-antdv-next/src/api/core/menu.ts @@ -83,6 +83,11 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([ 'SystemMenuDelete', 'SystemMenuEdit', 'SystemNetwork', + 'SystemNetworkDdnsCreate', + 'SystemNetworkDdnsDelete', + 'SystemNetworkDdnsList', + 'SystemNetworkDdnsRetry', + 'SystemNetworkDdnsUpdate', 'SystemNetworkPortForwardCreate', 'SystemNetworkPortForwardDelete', 'SystemNetworkPortForwardHistory', diff --git a/apps/web-antdv-next/src/api/system/network.spec.ts b/apps/web-antdv-next/src/api/system/network.spec.ts index 7330bf0..a471219 100644 --- a/apps/web-antdv-next/src/api/system/network.spec.ts +++ b/apps/web-antdv-next/src/api/system/network.spec.ts @@ -3,16 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { requestClient } from '#/api/request'; import { + createNetworkDdnsRecord, createNetworkPortForward, + deleteNetworkDdnsRecord, deleteNetworkPortForward, disableNetworkPortForwardKeeper, enableNetworkPortForwardKeeper, getNetworkAgentStatus, + getNetworkDdnsList, + getNetworkDdnsProviderStatus, + getNetworkDdnsSourceOptions, getNetworkManagementEventsUrl, getNetworkPortForwardEndpointHistory, getNetworkPortForwardList, probeNetworkPortForward, + retryNetworkDdnsRecord, retryNetworkPortForward, + updateNetworkDdnsRecord, updateNetworkPortForward, } from './network'; @@ -34,6 +41,17 @@ const input = { remark: 'managed mapping', }; +const ddnsInput = { + domain: 'kwitsukasa.top', + enabled: true, + name: 'NAS IPv4', + portForwardId: '90071992547409930', + recordType: 'A' as const, + remark: 'managed DNS', + sourceType: 'port_forward_ipv4' as const, + subDomain: 'nas', +}; + describe('system network api', () => { beforeEach(() => { vi.clearAllMocks(); @@ -105,6 +123,71 @@ describe('system network api', () => { ); }); + it('uses the dual-stack DDNS CRUD endpoints without credential fields', async () => { + await getNetworkDdnsList({ + pageNo: 1, + pageSize: 20, + recordType: 'AAAA', + }); + await getNetworkDdnsSourceOptions('A'); + await getNetworkDdnsProviderStatus(); + await createNetworkDdnsRecord(ddnsInput); + await updateNetworkDdnsRecord('ddns-1', ddnsInput); + await retryNetworkDdnsRecord('ddns-1'); + await deleteNetworkDdnsRecord('ddns-1'); + + expect(requestClient.get).toHaveBeenNthCalledWith( + 1, + '/system/network/ddns/list', + { params: { pageNo: 1, pageSize: 20, recordType: 'AAAA' } }, + ); + expect(requestClient.get).toHaveBeenNthCalledWith( + 2, + '/system/network/ddns/source-options', + { params: { recordType: 'A' } }, + ); + expect(requestClient.get).toHaveBeenNthCalledWith( + 3, + '/system/network/ddns/provider-status', + ); + expect(requestClient.post).toHaveBeenNthCalledWith( + 1, + '/system/network/ddns', + ddnsInput, + ); + expect(requestClient.put).toHaveBeenCalledWith( + '/system/network/ddns/ddns-1', + ddnsInput, + ); + expect(requestClient.post).toHaveBeenNthCalledWith( + 2, + '/system/network/ddns/ddns-1/retry', + ); + expect(requestClient.delete).toHaveBeenCalledWith( + '/system/network/ddns/ddns-1', + ); + expect(JSON.stringify(ddnsInput)).not.toMatch( + /secret|credential|password|token/i, + ); + }); + + it('keeps AAAA source selection server-controlled and free of a mapping ID', async () => { + const ipv6Input = { + ...ddnsInput, + portForwardId: undefined, + recordType: 'AAAA' as const, + sourceType: 'agent_ipv6' as const, + subDomain: 'nas6', + }; + + await createNetworkDdnsRecord(ipv6Input); + + expect(requestClient.post).toHaveBeenCalledWith( + '/system/network/ddns', + ipv6Input, + ); + }); + it('builds the credentialed SSE URL with an optional replay cursor', () => { expect(getNetworkManagementEventsUrl()).toBe( '/api/system/network/events/stream', diff --git a/apps/web-antdv-next/src/api/system/network.ts b/apps/web-antdv-next/src/api/system/network.ts index 004cb9d..5f4b567 100644 --- a/apps/web-antdv-next/src/api/system/network.ts +++ b/apps/web-antdv-next/src/api/system/network.ts @@ -3,6 +3,15 @@ import type { Recordable } from '@vben/types'; import { requestClient } from '#/api/request'; export namespace SystemNetworkApi { + export type DdnsRecordType = 'A' | 'AAAA'; + export type DdnsSourceType = 'agent_ipv6' | 'port_forward_ipv4'; + export type DdnsSyncStatus = + | 'disabled' + | 'failed' + | 'pending' + | 'synced' + | 'syncing' + | 'waiting_source'; export type DesiredPresence = 'absent' | 'present'; export type EndpointEventType = | 'changed' @@ -16,7 +25,7 @@ export namespace SystemNetworkApi { | 'stale' | 'starting'; export type Protocol = 'tcp' | 'udp'; - export type StateChangeSource = 'events' | 'reported' | 'status'; + export type StateChangeSource = 'ddns' | 'events' | 'reported' | 'status'; export type Revision = string; export type SyncStatus = | 'conflict' @@ -30,6 +39,8 @@ export namespace SystemNetworkApi { agentId: string; appliedRevision: Revision; desiredRevision: Revision; + currentIpv6ObservedAt?: null | string; + currentPublicIpv6?: null | string; lastErrorCode?: null | string; lastErrorMessage?: null | string; lastHeartbeatAt?: null | string; @@ -53,6 +64,72 @@ export namespace SystemNetworkApi { withdrawalReason?: null | string; } + export interface DdnsProviderStatus { + configured: boolean; + enabled: boolean; + provider: 'dnspod'; + } + + export interface DdnsSourceOption { + currentAddress?: null | string; + disabledReasonCode?: null | string; + eligible: boolean; + externalPort?: number; + id: string; + name: string; + observedAt?: null | string; + protocol?: Protocol; + sourceType: DdnsSourceType; + validUntil?: null | string; + } + + export interface DdnsSourceOptionsResult { + items: DdnsSourceOption[]; + } + + export interface DdnsRecord { + appliedAddress?: null | string; + domain: string; + enabled: boolean; + fqdn: string; + id: string; + lastErrorCode?: null | string; + lastErrorMessage?: null | string; + lastSyncedAt?: null | string; + name: string; + nextRetryAt?: null | string; + portForwardId?: null | string; + recordType: DdnsRecordType; + remark?: null | string; + retryCount: number; + source: DdnsSourceOption; + sourceAddress?: null | string; + sourceType: DdnsSourceType; + subDomain: string; + syncStatus: DdnsSyncStatus; + updateTime?: string; + } + + export interface DdnsRecordInput { + domain: string; + enabled: boolean; + name: string; + portForwardId?: string; + recordType: DdnsRecordType; + remark?: string; + sourceType: DdnsSourceType; + subDomain: string; + } + + export interface DdnsRecordQuery extends Recordable { + enabled?: boolean; + name?: string; + pageNo?: number; + pageSize?: number; + recordType?: DdnsRecordType; + syncStatus?: DdnsSyncStatus; + } + export interface PageResult { items: T[]; list?: T[]; @@ -116,6 +193,93 @@ export namespace SystemNetworkApi { } } +/** + * Loads the persisted automatic-DDNS record page for either address family. + * @param params - Pagination and independent record/status filters. + * @returns DDNS rows with API-provided FQDN and source state. + */ +export function getNetworkDdnsList(params: SystemNetworkApi.DdnsRecordQuery) { + return requestClient.get< + SystemNetworkApi.PageResult + >('/system/network/ddns/list', { params }); +} + +/** + * Loads eligible and disabled source choices for one DNS address family. + * @param recordType - A selects port-forward IPv4 sources; AAAA selects Agent IPv6. + * @returns Server-controlled source options and their current address state. + */ +export function getNetworkDdnsSourceOptions( + recordType: SystemNetworkApi.DdnsRecordType, +) { + return requestClient.get( + '/system/network/ddns/source-options', + { params: { recordType } }, + ); +} + +/** + * Loads the redacted DNSPod runtime readiness state. + * @returns Provider name plus enabled/configured flags, never credentials. + */ +export function getNetworkDdnsProviderStatus() { + return requestClient.get( + '/system/network/ddns/provider-status', + ); +} + +/** + * Creates one generic A or AAAA automatic-DDNS binding. + * @param data - User-editable record and server-recognized source selector. + * @returns Persisted DDNS record with asynchronous synchronization state. + */ +export function createNetworkDdnsRecord( + data: SystemNetworkApi.DdnsRecordInput, +) { + return requestClient.post( + '/system/network/ddns', + data, + ); +} + +/** + * Updates one persisted automatic-DDNS binding. + * @param id - Stable string record ID. + * @param data - Complete editable DDNS input. + * @returns Updated record with pending synchronization state. + */ +export function updateNetworkDdnsRecord( + id: string, + data: SystemNetworkApi.DdnsRecordInput, +) { + return requestClient.put( + `/system/network/ddns/${id}`, + data, + ); +} + +/** + * Deletes only the local automatic-update binding, leaving DNSPod unchanged. + * @param id - Stable string record ID. + * @returns Deleted local record state. + */ +export function deleteNetworkDdnsRecord(id: string) { + return requestClient.delete( + `/system/network/ddns/${id}`, + ); +} + +/** + * Requests one immediate provider reconciliation for an eligible source. + * @param id - Stable string record ID. + * @returns Latest DDNS synchronization state. + */ +export function retryNetworkDdnsRecord(id: string) { + return requestClient.post( + `/system/network/ddns/${id}/retry`, + ); +} + /** * Loads the persisted port-forward resource page from the API fact source. * @param params - Pagination and independent protocol/synchronization filters. 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 77fc64a..e3cb895 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 @@ -120,6 +120,48 @@ "copyEndpointAction": "Copy Endpoint", "createAction": "Create Port Forward", "createTitle": "Create Port Forward", + "ddnsAgentIpv6Source": "Agent Public IPv6", + "ddnsAppliedAddress": "Applied Address", + "ddnsCreateAction": "Create Automatic DDNS", + "ddnsCreateTitle": "Create Automatic DDNS", + "ddnsDeleteConfirm": "Delete the local automatic-update binding? The existing Tencent Cloud DNS A/AAAA record and value will remain unchanged.", + "ddnsDeleteSubmitted": "Local automatic update removed; Tencent Cloud DNS record unchanged", + "ddnsDomain": "Domain", + "ddnsDomainInvalid": "Enter a valid domain without protocol, path, query, spaces, or port", + "ddnsEditTitle": "Edit Automatic DDNS", + "ddnsEnabled": "Enable Automatic Updates", + "ddnsLastSync": "Last Sync / Error", + "ddnsNextRetry": "Next Retry", + "ddnsProviderName": "Tencent Cloud DNS", + "ddnsProviderReady": "Ready", + "ddnsProviderUnavailable": "Disabled or not configured", + "ddnsProviderUnknown": "Status Unknown", + "ddnsRecord": "Name / FQDN", + "ddnsRecordType": "Record Type", + "ddnsRetryAction": "Sync Now", + "ddnsRetryDisabled": "Automatic updates are disabled", + "ddnsRetrySubmitted": "DDNS synchronization requested", + "ddnsSaved": "Automatic DDNS configuration saved", + "ddnsSelectSource": "Select a public-address source", + "ddnsSource": "Address Source", + "ddnsSourceAddress": "Source Address", + "ddnsSourceAddressMissing": "The source has no current public address", + "ddnsSourceLoading": "Loading Agent IPv6 state", + "ddnsSourceRequired": "Select an eligible IPv4 port-forward source", + "ddnsSourceUnavailable": "Source unavailable", + "ddnsStatusDisabled": "Disabled", + "ddnsStatusFailed": "Failed", + "ddnsStatusPending": "Pending", + "ddnsStatusSynced": "Synced", + "ddnsStatusSyncing": "Syncing", + "ddnsStatusWaitingSource": "Waiting for Source", + "ddnsSubDomain": "Host Record", + "ddnsSubDomainInvalid": "Enter a valid host record; use @ for the root", + "ddnsSyncInProgress": "Synchronization is in progress", + "ddnsSyncStatus": "DDNS Status", + "ddnsTab": "Automatic DDNS", + "ddnsTitle": "Automatic DDNS", + "ddnsWaitingSourceAddress": "Waiting for a valid public address", "deleteAction": "Delete", "deleteConfirm": "Submit deletion for \"{0}\"?", "deleteSubmitted": "Deletion submitted; waiting for Agent confirmation", @@ -156,6 +198,7 @@ "noCurrentEndpoint": "No valid public endpoint is currently available", "operationInProgress": "Another action is already in progress for this row", "portForwardTitle": "Port Forwards", + "portForwardTab": "Port Forwards", "portRange": "The port must be an integer from 1 to 65535", "probeSubmitted": "Immediate STUN probe request submitted", "probeAction": "Refresh Now", 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 e22ac63..38946a6 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 @@ -122,6 +122,48 @@ "copyEndpointAction": "复制端点", "createAction": "新增端口转发", "createTitle": "新增端口转发", + "ddnsAgentIpv6Source": "Agent 公网 IPv6", + "ddnsAppliedAddress": "已应用地址", + "ddnsCreateAction": "新增自动 DDNS", + "ddnsCreateTitle": "新增自动 DDNS", + "ddnsDeleteConfirm": "确认删除本地自动更新配置吗?腾讯云云解析 DNS 中现有的 A/AAAA 记录和值将保持不变。", + "ddnsDeleteSubmitted": "本地自动更新配置已删除,腾讯云云解析 DNS 记录保持不变", + "ddnsDomain": "主域名", + "ddnsDomainInvalid": "请输入不含协议、路径、查询、空格或端口的有效主域名", + "ddnsEditTitle": "编辑自动 DDNS", + "ddnsEnabled": "启用自动更新", + "ddnsLastSync": "最近同步 / 错误", + "ddnsNextRetry": "下次重试", + "ddnsProviderName": "腾讯云云解析 DNS", + "ddnsProviderReady": "已就绪", + "ddnsProviderUnavailable": "未启用或未配置", + "ddnsProviderUnknown": "状态未知", + "ddnsRecord": "名称 / FQDN", + "ddnsRecordType": "记录类型", + "ddnsRetryAction": "立即同步", + "ddnsRetryDisabled": "自动更新已停用", + "ddnsRetrySubmitted": "已提交 DDNS 同步请求", + "ddnsSaved": "自动 DDNS 配置已保存", + "ddnsSelectSource": "选择公网地址来源", + "ddnsSource": "地址来源", + "ddnsSourceAddress": "来源地址", + "ddnsSourceAddressMissing": "来源当前没有可用公网地址", + "ddnsSourceLoading": "正在读取 Agent IPv6 状态", + "ddnsSourceRequired": "请选择一个可用的 IPv4 端口转发来源", + "ddnsSourceUnavailable": "来源不可用", + "ddnsStatusDisabled": "已停用", + "ddnsStatusFailed": "失败", + "ddnsStatusPending": "等待同步", + "ddnsStatusSynced": "已同步", + "ddnsStatusSyncing": "同步中", + "ddnsStatusWaitingSource": "等待来源", + "ddnsSubDomain": "主机记录", + "ddnsSubDomainInvalid": "请输入有效主机记录,根记录使用 @", + "ddnsSyncInProgress": "同步正在进行中", + "ddnsSyncStatus": "DDNS 状态", + "ddnsTab": "自动 DDNS", + "ddnsTitle": "自动 DDNS", + "ddnsWaitingSourceAddress": "等待有效公网地址", "deleteAction": "删除", "deleteConfirm": "确认提交「{0}」的删除请求吗?", "deleteSubmitted": "删除请求已提交,等待 Agent 确认", @@ -158,6 +200,7 @@ "noCurrentEndpoint": "当前没有可复制的有效公网端点", "operationInProgress": "该记录已有操作正在提交", "portForwardTitle": "端口转发", + "portForwardTab": "端口转发", "portRange": "端口必须是 1 到 65535 的整数", "probeSubmitted": "已提交立即 STUN 刷新请求", "probeAction": "立即刷新", diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx new file mode 100644 index 0000000..578f232 --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.spec.tsx @@ -0,0 +1,370 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable vue/one-component-per-file, vue/require-default-prop */ + +import type { SystemNetworkApi } from '#/api/system/network'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import NetworkDdnsRecordModal, { + isValidDdnsDomain, + isValidDdnsSubDomain, +} from './NetworkDdnsRecordModal'; + +const mocks = vi.hoisted(() => { + const modalApi = { + close: vi.fn(async () => {}), + getData: vi.fn(), + lock: vi.fn(), + open: vi.fn(), + setData: 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, + getSourceOptions: vi.fn(), + 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', + 'refine', + '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), + 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', () => ({ + createNetworkDdnsRecord: mocks.create, + getNetworkDdnsSourceOptions: mocks.getSourceOptions, + updateNetworkDdnsRecord: mocks.update, +})); + +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); + +/** Builds one server-returned DDNS row with a Snowflake string ID. */ +function createDdnsRow( + overrides: Partial = {}, +): SystemNetworkApi.DdnsRecord { + return { + appliedAddress: '123.45.67.89', + domain: 'kwitsukasa.top', + enabled: true, + fqdn: 'nas.kwitsukasa.top', + id: '90071992547409930', + name: 'NAS IPv4', + portForwardId: '90071992547409931', + recordType: 'A', + retryCount: 0, + source: { + currentAddress: '123.45.67.89', + eligible: true, + externalPort: 45_678, + id: '90071992547409931', + name: 'NAS UDP', + protocol: 'udp', + sourceType: 'port_forward_ipv4', + }, + sourceAddress: '123.45.67.89', + sourceType: 'port_forward_ipv4', + subDomain: 'nas', + syncStatus: 'synced', + ...overrides, + }; +} + +describe('network DDNS record modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.modalApi.getData.mockImplementation( + () => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {}, + ); + mocks.modalApi.setData.mockImplementation(() => mocks.modalApi); + mocks.modalApi.open.mockImplementation(() => { + mocks.modalOptions.onOpenChange?.(true); + return mocks.modalApi; + }); + mocks.create.mockResolvedValue({}); + mocks.update.mockResolvedValue({}); + mocks.getSourceOptions.mockImplementation(async (recordType: string) => ({ + items: + recordType === 'AAAA' + ? [ + { + currentAddress: '2409:8a31::1', + eligible: true, + id: 'agent-ipv6', + name: 'Agent IPv6', + sourceType: 'agent_ipv6', + }, + ] + : [ + { + currentAddress: '123.45.67.89', + disabledReasonCode: null, + eligible: true, + externalPort: 45_678, + id: '90071992547409931', + name: 'NAS UDP', + protocol: 'udp', + sourceType: 'port_forward_ipv4', + }, + { + currentAddress: null, + disabledReasonCode: 'KEEPER_DISABLED', + eligible: false, + externalPort: 8213, + id: '90071992547409932', + name: 'Disabled UDP', + protocol: 'udp', + sourceType: 'port_forward_ipv4', + }, + ], + })); + mocks.formApi.getValues.mockResolvedValue({ + domain: ' kwitsukasa.top ', + enabled: true, + name: ' NAS IPv4 ', + portForwardId: '90071992547409931', + recordType: 'A', + remark: ' managed ', + subDomain: ' nas ', + }); + }); + + it('contains only approved dual-stack fields and no provider credential fields', () => { + mount(NetworkDdnsRecordModal); + + expect( + mocks.formOptions.schema.map((field: any) => field.fieldName), + ).toEqual([ + 'name', + 'recordType', + 'domain', + 'subDomain', + 'portForwardId', + 'enabled', + 'remark', + ]); + expect( + mocks.formOptions.schema.map((field: any) => field.fieldName).join(' '), + ).not.toMatch( + /secret|credential|password|token|ttl|recordLine|recordValue/i, + ); + }); + + it.each([ + [ + 'create', + (wrapper: ReturnType) => (wrapper.vm as any).openCreate(), + ], + [ + 'edit', + (wrapper: ReturnType) => + (wrapper.vm as any).openEdit(createDdnsRow()), + ], + ])( + 'opens before restoring the %s form and loading sources', + async (_, open) => { + mocks.formApi.resetForm.mockImplementation(async () => { + expect(mocks.modalApi.open).toHaveBeenCalledOnce(); + }); + const wrapper = mount(NetworkDdnsRecordModal); + + await open(wrapper); + await flushPromises(); + + expect(mocks.modalApi.open).toHaveBeenCalledOnce(); + expect(mocks.formApi.resetForm).toHaveBeenCalledOnce(); + expect(mocks.getSourceOptions).toHaveBeenCalledWith('A'); + }, + ); + + it('keeps source IDs as strings and disables ineligible IPv4 choices with a reason', async () => { + const wrapper = mount(NetworkDdnsRecordModal); + await (wrapper.vm as any).openCreate(); + await flushPromises(); + const sourceField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'portForwardId', + ); + const options = sourceField.componentProps().options; + + expect(options[0]).toMatchObject({ + disabled: false, + value: '90071992547409931', + }); + expect(options[1]).toMatchObject({ + disabled: true, + value: '90071992547409932', + }); + expect(options[1].label).toContain('KEEPER_DISABLED'); + expect(typeof options[0].value).toBe('string'); + }); + + it('switches AAAA to the server-controlled Agent IPv6 source and omits a mapping ID', async () => { + const wrapper = mount(NetworkDdnsRecordModal); + await (wrapper.vm as any).openCreate(); + await mocks.formOptions.handleValuesChange({ recordType: 'AAAA' }, [ + 'recordType', + ]); + await flushPromises(); + mocks.formApi.getValues.mockResolvedValue({ + domain: 'kwitsukasa.top', + enabled: true, + name: 'NAS IPv6', + portForwardId: 'must-not-leak', + recordType: 'AAAA', + remark: '', + subDomain: 'nas6', + }); + + await mocks.modalOptions.onConfirm(); + + expect(mocks.getSourceOptions).toHaveBeenLastCalledWith('AAAA'); + expect(mocks.create).toHaveBeenCalledWith({ + domain: 'kwitsukasa.top', + enabled: true, + name: 'NAS IPv6', + portForwardId: undefined, + recordType: 'AAAA', + remark: '', + sourceType: 'agent_ipv6', + subDomain: 'nas6', + }); + expect(wrapper.text()).toContain('2409:8a31::1'); + }); + + it('updates with the exact string ID and trims only editable values', async () => { + const row = createDdnsRow(); + const wrapper = mount(NetworkDdnsRecordModal); + await (wrapper.vm as any).openEdit(row); + await mocks.modalOptions.onConfirm(); + + expect(mocks.update).toHaveBeenCalledWith('90071992547409930', { + domain: 'kwitsukasa.top', + enabled: true, + name: 'NAS IPv4', + portForwardId: '90071992547409931', + recordType: 'A', + remark: 'managed', + sourceType: 'port_forward_ipv4', + subDomain: 'nas', + }); + }); + + it('keeps the modal open and always unlocks after an API error', async () => { + mocks.create.mockRejectedValue(new Error('provider unavailable')); + const wrapper = mount(NetworkDdnsRecordModal); + await (wrapper.vm as any).openCreate(); + + await expect(mocks.modalOptions.onConfirm()).rejects.toThrow( + 'provider unavailable', + ); + + expect(mocks.modalApi.close).not.toHaveBeenCalled(); + expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); + }); +}); + +describe('ddns domain validation', () => { + it.each([ + 'https://kwitsukasa.top', + 'kwitsukasa.top/path', + 'kwitsukasa.top?x=1', + 'kwitsukasa.top:53', + 'kwi tsukasa.top', + '-bad.kwitsukasa.top', + 'bad-.kwitsukasa.top', + 'bad..kwitsukasa.top', + ])('rejects malformed zone %s', (value) => { + expect(isValidDdnsDomain(value)).toBe(false); + }); + + it.each([ + 'https://nas', + 'nas/path', + 'nas?x=1', + 'nas:8213', + 'nas home', + '-nas', + 'nas-', + 'nas..home', + ])('rejects malformed host record %s', (value) => { + expect(isValidDdnsSubDomain(value)).toBe(false); + }); + + it('accepts normal dual-stack host records and the root marker', () => { + expect(isValidDdnsDomain('kwitsukasa.top')).toBe(true); + expect(isValidDdnsSubDomain('nas6')).toBe(true); + expect(isValidDdnsSubDomain('home.nas')).toBe(true); + expect(isValidDdnsSubDomain('@')).toBe(true); + }); +}); diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.tsx new file mode 100644 index 0000000..b4582f8 --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsRecordModal.tsx @@ -0,0 +1,396 @@ +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 { + createNetworkDdnsRecord, + getNetworkDdnsSourceOptions, + updateNetworkDdnsRecord, +} from '#/api/system/network'; +import { $t } from '#/locales'; + +export interface NetworkDdnsRecordModalExposed { + openCreate: () => void; + openEdit: (row: SystemNetworkApi.DdnsRecord) => void; +} + +interface NetworkDdnsRecordFormValues { + domain: string; + enabled: boolean; + name: string; + portForwardId?: string; + recordType: SystemNetworkApi.DdnsRecordType; + remark?: string; + subDomain: string; +} + +interface NetworkDdnsRecordModalData { + values: Partial; +} + +const recordTypeOptions = [ + { label: 'A (IPv4)', value: 'A' }, + { label: 'AAAA (IPv6)', value: 'AAAA' }, +]; +const dnsLabelPattern = /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i; + +export default defineComponent({ + name: 'NetworkDdnsRecordModal', + emits: ['saved'], + /** + * Owns the dual-stack DDNS CRUD form while keeping provider details server-side. + */ + setup(_, { emit, expose }) { + const editingRow = ref(); + const recordType = ref('A'); + const sourceOptions = ref([]); + let sourceLoadRevision = 0; + const [DdnsForm, formApi] = useVbenForm({ + commonConfig: { + labelClass: 'w-24', + }, + /** + * Replaces address-family sources immediately without retaining a stale mapping ID. + * @param values - Current form values after the field update. + * @param fieldsChanged - Field names changed in this form event. + */ + async handleValuesChange(values, fieldsChanged) { + if (!fieldsChanged.includes('recordType')) return; + const nextRecordType = + values.recordType === 'AAAA' ? 'AAAA' : ('A' as const); + recordType.value = nextRecordType; + await formApi.setValues({ portForwardId: undefined }); + await loadSourceOptions(nextRecordType); + }, + layout: 'horizontal', + schema: createFormSchema(sourceOptions), + showDefaultActions: false, + wrapperClass: 'grid-cols-1', + }); + const modalTitle = computed(() => + editingRow.value + ? $t('system.network.ddnsEditTitle') + : $t('system.network.ddnsCreateTitle'), + ); + const agentIpv6Source = computed(() => + sourceOptions.value.find((source) => source.sourceType === 'agent_ipv6'), + ); + const agentIpv6Summary = computed(() => { + const source = agentIpv6Source.value; + if (!source) return $t('system.network.ddnsSourceLoading'); + if (!source.eligible) { + return formatSourceDisabledReason(source.disabledReasonCode); + } + return ( + source.currentAddress || $t('system.network.ddnsWaitingSourceAddress') + ); + }); + const [Modal, modalApi] = useVbenModal({ + class: 'w-[680px]', + fullscreenButton: false, + async onConfirm() { + await submit(); + }, + /** + * Restores form state only after destroy-on-close content has mounted. + * @param isOpen - Whether the modal content is now visible. + */ + async onOpenChange(isOpen: boolean) { + if (!isOpen) return; + const { values } = modalApi.getData(); + const nextRecordType = + values.recordType === 'AAAA' ? 'AAAA' : ('A' as const); + recordType.value = nextRecordType; + await resetForm(values); + if (recordType.value !== nextRecordType) return; + await loadSourceOptions(nextRecordType); + }, + }); + + /** Opens a blank IPv4 DDNS form without provider or credential fields. */ + function openCreate() { + editingRow.value = undefined; + recordType.value = 'A'; + sourceOptions.value = []; + modalApi + .setData({ + values: { + domain: '', + enabled: true, + name: '', + portForwardId: undefined, + recordType: 'A', + remark: '', + subDomain: '', + }, + } satisfies NetworkDdnsRecordModalData) + .open(); + } + + /** + * Opens one record with only the editable DDNS contract copied into the form. + * @param row - Persisted DDNS row selected from KtTable. + */ + function openEdit(row: SystemNetworkApi.DdnsRecord) { + editingRow.value = row; + recordType.value = row.recordType; + sourceOptions.value = []; + modalApi + .setData({ + values: { + domain: row.domain, + enabled: row.enabled, + name: row.name, + portForwardId: + row.sourceType === 'port_forward_ipv4' + ? row.portForwardId || undefined + : undefined, + recordType: row.recordType, + remark: row.remark || '', + subDomain: row.subDomain, + }, + } satisfies NetworkDdnsRecordModalData) + .open(); + } + + /** + * Resets validation and installs only user-editable values. + * @param values - Modal session values excluding provider/runtime state. + */ + async function resetForm(values: Partial) { + await formApi.resetForm(); + await formApi.setValues(values); + await formApi.resetValidate(); + } + + /** + * Loads sources for the selected family while dropping stale response races. + * @param nextRecordType - Address family whose sources the server must evaluate. + */ + async function loadSourceOptions( + nextRecordType: SystemNetworkApi.DdnsRecordType, + ) { + const requestRevision = ++sourceLoadRevision; + const result = await getNetworkDdnsSourceOptions(nextRecordType); + if ( + requestRevision !== sourceLoadRevision || + recordType.value !== nextRecordType + ) { + return; + } + sourceOptions.value = result.items; + } + + /** Persists one normalized A/AAAA binding and leaves provider work asynchronous. */ + async function submit() { + const { valid } = await formApi.validate(); + if (!valid) return; + const values = await formApi.getValues(); + const nextRecordType = + values.recordType === 'AAAA' ? 'AAAA' : ('A' as const); + const portForwardId = + nextRecordType === 'A' ? values.portForwardId?.trim() : undefined; + if (nextRecordType === 'A' && !portForwardId) { + message.warning($t('system.network.ddnsSourceRequired')); + return; + } + const payload: SystemNetworkApi.DdnsRecordInput = { + domain: values.domain.trim().toLowerCase(), + enabled: !!values.enabled, + name: values.name.trim(), + portForwardId, + recordType: nextRecordType, + remark: values.remark?.trim() || '', + sourceType: + nextRecordType === 'AAAA' ? 'agent_ipv6' : 'port_forward_ipv4', + subDomain: values.subDomain.trim().toLowerCase(), + }; + + modalApi.lock(); + try { + await (editingRow.value + ? updateNetworkDdnsRecord(editingRow.value.id, payload) + : createNetworkDdnsRecord(payload)); + message.success($t('system.network.ddnsSaved')); + await modalApi.close(); + emit('saved'); + } finally { + modalApi.unlock(); + } + } + + expose({ openCreate, openEdit } satisfies NetworkDdnsRecordModalExposed); + + return () => ( + + {recordType.value === 'AAAA' ? ( + + ) : null} + + + ); + }, +}); + +/** + * Builds the approved dual-stack DDNS form schema without provider credentials. + * @param sourceOptions - Reactive API-owned source choices used by the IPv4 select. + * @returns Vben form fields for record identity, family, source and local behavior. + */ +function createFormSchema( + sourceOptions: Readonly<{ + value: SystemNetworkApi.DdnsSourceOption[]; + }>, +): VbenFormSchema[] { + 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: recordTypeOptions }, + defaultValue: 'A', + fieldName: 'recordType', + label: $t('system.network.ddnsRecordType'), + rules: z.enum(['A', 'AAAA']), + }, + { + component: 'Input', + componentProps: { allowClear: true, maxlength: 253 }, + fieldName: 'domain', + label: $t('system.network.ddnsDomain'), + rules: z + .string() + .trim() + .min(1) + .max(253) + .refine(isValidDdnsDomain, $t('system.network.ddnsDomainInvalid')), + }, + { + component: 'Input', + componentProps: { allowClear: true, maxlength: 253 }, + fieldName: 'subDomain', + label: $t('system.network.ddnsSubDomain'), + rules: z + .string() + .trim() + .min(1) + .max(253) + .refine( + isValidDdnsSubDomain, + $t('system.network.ddnsSubDomainInvalid'), + ), + }, + { + component: 'Select', + componentProps: () => ({ + allowClear: true, + options: sourceOptions.value.map((source) => + formatSourceOption(source), + ), + placeholder: $t('system.network.ddnsSelectSource'), + }), + dependencies: { + if(values) { + return values.recordType !== 'AAAA'; + }, + triggerFields: ['recordType'], + }, + fieldName: 'portForwardId', + label: $t('system.network.ddnsSource'), + rules: z.string().min(1), + }, + { + component: 'Switch', + defaultValue: true, + fieldName: 'enabled', + label: $t('system.network.ddnsEnabled'), + }, + { + 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('')), + }, + ]; +} + +/** + * Formats one server-evaluated source without making eligibility decisions locally. + * @param source - Source option returned by the API. + * @returns Ant Design Select option with a stable string ID and disabled reason. + */ +function formatSourceOption(source: SystemNetworkApi.DdnsSourceOption) { + const endpoint = + source.protocol && source.externalPort + ? `${source.protocol.toUpperCase()}:${source.externalPort}` + : source.sourceType; + const currentAddress = + source.currentAddress || $t('system.network.ddnsWaitingSourceAddress'); + const reason = source.eligible + ? '' + : ` · ${formatSourceDisabledReason(source.disabledReasonCode)}`; + return { + disabled: !source.eligible, + label: `${source.name} · ${endpoint} · ${currentAddress}${reason}`, + value: String(source.id), + }; +} + +/** + * Formats a stable server reason code without inventing source eligibility rules. + * @param reasonCode - Optional API reason code for an ineligible source. + * @returns Localized prefix plus the stable code for diagnostics. + */ +function formatSourceDisabledReason(reasonCode?: null | string): string { + return reasonCode + ? `${$t('system.network.ddnsSourceUnavailable')}: ${reasonCode}` + : $t('system.network.ddnsSourceUnavailable'); +} + +/** + * Validates a DNSPod zone as ASCII DNS labels with no URL or port syntax. + * @param value - Candidate zone name. + * @returns True only for a normalized multi-label DNS zone. + */ +export function isValidDdnsDomain(value: string): boolean { + const normalized = value.trim(); + if ( + normalized.length > 253 || + normalized.endsWith('.') || + !normalized.includes('.') + ) { + return false; + } + return normalized.split('.').every((label) => dnsLabelPattern.test(label)); +} + +/** + * Validates an A/AAAA host record, including DNSPod's root-record marker. + * @param value - Candidate host record. + * @returns True for `@` or one or more valid ASCII DNS labels. + */ +export function isValidDdnsSubDomain(value: string): boolean { + const normalized = value.trim(); + if (normalized === '@') return true; + if (normalized.length > 253 || normalized.endsWith('.')) return false; + return normalized.split('.').every((label) => dnsLabelPattern.test(label)); +} diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx new file mode 100644 index 0000000..6fe386a --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.spec.tsx @@ -0,0 +1,276 @@ +/* @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 { beforeEach, describe, expect, it, vi } from 'vitest'; + +import NetworkDdnsTable, { + getDdnsRetryDisabledReason, +} from './NetworkDdnsTable'; + +const mocks = vi.hoisted(() => ({ + api: { + deleteRecord: vi.fn(), + getList: vi.fn(), + getProviderStatus: vi.fn(), + retryRecord: vi.fn(), + }, + messageSuccess: vi.fn(), + modalOpenCreate: vi.fn(), + modalOpenEdit: vi.fn(), + tableApi: { + reload: vi.fn(), + }, + tableOptions: undefined as any, +})); + +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': 'ddns-table' }, [ + slots.headerControls?.(), + slots.bodyCell?.({ + column: { key: 'identity' }, + record: createDdnsRow(), + }), + slots.bodyCell?.({ + column: { key: 'syncStatus' }, + record: createDdnsRow(), + }), + ]); + }, + }), + useKtTable: vi.fn((options) => { + mocks.tableOptions = options; + return [vi.fn(), mocks.tableApi]; + }), +})); + +vi.mock('./NetworkDdnsRecordModal', () => ({ + default: defineComponent({ + name: 'MockNetworkDdnsRecordModal', + setup(_, { expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => h('div'); + }, + }), +})); + +vi.mock('#/api/system/network', () => ({ + deleteNetworkDdnsRecord: mocks.api.deleteRecord, + getNetworkDdnsList: mocks.api.getList, + getNetworkDdnsProviderStatus: mocks.api.getProviderStatus, + retryNetworkDdnsRecord: mocks.api.retryRecord, +})); + +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); + +/** Builds one dual-stack-capable DDNS table fixture. */ +function createDdnsRow( + overrides: Partial = {}, +): SystemNetworkApi.DdnsRecord { + return { + appliedAddress: '123.45.67.89', + domain: 'kwitsukasa.top', + enabled: true, + fqdn: 'nas.kwitsukasa.top', + id: '90071992547409930', + lastSyncedAt: '2026-07-23T08:00:00.000Z', + name: 'NAS IPv4', + portForwardId: '90071992547409931', + recordType: 'A', + retryCount: 0, + source: { + currentAddress: '123.45.67.89', + eligible: true, + externalPort: 45_678, + id: '90071992547409931', + name: 'NAS UDP', + protocol: 'udp', + sourceType: 'port_forward_ipv4', + }, + sourceAddress: '123.45.67.89', + sourceType: 'port_forward_ipv4', + subDomain: 'nas', + syncStatus: 'synced', + updateTime: '2026-07-23T08:00:00.000Z', + ...overrides, + }; +} + +describe('network DDNS table', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.api.getProviderStatus.mockResolvedValue({ + configured: true, + enabled: true, + provider: 'dnspod', + }); + mocks.api.deleteRecord.mockResolvedValue({}); + mocks.api.retryRecord.mockResolvedValue({}); + mocks.tableApi.reload.mockResolvedValue(undefined); + }); + + it('uses an independent KtTable with dual-stack address columns', () => { + const wrapper = mount(NetworkDdnsTable); + + expect(wrapper.find('[data-testid="ddns-table"]').exists()).toBe(true); + expect(mocks.tableOptions.immediate).toBe(false); + expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([ + 'identity', + 'source', + 'sourceAddress', + 'appliedAddress', + 'syncStatus', + 'lastSync', + 'updateTime', + ]); + expect(mocks.tableOptions.tableTitle).toBe('system.network.ddnsTitle'); + }); + + it('shows API-provided FQDN without appending the source external port', () => { + const wrapper = mount(NetworkDdnsTable); + + expect(wrapper.text()).toContain('nas.kwitsukasa.top'); + expect(wrapper.text()).not.toContain('45678'); + }); + + it('keeps retry visible but disables it for every unsafe state with an exact reason', () => { + mount(NetworkDdnsTable); + const retry = mocks.tableOptions.rowActions.find( + (item: any) => item.key === 'retry', + ); + + expect(retry.rowVisible).toBeUndefined(); + expect(retry.disabled(createDdnsRow({ enabled: false }))).toBe(true); + expect(getDdnsRetryDisabledReason(createDdnsRow({ enabled: false }))).toBe( + 'system.network.ddnsRetryDisabled', + ); + expect( + getDdnsRetryDisabledReason(createDdnsRow({ syncStatus: 'syncing' })), + ).toBe('system.network.ddnsSyncInProgress'); + expect( + getDdnsRetryDisabledReason( + createDdnsRow({ + source: { + ...createDdnsRow().source, + disabledReasonCode: 'KEEPER_DISABLED', + eligible: false, + }, + }), + ), + ).toContain('KEEPER_DISABLED'); + expect( + getDdnsRetryDisabledReason( + createDdnsRow({ + source: { ...createDdnsRow().source, currentAddress: null }, + sourceAddress: null, + }), + ), + ).toBe('system.network.ddnsSourceAddressMissing'); + }); + + it('uses scoped permissions and explains that delete preserves DNSPod records', () => { + mount(NetworkDdnsTable); + + expect(mocks.tableOptions.buttons[0].permissionCodes).toEqual([ + 'System:Network:Ddns:Create', + ]); + expect( + mocks.tableOptions.rowActions.map((item: any) => [ + item.key, + item.permissionCodes, + ]), + ).toEqual([ + ['edit', ['System:Network:Ddns:Update']], + ['retry', ['System:Network:Ddns:Retry']], + ['delete', ['System:Network:Ddns:Delete']], + ]); + const deleteAction = mocks.tableOptions.rowActions.find( + (item: any) => item.key === 'delete', + ); + expect(deleteAction.confirm(createDdnsRow())).toBe( + 'system.network.ddnsDeleteConfirm', + ); + }); + + it('opens create/edit modals and refreshes both list and provider status on demand', async () => { + const wrapper = mount(NetworkDdnsTable); + const row = createDdnsRow(); + + await mocks.tableOptions.buttons[0].onClick({}); + await mocks.tableOptions.rowActions + .find((item: any) => item.key === 'edit') + .onClick(row, {}); + await (wrapper.vm as any).reload(); + await flushPromises(); + + expect(mocks.modalOpenCreate).toHaveBeenCalledOnce(); + expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row); + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + expect(mocks.api.getProviderStatus).toHaveBeenCalledOnce(); + }); + + it('supports IPv6 source and applied addresses without IPv4-specific labels', () => { + mount(NetworkDdnsTable); + const row = createDdnsRow({ + appliedAddress: '2409:8a31::1', + fqdn: 'nas6.kwitsukasa.top', + portForwardId: null, + recordType: 'AAAA', + source: { + currentAddress: '2409:8a31::1', + eligible: true, + id: 'agent-ipv6', + name: 'Agent IPv6', + sourceType: 'agent_ipv6', + }, + sourceAddress: '2409:8a31::1', + sourceType: 'agent_ipv6', + subDomain: 'nas6', + }); + const renderBodyCell = (mocks.tableOptions as any).renderBodyCell; + + expect(row.source.currentAddress).toBe('2409:8a31::1'); + expect(JSON.stringify(mocks.tableOptions.columns)).not.toMatch( + /sourceIpv4|appliedIpv4/i, + ); + expect(renderBodyCell).toBeUndefined(); + }); +}); diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.tsx new file mode 100644 index 0000000..d3a3383 --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkDdnsTable.tsx @@ -0,0 +1,415 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { NetworkDdnsRecordModalExposed } from './NetworkDdnsRecordModal'; + +import type { SystemNetworkApi } from '#/api/system/network'; +import type { + KtTableApi, + KtTableButton, + KtTableRowAction, +} from '#/components/ktTable'; + +import { defineComponent, ref } from 'vue'; + +import { Plus } from '@vben/icons'; + +import { message, Space, Tag, Typography } from 'antdv-next'; + +import { + deleteNetworkDdnsRecord, + getNetworkDdnsList, + getNetworkDdnsProviderStatus, + retryNetworkDdnsRecord, +} from '#/api/system/network'; +import { KtTable, useKtTable } from '#/components/ktTable'; +import { $t } from '#/locales'; + +import NetworkDdnsRecordModal from './NetworkDdnsRecordModal'; + +const AKtTable = KtTable as any; +const ATypographyText = Typography.Text as any; +const recordTypeOptions = [ + { label: 'A (IPv4)', value: 'A' }, + { label: 'AAAA (IPv6)', value: 'AAAA' }, +]; +const syncStatusOptions = [ + { label: $t('system.network.ddnsStatusPending'), value: 'pending' }, + { label: $t('system.network.ddnsStatusSyncing'), value: 'syncing' }, + { label: $t('system.network.ddnsStatusSynced'), value: 'synced' }, + { + label: $t('system.network.ddnsStatusWaitingSource'), + value: 'waiting_source', + }, + { label: $t('system.network.ddnsStatusFailed'), value: 'failed' }, + { label: $t('system.network.ddnsStatusDisabled'), value: 'disabled' }, +]; +const syncStatusColors: Record = { + disabled: 'default', + failed: 'error', + pending: 'processing', + synced: 'success', + syncing: 'processing', + waiting_source: 'warning', +}; +const syncStatusLabels: Record = { + disabled: $t('system.network.ddnsStatusDisabled'), + failed: $t('system.network.ddnsStatusFailed'), + pending: $t('system.network.ddnsStatusPending'), + synced: $t('system.network.ddnsStatusSynced'), + syncing: $t('system.network.ddnsStatusSyncing'), + waiting_source: $t('system.network.ddnsStatusWaitingSource'), +}; + +export interface NetworkDdnsTableExposed { + reload: () => Promise; +} + +export default defineComponent({ + name: 'NetworkDdnsTable', + /** + * Owns the independent DDNS KtTable while the route page owns SSE and active-tab refresh. + */ + setup(_, { expose }) { + const busyRowIds = ref>(new Set()); + const modalRef = ref(); + const providerStatus = ref(); + const providerStatusUnknown = ref(true); + const columns: Array> = [ + { + key: 'identity', + title: $t('system.network.ddnsRecord'), + width: 230, + }, + { + key: 'source', + title: $t('system.network.ddnsSource'), + width: 210, + }, + { + dataIndex: 'sourceAddress', + key: 'sourceAddress', + title: $t('system.network.ddnsSourceAddress'), + width: 210, + }, + { + dataIndex: 'appliedAddress', + key: 'appliedAddress', + title: $t('system.network.ddnsAppliedAddress'), + width: 210, + }, + { + dataIndex: 'syncStatus', + key: 'syncStatus', + title: $t('system.network.ddnsSyncStatus'), + width: 120, + }, + { + key: 'lastSync', + title: $t('system.network.ddnsLastSync'), + width: 260, + }, + { + dataIndex: 'updateTime', + key: 'updateTime', + title: $t('system.network.updateTime'), + width: 180, + }, + ]; + const api: KtTableApi = { + /** + * Loads the latest persisted DDNS fact page. + * @param params - KtTable pagination and search values. + * @returns Server-owned DDNS rows and total. + */ + list: async (params) => await getNetworkDdnsList(params), + }; + const buttons: Array> = [ + { + icon: , + key: 'create', + label: $t('system.network.ddnsCreateAction'), + onClick: openCreate, + permissionCodes: ['System:Network:Ddns:Create'], + type: 'primary', + }, + ]; + const rowActions: Array> = [ + { + disabled: (row) => isRowBusy(row), + disabledReason: (row) => + isRowBusy(row) ? $t('system.network.operationInProgress') : undefined, + key: 'edit', + label: $t('system.network.editAction'), + onClick: openEdit, + permissionCodes: ['System:Network:Ddns:Update'], + }, + { + disabled: (row) => isRowBusy(row) || !!getDdnsRetryDisabledReason(row), + disabledReason: (row) => + isRowBusy(row) + ? $t('system.network.operationInProgress') + : getDdnsRetryDisabledReason(row), + key: 'retry', + label: $t('system.network.ddnsRetryAction'), + onClick: async (row) => { + await runRowMutation( + row, + retryNetworkDdnsRecord, + $t('system.network.ddnsRetrySubmitted'), + ); + }, + permissionCodes: ['System:Network:Ddns:Retry'], + }, + { + confirm: () => $t('system.network.ddnsDeleteConfirm'), + danger: true, + disabled: (row) => isRowBusy(row), + disabledReason: (row) => + isRowBusy(row) ? $t('system.network.operationInProgress') : undefined, + key: 'delete', + label: $t('system.network.deleteAction'), + onClick: async (row) => { + await runRowMutation( + row, + deleteNetworkDdnsRecord, + $t('system.network.ddnsDeleteSubmitted'), + ); + }, + permissionCodes: ['System:Network:Ddns: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: recordTypeOptions, + }, + fieldName: 'recordType', + label: $t('system.network.ddnsRecordType'), + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: syncStatusOptions, + }, + fieldName: 'syncStatus', + label: $t('system.network.ddnsSyncStatus'), + }, + ], + }, + immediate: false, + rowActions, + rowActionVisibleCount: 3, + rowKey: 'id', + tableTitle: $t('system.network.ddnsTitle'), + }); + + /** Opens the blank dual-stack DDNS modal. */ + function openCreate() { + modalRef.value?.openCreate(); + } + + /** + * Opens one DDNS record unless another mutation owns its row. + * @param row - Persisted DDNS record. + */ + function openEdit(row: SystemNetworkApi.DdnsRecord) { + if (!isRowBusy(row)) modalRef.value?.openEdit(row); + } + + /** Refreshes the table after a successful modal save. */ + function handleModalSaved() { + void reload(); + } + + /** + * Runs one DDNS mutation with per-row duplicate suppression. + * @param row - Selected persisted record. + * @param mutation - API operation accepting the stable record ID. + * @param successMessage - Accurate asynchronous-success wording. + */ + async function runRowMutation( + row: SystemNetworkApi.DdnsRecord, + mutation: (id: string) => Promise, + successMessage: string, + ) { + if (isRowBusy(row)) return; + setRowBusy(row.id, true); + try { + await mutation(row.id); + message.success(successMessage); + await reload(); + } finally { + setRowBusy(row.id, false); + } + } + + /** + * Replaces the reactive in-flight set instead of mutating it in place. + * @param id - Stable DDNS record ID. + * @param busy - Whether this row currently owns a write. + */ + 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 a write request. + * @param row - Persisted DDNS record. + * @returns True while a mutation is in flight. + */ + function isRowBusy(row: SystemNetworkApi.DdnsRecord): boolean { + return busyRowIds.value.has(row.id); + } + + /** Loads redacted provider readiness without translating failure into disabled. */ + async function loadProviderStatus() { + try { + providerStatus.value = await getNetworkDdnsProviderStatus(); + providerStatusUnknown.value = false; + } catch { + providerStatusUnknown.value = true; + } + } + + /** + * Reloads only DDNS-owned data when requested by the route or a local mutation. + */ + async function reload(): Promise { + await Promise.allSettled([tableApi.reload(), loadProviderStatus()]); + } + + /** Renders provider readiness without exposing credential values. */ + function renderProviderStatus() { + const status = providerStatus.value; + let color = 'warning'; + let label = $t('system.network.ddnsProviderUnavailable'); + if (providerStatusUnknown.value) { + color = 'default'; + label = $t('system.network.ddnsProviderUnknown'); + } else if (status?.enabled && status.configured) { + color = 'success'; + label = $t('system.network.ddnsProviderReady'); + } + return ( + + + {$t('system.network.ddnsProviderName')}: {label} + + + ); + } + + /** + * Renders dual-stack DDNS cells using only API-returned address and FQDN facts. + * @param cell - KtTable body-cell slot context. + * @param cell.column - Current table column. + * @param cell.record - Current DDNS record. + * @returns Cell content or undefined for default rendering. + */ + function renderBodyCell({ column, record }: any) { + const row = record as SystemNetworkApi.DdnsRecord; + if (column.key === 'identity') { + return ( + + + {row.name} + + {row.recordType} + + + {row.fqdn} + + ); + } + if (column.key === 'source') { + const detail = + row.source.sourceType === 'port_forward_ipv4' && + row.source.protocol && + row.source.externalPort + ? `${row.source.protocol.toUpperCase()}:${row.source.externalPort}` + : $t('system.network.ddnsAgentIpv6Source'); + return `${row.source.name} · ${detail}`; + } + if (column.key === 'sourceAddress') { + return row.sourceAddress || row.source.currentAddress || '-'; + } + if (column.key === 'appliedAddress') { + return row.appliedAddress || '-'; + } + if (column.key === 'syncStatus') { + return ( + + {syncStatusLabels[row.syncStatus]} + + ); + } + if (column.key === 'lastSync') { + if (row.lastErrorMessage) { + return `${row.lastErrorCode || '-'} · ${row.lastErrorMessage}`; + } + const retry = row.nextRetryAt + ? ` · ${$t('system.network.ddnsNextRetry')}: ${row.nextRetryAt}` + : ''; + return `${row.lastSyncedAt || '-'}${retry}`; + } + return undefined; + } + + expose({ reload } satisfies NetworkDdnsTableExposed); + + return () => ( + <> + + + + ); + }, +}); + +/** + * Resolves whether retry is unsafe without hiding the action. + * @param row - Persisted DDNS record with server-evaluated source eligibility. + * @returns Exact disabled reason, or undefined when retry is allowed. + */ +export function getDdnsRetryDisabledReason( + row: SystemNetworkApi.DdnsRecord, +): string | undefined { + if (!row.enabled) return $t('system.network.ddnsRetryDisabled'); + if (row.syncStatus === 'syncing') { + return $t('system.network.ddnsSyncInProgress'); + } + if (!row.source.eligible) { + return row.source.disabledReasonCode + ? `${$t('system.network.ddnsSourceUnavailable')}: ${ + row.source.disabledReasonCode + }` + : $t('system.network.ddnsSourceUnavailable'); + } + if (!row.source.currentAddress) { + return $t('system.network.ddnsSourceAddressMissing'); + } + return undefined; +} diff --git a/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts b/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts index e08d55d..de0de8d 100644 --- a/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts +++ b/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts @@ -72,7 +72,7 @@ export function useNetworkManagementStream( if ( !payload.eventId || !payload.observedAt || - !['events', 'reported', 'status'].includes(payload.source || '') + !['ddns', 'events', 'reported', 'status'].includes(payload.source || '') ) { return undefined; } 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 index 92968cd..23422dd 100644 --- a/apps/web-antdv-next/src/views/system/network/list.spec.tsx +++ b/apps/web-antdv-next/src/views/system/network/list.spec.tsx @@ -58,6 +58,10 @@ class FakeEventSource { } const mocks = vi.hoisted(() => ({ + accessCodes: new Set([ + 'System:Network:Ddns:List', + 'System:Network:PortForward:List', + ]), api: { deleteMapping: vi.fn(), disableKeeper: vi.fn(), @@ -67,6 +71,7 @@ const mocks = vi.hoisted(() => ({ probe: vi.fn(), retry: vi.fn(), }, + ddnsReload: vi.fn(), messageSuccess: vi.fn(), modalOpenCreate: vi.fn(), modalOpenEdit: vi.fn(), @@ -77,6 +82,13 @@ const mocks = vi.hoisted(() => ({ tableOptions: undefined as any, })); +vi.mock('@vben/access', () => ({ + useAccess: () => ({ + hasAccessByCodes: (codes: string[]) => + codes.every((code) => mocks.accessCodes.has(code)), + }), +})); + vi.mock('@vben/common-ui', () => ({ Page: defineComponent({ name: 'MockPage', @@ -100,6 +112,31 @@ vi.mock('antdv-next', () => ({ return () => h('span', slots.default?.()); }, }), + Tabs: defineComponent({ + name: 'MockTabs', + props: { + activeKey: { default: '', type: String }, + items: { default: () => [], type: Array }, + }, + emits: ['update:activeKey'], + setup(props, { emit }) { + return () => + h( + 'nav', + { 'data-testid': 'network-tabs' }, + (props.items as Array<{ key: string; label: string }>).map((item) => + h( + 'button', + { + 'data-tab': item.key, + onClick: () => emit('update:activeKey', item.key), + }, + item.label, + ), + ), + ); + }, + }), Typography: { Text: defineComponent({ name: 'MockText', @@ -150,6 +187,16 @@ vi.mock('./components/NetworkPortForwardModal', () => ({ }), })); +vi.mock('./components/NetworkDdnsTable', () => ({ + default: defineComponent({ + name: 'MockNetworkDdnsTable', + setup(_, { expose }) { + expose({ reload: mocks.ddnsReload }); + return () => h('section', { 'data-testid': 'ddns-table' }); + }, + }), +})); + vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({ default: defineComponent({ name: 'MockNetworkEndpointHistoryDrawer', @@ -214,6 +261,11 @@ describe('system network persisted list', () => { FakeEventSource.instances = []; vi.stubGlobal('EventSource', FakeEventSource); mocks.tableOptions = undefined; + mocks.accessCodes = new Set([ + 'System:Network:Ddns:List', + 'System:Network:PortForward:List', + ]); + mocks.ddnsReload.mockResolvedValue(undefined); mocks.tableApi.getRows.mockReturnValue([]); mocks.tableApi.reload.mockResolvedValue(undefined); mocks.api.getAgentStatus.mockResolvedValue({ @@ -245,6 +297,12 @@ describe('system network persisted list', () => { expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1); expect(wrapper.find('[data-testid="network-table"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="ddns-table"]').exists()).toBe(true); + expect( + wrapper + .findAll('[data-testid="network-tabs"] button') + .map((item) => item.text()), + ).toEqual(['system.network.portForwardTab', 'system.network.ddnsTab']); expect(network).toMatchObject({ path: '/system/network' }); expect(String(network?.component)).toContain('network/list'); expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([ @@ -261,6 +319,22 @@ describe('system network persisted list', () => { ]); }); + it('filters tabs by List permission and never requests a forbidden list', async () => { + mocks.accessCodes = new Set(['System:Network:Ddns:List']); + const wrapper = mount(NetworkList); + await flushPromises(); + + expect( + wrapper + .findAll('[data-testid="network-tabs"] button') + .map((item) => item.text()), + ).toEqual(['system.network.ddnsTab']); + expect(mocks.tableApi.reload).not.toHaveBeenCalled(); + expect(mocks.api.getAgentStatus).not.toHaveBeenCalled(); + expect(mocks.ddnsReload).toHaveBeenCalledOnce(); + expect(FakeEventSource.instances).toHaveLength(1); + }); + it('routes list labels and status values through the network locale namespace', () => { const wrapper = mount(NetworkList); @@ -411,6 +485,15 @@ describe('system network persisted list', () => { await flushPromises(); expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1); + FakeEventSource.instances[0]?.dispatch('network-state-changed', { + eventId: 'network-ddns-event-while-port-forward-active', + observedAt: '2026-07-23T00:00:00.500Z', + source: 'ddns', + }); + await flushPromises(); + expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1); + expect(mocks.ddnsReload).not.toHaveBeenCalled(); + FakeEventSource.instances[0]?.dispatch('network-state-changed', { eventId: 'network-event-1', observedAt: '2026-07-23T00:00:01.000Z', @@ -431,6 +514,59 @@ describe('system network persisted list', () => { await flushPromises(); expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2); }); + + it('routes DDNS and replay-gap events to one active table without polling', async () => { + const wrapper = mount(NetworkList); + await flushPromises(); + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + expect(mocks.ddnsReload).not.toHaveBeenCalled(); + expect(FakeEventSource.instances).toHaveLength(1); + + await wrapper + .find('[data-testid="network-tabs"] [data-tab="ddns"]') + .trigger('click'); + await flushPromises(); + expect(mocks.ddnsReload).toHaveBeenCalledOnce(); + + FakeEventSource.instances[0]?.dispatch('network-state-changed', { + eventId: 'network-reported-event-while-ddns-active', + observedAt: '2026-07-23T00:00:00.500Z', + source: 'reported', + }); + await flushPromises(); + expect(mocks.ddnsReload).toHaveBeenCalledOnce(); + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + + FakeEventSource.instances[0]?.dispatch('network-state-changed', { + eventId: 'network-ddns-event-1', + observedAt: '2026-07-23T00:00:01.000Z', + source: 'ddns', + }); + await flushPromises(); + expect(mocks.ddnsReload).toHaveBeenCalledTimes(2); + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + + FakeEventSource.instances[0]?.dispatch('network-state-changed', { + eventId: 'network-ddns-event-1', + observedAt: '2026-07-23T00:00:01.000Z', + source: 'ddns', + }); + FakeEventSource.instances[0]?.dispatch('heartbeat', { + observedAt: '2026-07-23T00:00:02.000Z', + }); + await flushPromises(); + expect(mocks.ddnsReload).toHaveBeenCalledTimes(2); + + FakeEventSource.instances[0]?.dispatch('snapshot-required', { + observedAt: '2026-07-23T00:00:03.000Z', + }); + await flushPromises(); + expect(mocks.ddnsReload).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(60_000); + expect(mocks.ddnsReload).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + }); }); describe('network action constraints', () => { diff --git a/apps/web-antdv-next/src/views/system/network/list.tsx b/apps/web-antdv-next/src/views/system/network/list.tsx index 2846e52..370ff01 100644 --- a/apps/web-antdv-next/src/views/system/network/list.tsx +++ b/apps/web-antdv-next/src/views/system/network/list.tsx @@ -1,5 +1,6 @@ import type { TableColumnType } from 'antdv-next'; +import type { NetworkDdnsTableExposed } from './components/NetworkDdnsTable'; import type { NetworkEndpointHistoryDrawerExposed } from './components/NetworkEndpointHistoryDrawer'; import type { NetworkPortForwardModalExposed } from './components/NetworkPortForwardModal'; @@ -19,10 +20,11 @@ import { ref, } from 'vue'; +import { useAccess } from '@vben/access'; import { Page } from '@vben/common-ui'; import { Plus } from '@vben/icons'; -import { message, Space, Tag, Typography } from 'antdv-next'; +import { message, Space, Tabs, Tag, Typography } from 'antdv-next'; import { deleteNetworkPortForward, @@ -36,12 +38,15 @@ import { import { KtTable, useKtTable } from '#/components/ktTable'; import { $t } from '#/locales'; +import NetworkDdnsTable from './components/NetworkDdnsTable'; import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer'; import NetworkPortForwardModal from './components/NetworkPortForwardModal'; import { useNetworkManagementStream } from './composables/useNetworkManagementStream'; const AKtTable = KtTable as any; +const ATabs = Tabs as any; const ATypographyText = Typography.Text as any; +type NetworkTabKey = 'ddns' | 'port-forward'; const protocolOptions = [ { label: 'TCP', value: 'tcp' }, { label: 'UDP', value: 'udp' }, @@ -91,17 +96,42 @@ export default defineComponent({ * Builds the generic persisted network-resource table and event-driven refresh stream. */ setup() { + const { hasAccessByCodes } = useAccess(); + const canViewPortForward = hasAccessByCodes([ + 'System:Network:PortForward:List', + ]); + const canViewDdns = hasAccessByCodes(['System:Network:Ddns:List']); + const tabItems = [ + ...(canViewPortForward + ? [ + { + key: 'port-forward' as const, + label: $t('system.network.portForwardTab'), + }, + ] + : []), + ...(canViewDdns + ? [ + { + key: 'ddns' as const, + label: $t('system.network.ddnsTab'), + }, + ] + : []), + ]; + const activeTab = ref(tabItems[0]?.key || 'port-forward'); const agentStatus = ref(); const agentStatusUnknown = ref(true); const busyRowIds = ref>(new Set()); + const ddnsTableRef = ref(); const modalRef = ref(); const historyDrawerRef = ref(); let pageActive = false; let refreshInFlight: Promise | undefined; - let refreshQueued = false; + const queuedRefreshes = new Set(); const managementStream = useNetworkManagementStream({ - onSnapshotRequired: handleStreamRefresh, - onStateChanged: handleStreamRefresh, + onSnapshotRequired: handleSnapshotRequired, + onStateChanged: handleStateChanged, }); const columns: Array> = [ @@ -356,7 +386,7 @@ export default defineComponent({ /** Reloads after a modal save while preserving serialized request order. */ function handleModalSaved() { - void requestRefresh(); + void requestRefresh('port-forward'); } /** Copies an API-approved current endpoint without logging it. */ @@ -415,39 +445,84 @@ export default defineComponent({ /** * Serializes table and Agent refreshes so stale responses cannot overtake writes. */ - async function requestRefresh(): Promise { + async function requestRefresh( + resource: NetworkTabKey = activeTab.value, + ): Promise { + if (!canViewResource(resource)) return; if (refreshInFlight) { - refreshQueued = true; + queuedRefreshes.add(resource); await refreshInFlight; return; } - refreshInFlight = Promise.allSettled([ - tableApi.reload(), - loadAgentStatus(), - ]).then(() => undefined); + refreshInFlight = performResourceRefresh(resource); try { await refreshInFlight; } finally { refreshInFlight = undefined; } - if (refreshQueued) { - refreshQueued = false; - await requestRefresh(); + const nextResource = queuedRefreshes.values().next().value as + | NetworkTabKey + | undefined; + if (nextResource) { + queuedRefreshes.delete(nextResource); + await requestRefresh(nextResource); } } - /** Refreshes persisted facts only for a committed topic event or replay gap. */ - function handleStreamRefresh() { - if (pageActive) void requestRefresh(); + /** + * Loads exactly one permission-approved resource without cross-tab requests. + * @param resource - Active or locally-mutated network resource. + */ + async function performResourceRefresh(resource: NetworkTabKey) { + if (resource === 'ddns') { + await ddnsTableRef.value?.reload(); + return; + } + await Promise.allSettled([tableApi.reload(), loadAgentStatus()]); + } + + /** + * Checks list permission before any resource request. + * @param resource - Candidate tab resource. + * @returns True when its List permission is present. + */ + function canViewResource(resource: NetworkTabKey): boolean { + return resource === 'ddns' ? canViewDdns : canViewPortForward; + } + + /** + * Refreshes only the active resource addressed by one semantic SSE event. + * @param event - Committed API event classified by network resource source. + */ + function handleStateChanged(event: SystemNetworkApi.StateChangeEvent) { + const resource = event.source === 'ddns' ? 'ddns' : 'port-forward'; + if (pageActive && activeTab.value === resource) { + void requestRefresh(resource); + } + } + + /** Refreshes the active resource when the API cannot replay a missed event. */ + function handleSnapshotRequired() { + if (pageActive) void requestRefresh(activeTab.value); + } + + /** + * Selects one allowed tab and immediately requests its first fact snapshot. + * @param key - Tab key emitted by Ant Design Tabs. + */ + function handleActiveTabChange(key: NetworkTabKey) { + if (!canViewResource(key) || activeTab.value === key) return; + activeTab.value = key; + if (pageActive) void requestRefresh(key); } /** Opens the stream before the one initial page snapshot to avoid a subscription gap. */ function activatePage() { - if (pageActive) return; + if (pageActive || tabItems.length === 0) return; pageActive = true; managementStream.start(); - void requestRefresh(); + void requestRefresh(activeTab.value); } /** Closes the route-owned stream while preserving its replay cursor. */ @@ -534,15 +609,42 @@ export default defineComponent({ return () => ( - - - + {tabItems.length > 0 ? ( + + ) : null} + {canViewPortForward ? ( +
+ +
+ ) : null} + {canViewDdns ? ( +
+ +
+ ) : null} + {canViewPortForward ? ( + <> + + + + ) : null}
); },