feat: 增加双栈自动DDNS管理

This commit is contained in:
sunlei 2026-07-23 13:00:50 +08:00
parent f5165b238c
commit f94a98c09c
14 changed files with 2114 additions and 31 deletions

View File

@ -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 的公网 IPv4AAAA 只使用 Agent 全局 IPv6DNS 值不包含端口。首屏读取一次 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`

View File

@ -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',
},
],
},
],

View File

@ -83,6 +83,11 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'SystemMenuDelete',
'SystemMenuEdit',
'SystemNetwork',
'SystemNetworkDdnsCreate',
'SystemNetworkDdnsDelete',
'SystemNetworkDdnsList',
'SystemNetworkDdnsRetry',
'SystemNetworkDdnsUpdate',
'SystemNetworkPortForwardCreate',
'SystemNetworkPortForwardDelete',
'SystemNetworkPortForwardHistory',

View File

@ -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',

View File

@ -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<any> {
enabled?: boolean;
name?: string;
pageNo?: number;
pageSize?: number;
recordType?: DdnsRecordType;
syncStatus?: DdnsSyncStatus;
}
export interface PageResult<T> {
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<SystemNetworkApi.DdnsRecord>
>('/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<SystemNetworkApi.DdnsSourceOptionsResult>(
'/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<SystemNetworkApi.DdnsProviderStatus>(
'/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<SystemNetworkApi.DdnsRecord>(
'/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<SystemNetworkApi.DdnsRecord>(
`/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<SystemNetworkApi.DdnsRecord>(
`/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<SystemNetworkApi.DdnsRecord>(
`/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.

View File

@ -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",

View File

@ -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": "立即刷新",

View File

@ -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> = {},
): 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<typeof mount>) => (wrapper.vm as any).openCreate(),
],
[
'edit',
(wrapper: ReturnType<typeof mount>) =>
(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);
});
});

View File

@ -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<NetworkDdnsRecordFormValues>;
}
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<SystemNetworkApi.DdnsRecord>();
const recordType = ref<SystemNetworkApi.DdnsRecordType>('A');
const sourceOptions = ref<SystemNetworkApi.DdnsSourceOption[]>([]);
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<NetworkDdnsRecordModalData>();
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<NetworkDdnsRecordFormValues>) {
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<NetworkDdnsRecordFormValues>();
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 () => (
<Modal title={modalTitle.value}>
{recordType.value === 'AAAA' ? (
<Alert
class="mb-4"
message={`${$t('system.network.ddnsAgentIpv6Source')}: ${
agentIpv6Summary.value
}`}
showIcon
type={agentIpv6Source.value?.eligible ? 'info' : 'warning'}
/>
) : null}
<DdnsForm class="mx-2" />
</Modal>
);
},
});
/**
* 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));
}

View File

@ -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> = {},
): 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();
});
});

View File

@ -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<SystemNetworkApi.DdnsSyncStatus, string> = {
disabled: 'default',
failed: 'error',
pending: 'processing',
synced: 'success',
syncing: 'processing',
waiting_source: 'warning',
};
const syncStatusLabels: Record<SystemNetworkApi.DdnsSyncStatus, string> = {
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<void>;
}
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<Set<string>>(new Set());
const modalRef = ref<NetworkDdnsRecordModalExposed>();
const providerStatus = ref<SystemNetworkApi.DdnsProviderStatus>();
const providerStatusUnknown = ref(true);
const columns: Array<TableColumnType<SystemNetworkApi.DdnsRecord>> = [
{
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<SystemNetworkApi.DdnsRecord> = {
/**
* 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<KtTableButton<SystemNetworkApi.DdnsRecord>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: $t('system.network.ddnsCreateAction'),
onClick: openCreate,
permissionCodes: ['System:Network:Ddns:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<SystemNetworkApi.DdnsRecord>> = [
{
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<SystemNetworkApi.DdnsRecord>({
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<unknown>,
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<void> {
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 (
<Space wrap>
<Tag color={color}>
{$t('system.network.ddnsProviderName')}: {label}
</Tag>
</Space>
);
}
/**
* 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 (
<Space direction="vertical" size={0}>
<Space size={4}>
<ATypographyText strong>{row.name}</ATypographyText>
<Tag color={row.recordType === 'AAAA' ? 'purple' : 'blue'}>
{row.recordType}
</Tag>
</Space>
<ATypographyText type="secondary">{row.fqdn}</ATypographyText>
</Space>
);
}
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 (
<Tag color={syncStatusColors[row.syncStatus]}>
{syncStatusLabels[row.syncStatus]}
</Tag>
);
}
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 () => (
<>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: renderBodyCell,
headerControls: renderProviderStatus,
}}
/>
<NetworkDdnsRecordModal onSaved={handleModalSaved} ref={modalRef} />
</>
);
},
});
/**
* 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;
}

View File

@ -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;
}

View File

@ -58,6 +58,10 @@ class FakeEventSource {
}
const mocks = vi.hoisted(() => ({
accessCodes: new Set<string>([
'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', () => {

View File

@ -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<NetworkTabKey>(tabItems[0]?.key || 'port-forward');
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
const agentStatusUnknown = ref(true);
const busyRowIds = ref<Set<string>>(new Set());
const ddnsTableRef = ref<NetworkDdnsTableExposed>();
const modalRef = ref<NetworkPortForwardModalExposed>();
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
let pageActive = false;
let refreshInFlight: Promise<void> | undefined;
let refreshQueued = false;
const queuedRefreshes = new Set<NetworkTabKey>();
const managementStream = useNetworkManagementStream({
onSnapshotRequired: handleStreamRefresh,
onStateChanged: handleStreamRefresh,
onSnapshotRequired: handleSnapshotRequired,
onStateChanged: handleStateChanged,
});
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
@ -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<void> {
async function requestRefresh(
resource: NetworkTabKey = activeTab.value,
): Promise<void> {
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 () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: renderBodyCell,
headerControls: renderAgentControls,
}}
/>
<NetworkPortForwardModal onSaved={handleModalSaved} ref={modalRef} />
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
{tabItems.length > 0 ? (
<ATabs
activeKey={activeTab.value}
items={tabItems}
onUpdate:activeKey={handleActiveTabChange}
/>
) : null}
{canViewPortForward ? (
<div
style={{
display: activeTab.value === 'port-forward' ? '' : 'none',
}}
>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: renderBodyCell,
headerControls: renderAgentControls,
}}
/>
</div>
) : null}
{canViewDdns ? (
<div style={{ display: activeTab.value === 'ddns' ? '' : 'none' }}>
<NetworkDdnsTable ref={ddnsTableRef} />
</div>
) : null}
{canViewPortForward ? (
<>
<NetworkPortForwardModal
onSaved={handleModalSaved}
ref={modalRef}
/>
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
</>
) : null}
</Page>
);
},