feat: 增加双栈自动DDNS管理
This commit is contained in:
parent
f5165b238c
commit
f94a98c09c
@ -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 放进地址栏。
|
- 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` 页面。
|
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
|
||||||
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
- 系统管理 / 站内信是日志级通知列表,只展示 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 / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
||||||
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||||
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
||||||
|
|||||||
@ -276,6 +276,31 @@ describe('core menu api', () => {
|
|||||||
authCode: 'System:Network:PortForward:History',
|
authCode: 'System:Network:PortForward:History',
|
||||||
type: 'button',
|
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',
|
authCode: 'System:Network:PortForward:History',
|
||||||
type: 'button',
|
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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -83,6 +83,11 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
|
|||||||
'SystemMenuDelete',
|
'SystemMenuDelete',
|
||||||
'SystemMenuEdit',
|
'SystemMenuEdit',
|
||||||
'SystemNetwork',
|
'SystemNetwork',
|
||||||
|
'SystemNetworkDdnsCreate',
|
||||||
|
'SystemNetworkDdnsDelete',
|
||||||
|
'SystemNetworkDdnsList',
|
||||||
|
'SystemNetworkDdnsRetry',
|
||||||
|
'SystemNetworkDdnsUpdate',
|
||||||
'SystemNetworkPortForwardCreate',
|
'SystemNetworkPortForwardCreate',
|
||||||
'SystemNetworkPortForwardDelete',
|
'SystemNetworkPortForwardDelete',
|
||||||
'SystemNetworkPortForwardHistory',
|
'SystemNetworkPortForwardHistory',
|
||||||
|
|||||||
@ -3,16 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createNetworkDdnsRecord,
|
||||||
createNetworkPortForward,
|
createNetworkPortForward,
|
||||||
|
deleteNetworkDdnsRecord,
|
||||||
deleteNetworkPortForward,
|
deleteNetworkPortForward,
|
||||||
disableNetworkPortForwardKeeper,
|
disableNetworkPortForwardKeeper,
|
||||||
enableNetworkPortForwardKeeper,
|
enableNetworkPortForwardKeeper,
|
||||||
getNetworkAgentStatus,
|
getNetworkAgentStatus,
|
||||||
|
getNetworkDdnsList,
|
||||||
|
getNetworkDdnsProviderStatus,
|
||||||
|
getNetworkDdnsSourceOptions,
|
||||||
getNetworkManagementEventsUrl,
|
getNetworkManagementEventsUrl,
|
||||||
getNetworkPortForwardEndpointHistory,
|
getNetworkPortForwardEndpointHistory,
|
||||||
getNetworkPortForwardList,
|
getNetworkPortForwardList,
|
||||||
probeNetworkPortForward,
|
probeNetworkPortForward,
|
||||||
|
retryNetworkDdnsRecord,
|
||||||
retryNetworkPortForward,
|
retryNetworkPortForward,
|
||||||
|
updateNetworkDdnsRecord,
|
||||||
updateNetworkPortForward,
|
updateNetworkPortForward,
|
||||||
} from './network';
|
} from './network';
|
||||||
|
|
||||||
@ -34,6 +41,17 @@ const input = {
|
|||||||
remark: 'managed mapping',
|
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', () => {
|
describe('system network api', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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', () => {
|
it('builds the credentialed SSE URL with an optional replay cursor', () => {
|
||||||
expect(getNetworkManagementEventsUrl()).toBe(
|
expect(getNetworkManagementEventsUrl()).toBe(
|
||||||
'/api/system/network/events/stream',
|
'/api/system/network/events/stream',
|
||||||
|
|||||||
@ -3,6 +3,15 @@ import type { Recordable } from '@vben/types';
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace SystemNetworkApi {
|
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 DesiredPresence = 'absent' | 'present';
|
||||||
export type EndpointEventType =
|
export type EndpointEventType =
|
||||||
| 'changed'
|
| 'changed'
|
||||||
@ -16,7 +25,7 @@ export namespace SystemNetworkApi {
|
|||||||
| 'stale'
|
| 'stale'
|
||||||
| 'starting';
|
| 'starting';
|
||||||
export type Protocol = 'tcp' | 'udp';
|
export type Protocol = 'tcp' | 'udp';
|
||||||
export type StateChangeSource = 'events' | 'reported' | 'status';
|
export type StateChangeSource = 'ddns' | 'events' | 'reported' | 'status';
|
||||||
export type Revision = string;
|
export type Revision = string;
|
||||||
export type SyncStatus =
|
export type SyncStatus =
|
||||||
| 'conflict'
|
| 'conflict'
|
||||||
@ -30,6 +39,8 @@ export namespace SystemNetworkApi {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
appliedRevision: Revision;
|
appliedRevision: Revision;
|
||||||
desiredRevision: Revision;
|
desiredRevision: Revision;
|
||||||
|
currentIpv6ObservedAt?: null | string;
|
||||||
|
currentPublicIpv6?: null | string;
|
||||||
lastErrorCode?: null | string;
|
lastErrorCode?: null | string;
|
||||||
lastErrorMessage?: null | string;
|
lastErrorMessage?: null | string;
|
||||||
lastHeartbeatAt?: null | string;
|
lastHeartbeatAt?: null | string;
|
||||||
@ -53,6 +64,72 @@ export namespace SystemNetworkApi {
|
|||||||
withdrawalReason?: null | string;
|
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> {
|
export interface PageResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
list?: 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.
|
* Loads the persisted port-forward resource page from the API fact source.
|
||||||
* @param params - Pagination and independent protocol/synchronization filters.
|
* @param params - Pagination and independent protocol/synchronization filters.
|
||||||
|
|||||||
@ -120,6 +120,48 @@
|
|||||||
"copyEndpointAction": "Copy Endpoint",
|
"copyEndpointAction": "Copy Endpoint",
|
||||||
"createAction": "Create Port Forward",
|
"createAction": "Create Port Forward",
|
||||||
"createTitle": "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",
|
"deleteAction": "Delete",
|
||||||
"deleteConfirm": "Submit deletion for \"{0}\"?",
|
"deleteConfirm": "Submit deletion for \"{0}\"?",
|
||||||
"deleteSubmitted": "Deletion submitted; waiting for Agent confirmation",
|
"deleteSubmitted": "Deletion submitted; waiting for Agent confirmation",
|
||||||
@ -156,6 +198,7 @@
|
|||||||
"noCurrentEndpoint": "No valid public endpoint is currently available",
|
"noCurrentEndpoint": "No valid public endpoint is currently available",
|
||||||
"operationInProgress": "Another action is already in progress for this row",
|
"operationInProgress": "Another action is already in progress for this row",
|
||||||
"portForwardTitle": "Port Forwards",
|
"portForwardTitle": "Port Forwards",
|
||||||
|
"portForwardTab": "Port Forwards",
|
||||||
"portRange": "The port must be an integer from 1 to 65535",
|
"portRange": "The port must be an integer from 1 to 65535",
|
||||||
"probeSubmitted": "Immediate STUN probe request submitted",
|
"probeSubmitted": "Immediate STUN probe request submitted",
|
||||||
"probeAction": "Refresh Now",
|
"probeAction": "Refresh Now",
|
||||||
|
|||||||
@ -122,6 +122,48 @@
|
|||||||
"copyEndpointAction": "复制端点",
|
"copyEndpointAction": "复制端点",
|
||||||
"createAction": "新增端口转发",
|
"createAction": "新增端口转发",
|
||||||
"createTitle": "新增端口转发",
|
"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": "删除",
|
"deleteAction": "删除",
|
||||||
"deleteConfirm": "确认提交「{0}」的删除请求吗?",
|
"deleteConfirm": "确认提交「{0}」的删除请求吗?",
|
||||||
"deleteSubmitted": "删除请求已提交,等待 Agent 确认",
|
"deleteSubmitted": "删除请求已提交,等待 Agent 确认",
|
||||||
@ -158,6 +200,7 @@
|
|||||||
"noCurrentEndpoint": "当前没有可复制的有效公网端点",
|
"noCurrentEndpoint": "当前没有可复制的有效公网端点",
|
||||||
"operationInProgress": "该记录已有操作正在提交",
|
"operationInProgress": "该记录已有操作正在提交",
|
||||||
"portForwardTitle": "端口转发",
|
"portForwardTitle": "端口转发",
|
||||||
|
"portForwardTab": "端口转发",
|
||||||
"portRange": "端口必须是 1 到 65535 的整数",
|
"portRange": "端口必须是 1 到 65535 的整数",
|
||||||
"probeSubmitted": "已提交立即 STUN 刷新请求",
|
"probeSubmitted": "已提交立即 STUN 刷新请求",
|
||||||
"probeAction": "立即刷新",
|
"probeAction": "立即刷新",
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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));
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -72,7 +72,7 @@ export function useNetworkManagementStream(
|
|||||||
if (
|
if (
|
||||||
!payload.eventId ||
|
!payload.eventId ||
|
||||||
!payload.observedAt ||
|
!payload.observedAt ||
|
||||||
!['events', 'reported', 'status'].includes(payload.source || '')
|
!['ddns', 'events', 'reported', 'status'].includes(payload.source || '')
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,10 @@ class FakeEventSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
accessCodes: new Set<string>([
|
||||||
|
'System:Network:Ddns:List',
|
||||||
|
'System:Network:PortForward:List',
|
||||||
|
]),
|
||||||
api: {
|
api: {
|
||||||
deleteMapping: vi.fn(),
|
deleteMapping: vi.fn(),
|
||||||
disableKeeper: vi.fn(),
|
disableKeeper: vi.fn(),
|
||||||
@ -67,6 +71,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
probe: vi.fn(),
|
probe: vi.fn(),
|
||||||
retry: vi.fn(),
|
retry: vi.fn(),
|
||||||
},
|
},
|
||||||
|
ddnsReload: vi.fn(),
|
||||||
messageSuccess: vi.fn(),
|
messageSuccess: vi.fn(),
|
||||||
modalOpenCreate: vi.fn(),
|
modalOpenCreate: vi.fn(),
|
||||||
modalOpenEdit: vi.fn(),
|
modalOpenEdit: vi.fn(),
|
||||||
@ -77,6 +82,13 @@ const mocks = vi.hoisted(() => ({
|
|||||||
tableOptions: undefined as any,
|
tableOptions: undefined as any,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@vben/access', () => ({
|
||||||
|
useAccess: () => ({
|
||||||
|
hasAccessByCodes: (codes: string[]) =>
|
||||||
|
codes.every((code) => mocks.accessCodes.has(code)),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@vben/common-ui', () => ({
|
vi.mock('@vben/common-ui', () => ({
|
||||||
Page: defineComponent({
|
Page: defineComponent({
|
||||||
name: 'MockPage',
|
name: 'MockPage',
|
||||||
@ -100,6 +112,31 @@ vi.mock('antdv-next', () => ({
|
|||||||
return () => h('span', slots.default?.());
|
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: {
|
Typography: {
|
||||||
Text: defineComponent({
|
Text: defineComponent({
|
||||||
name: 'MockText',
|
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', () => ({
|
vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'MockNetworkEndpointHistoryDrawer',
|
name: 'MockNetworkEndpointHistoryDrawer',
|
||||||
@ -214,6 +261,11 @@ describe('system network persisted list', () => {
|
|||||||
FakeEventSource.instances = [];
|
FakeEventSource.instances = [];
|
||||||
vi.stubGlobal('EventSource', FakeEventSource);
|
vi.stubGlobal('EventSource', FakeEventSource);
|
||||||
mocks.tableOptions = undefined;
|
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.getRows.mockReturnValue([]);
|
||||||
mocks.tableApi.reload.mockResolvedValue(undefined);
|
mocks.tableApi.reload.mockResolvedValue(undefined);
|
||||||
mocks.api.getAgentStatus.mockResolvedValue({
|
mocks.api.getAgentStatus.mockResolvedValue({
|
||||||
@ -245,6 +297,12 @@ describe('system network persisted list', () => {
|
|||||||
|
|
||||||
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
|
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
|
||||||
expect(wrapper.find('[data-testid="network-table"]').exists()).toBe(true);
|
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(network).toMatchObject({ path: '/system/network' });
|
||||||
expect(String(network?.component)).toContain('network/list');
|
expect(String(network?.component)).toContain('network/list');
|
||||||
expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([
|
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', () => {
|
it('routes list labels and status values through the network locale namespace', () => {
|
||||||
const wrapper = mount(NetworkList);
|
const wrapper = mount(NetworkList);
|
||||||
|
|
||||||
@ -411,6 +485,15 @@ describe('system network persisted list', () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
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', {
|
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||||
eventId: 'network-event-1',
|
eventId: 'network-event-1',
|
||||||
observedAt: '2026-07-23T00:00:01.000Z',
|
observedAt: '2026-07-23T00:00:01.000Z',
|
||||||
@ -431,6 +514,59 @@ describe('system network persisted list', () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
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', () => {
|
describe('network action constraints', () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { TableColumnType } from 'antdv-next';
|
import type { TableColumnType } from 'antdv-next';
|
||||||
|
|
||||||
|
import type { NetworkDdnsTableExposed } from './components/NetworkDdnsTable';
|
||||||
import type { NetworkEndpointHistoryDrawerExposed } from './components/NetworkEndpointHistoryDrawer';
|
import type { NetworkEndpointHistoryDrawerExposed } from './components/NetworkEndpointHistoryDrawer';
|
||||||
import type { NetworkPortForwardModalExposed } from './components/NetworkPortForwardModal';
|
import type { NetworkPortForwardModalExposed } from './components/NetworkPortForwardModal';
|
||||||
|
|
||||||
@ -19,10 +20,11 @@ import {
|
|||||||
ref,
|
ref,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
|
import { useAccess } from '@vben/access';
|
||||||
import { Page } from '@vben/common-ui';
|
import { Page } from '@vben/common-ui';
|
||||||
import { Plus } from '@vben/icons';
|
import { Plus } from '@vben/icons';
|
||||||
|
|
||||||
import { message, Space, Tag, Typography } from 'antdv-next';
|
import { message, Space, Tabs, Tag, Typography } from 'antdv-next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteNetworkPortForward,
|
deleteNetworkPortForward,
|
||||||
@ -36,12 +38,15 @@ import {
|
|||||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import NetworkDdnsTable from './components/NetworkDdnsTable';
|
||||||
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
||||||
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
||||||
import { useNetworkManagementStream } from './composables/useNetworkManagementStream';
|
import { useNetworkManagementStream } from './composables/useNetworkManagementStream';
|
||||||
|
|
||||||
const AKtTable = KtTable as any;
|
const AKtTable = KtTable as any;
|
||||||
|
const ATabs = Tabs as any;
|
||||||
const ATypographyText = Typography.Text as any;
|
const ATypographyText = Typography.Text as any;
|
||||||
|
type NetworkTabKey = 'ddns' | 'port-forward';
|
||||||
const protocolOptions = [
|
const protocolOptions = [
|
||||||
{ label: 'TCP', value: 'tcp' },
|
{ label: 'TCP', value: 'tcp' },
|
||||||
{ label: 'UDP', value: 'udp' },
|
{ label: 'UDP', value: 'udp' },
|
||||||
@ -91,17 +96,42 @@ export default defineComponent({
|
|||||||
* Builds the generic persisted network-resource table and event-driven refresh stream.
|
* Builds the generic persisted network-resource table and event-driven refresh stream.
|
||||||
*/
|
*/
|
||||||
setup() {
|
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 agentStatus = ref<SystemNetworkApi.AgentStatus>();
|
||||||
const agentStatusUnknown = ref(true);
|
const agentStatusUnknown = ref(true);
|
||||||
const busyRowIds = ref<Set<string>>(new Set());
|
const busyRowIds = ref<Set<string>>(new Set());
|
||||||
|
const ddnsTableRef = ref<NetworkDdnsTableExposed>();
|
||||||
const modalRef = ref<NetworkPortForwardModalExposed>();
|
const modalRef = ref<NetworkPortForwardModalExposed>();
|
||||||
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
|
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
|
||||||
let pageActive = false;
|
let pageActive = false;
|
||||||
let refreshInFlight: Promise<void> | undefined;
|
let refreshInFlight: Promise<void> | undefined;
|
||||||
let refreshQueued = false;
|
const queuedRefreshes = new Set<NetworkTabKey>();
|
||||||
const managementStream = useNetworkManagementStream({
|
const managementStream = useNetworkManagementStream({
|
||||||
onSnapshotRequired: handleStreamRefresh,
|
onSnapshotRequired: handleSnapshotRequired,
|
||||||
onStateChanged: handleStreamRefresh,
|
onStateChanged: handleStateChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
|
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
|
||||||
@ -356,7 +386,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
/** Reloads after a modal save while preserving serialized request order. */
|
/** Reloads after a modal save while preserving serialized request order. */
|
||||||
function handleModalSaved() {
|
function handleModalSaved() {
|
||||||
void requestRefresh();
|
void requestRefresh('port-forward');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copies an API-approved current endpoint without logging it. */
|
/** 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.
|
* 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) {
|
if (refreshInFlight) {
|
||||||
refreshQueued = true;
|
queuedRefreshes.add(resource);
|
||||||
await refreshInFlight;
|
await refreshInFlight;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshInFlight = Promise.allSettled([
|
refreshInFlight = performResourceRefresh(resource);
|
||||||
tableApi.reload(),
|
|
||||||
loadAgentStatus(),
|
|
||||||
]).then(() => undefined);
|
|
||||||
try {
|
try {
|
||||||
await refreshInFlight;
|
await refreshInFlight;
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = undefined;
|
refreshInFlight = undefined;
|
||||||
}
|
}
|
||||||
if (refreshQueued) {
|
const nextResource = queuedRefreshes.values().next().value as
|
||||||
refreshQueued = false;
|
| NetworkTabKey
|
||||||
await requestRefresh();
|
| undefined;
|
||||||
|
if (nextResource) {
|
||||||
|
queuedRefreshes.delete(nextResource);
|
||||||
|
await requestRefresh(nextResource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refreshes persisted facts only for a committed topic event or replay gap. */
|
/**
|
||||||
function handleStreamRefresh() {
|
* Loads exactly one permission-approved resource without cross-tab requests.
|
||||||
if (pageActive) void requestRefresh();
|
* @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. */
|
/** Opens the stream before the one initial page snapshot to avoid a subscription gap. */
|
||||||
function activatePage() {
|
function activatePage() {
|
||||||
if (pageActive) return;
|
if (pageActive || tabItems.length === 0) return;
|
||||||
pageActive = true;
|
pageActive = true;
|
||||||
managementStream.start();
|
managementStream.start();
|
||||||
void requestRefresh();
|
void requestRefresh(activeTab.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Closes the route-owned stream while preserving its replay cursor. */
|
/** Closes the route-owned stream while preserving its replay cursor. */
|
||||||
@ -534,6 +609,19 @@ export default defineComponent({
|
|||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<Page autoContentHeight>
|
<Page autoContentHeight>
|
||||||
|
{tabItems.length > 0 ? (
|
||||||
|
<ATabs
|
||||||
|
activeKey={activeTab.value}
|
||||||
|
items={tabItems}
|
||||||
|
onUpdate:activeKey={handleActiveTabChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canViewPortForward ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: activeTab.value === 'port-forward' ? '' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AKtTable
|
<AKtTable
|
||||||
onRegister={registerTable}
|
onRegister={registerTable}
|
||||||
v-slots={{
|
v-slots={{
|
||||||
@ -541,8 +629,22 @@ export default defineComponent({
|
|||||||
headerControls: renderAgentControls,
|
headerControls: renderAgentControls,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NetworkPortForwardModal onSaved={handleModalSaved} ref={modalRef} />
|
</div>
|
||||||
|
) : null}
|
||||||
|
{canViewDdns ? (
|
||||||
|
<div style={{ display: activeTab.value === 'ddns' ? '' : 'none' }}>
|
||||||
|
<NetworkDdnsTable ref={ddnsTableRef} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{canViewPortForward ? (
|
||||||
|
<>
|
||||||
|
<NetworkPortForwardModal
|
||||||
|
onSaved={handleModalSaved}
|
||||||
|
ref={modalRef}
|
||||||
|
/>
|
||||||
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
|
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user