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 放进地址栏。
|
||||
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
|
||||
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
||||
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。首屏读取一次 HTTP 快照,后续只在 API SSE 状态事件或主动操作后刷新;心跳不刷新页面,不使用定时轮询。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。
|
||||
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态,并通过独立页签管理腾讯云云解析 DNS 的 A/AAAA 自动更新绑定。页面展示 Agent、路由同步、UDP Keeper、公网端点租约和 DDNS 同步状态,支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史;A 只使用合格 UDP Keeper 的公网 IPv4,AAAA 只使用 Agent 全局 IPv6,DNS 值不包含端口。首屏读取一次 HTTP 快照,后续只按 API SSE 的资源来源刷新当前活动页签;心跳和其他页签事件不刷新,不使用定时轮询。Admin 不接触路由器、MQTT 或腾讯云凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。
|
||||
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
||||
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
||||
|
||||
@ -276,6 +276,31 @@ describe('core menu api', () => {
|
||||
authCode: 'System:Network:PortForward:History',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsList',
|
||||
authCode: 'System:Network:Ddns:List',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsCreate',
|
||||
authCode: 'System:Network:Ddns:Create',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsUpdate',
|
||||
authCode: 'System:Network:Ddns:Update',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsDelete',
|
||||
authCode: 'System:Network:Ddns:Delete',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsRetry',
|
||||
authCode: 'System:Network:Ddns:Retry',
|
||||
type: 'button',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -335,6 +360,31 @@ describe('core menu api', () => {
|
||||
authCode: 'System:Network:PortForward:History',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsList',
|
||||
authCode: 'System:Network:Ddns:List',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsCreate',
|
||||
authCode: 'System:Network:Ddns:Create',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsUpdate',
|
||||
authCode: 'System:Network:Ddns:Update',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsDelete',
|
||||
authCode: 'System:Network:Ddns:Delete',
|
||||
type: 'button',
|
||||
},
|
||||
{
|
||||
name: 'SystemNetworkDdnsRetry',
|
||||
authCode: 'System:Network:Ddns:Retry',
|
||||
type: 'button',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -83,6 +83,11 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
|
||||
'SystemMenuDelete',
|
||||
'SystemMenuEdit',
|
||||
'SystemNetwork',
|
||||
'SystemNetworkDdnsCreate',
|
||||
'SystemNetworkDdnsDelete',
|
||||
'SystemNetworkDdnsList',
|
||||
'SystemNetworkDdnsRetry',
|
||||
'SystemNetworkDdnsUpdate',
|
||||
'SystemNetworkPortForwardCreate',
|
||||
'SystemNetworkPortForwardDelete',
|
||||
'SystemNetworkPortForwardHistory',
|
||||
|
||||
@ -3,16 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
import {
|
||||
createNetworkDdnsRecord,
|
||||
createNetworkPortForward,
|
||||
deleteNetworkDdnsRecord,
|
||||
deleteNetworkPortForward,
|
||||
disableNetworkPortForwardKeeper,
|
||||
enableNetworkPortForwardKeeper,
|
||||
getNetworkAgentStatus,
|
||||
getNetworkDdnsList,
|
||||
getNetworkDdnsProviderStatus,
|
||||
getNetworkDdnsSourceOptions,
|
||||
getNetworkManagementEventsUrl,
|
||||
getNetworkPortForwardEndpointHistory,
|
||||
getNetworkPortForwardList,
|
||||
probeNetworkPortForward,
|
||||
retryNetworkDdnsRecord,
|
||||
retryNetworkPortForward,
|
||||
updateNetworkDdnsRecord,
|
||||
updateNetworkPortForward,
|
||||
} from './network';
|
||||
|
||||
@ -34,6 +41,17 @@ const input = {
|
||||
remark: 'managed mapping',
|
||||
};
|
||||
|
||||
const ddnsInput = {
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: true,
|
||||
name: 'NAS IPv4',
|
||||
portForwardId: '90071992547409930',
|
||||
recordType: 'A' as const,
|
||||
remark: 'managed DNS',
|
||||
sourceType: 'port_forward_ipv4' as const,
|
||||
subDomain: 'nas',
|
||||
};
|
||||
|
||||
describe('system network api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -105,6 +123,71 @@ describe('system network api', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the dual-stack DDNS CRUD endpoints without credential fields', async () => {
|
||||
await getNetworkDdnsList({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
recordType: 'AAAA',
|
||||
});
|
||||
await getNetworkDdnsSourceOptions('A');
|
||||
await getNetworkDdnsProviderStatus();
|
||||
await createNetworkDdnsRecord(ddnsInput);
|
||||
await updateNetworkDdnsRecord('ddns-1', ddnsInput);
|
||||
await retryNetworkDdnsRecord('ddns-1');
|
||||
await deleteNetworkDdnsRecord('ddns-1');
|
||||
|
||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/system/network/ddns/list',
|
||||
{ params: { pageNo: 1, pageSize: 20, recordType: 'AAAA' } },
|
||||
);
|
||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/system/network/ddns/source-options',
|
||||
{ params: { recordType: 'A' } },
|
||||
);
|
||||
expect(requestClient.get).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/system/network/ddns/provider-status',
|
||||
);
|
||||
expect(requestClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/system/network/ddns',
|
||||
ddnsInput,
|
||||
);
|
||||
expect(requestClient.put).toHaveBeenCalledWith(
|
||||
'/system/network/ddns/ddns-1',
|
||||
ddnsInput,
|
||||
);
|
||||
expect(requestClient.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/system/network/ddns/ddns-1/retry',
|
||||
);
|
||||
expect(requestClient.delete).toHaveBeenCalledWith(
|
||||
'/system/network/ddns/ddns-1',
|
||||
);
|
||||
expect(JSON.stringify(ddnsInput)).not.toMatch(
|
||||
/secret|credential|password|token/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps AAAA source selection server-controlled and free of a mapping ID', async () => {
|
||||
const ipv6Input = {
|
||||
...ddnsInput,
|
||||
portForwardId: undefined,
|
||||
recordType: 'AAAA' as const,
|
||||
sourceType: 'agent_ipv6' as const,
|
||||
subDomain: 'nas6',
|
||||
};
|
||||
|
||||
await createNetworkDdnsRecord(ipv6Input);
|
||||
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
'/system/network/ddns',
|
||||
ipv6Input,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the credentialed SSE URL with an optional replay cursor', () => {
|
||||
expect(getNetworkManagementEventsUrl()).toBe(
|
||||
'/api/system/network/events/stream',
|
||||
|
||||
@ -3,6 +3,15 @@ import type { Recordable } from '@vben/types';
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemNetworkApi {
|
||||
export type DdnsRecordType = 'A' | 'AAAA';
|
||||
export type DdnsSourceType = 'agent_ipv6' | 'port_forward_ipv4';
|
||||
export type DdnsSyncStatus =
|
||||
| 'disabled'
|
||||
| 'failed'
|
||||
| 'pending'
|
||||
| 'synced'
|
||||
| 'syncing'
|
||||
| 'waiting_source';
|
||||
export type DesiredPresence = 'absent' | 'present';
|
||||
export type EndpointEventType =
|
||||
| 'changed'
|
||||
@ -16,7 +25,7 @@ export namespace SystemNetworkApi {
|
||||
| 'stale'
|
||||
| 'starting';
|
||||
export type Protocol = 'tcp' | 'udp';
|
||||
export type StateChangeSource = 'events' | 'reported' | 'status';
|
||||
export type StateChangeSource = 'ddns' | 'events' | 'reported' | 'status';
|
||||
export type Revision = string;
|
||||
export type SyncStatus =
|
||||
| 'conflict'
|
||||
@ -30,6 +39,8 @@ export namespace SystemNetworkApi {
|
||||
agentId: string;
|
||||
appliedRevision: Revision;
|
||||
desiredRevision: Revision;
|
||||
currentIpv6ObservedAt?: null | string;
|
||||
currentPublicIpv6?: null | string;
|
||||
lastErrorCode?: null | string;
|
||||
lastErrorMessage?: null | string;
|
||||
lastHeartbeatAt?: null | string;
|
||||
@ -53,6 +64,72 @@ export namespace SystemNetworkApi {
|
||||
withdrawalReason?: null | string;
|
||||
}
|
||||
|
||||
export interface DdnsProviderStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
provider: 'dnspod';
|
||||
}
|
||||
|
||||
export interface DdnsSourceOption {
|
||||
currentAddress?: null | string;
|
||||
disabledReasonCode?: null | string;
|
||||
eligible: boolean;
|
||||
externalPort?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
observedAt?: null | string;
|
||||
protocol?: Protocol;
|
||||
sourceType: DdnsSourceType;
|
||||
validUntil?: null | string;
|
||||
}
|
||||
|
||||
export interface DdnsSourceOptionsResult {
|
||||
items: DdnsSourceOption[];
|
||||
}
|
||||
|
||||
export interface DdnsRecord {
|
||||
appliedAddress?: null | string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
fqdn: string;
|
||||
id: string;
|
||||
lastErrorCode?: null | string;
|
||||
lastErrorMessage?: null | string;
|
||||
lastSyncedAt?: null | string;
|
||||
name: string;
|
||||
nextRetryAt?: null | string;
|
||||
portForwardId?: null | string;
|
||||
recordType: DdnsRecordType;
|
||||
remark?: null | string;
|
||||
retryCount: number;
|
||||
source: DdnsSourceOption;
|
||||
sourceAddress?: null | string;
|
||||
sourceType: DdnsSourceType;
|
||||
subDomain: string;
|
||||
syncStatus: DdnsSyncStatus;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface DdnsRecordInput {
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
portForwardId?: string;
|
||||
recordType: DdnsRecordType;
|
||||
remark?: string;
|
||||
sourceType: DdnsSourceType;
|
||||
subDomain: string;
|
||||
}
|
||||
|
||||
export interface DdnsRecordQuery extends Recordable<any> {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
recordType?: DdnsRecordType;
|
||||
syncStatus?: DdnsSyncStatus;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
list?: T[];
|
||||
@ -116,6 +193,93 @@ export namespace SystemNetworkApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the persisted automatic-DDNS record page for either address family.
|
||||
* @param params - Pagination and independent record/status filters.
|
||||
* @returns DDNS rows with API-provided FQDN and source state.
|
||||
*/
|
||||
export function getNetworkDdnsList(params: SystemNetworkApi.DdnsRecordQuery) {
|
||||
return requestClient.get<
|
||||
SystemNetworkApi.PageResult<SystemNetworkApi.DdnsRecord>
|
||||
>('/system/network/ddns/list', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads eligible and disabled source choices for one DNS address family.
|
||||
* @param recordType - A selects port-forward IPv4 sources; AAAA selects Agent IPv6.
|
||||
* @returns Server-controlled source options and their current address state.
|
||||
*/
|
||||
export function getNetworkDdnsSourceOptions(
|
||||
recordType: SystemNetworkApi.DdnsRecordType,
|
||||
) {
|
||||
return requestClient.get<SystemNetworkApi.DdnsSourceOptionsResult>(
|
||||
'/system/network/ddns/source-options',
|
||||
{ params: { recordType } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the redacted DNSPod runtime readiness state.
|
||||
* @returns Provider name plus enabled/configured flags, never credentials.
|
||||
*/
|
||||
export function getNetworkDdnsProviderStatus() {
|
||||
return requestClient.get<SystemNetworkApi.DdnsProviderStatus>(
|
||||
'/system/network/ddns/provider-status',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one generic A or AAAA automatic-DDNS binding.
|
||||
* @param data - User-editable record and server-recognized source selector.
|
||||
* @returns Persisted DDNS record with asynchronous synchronization state.
|
||||
*/
|
||||
export function createNetworkDdnsRecord(
|
||||
data: SystemNetworkApi.DdnsRecordInput,
|
||||
) {
|
||||
return requestClient.post<SystemNetworkApi.DdnsRecord>(
|
||||
'/system/network/ddns',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates one persisted automatic-DDNS binding.
|
||||
* @param id - Stable string record ID.
|
||||
* @param data - Complete editable DDNS input.
|
||||
* @returns Updated record with pending synchronization state.
|
||||
*/
|
||||
export function updateNetworkDdnsRecord(
|
||||
id: string,
|
||||
data: SystemNetworkApi.DdnsRecordInput,
|
||||
) {
|
||||
return requestClient.put<SystemNetworkApi.DdnsRecord>(
|
||||
`/system/network/ddns/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes only the local automatic-update binding, leaving DNSPod unchanged.
|
||||
* @param id - Stable string record ID.
|
||||
* @returns Deleted local record state.
|
||||
*/
|
||||
export function deleteNetworkDdnsRecord(id: string) {
|
||||
return requestClient.delete<SystemNetworkApi.DdnsRecord>(
|
||||
`/system/network/ddns/${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests one immediate provider reconciliation for an eligible source.
|
||||
* @param id - Stable string record ID.
|
||||
* @returns Latest DDNS synchronization state.
|
||||
*/
|
||||
export function retryNetworkDdnsRecord(id: string) {
|
||||
return requestClient.post<SystemNetworkApi.DdnsRecord>(
|
||||
`/system/network/ddns/${id}/retry`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the persisted port-forward resource page from the API fact source.
|
||||
* @param params - Pagination and independent protocol/synchronization filters.
|
||||
|
||||
@ -120,6 +120,48 @@
|
||||
"copyEndpointAction": "Copy Endpoint",
|
||||
"createAction": "Create Port Forward",
|
||||
"createTitle": "Create Port Forward",
|
||||
"ddnsAgentIpv6Source": "Agent Public IPv6",
|
||||
"ddnsAppliedAddress": "Applied Address",
|
||||
"ddnsCreateAction": "Create Automatic DDNS",
|
||||
"ddnsCreateTitle": "Create Automatic DDNS",
|
||||
"ddnsDeleteConfirm": "Delete the local automatic-update binding? The existing Tencent Cloud DNS A/AAAA record and value will remain unchanged.",
|
||||
"ddnsDeleteSubmitted": "Local automatic update removed; Tencent Cloud DNS record unchanged",
|
||||
"ddnsDomain": "Domain",
|
||||
"ddnsDomainInvalid": "Enter a valid domain without protocol, path, query, spaces, or port",
|
||||
"ddnsEditTitle": "Edit Automatic DDNS",
|
||||
"ddnsEnabled": "Enable Automatic Updates",
|
||||
"ddnsLastSync": "Last Sync / Error",
|
||||
"ddnsNextRetry": "Next Retry",
|
||||
"ddnsProviderName": "Tencent Cloud DNS",
|
||||
"ddnsProviderReady": "Ready",
|
||||
"ddnsProviderUnavailable": "Disabled or not configured",
|
||||
"ddnsProviderUnknown": "Status Unknown",
|
||||
"ddnsRecord": "Name / FQDN",
|
||||
"ddnsRecordType": "Record Type",
|
||||
"ddnsRetryAction": "Sync Now",
|
||||
"ddnsRetryDisabled": "Automatic updates are disabled",
|
||||
"ddnsRetrySubmitted": "DDNS synchronization requested",
|
||||
"ddnsSaved": "Automatic DDNS configuration saved",
|
||||
"ddnsSelectSource": "Select a public-address source",
|
||||
"ddnsSource": "Address Source",
|
||||
"ddnsSourceAddress": "Source Address",
|
||||
"ddnsSourceAddressMissing": "The source has no current public address",
|
||||
"ddnsSourceLoading": "Loading Agent IPv6 state",
|
||||
"ddnsSourceRequired": "Select an eligible IPv4 port-forward source",
|
||||
"ddnsSourceUnavailable": "Source unavailable",
|
||||
"ddnsStatusDisabled": "Disabled",
|
||||
"ddnsStatusFailed": "Failed",
|
||||
"ddnsStatusPending": "Pending",
|
||||
"ddnsStatusSynced": "Synced",
|
||||
"ddnsStatusSyncing": "Syncing",
|
||||
"ddnsStatusWaitingSource": "Waiting for Source",
|
||||
"ddnsSubDomain": "Host Record",
|
||||
"ddnsSubDomainInvalid": "Enter a valid host record; use @ for the root",
|
||||
"ddnsSyncInProgress": "Synchronization is in progress",
|
||||
"ddnsSyncStatus": "DDNS Status",
|
||||
"ddnsTab": "Automatic DDNS",
|
||||
"ddnsTitle": "Automatic DDNS",
|
||||
"ddnsWaitingSourceAddress": "Waiting for a valid public address",
|
||||
"deleteAction": "Delete",
|
||||
"deleteConfirm": "Submit deletion for \"{0}\"?",
|
||||
"deleteSubmitted": "Deletion submitted; waiting for Agent confirmation",
|
||||
@ -156,6 +198,7 @@
|
||||
"noCurrentEndpoint": "No valid public endpoint is currently available",
|
||||
"operationInProgress": "Another action is already in progress for this row",
|
||||
"portForwardTitle": "Port Forwards",
|
||||
"portForwardTab": "Port Forwards",
|
||||
"portRange": "The port must be an integer from 1 to 65535",
|
||||
"probeSubmitted": "Immediate STUN probe request submitted",
|
||||
"probeAction": "Refresh Now",
|
||||
|
||||
@ -122,6 +122,48 @@
|
||||
"copyEndpointAction": "复制端点",
|
||||
"createAction": "新增端口转发",
|
||||
"createTitle": "新增端口转发",
|
||||
"ddnsAgentIpv6Source": "Agent 公网 IPv6",
|
||||
"ddnsAppliedAddress": "已应用地址",
|
||||
"ddnsCreateAction": "新增自动 DDNS",
|
||||
"ddnsCreateTitle": "新增自动 DDNS",
|
||||
"ddnsDeleteConfirm": "确认删除本地自动更新配置吗?腾讯云云解析 DNS 中现有的 A/AAAA 记录和值将保持不变。",
|
||||
"ddnsDeleteSubmitted": "本地自动更新配置已删除,腾讯云云解析 DNS 记录保持不变",
|
||||
"ddnsDomain": "主域名",
|
||||
"ddnsDomainInvalid": "请输入不含协议、路径、查询、空格或端口的有效主域名",
|
||||
"ddnsEditTitle": "编辑自动 DDNS",
|
||||
"ddnsEnabled": "启用自动更新",
|
||||
"ddnsLastSync": "最近同步 / 错误",
|
||||
"ddnsNextRetry": "下次重试",
|
||||
"ddnsProviderName": "腾讯云云解析 DNS",
|
||||
"ddnsProviderReady": "已就绪",
|
||||
"ddnsProviderUnavailable": "未启用或未配置",
|
||||
"ddnsProviderUnknown": "状态未知",
|
||||
"ddnsRecord": "名称 / FQDN",
|
||||
"ddnsRecordType": "记录类型",
|
||||
"ddnsRetryAction": "立即同步",
|
||||
"ddnsRetryDisabled": "自动更新已停用",
|
||||
"ddnsRetrySubmitted": "已提交 DDNS 同步请求",
|
||||
"ddnsSaved": "自动 DDNS 配置已保存",
|
||||
"ddnsSelectSource": "选择公网地址来源",
|
||||
"ddnsSource": "地址来源",
|
||||
"ddnsSourceAddress": "来源地址",
|
||||
"ddnsSourceAddressMissing": "来源当前没有可用公网地址",
|
||||
"ddnsSourceLoading": "正在读取 Agent IPv6 状态",
|
||||
"ddnsSourceRequired": "请选择一个可用的 IPv4 端口转发来源",
|
||||
"ddnsSourceUnavailable": "来源不可用",
|
||||
"ddnsStatusDisabled": "已停用",
|
||||
"ddnsStatusFailed": "失败",
|
||||
"ddnsStatusPending": "等待同步",
|
||||
"ddnsStatusSynced": "已同步",
|
||||
"ddnsStatusSyncing": "同步中",
|
||||
"ddnsStatusWaitingSource": "等待来源",
|
||||
"ddnsSubDomain": "主机记录",
|
||||
"ddnsSubDomainInvalid": "请输入有效主机记录,根记录使用 @",
|
||||
"ddnsSyncInProgress": "同步正在进行中",
|
||||
"ddnsSyncStatus": "DDNS 状态",
|
||||
"ddnsTab": "自动 DDNS",
|
||||
"ddnsTitle": "自动 DDNS",
|
||||
"ddnsWaitingSourceAddress": "等待有效公网地址",
|
||||
"deleteAction": "删除",
|
||||
"deleteConfirm": "确认提交「{0}」的删除请求吗?",
|
||||
"deleteSubmitted": "删除请求已提交,等待 Agent 确认",
|
||||
@ -158,6 +200,7 @@
|
||||
"noCurrentEndpoint": "当前没有可复制的有效公网端点",
|
||||
"operationInProgress": "该记录已有操作正在提交",
|
||||
"portForwardTitle": "端口转发",
|
||||
"portForwardTab": "端口转发",
|
||||
"portRange": "端口必须是 1 到 65535 的整数",
|
||||
"probeSubmitted": "已提交立即 STUN 刷新请求",
|
||||
"probeAction": "立即刷新",
|
||||
|
||||
@ -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 (
|
||||
!payload.eventId ||
|
||||
!payload.observedAt ||
|
||||
!['events', 'reported', 'status'].includes(payload.source || '')
|
||||
!['ddns', 'events', 'reported', 'status'].includes(payload.source || '')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -58,6 +58,10 @@ class FakeEventSource {
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accessCodes: new Set<string>([
|
||||
'System:Network:Ddns:List',
|
||||
'System:Network:PortForward:List',
|
||||
]),
|
||||
api: {
|
||||
deleteMapping: vi.fn(),
|
||||
disableKeeper: vi.fn(),
|
||||
@ -67,6 +71,7 @@ const mocks = vi.hoisted(() => ({
|
||||
probe: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
},
|
||||
ddnsReload: vi.fn(),
|
||||
messageSuccess: vi.fn(),
|
||||
modalOpenCreate: vi.fn(),
|
||||
modalOpenEdit: vi.fn(),
|
||||
@ -77,6 +82,13 @@ const mocks = vi.hoisted(() => ({
|
||||
tableOptions: undefined as any,
|
||||
}));
|
||||
|
||||
vi.mock('@vben/access', () => ({
|
||||
useAccess: () => ({
|
||||
hasAccessByCodes: (codes: string[]) =>
|
||||
codes.every((code) => mocks.accessCodes.has(code)),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@vben/common-ui', () => ({
|
||||
Page: defineComponent({
|
||||
name: 'MockPage',
|
||||
@ -100,6 +112,31 @@ vi.mock('antdv-next', () => ({
|
||||
return () => h('span', slots.default?.());
|
||||
},
|
||||
}),
|
||||
Tabs: defineComponent({
|
||||
name: 'MockTabs',
|
||||
props: {
|
||||
activeKey: { default: '', type: String },
|
||||
items: { default: () => [], type: Array },
|
||||
},
|
||||
emits: ['update:activeKey'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'nav',
|
||||
{ 'data-testid': 'network-tabs' },
|
||||
(props.items as Array<{ key: string; label: string }>).map((item) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-tab': item.key,
|
||||
onClick: () => emit('update:activeKey', item.key),
|
||||
},
|
||||
item.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
Typography: {
|
||||
Text: defineComponent({
|
||||
name: 'MockText',
|
||||
@ -150,6 +187,16 @@ vi.mock('./components/NetworkPortForwardModal', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./components/NetworkDdnsTable', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockNetworkDdnsTable',
|
||||
setup(_, { expose }) {
|
||||
expose({ reload: mocks.ddnsReload });
|
||||
return () => h('section', { 'data-testid': 'ddns-table' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockNetworkEndpointHistoryDrawer',
|
||||
@ -214,6 +261,11 @@ describe('system network persisted list', () => {
|
||||
FakeEventSource.instances = [];
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
mocks.tableOptions = undefined;
|
||||
mocks.accessCodes = new Set([
|
||||
'System:Network:Ddns:List',
|
||||
'System:Network:PortForward:List',
|
||||
]);
|
||||
mocks.ddnsReload.mockResolvedValue(undefined);
|
||||
mocks.tableApi.getRows.mockReturnValue([]);
|
||||
mocks.tableApi.reload.mockResolvedValue(undefined);
|
||||
mocks.api.getAgentStatus.mockResolvedValue({
|
||||
@ -245,6 +297,12 @@ describe('system network persisted list', () => {
|
||||
|
||||
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
|
||||
expect(wrapper.find('[data-testid="network-table"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="ddns-table"]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('[data-testid="network-tabs"] button')
|
||||
.map((item) => item.text()),
|
||||
).toEqual(['system.network.portForwardTab', 'system.network.ddnsTab']);
|
||||
expect(network).toMatchObject({ path: '/system/network' });
|
||||
expect(String(network?.component)).toContain('network/list');
|
||||
expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([
|
||||
@ -261,6 +319,22 @@ describe('system network persisted list', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters tabs by List permission and never requests a forbidden list', async () => {
|
||||
mocks.accessCodes = new Set(['System:Network:Ddns:List']);
|
||||
const wrapper = mount(NetworkList);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('[data-testid="network-tabs"] button')
|
||||
.map((item) => item.text()),
|
||||
).toEqual(['system.network.ddnsTab']);
|
||||
expect(mocks.tableApi.reload).not.toHaveBeenCalled();
|
||||
expect(mocks.api.getAgentStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledOnce();
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('routes list labels and status values through the network locale namespace', () => {
|
||||
const wrapper = mount(NetworkList);
|
||||
|
||||
@ -411,6 +485,15 @@ describe('system network persisted list', () => {
|
||||
await flushPromises();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||
eventId: 'network-ddns-event-while-port-forward-active',
|
||||
observedAt: '2026-07-23T00:00:00.500Z',
|
||||
source: 'ddns',
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.ddnsReload).not.toHaveBeenCalled();
|
||||
|
||||
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||
eventId: 'network-event-1',
|
||||
observedAt: '2026-07-23T00:00:01.000Z',
|
||||
@ -431,6 +514,59 @@ describe('system network persisted list', () => {
|
||||
await flushPromises();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('routes DDNS and replay-gap events to one active table without polling', async () => {
|
||||
const wrapper = mount(NetworkList);
|
||||
await flushPromises();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||
expect(mocks.ddnsReload).not.toHaveBeenCalled();
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
|
||||
await wrapper
|
||||
.find('[data-testid="network-tabs"] [data-tab="ddns"]')
|
||||
.trigger('click');
|
||||
await flushPromises();
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledOnce();
|
||||
|
||||
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||
eventId: 'network-reported-event-while-ddns-active',
|
||||
observedAt: '2026-07-23T00:00:00.500Z',
|
||||
source: 'reported',
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledOnce();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||
|
||||
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||
eventId: 'network-ddns-event-1',
|
||||
observedAt: '2026-07-23T00:00:01.000Z',
|
||||
source: 'ddns',
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
|
||||
|
||||
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||
eventId: 'network-ddns-event-1',
|
||||
observedAt: '2026-07-23T00:00:01.000Z',
|
||||
source: 'ddns',
|
||||
});
|
||||
FakeEventSource.instances[0]?.dispatch('heartbeat', {
|
||||
observedAt: '2026-07-23T00:00:02.000Z',
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledTimes(2);
|
||||
|
||||
FakeEventSource.instances[0]?.dispatch('snapshot-required', {
|
||||
observedAt: '2026-07-23T00:00:03.000Z',
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledTimes(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(mocks.ddnsReload).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network action constraints', () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { TableColumnType } from 'antdv-next';
|
||||
|
||||
import type { NetworkDdnsTableExposed } from './components/NetworkDdnsTable';
|
||||
import type { NetworkEndpointHistoryDrawerExposed } from './components/NetworkEndpointHistoryDrawer';
|
||||
import type { NetworkPortForwardModalExposed } from './components/NetworkPortForwardModal';
|
||||
|
||||
@ -19,10 +20,11 @@ import {
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { message, Space, Tag, Typography } from 'antdv-next';
|
||||
import { message, Space, Tabs, Tag, Typography } from 'antdv-next';
|
||||
|
||||
import {
|
||||
deleteNetworkPortForward,
|
||||
@ -36,12 +38,15 @@ import {
|
||||
import { KtTable, useKtTable } from '#/components/ktTable';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import NetworkDdnsTable from './components/NetworkDdnsTable';
|
||||
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
||||
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
||||
import { useNetworkManagementStream } from './composables/useNetworkManagementStream';
|
||||
|
||||
const AKtTable = KtTable as any;
|
||||
const ATabs = Tabs as any;
|
||||
const ATypographyText = Typography.Text as any;
|
||||
type NetworkTabKey = 'ddns' | 'port-forward';
|
||||
const protocolOptions = [
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
@ -91,17 +96,42 @@ export default defineComponent({
|
||||
* Builds the generic persisted network-resource table and event-driven refresh stream.
|
||||
*/
|
||||
setup() {
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canViewPortForward = hasAccessByCodes([
|
||||
'System:Network:PortForward:List',
|
||||
]);
|
||||
const canViewDdns = hasAccessByCodes(['System:Network:Ddns:List']);
|
||||
const tabItems = [
|
||||
...(canViewPortForward
|
||||
? [
|
||||
{
|
||||
key: 'port-forward' as const,
|
||||
label: $t('system.network.portForwardTab'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canViewDdns
|
||||
? [
|
||||
{
|
||||
key: 'ddns' as const,
|
||||
label: $t('system.network.ddnsTab'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const activeTab = ref<NetworkTabKey>(tabItems[0]?.key || 'port-forward');
|
||||
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
|
||||
const agentStatusUnknown = ref(true);
|
||||
const busyRowIds = ref<Set<string>>(new Set());
|
||||
const ddnsTableRef = ref<NetworkDdnsTableExposed>();
|
||||
const modalRef = ref<NetworkPortForwardModalExposed>();
|
||||
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
|
||||
let pageActive = false;
|
||||
let refreshInFlight: Promise<void> | undefined;
|
||||
let refreshQueued = false;
|
||||
const queuedRefreshes = new Set<NetworkTabKey>();
|
||||
const managementStream = useNetworkManagementStream({
|
||||
onSnapshotRequired: handleStreamRefresh,
|
||||
onStateChanged: handleStreamRefresh,
|
||||
onSnapshotRequired: handleSnapshotRequired,
|
||||
onStateChanged: handleStateChanged,
|
||||
});
|
||||
|
||||
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
|
||||
@ -356,7 +386,7 @@ export default defineComponent({
|
||||
|
||||
/** Reloads after a modal save while preserving serialized request order. */
|
||||
function handleModalSaved() {
|
||||
void requestRefresh();
|
||||
void requestRefresh('port-forward');
|
||||
}
|
||||
|
||||
/** Copies an API-approved current endpoint without logging it. */
|
||||
@ -415,39 +445,84 @@ export default defineComponent({
|
||||
/**
|
||||
* Serializes table and Agent refreshes so stale responses cannot overtake writes.
|
||||
*/
|
||||
async function requestRefresh(): Promise<void> {
|
||||
async function requestRefresh(
|
||||
resource: NetworkTabKey = activeTab.value,
|
||||
): Promise<void> {
|
||||
if (!canViewResource(resource)) return;
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true;
|
||||
queuedRefreshes.add(resource);
|
||||
await refreshInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInFlight = Promise.allSettled([
|
||||
tableApi.reload(),
|
||||
loadAgentStatus(),
|
||||
]).then(() => undefined);
|
||||
refreshInFlight = performResourceRefresh(resource);
|
||||
try {
|
||||
await refreshInFlight;
|
||||
} finally {
|
||||
refreshInFlight = undefined;
|
||||
}
|
||||
if (refreshQueued) {
|
||||
refreshQueued = false;
|
||||
await requestRefresh();
|
||||
const nextResource = queuedRefreshes.values().next().value as
|
||||
| NetworkTabKey
|
||||
| undefined;
|
||||
if (nextResource) {
|
||||
queuedRefreshes.delete(nextResource);
|
||||
await requestRefresh(nextResource);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes persisted facts only for a committed topic event or replay gap. */
|
||||
function handleStreamRefresh() {
|
||||
if (pageActive) void requestRefresh();
|
||||
/**
|
||||
* Loads exactly one permission-approved resource without cross-tab requests.
|
||||
* @param resource - Active or locally-mutated network resource.
|
||||
*/
|
||||
async function performResourceRefresh(resource: NetworkTabKey) {
|
||||
if (resource === 'ddns') {
|
||||
await ddnsTableRef.value?.reload();
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled([tableApi.reload(), loadAgentStatus()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks list permission before any resource request.
|
||||
* @param resource - Candidate tab resource.
|
||||
* @returns True when its List permission is present.
|
||||
*/
|
||||
function canViewResource(resource: NetworkTabKey): boolean {
|
||||
return resource === 'ddns' ? canViewDdns : canViewPortForward;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes only the active resource addressed by one semantic SSE event.
|
||||
* @param event - Committed API event classified by network resource source.
|
||||
*/
|
||||
function handleStateChanged(event: SystemNetworkApi.StateChangeEvent) {
|
||||
const resource = event.source === 'ddns' ? 'ddns' : 'port-forward';
|
||||
if (pageActive && activeTab.value === resource) {
|
||||
void requestRefresh(resource);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes the active resource when the API cannot replay a missed event. */
|
||||
function handleSnapshotRequired() {
|
||||
if (pageActive) void requestRefresh(activeTab.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects one allowed tab and immediately requests its first fact snapshot.
|
||||
* @param key - Tab key emitted by Ant Design Tabs.
|
||||
*/
|
||||
function handleActiveTabChange(key: NetworkTabKey) {
|
||||
if (!canViewResource(key) || activeTab.value === key) return;
|
||||
activeTab.value = key;
|
||||
if (pageActive) void requestRefresh(key);
|
||||
}
|
||||
|
||||
/** Opens the stream before the one initial page snapshot to avoid a subscription gap. */
|
||||
function activatePage() {
|
||||
if (pageActive) return;
|
||||
if (pageActive || tabItems.length === 0) return;
|
||||
pageActive = true;
|
||||
managementStream.start();
|
||||
void requestRefresh();
|
||||
void requestRefresh(activeTab.value);
|
||||
}
|
||||
|
||||
/** Closes the route-owned stream while preserving its replay cursor. */
|
||||
@ -534,15 +609,42 @@ export default defineComponent({
|
||||
|
||||
return () => (
|
||||
<Page autoContentHeight>
|
||||
<AKtTable
|
||||
onRegister={registerTable}
|
||||
v-slots={{
|
||||
bodyCell: renderBodyCell,
|
||||
headerControls: renderAgentControls,
|
||||
}}
|
||||
/>
|
||||
<NetworkPortForwardModal onSaved={handleModalSaved} ref={modalRef} />
|
||||
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
|
||||
{tabItems.length > 0 ? (
|
||||
<ATabs
|
||||
activeKey={activeTab.value}
|
||||
items={tabItems}
|
||||
onUpdate:activeKey={handleActiveTabChange}
|
||||
/>
|
||||
) : null}
|
||||
{canViewPortForward ? (
|
||||
<div
|
||||
style={{
|
||||
display: activeTab.value === 'port-forward' ? '' : 'none',
|
||||
}}
|
||||
>
|
||||
<AKtTable
|
||||
onRegister={registerTable}
|
||||
v-slots={{
|
||||
bodyCell: renderBodyCell,
|
||||
headerControls: renderAgentControls,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{canViewDdns ? (
|
||||
<div style={{ display: activeTab.value === 'ddns' ? '' : 'none' }}>
|
||||
<NetworkDdnsTable ref={ddnsTableRef} />
|
||||
</div>
|
||||
) : null}
|
||||
{canViewPortForward ? (
|
||||
<>
|
||||
<NetworkPortForwardModal
|
||||
onSaved={handleModalSaved}
|
||||
ref={modalRef}
|
||||
/>
|
||||
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
|
||||
</>
|
||||
) : null}
|
||||
</Page>
|
||||
);
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user