feat: 增加网络端口转发管理页面

This commit is contained in:
sunlei 2026-07-23 00:44:29 +08:00
parent d75c271d80
commit bba71812b8
19 changed files with 2497 additions and 4 deletions

View File

@ -50,6 +50,7 @@ pnpm run build:antdv-next
- Blog 左栏“管理”入口通过 `/#/auth/login?sso=1&redirect=%2Fblog%2Farticle` 进入 Admin SSO bootstrap。Admin 只在自身域内调用 `/api/auth/refresh` 恢复 HttpOnly refresh Cookie成功后进入文章管理失败则移除 `sso` 并显示登录表单,且只保留固定内部回跳 `/blog/article`。该链路不接受外部 return URL也不把 access token 放进地址栏。
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败STUN 操作保持可见且禁用。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API新设备验证二维码和腾讯验证码分开展示行操作“运行态”打开只读抽屉展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview``BlogArticlePreviewButton`

View File

@ -224,4 +224,121 @@ describe('core menu api', () => {
},
]);
});
it('keeps generic network management under System with scoped actions', async () => {
requestClientGet.mockResolvedValue([
{
name: 'System',
path: '/system',
children: [
{
name: 'SystemNetwork',
path: '/system/network',
component: '/system/network/list',
children: [
{
name: 'SystemNetworkPortForwardList',
authCode: 'System:Network:PortForward:List',
type: 'button',
},
{
name: 'SystemNetworkPortForwardCreate',
authCode: 'System:Network:PortForward:Create',
type: 'button',
},
{
name: 'SystemNetworkPortForwardUpdate',
authCode: 'System:Network:PortForward:Update',
type: 'button',
},
{
name: 'SystemNetworkPortForwardDelete',
authCode: 'System:Network:PortForward:Delete',
type: 'button',
},
{
name: 'SystemNetworkPortForwardRetry',
authCode: 'System:Network:PortForward:Retry',
type: 'button',
},
{
name: 'SystemNetworkPortForwardKeeper',
authCode: 'System:Network:PortForward:Keeper',
type: 'button',
},
{
name: 'SystemNetworkPortForwardProbe',
authCode: 'System:Network:PortForward:Probe',
type: 'button',
},
{
name: 'SystemNetworkPortForwardHistory',
authCode: 'System:Network:PortForward:History',
type: 'button',
},
],
},
],
},
]);
const { getAllMenusApi } = await import('./menu');
const menus = await getAllMenusApi();
expect(menus).toEqual([
{
name: 'System',
path: '/system',
children: [
{
name: 'SystemNetwork',
path: '/system/network',
component: '/system/network/list',
children: [
{
name: 'SystemNetworkPortForwardList',
authCode: 'System:Network:PortForward:List',
type: 'button',
},
{
name: 'SystemNetworkPortForwardCreate',
authCode: 'System:Network:PortForward:Create',
type: 'button',
},
{
name: 'SystemNetworkPortForwardUpdate',
authCode: 'System:Network:PortForward:Update',
type: 'button',
},
{
name: 'SystemNetworkPortForwardDelete',
authCode: 'System:Network:PortForward:Delete',
type: 'button',
},
{
name: 'SystemNetworkPortForwardRetry',
authCode: 'System:Network:PortForward:Retry',
type: 'button',
},
{
name: 'SystemNetworkPortForwardKeeper',
authCode: 'System:Network:PortForward:Keeper',
type: 'button',
},
{
name: 'SystemNetworkPortForwardProbe',
authCode: 'System:Network:PortForward:Probe',
type: 'button',
},
{
name: 'SystemNetworkPortForwardHistory',
authCode: 'System:Network:PortForward:History',
type: 'button',
},
],
},
],
},
]);
});
});

View File

@ -82,6 +82,15 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'SystemMenuCreate',
'SystemMenuDelete',
'SystemMenuEdit',
'SystemNetwork',
'SystemNetworkPortForwardCreate',
'SystemNetworkPortForwardDelete',
'SystemNetworkPortForwardHistory',
'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardList',
'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardUpdate',
'SystemNotice',
'SystemNoticeDelete',
'SystemNoticeEdit',

View File

@ -2,6 +2,7 @@ export * from './dept';
export * from './dict';
export * from './log';
export * from './menu';
export * from './network';
export * from './notice';
export * from './role';
export * from './user';

View File

@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestClient } from '#/api/request';
import {
createNetworkPortForward,
deleteNetworkPortForward,
disableNetworkPortForwardKeeper,
enableNetworkPortForwardKeeper,
getNetworkAgentStatus,
getNetworkPortForwardEndpointHistory,
getNetworkPortForwardList,
probeNetworkPortForward,
retryNetworkPortForward,
updateNetworkPortForward,
} from './network';
vi.mock('#/api/request', () => ({
requestClient: {
delete: vi.fn(),
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}));
const input = {
externalPort: 45_678,
internalPort: 45_678,
name: 'Game UDP',
protocol: 'udp' as const,
remark: 'managed mapping',
};
describe('system network api', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses the persisted CRUD endpoints without any router secret', async () => {
await getNetworkPortForwardList({ pageNo: 1, pageSize: 20 });
await createNetworkPortForward(input);
await updateNetworkPortForward('mapping-1', input);
await deleteNetworkPortForward('mapping-1');
expect(requestClient.get).toHaveBeenCalledWith(
'/system/network/port-forward/list',
{ params: { pageNo: 1, pageSize: 20 } },
);
expect(requestClient.post).toHaveBeenCalledWith(
'/system/network/port-forward',
input,
);
expect(requestClient.put).toHaveBeenCalledWith(
'/system/network/port-forward/mapping-1',
input,
);
expect(requestClient.delete).toHaveBeenCalledWith(
'/system/network/port-forward/mapping-1',
);
expect(JSON.stringify(input)).not.toContain('password');
});
it('uses distinct reconciliation and Keeper action endpoints', async () => {
await retryNetworkPortForward('mapping-1');
await enableNetworkPortForwardKeeper('mapping-1');
await disableNetworkPortForwardKeeper('mapping-1');
await probeNetworkPortForward('mapping-1');
expect(requestClient.post).toHaveBeenNthCalledWith(
1,
'/system/network/port-forward/mapping-1/retry',
);
expect(requestClient.post).toHaveBeenNthCalledWith(
2,
'/system/network/port-forward/mapping-1/keeper/enable',
);
expect(requestClient.post).toHaveBeenNthCalledWith(
3,
'/system/network/port-forward/mapping-1/keeper/disable',
);
expect(requestClient.post).toHaveBeenNthCalledWith(
4,
'/system/network/port-forward/mapping-1/probe',
);
});
it('loads Agent status and paged endpoint history independently', async () => {
await getNetworkAgentStatus();
await getNetworkPortForwardEndpointHistory('mapping-1', {
pageNo: 2,
pageSize: 10,
});
expect(requestClient.get).toHaveBeenNthCalledWith(
1,
'/system/network/agent/status',
);
expect(requestClient.get).toHaveBeenNthCalledWith(
2,
'/system/network/port-forward/mapping-1/endpoint-history',
{ params: { pageNo: 2, pageSize: 10 } },
);
});
});

View File

@ -0,0 +1,235 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemNetworkApi {
export type DesiredPresence = 'absent' | 'present';
export type EndpointEventType =
| 'changed'
| 'published'
| 'restored'
| 'withdrawn';
export type KeeperStatus =
| 'active'
| 'disabled'
| 'failed'
| 'stale'
| 'starting';
export type Protocol = 'tcp' | 'udp';
export type Revision = string;
export type SyncStatus =
| 'conflict'
| 'deleting'
| 'failed'
| 'pending'
| 'synced'
| 'syncing';
export interface AgentStatus {
agentId: string;
appliedRevision: Revision;
desiredRevision: Revision;
lastErrorCode?: null | string;
lastErrorMessage?: null | string;
lastHeartbeatAt?: null | string;
online: boolean;
publishedRevision: Revision;
startedAt?: null | string;
targetIpv4: string;
version?: null | string;
}
export interface EndpointHistoryItem {
eventId: string;
eventType: EndpointEventType;
firstObservedAt?: null | string;
id: string;
lastObservedAt?: null | string;
occurredAt: string;
portForwardId: string;
publicIpv4?: null | string;
publicPort?: null | number;
withdrawalReason?: null | string;
}
export interface PageResult<T> {
items: T[];
list?: T[];
total: number;
}
export interface PortForward {
activeKey?: null | string;
createTime?: string;
currentObservedAt?: null | string;
currentPublicIpv4?: null | string;
currentPublicPort?: null | number;
currentValidUntil?: null | string;
desiredPresence: DesiredPresence;
desiredRevision: Revision;
externalPort: number;
id: string;
internalPort: number;
isDeleted: boolean;
keeperDesiredEnabled: boolean;
keeperStatus: KeeperStatus;
lastErrorCode?: null | string;
lastErrorMessage?: null | string;
lastObservedAt?: null | string;
lastObservedIpv4?: null | string;
lastObservedPort?: null | number;
name: string;
probeRequestId?: null | string;
protocol: Protocol;
remark?: null | string;
reportedRevision?: null | Revision;
syncStatus: SyncStatus;
targetIpv4: string;
updateTime?: string;
}
export interface PortForwardInput {
externalPort: number;
internalPort: number;
name: string;
protocol: Protocol;
remark?: string;
}
export type PortForwardItem = PortForward;
export type PortForwardPayload = PortForwardInput;
export interface PortForwardQuery extends Recordable<any> {
keeperStatus?: KeeperStatus;
name?: string;
pageNo?: number;
pageSize?: number;
protocol?: Protocol;
syncStatus?: SyncStatus;
}
}
/**
* Loads the persisted port-forward resource page from the API fact source.
* @param params - Pagination and independent protocol/synchronization filters.
* @returns Port-forward rows and the total record count.
*/
export function getNetworkPortForwardList(
params: SystemNetworkApi.PortForwardQuery,
) {
return requestClient.get<
SystemNetworkApi.PageResult<SystemNetworkApi.PortForward>
>('/system/network/port-forward/list', { params });
}
/**
* Creates a desired router mapping without exposing router credentials.
* @param data - User-editable mapping fields.
* @returns The persisted pending desired record.
*/
export function createNetworkPortForward(
data: SystemNetworkApi.PortForwardInput,
) {
return requestClient.post<SystemNetworkApi.PortForward>(
'/system/network/port-forward',
data,
);
}
/**
* Updates the editable desired fields of one active mapping.
* @param id - Stable port-forward record ID.
* @param data - Complete editable mapping fields.
* @returns The updated pending desired record.
*/
export function updateNetworkPortForward(
id: string,
data: SystemNetworkApi.PortForwardInput,
) {
return requestClient.put<SystemNetworkApi.PortForward>(
`/system/network/port-forward/${id}`,
data,
);
}
/**
* Requests confirmed asynchronous deletion of one managed mapping.
* @param id - Stable port-forward record ID.
* @returns The deleting tombstone retained until Agent acknowledgement.
*/
export function deleteNetworkPortForward(id: string) {
return requestClient.delete<SystemNetworkApi.PortForward>(
`/system/network/port-forward/${id}`,
);
}
/**
* Raises a new reconciliation revision for a failed or conflicted mapping.
* @param id - Stable port-forward record ID.
* @returns The latest pending desired record.
*/
export function retryNetworkPortForward(id: string) {
return requestClient.post<SystemNetworkApi.PortForward>(
`/system/network/port-forward/${id}/retry`,
);
}
/**
* Enables persistent UDP STUN observation and triggers an immediate probe.
* @param id - Eligible UDP same-port mapping ID.
* @returns The latest pending desired record.
*/
export function enableNetworkPortForwardKeeper(id: string) {
return requestClient.post<SystemNetworkApi.PortForward>(
`/system/network/port-forward/${id}/keeper/enable`,
);
}
/**
* Stops future STUN renewals without deleting the router mapping.
* @param id - UDP mapping ID whose Keeper is currently desired.
* @returns The latest pending desired record.
*/
export function disableNetworkPortForwardKeeper(id: string) {
return requestClient.post<SystemNetworkApi.PortForward>(
`/system/network/port-forward/${id}/keeper/disable`,
);
}
/**
* Generates an idempotent request ID for one immediate UDP STUN cycle.
* @param id - Enabled UDP same-port mapping ID.
* @returns The latest desired record carrying the new request ID.
*/
export function probeNetworkPortForward(id: string) {
return requestClient.post<SystemNetworkApi.PortForward>(
`/system/network/port-forward/${id}/probe`,
);
}
/**
* Loads append-only endpoint transition history for one mapping.
* @param id - Stable port-forward record ID.
* @param params - History pagination values.
* @param params.pageNo - One-based history page number.
* @param params.pageSize - Maximum rows requested for the page.
* @returns Endpoint transition records and their total count.
*/
export function getNetworkPortForwardEndpointHistory(
id: string,
params: { pageNo?: number; pageSize?: number },
) {
return requestClient.get<
SystemNetworkApi.PageResult<SystemNetworkApi.EndpointHistoryItem>
>(`/system/network/port-forward/${id}/endpoint-history`, { params });
}
/**
* Loads independent Agent connectivity and revision convergence state.
* @returns Current Agent status without inferring per-record failures.
*/
export function getNetworkAgentStatus() {
return requestClient.get<SystemNetworkApi.AgentStatus>(
'/system/network/agent/status',
);
}

View File

@ -0,0 +1,129 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { describe, expect, it, vi } from 'vitest';
import { useKtTableActions } from './useKtTableActions';
vi.mock('antdv-next', () => ({
Button: defineComponent({
name: 'MockButton',
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () =>
h(
'button',
{
disabled: attrs.disabled,
onClick: attrs.onClick,
},
slots.default?.(),
);
},
}),
Modal: { confirm: vi.fn() },
Tooltip: defineComponent({
name: 'MockTooltip',
props: { title: String },
setup(props, { slots }) {
return () =>
h('span', { 'data-disabled-reason': props.title }, slots.default?.());
},
}),
}));
/** Creates the smallest action runtime needed to exercise row rendering. */
function createActionRuntime() {
const context = {
formApi: {},
getRows: vi.fn(() => []),
getSearchValues: vi.fn(async () => ({})),
registerHook: vi.fn(),
reload: vi.fn(async () => {}),
reset: vi.fn(async () => {}),
search: vi.fn(async () => {}),
selectedRowKeys: vi.fn(() => []),
selectedRows: vi.fn(() => []),
setSearchValues: vi.fn(async () => {}),
unregisterHook: vi.fn(),
} as any;
return useKtTableActions({
context,
permissions: {
filterVisibleActions: (actions: any[]) => actions,
filterVisibleButtons: (buttons: any[]) => buttons,
resolveBoolean: (value: unknown, fallback: boolean) =>
typeof value === 'boolean' ? value : fallback,
},
props: {
buttons: [],
modules: [],
rowActions: [],
showDefaultButtons: false,
} as any,
reload: context.reload,
reset: context.reset,
runHook: vi.fn(async () => {}),
search: context.search,
});
}
describe('ktTable disabled row action reason', () => {
it('keeps a disabled action visible and exposes its readable reason', async () => {
const onClick = vi.fn();
const runtime = createActionRuntime();
const Harness = defineComponent({
setup() {
return () =>
runtime.renderRowAction(
{
disabled: true,
disabledReason: () => 'TCP 仅支持端口转发 CRUD',
key: 'keeper',
label: '启用 Keeper',
onClick,
},
{ id: 'tcp-1' },
);
},
});
const wrapper = mount(Harness);
expect(wrapper.text()).toContain('启用 Keeper');
expect(wrapper.get('[data-disabled-reason]').attributes()).toMatchObject({
'data-disabled-reason': 'TCP 仅支持端口转发 CRUD',
});
await wrapper.get('button').trigger('click');
expect(onClick).not.toHaveBeenCalled();
});
it('executes an enabled row action without a disabled tooltip', async () => {
const onClick = vi.fn();
const runtime = createActionRuntime();
const Harness = defineComponent({
setup() {
return () =>
runtime.renderRowAction(
{
disabled: false,
disabledReason: 'not rendered',
key: 'edit',
label: '编辑',
onClick,
},
{ id: 'udp-1' },
);
},
});
const wrapper = mount(Harness);
expect(wrapper.find('[data-disabled-reason]').exists()).toBe(false);
await wrapper.get('button').trigger('click');
expect(onClick).toHaveBeenCalledOnce();
});
});

View File

@ -14,9 +14,10 @@ import { computed } from 'vue';
import { Search, SearchX } from '@vben/icons';
import { Button, Modal } from 'antdv-next';
import { Button, Modal, Tooltip } from 'antdv-next';
const AButton = Button as any;
const ATooltip = Tooltip as any;
type PermissionHelpers = Pick<
ReturnType<typeof useKtTablePermission>,
@ -236,21 +237,50 @@ export function useKtTableActions(options: UseKtTableActionsOptions) {
typeof action.disabled === 'function'
? action.disabled(row, context)
: resolveBoolean(action.disabled, false);
const disabledReason = resolveDisabledReason(action, row, disabled);
return (
const button = (
<AButton
danger={action.danger}
disabled={disabled}
key={action.key}
onClick={() => confirmRowAction(action, row)}
onClick={disabled ? undefined : () => confirmRowAction(action, row)}
type={action.type || 'link'}
>
{renderIcon(action.icon)}
{action.label}
</AButton>
);
if (!disabledReason) {
return <span key={action.key}>{button}</span>;
}
return (
<ATooltip key={action.key} title={disabledReason}>
<span class="kt-table__disabled-action">{button}</span>
</ATooltip>
);
};
/**
* Resolves a readable disabled reason only while a row action is disabled.
*
* @param action Current row action configuration.
* @param row Current table row.
* @param disabled Resolved disabled state for this row.
* @returns Tooltip text, or undefined when no explanation should render.
*/
function resolveDisabledReason(
action: KtTableRowAction,
row: KtTableRecord,
disabled: boolean,
): string | undefined {
if (!disabled || !action.disabledReason) return undefined;
return typeof action.disabledReason === 'function'
? action.disabledReason(row, context)
: action.disabledReason;
}
return {
formButtons,
getVisibleRowActions,

View File

@ -37,6 +37,11 @@
&__row-action-more-icon {
font-size: 14px;
}
&__disabled-action {
display: inline-flex;
cursor: not-allowed;
}
}
#{kt.$block}__row-action-popover-content {

View File

@ -100,6 +100,12 @@ export interface KtTableRowAction<
disabled?:
| ((row: Row, context: KtTableContext<Row, SearchValues>) => boolean)
| boolean;
disabledReason?:
| ((
row: Row,
context: KtTableContext<Row, SearchValues>,
) => string | undefined)
| string;
onClick?: (
row: Row,
context: KtTableContext<Row, SearchValues>,

View File

@ -112,6 +112,78 @@
"total": "Total",
"unconfigured": "Loki Not Configured"
},
"network": {
"agentLabel": "Agent",
"agentOffline": "Offline",
"agentOnline": "Online",
"agentUnknown": "Unknown",
"copyEndpointAction": "Copy Endpoint",
"createAction": "Create Port Forward",
"createTitle": "Create Port Forward",
"deleteAction": "Delete",
"deleteConfirm": "Submit deletion for \"{0}\"?",
"deleteSubmitted": "Deletion submitted; waiting for Agent confirmation",
"deletingImmutable": "The mapping is immutable while deletion is pending",
"desiredSaved": "Desired configuration saved; waiting for Agent",
"disableKeeperBeforeEdit": "Disable Keeper before changing protocol or port relationship",
"disableKeeperAction": "Disable Keeper",
"desiredOff": "Desired: Off",
"desiredOn": "Desired: On",
"editAction": "Edit",
"editTitle": "Edit Port Forward",
"enableKeeperFirst": "Enable persistent keepalive first",
"enableKeeperAction": "Enable Keeper",
"endpointCopied": "Public endpoint copied",
"endpointHistory": "Endpoint History",
"externalPort": "External Port",
"firstObservedAt": "First Observed",
"historyEvent": "Event",
"historyAction": "View History",
"internalTarget": "Target IPv4:Port",
"internalPort": "Internal Port",
"keeperDisableSubmitted": "Stop-renewal request submitted",
"keeperEnableSubmitted": "Keepalive and immediate probe request submitted",
"keeperActive": "Active",
"keeperDisabled": "Disabled",
"keeperFailed": "Failed",
"keeperStale": "Stale",
"keeperStarting": "Starting",
"keeperState": "Keeper Desired / Actual",
"lastObservedAt": "Last Observed",
"lastObserved": "Last Successful Observation",
"name": "Name",
"nameRequired": "Enter a mapping name",
"noCurrentEndpoint": "No valid public endpoint is currently available",
"operationInProgress": "Another action is already in progress for this row",
"portForwardTitle": "Port Forwards",
"portRange": "The port must be an integer from 1 to 65535",
"probeSubmitted": "Immediate STUN probe request submitted",
"probeAction": "Refresh Now",
"protocol": "Protocol",
"publicEndpoint": "Public Endpoint",
"remark": "Remark",
"retryAction": "Retry",
"retryNotRequired": "The current state does not require retry",
"retrySubmitted": "Reconciliation retry submitted",
"revisionLabel": "Revision",
"summary": "Pending / Error Summary",
"syncConflict": "Conflict",
"syncDeleting": "Deleting",
"syncFailed": "Failed",
"syncPending": "Pending",
"syncStatus": "Sync Status",
"syncSynced": "Synced",
"syncSyncing": "Syncing",
"targetLabel": "Target",
"targetIpv4": "NAS Target IPv4",
"tcpKeeperUnsupported": "TCP supports port-forward CRUD only",
"title": "Network Management",
"updateTime": "Updated At",
"udpSamePortRequired": "UDP STUN requires equal external and internal ports",
"waitingForAgent": "Waiting for the Agent to reconnect",
"waitingForSync": "Waiting for Agent acknowledgement",
"withdrawalReason": "Withdrawal Reason"
},
"notice": {
"cancelTop": "Unpin",
"cancelTopSuccess": "Unpinned",

View File

@ -114,6 +114,78 @@
"total": "总量",
"unconfigured": "Loki 未配置"
},
"network": {
"agentLabel": "Agent",
"agentOffline": "离线",
"agentOnline": "在线",
"agentUnknown": "未知",
"copyEndpointAction": "复制端点",
"createAction": "新增端口转发",
"createTitle": "新增端口转发",
"deleteAction": "删除",
"deleteConfirm": "确认提交「{0}」的删除请求吗?",
"deleteSubmitted": "删除请求已提交,等待 Agent 确认",
"deletingImmutable": "删除确认期间不能修改或重复删除",
"desiredSaved": "期望配置已保存,等待 Agent 同步",
"disableKeeperBeforeEdit": "请先停用 Keeper再修改协议或端口关系",
"disableKeeperAction": "停用 Keeper",
"desiredOff": "期望:关闭",
"desiredOn": "期望:开启",
"editAction": "编辑",
"editTitle": "编辑端口转发",
"enableKeeperFirst": "请先启用持续保活",
"enableKeeperAction": "启用 Keeper",
"endpointCopied": "公网端点已复制",
"endpointHistory": "端点历史",
"externalPort": "外部端口",
"firstObservedAt": "首次观测",
"historyEvent": "事件类型",
"historyAction": "查看历史",
"internalTarget": "目标 IPv4:端口",
"internalPort": "内网端口",
"keeperDisableSubmitted": "已提交停止续约请求",
"keeperEnableSubmitted": "已提交持续保活与立即探测请求",
"keeperActive": "运行中",
"keeperDisabled": "已停用",
"keeperFailed": "失败",
"keeperStale": "已过期",
"keeperStarting": "启动中",
"keeperState": "Keeper 期望 / 实际",
"lastObservedAt": "最近观测",
"lastObserved": "最近成功观测",
"name": "名称",
"nameRequired": "请输入规则名称",
"noCurrentEndpoint": "当前没有可复制的有效公网端点",
"operationInProgress": "该记录已有操作正在提交",
"portForwardTitle": "端口转发",
"portRange": "端口必须是 1 到 65535 的整数",
"probeSubmitted": "已提交立即 STUN 刷新请求",
"probeAction": "立即刷新",
"protocol": "协议",
"publicEndpoint": "公网端点",
"remark": "备注",
"retryAction": "重试",
"retryNotRequired": "当前状态不需要重试",
"retrySubmitted": "已提交重新协调请求",
"revisionLabel": "版本",
"summary": "等待 / 错误摘要",
"syncConflict": "冲突",
"syncDeleting": "删除中",
"syncFailed": "失败",
"syncPending": "等待同步",
"syncStatus": "同步状态",
"syncSynced": "已同步",
"syncSyncing": "同步中",
"targetLabel": "目标",
"targetIpv4": "NAS 目标 IPv4",
"tcpKeeperUnsupported": "TCP 仅支持端口转发 CRUD",
"title": "网络管理",
"updateTime": "更新时间",
"udpSamePortRequired": "UDP STUN 要求外部端口与内部端口相同",
"waitingForAgent": "等待 Agent 上线后协调",
"waitingForSync": "等待 Agent 确认期望状态",
"withdrawalReason": "撤下原因"
},
"notice": {
"cancelTop": "取消置顶",
"cancelTopSuccess": "取消置顶成功",

View File

@ -75,6 +75,15 @@ const routes: RouteRecordRaw[] = [
},
component: () => import('#/views/system/notice/list.vue'),
},
{
path: '/system/network',
name: 'SystemNetwork',
meta: {
icon: 'lucide:router',
title: $t('system.network.title'),
},
component: () => import('#/views/system/network/list'),
},
{
path: '/system/ktTableDemo',
name: 'SystemKtTableDemo',

View File

@ -0,0 +1,114 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file */
import type { SystemNetworkApi } from '#/api/system/network';
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import NetworkEndpointHistoryDrawer from './NetworkEndpointHistoryDrawer';
const mocks = vi.hoisted(() => ({
drawerApi: { open: vi.fn() },
drawerOptions: undefined as any,
getHistory: vi.fn(),
tableApi: { reload: vi.fn() },
tableOptions: undefined as any,
}));
vi.mock('@vben/common-ui', () => ({
useVbenDrawer: vi.fn((options) => {
mocks.drawerOptions = options;
const Drawer = defineComponent({
name: 'MockDrawer',
setup(_, { slots }) {
return () => h('aside', slots.default?.());
},
});
return [Drawer, mocks.drawerApi];
}),
}));
vi.mock('antdv-next', () => ({
Tag: defineComponent({
name: 'MockTag',
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
}));
vi.mock('#/components/ktTable', () => ({
KtTable: defineComponent({
name: 'MockKtTable',
setup() {
return () => h('section');
},
}),
useKtTable: vi.fn((options) => {
mocks.tableOptions = options;
return [vi.fn(), mocks.tableApi];
}),
}));
vi.mock('#/api/system/network', () => ({
getNetworkPortForwardEndpointHistory: mocks.getHistory,
}));
vi.mock('#/locales', () => ({
$t: (key: string) => key,
}));
/** Creates the minimal row needed to scope a history request. */
function createRow(id: string): SystemNetworkApi.PortForward {
return {
desiredPresence: 'present',
desiredRevision: '1',
externalPort: 40_000,
id,
internalPort: 40_000,
isDeleted: false,
keeperDesiredEnabled: true,
keeperStatus: 'active',
name: `mapping-${id}`,
protocol: 'udp',
syncStatus: 'synced',
targetIpv4: '192.168.31.224',
};
}
describe('network endpoint history drawer', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getHistory.mockResolvedValue({ items: [], total: 0 });
});
it('stays lazy until opened and uses a disposable Vben drawer', () => {
mount(NetworkEndpointHistoryDrawer);
expect(mocks.tableOptions.immediate).toBe(false);
expect(mocks.drawerOptions.destroyOnClose).toBe(true);
expect(mocks.getHistory).not.toHaveBeenCalled();
});
it('loads the selected record ID and does not reuse a prior selection', async () => {
const wrapper = mount(NetworkEndpointHistoryDrawer);
(wrapper.vm as any).open(createRow('mapping-1'));
mocks.drawerOptions.onOpened();
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
await mocks.tableOptions.api.list({ pageNo: 2, pageSize: 10 });
expect(mocks.getHistory).toHaveBeenLastCalledWith('mapping-1', {
pageNo: 2,
pageSize: 10,
});
(wrapper.vm as any).open(createRow('mapping-2'));
await mocks.tableOptions.api.list({ pageNo: 1, pageSize: 20 });
expect(mocks.getHistory).toHaveBeenLastCalledWith('mapping-2', {
pageNo: 1,
pageSize: 20,
});
});
});

View File

@ -0,0 +1,148 @@
import type { TableColumnType } from 'antdv-next';
import type { SystemNetworkApi } from '#/api/system/network';
import type { KtTableApi } from '#/components/ktTable';
import { computed, defineComponent, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { Tag } from 'antdv-next';
import { getNetworkPortForwardEndpointHistory } from '#/api/system/network';
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
export interface NetworkEndpointHistoryDrawerExposed {
open: (row: SystemNetworkApi.PortForward) => void;
}
const AKtTable = KtTable as any;
const eventColors: Record<SystemNetworkApi.EndpointEventType, string> = {
changed: 'warning',
published: 'success',
restored: 'processing',
withdrawn: 'default',
};
export default defineComponent({
name: 'NetworkEndpointHistoryDrawer',
/** Creates a lazy, record-scoped history table inside a disposable drawer. */
setup(_, { expose }) {
const selectedRow = ref<SystemNetworkApi.PortForward>();
const columns: Array<
TableColumnType<SystemNetworkApi.EndpointHistoryItem>
> = [
{
dataIndex: 'eventType',
key: 'eventType',
title: $t('system.network.historyEvent'),
width: 110,
},
{
key: 'publicEndpoint',
title: $t('system.network.publicEndpoint'),
width: 190,
},
{
dataIndex: 'firstObservedAt',
key: 'firstObservedAt',
title: $t('system.network.firstObservedAt'),
width: 190,
},
{
dataIndex: 'lastObservedAt',
key: 'lastObservedAt',
title: $t('system.network.lastObservedAt'),
width: 190,
},
{
dataIndex: 'withdrawalReason',
key: 'withdrawalReason',
title: $t('system.network.withdrawalReason'),
width: 220,
},
];
const api: KtTableApi<SystemNetworkApi.EndpointHistoryItem> = {
/** Loads only the selected mapping's append-only endpoint transitions. */
list: async (params) => {
if (!selectedRow.value) return { items: [], total: 0 };
return await getNetworkPortForwardEndpointHistory(
selectedRow.value.id,
{
pageNo: Number(params.pageNo || params.page || 1),
pageSize: Number(params.pageSize || 10),
},
);
},
};
const [registerTable, tableApi] =
useKtTable<SystemNetworkApi.EndpointHistoryItem>({
api,
columns,
immediate: false,
pageSize: 10,
rowKey: 'eventId',
showDefaultButtons: false,
showIndex: false,
tableSettings: {
column: true,
density: true,
fullscreen: false,
reload: true,
showSearch: false,
},
tableTitle: $t('system.network.endpointHistory'),
});
const drawerTitle = computed(() =>
selectedRow.value
? `${$t('system.network.endpointHistory')} · ${selectedRow.value.name}`
: $t('system.network.endpointHistory'),
);
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[860px]',
destroyOnClose: true,
footer: false,
onOpened() {
void tableApi.reload();
},
});
/**
* Opens history for one mapping and discards rows from the prior selection.
* @param row - Mapping whose append-only history should be queried.
*/
function open(row: SystemNetworkApi.PortForward) {
selectedRow.value = row;
drawerApi.open();
}
expose({ open } satisfies NetworkEndpointHistoryDrawerExposed);
return () => (
<Drawer title={drawerTitle.value}>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: ({ column, record }: any) => {
const item = record as SystemNetworkApi.EndpointHistoryItem;
if (column.key === 'eventType') {
return (
<Tag color={eventColors[item.eventType]}>
{item.eventType}
</Tag>
);
}
if (column.key === 'publicEndpoint') {
return item.publicIpv4 && item.publicPort
? `${item.publicIpv4}:${item.publicPort}`
: '-';
}
return undefined;
},
}}
/>
</Drawer>
);
},
});

View File

@ -0,0 +1,197 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import NetworkPortForwardModal from './NetworkPortForwardModal';
const mocks = vi.hoisted(() => {
const modalApi = {
close: vi.fn(async () => {}),
lock: vi.fn(),
open: vi.fn(),
unlock: vi.fn(),
};
const formApi = {
getValues: vi.fn(),
resetForm: vi.fn(async () => {}),
resetValidate: vi.fn(async () => {}),
setValues: vi.fn(async () => {}),
validate: vi.fn(async () => ({ valid: true })),
};
return {
create: vi.fn(),
formApi,
formOptions: undefined as any,
modalApi,
modalOptions: undefined as any,
update: vi.fn(),
};
});
/** Creates a fluent no-op validation rule for the form-schema fixture. */
function createRule(): any {
const rule: any = {};
for (const method of ['int', 'max', 'min', 'optional', 'or', 'trim']) {
rule[method] = vi.fn(() => rule);
}
return rule;
}
vi.mock('#/adapter/form', () => ({
useVbenForm: vi.fn((options) => {
mocks.formOptions = options;
const Form = defineComponent({
name: 'MockForm',
setup() {
return () => h('form');
},
});
return [Form, mocks.formApi];
}),
z: {
enum: vi.fn(createRule),
literal: vi.fn(createRule),
number: vi.fn(createRule),
string: vi.fn(createRule),
},
}));
vi.mock('@vben/common-ui', () => ({
useVbenModal: vi.fn((options) => {
mocks.modalOptions = options;
const Modal = defineComponent({
name: 'MockModal',
setup(_, { slots }) {
return () => h('section', slots.default?.());
},
});
return [Modal, mocks.modalApi];
}),
}));
vi.mock('antdv-next', () => ({
Alert: defineComponent({
name: 'MockAlert',
props: { message: String },
setup(props) {
return () => h('p', props.message);
},
}),
message: { success: vi.fn(), warning: vi.fn() },
}));
vi.mock('#/api/system/network', () => ({
createNetworkPortForward: mocks.create,
updateNetworkPortForward: mocks.update,
}));
vi.mock('#/locales', () => ({
$t: (key: string) => key,
}));
describe('network port-forward modal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.create.mockResolvedValue({});
mocks.update.mockResolvedValue({});
mocks.formApi.getValues.mockResolvedValue({
externalPort: 45_678,
internalPort: 45_678,
name: ' Game UDP ',
protocol: 'udp',
remark: ' managed ',
});
});
it('contains only approved editable fields and shows target IPv4 read-only', async () => {
const wrapper = mount(NetworkPortForwardModal);
await (wrapper.vm as any).openCreate('192.168.31.224');
expect(
mocks.formOptions.schema.map((field: any) => field.fieldName),
).toEqual(['name', 'protocol', 'externalPort', 'internalPort', 'remark']);
expect(JSON.stringify(mocks.formOptions.schema)).not.toMatch(
/password|routerUrl|token/i,
);
expect(wrapper.text()).toContain('192.168.31.224');
expect(mocks.formApi.setValues).toHaveBeenCalledWith(
expect.not.objectContaining({
keeperDesiredEnabled: expect.anything(),
targetIpv4: expect.anything(),
}),
);
});
it('matches the API name and remark length contract', () => {
mount(NetworkPortForwardModal);
const schema = mocks.formOptions.schema as Array<{
componentProps?: { maxlength?: number };
fieldName: string;
}>;
expect(
schema.find((field) => field.fieldName === 'name')?.componentProps
?.maxlength,
).toBe(100);
expect(
schema.find((field) => field.fieldName === 'remark')?.componentProps
?.maxlength,
).toBe(500);
});
it('creates a trimmed desired payload without runtime or secret fields', async () => {
const wrapper = mount(NetworkPortForwardModal);
await (wrapper.vm as any).openCreate('192.168.31.224');
await mocks.modalOptions.onConfirm();
expect(mocks.create).toHaveBeenCalledWith({
externalPort: 45_678,
internalPort: 45_678,
name: 'Game UDP',
protocol: 'udp',
remark: 'managed',
});
expect(JSON.stringify(mocks.create.mock.calls[0]?.[0])).not.toMatch(
/password|targetIpv4|keeper|token/i,
);
expect(mocks.modalApi.lock).toHaveBeenCalledOnce();
expect(mocks.modalApi.unlock).toHaveBeenCalledOnce();
});
it('sends an explicit empty remark when an existing value is cleared', async () => {
mocks.formApi.getValues.mockResolvedValue({
externalPort: 45_678,
internalPort: 45_678,
name: 'Game UDP',
protocol: 'udp',
remark: ' ',
});
const wrapper = mount(NetworkPortForwardModal);
await (wrapper.vm as any).openEdit({
desiredPresence: 'present',
desiredRevision: '7',
externalPort: 45_678,
id: '42',
internalPort: 45_678,
isDeleted: false,
keeperDesiredEnabled: false,
keeperStatus: 'disabled',
name: 'Game UDP',
protocol: 'udp',
remark: 'old remark',
syncStatus: 'synced',
targetIpv4: '192.168.31.224',
});
await mocks.modalOptions.onConfirm();
expect(mocks.update).toHaveBeenCalledWith(
'42',
expect.objectContaining({ remark: '' }),
);
});
});

View File

@ -0,0 +1,218 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { SystemNetworkApi } from '#/api/system/network';
import { computed, defineComponent, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
import {
createNetworkPortForward,
updateNetworkPortForward,
} from '#/api/system/network';
import { $t } from '#/locales';
export interface NetworkPortForwardModalExposed {
openCreate: (targetIpv4: string) => Promise<void>;
openEdit: (row: SystemNetworkApi.PortForward) => Promise<void>;
}
const protocolOptions = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
];
export default defineComponent({
name: 'NetworkPortForwardModal',
emits: ['saved'],
/**
* Owns the CRUD form while keeping the NAS target and Keeper state read-only.
*/
setup(_, { emit, expose }) {
const editingRow = ref<SystemNetworkApi.PortForward>();
const targetIpv4 = ref('');
const [PortForwardForm, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
layout: 'horizontal',
schema: createFormSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
const modalTitle = computed(() =>
editingRow.value
? $t('system.network.editTitle')
: $t('system.network.createTitle'),
);
const [Modal, modalApi] = useVbenModal({
class: 'w-[640px]',
fullscreenButton: false,
async onConfirm() {
await submit();
},
});
/**
* Opens a blank form for one new desired mapping.
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
*/
async function openCreate(fixedTargetIpv4: string) {
editingRow.value = undefined;
targetIpv4.value = fixedTargetIpv4;
await resetForm({
externalPort: undefined,
internalPort: undefined,
name: '',
protocol: 'udp',
remark: '',
});
modalApi.open();
}
/**
* Opens an existing row for editing without copying reported/runtime fields.
* @param row - Persisted desired record selected from KtTable.
*/
async function openEdit(row: SystemNetworkApi.PortForward) {
editingRow.value = row;
targetIpv4.value = row.targetIpv4;
await resetForm({
externalPort: row.externalPort,
internalPort: row.internalPort,
name: row.name,
protocol: row.protocol,
remark: row.remark || '',
});
modalApi.open();
}
/**
* Resets validation and installs only user-editable values.
* @param values - Form values excluding target IP, Keeper and secrets.
*/
async function resetForm(
values: Partial<SystemNetworkApi.PortForwardInput>,
) {
await formApi.resetForm();
await formApi.setValues(values);
await formApi.resetValidate();
}
/**
* Persists the desired mapping and leaves all runtime effects asynchronous.
*/
async function submit() {
const { valid } = await formApi.validate();
if (!valid) return;
const values =
await formApi.getValues<SystemNetworkApi.PortForwardInput>();
const payload: SystemNetworkApi.PortForwardInput = {
externalPort: Number(values.externalPort),
internalPort: Number(values.internalPort),
name: values.name.trim(),
protocol: values.protocol,
remark: values.remark?.trim() || '',
};
if (
editingRow.value?.keeperDesiredEnabled &&
(payload.protocol !== 'udp' ||
payload.externalPort !== payload.internalPort)
) {
message.warning($t('system.network.disableKeeperBeforeEdit'));
return;
}
modalApi.lock();
try {
await (editingRow.value
? updateNetworkPortForward(editingRow.value.id, payload)
: createNetworkPortForward(payload));
message.success($t('system.network.desiredSaved'));
await modalApi.close();
emit('saved');
} finally {
modalApi.unlock();
}
}
expose({ openCreate, openEdit } satisfies NetworkPortForwardModalExposed);
return () => (
<Modal title={modalTitle.value}>
<Alert
class="mb-4"
message={`${$t('system.network.targetIpv4')}: ${
targetIpv4.value || '-'
}`}
showIcon
type="info"
/>
<PortForwardForm class="mx-2" />
</Modal>
);
},
});
/**
* Builds the modal's approved editable schema without any credential field.
* @returns Vben form fields for name, protocol, ports and optional remark.
*/
function createFormSchema(): VbenFormSchema[] {
const portRule = z
.number()
.int()
.min(1, $t('system.network.portRange'))
.max(65_535, $t('system.network.portRange'));
return [
{
component: 'Input',
componentProps: { allowClear: true, maxlength: 100 },
fieldName: 'name',
label: $t('system.network.name'),
rules: z.string().trim().min(1).max(100),
},
{
component: 'Select',
componentProps: { options: protocolOptions },
defaultValue: 'udp',
fieldName: 'protocol',
label: $t('system.network.protocol'),
rules: z.enum(['tcp', 'udp']),
},
{
component: 'InputNumber',
componentProps: {
class: 'w-full',
max: 65_535,
min: 1,
precision: 0,
},
fieldName: 'externalPort',
label: $t('system.network.externalPort'),
rules: portRule,
},
{
component: 'InputNumber',
componentProps: {
class: 'w-full',
max: 65_535,
min: 1,
precision: 0,
},
fieldName: 'internalPort',
label: $t('system.network.internalPort'),
rules: portRule,
},
{
component: 'Textarea',
componentProps: { allowClear: true, maxlength: 500, rows: 3 },
fieldName: 'remark',
label: $t('system.network.remark'),
rules: z.string().max(500).optional().or(z.literal('')),
},
];
}

View File

@ -0,0 +1,358 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file */
import type { SystemNetworkApi } from '#/api/system/network';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import routes from '#/router/routes/modules/system';
import NetworkList, {
getCurrentEndpoint,
getKeeperDisabledReason,
isDeleting,
} from './list';
const mocks = vi.hoisted(() => ({
api: {
deleteMapping: vi.fn(),
disableKeeper: vi.fn(),
enableKeeper: vi.fn(),
getAgentStatus: vi.fn(),
getList: vi.fn(),
probe: vi.fn(),
retry: vi.fn(),
},
messageSuccess: vi.fn(),
tableApi: {
getRows: vi.fn(() => []),
reload: vi.fn(),
},
tableOptions: undefined as any,
}));
vi.mock('@vben/common-ui', () => ({
Page: defineComponent({
name: 'MockPage',
setup(_, { slots }) {
return () => h('main', { 'data-testid': 'page-root' }, slots.default?.());
},
}),
}));
vi.mock('antdv-next', () => ({
message: { success: mocks.messageSuccess },
Space: defineComponent({
name: 'MockSpace',
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
Tag: defineComponent({
name: 'MockTag',
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
Typography: {
Text: defineComponent({
name: 'MockText',
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
},
}));
vi.mock('#/components/ktTable', () => ({
KtTable: defineComponent({
name: 'MockKtTable',
setup(_, { slots }) {
return () =>
h('section', { 'data-testid': 'network-table' }, [
slots.headerControls?.(),
slots.bodyCell?.({
column: { key: 'syncStatus' },
record: { syncStatus: 'pending' },
}),
slots.bodyCell?.({
column: { key: 'keeper' },
record: {
keeperDesiredEnabled: true,
keeperStatus: 'active',
},
}),
]);
},
}),
useKtTable: vi.fn((options) => {
mocks.tableOptions = options;
return [vi.fn(), mocks.tableApi];
}),
}));
vi.mock('./components/NetworkPortForwardModal', () => ({
default: defineComponent({
name: 'MockNetworkPortForwardModal',
setup(_, { expose }) {
expose({ openCreate: vi.fn(), openEdit: vi.fn() });
return () => h('div');
},
}),
}));
vi.mock('./components/NetworkEndpointHistoryDrawer', () => ({
default: defineComponent({
name: 'MockNetworkEndpointHistoryDrawer',
setup(_, { expose }) {
expose({ open: vi.fn() });
return () => h('div');
},
}),
}));
vi.mock('#/api/system/network', () => ({
deleteNetworkPortForward: mocks.api.deleteMapping,
disableNetworkPortForwardKeeper: mocks.api.disableKeeper,
enableNetworkPortForwardKeeper: mocks.api.enableKeeper,
getNetworkAgentStatus: mocks.api.getAgentStatus,
getNetworkPortForwardList: mocks.api.getList,
probeNetworkPortForward: mocks.api.probe,
retryNetworkPortForward: mocks.api.retry,
}));
vi.mock('#/locales', () => ({
$t: (key: string) => key,
}));
/** Creates a complete UDP row fixture with string revisions. */
function createRow(
overrides: Partial<SystemNetworkApi.PortForward> = {},
): SystemNetworkApi.PortForward {
return {
currentObservedAt: null,
currentPublicIpv4: null,
currentPublicPort: null,
currentValidUntil: null,
desiredPresence: 'present',
desiredRevision: '42',
externalPort: 45_678,
id: '90071992547409930',
internalPort: 45_678,
isDeleted: false,
keeperDesiredEnabled: false,
keeperStatus: 'disabled',
lastObservedAt: null,
lastObservedIpv4: null,
lastObservedPort: null,
name: 'Game UDP',
protocol: 'udp',
reportedRevision: '41',
syncStatus: 'synced',
targetIpv4: '192.168.31.224',
updateTime: '2026-07-22T00:00:00.000Z',
...overrides,
};
}
describe('system network persisted list', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocks.tableOptions = undefined;
mocks.tableApi.getRows.mockReturnValue([]);
mocks.tableApi.reload.mockResolvedValue(undefined);
mocks.api.getAgentStatus.mockResolvedValue({
agentId: 'nas-main',
appliedRevision: '41',
desiredRevision: '42',
online: false,
publishedRevision: '42',
targetIpv4: '192.168.31.224',
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.useRealTimers();
});
it('renders one Page root and the standard system list route', async () => {
const wrapper = mount(NetworkList);
await flushPromises();
const system = routes.find((route) => route.name === 'System');
const network = system?.children?.find(
(route) => route.name === 'SystemNetwork',
);
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
expect(wrapper.find('[data-testid="network-table"]').exists()).toBe(true);
expect(network).toMatchObject({ path: '/system/network' });
expect(String(network?.component)).toContain('network/list');
expect(mocks.tableOptions.columns.map((item: any) => item.key)).toEqual([
'name',
'protocol',
'externalPort',
'internalTarget',
'syncStatus',
'keeper',
'publicEndpoint',
'lastObserved',
'summary',
'updateTime',
]);
});
it('routes list labels and status values through the network locale namespace', () => {
const wrapper = mount(NetworkList);
expect(mocks.tableOptions.tableTitle).toBe(
'system.network.portForwardTitle',
);
expect(mocks.tableOptions.buttons[0].label).toBe(
'system.network.createAction',
);
expect(
mocks.tableOptions.columns.map((column: any) => column.title),
).toEqual([
'system.network.name',
'system.network.protocol',
'system.network.externalPort',
'system.network.internalTarget',
'system.network.syncStatus',
'system.network.keeperState',
'system.network.publicEndpoint',
'system.network.lastObserved',
'system.network.summary',
'system.network.updateTime',
]);
expect(
mocks.tableOptions.rowActions.map((action: any) => action.label),
).toEqual([
'system.network.editAction',
'system.network.retryAction',
'system.network.enableKeeperAction',
'system.network.disableKeeperAction',
'system.network.probeAction',
'system.network.copyEndpointAction',
'system.network.historyAction',
'system.network.deleteAction',
]);
expect(wrapper.text()).toContain('system.network.syncPending');
expect(wrapper.text()).toContain('system.network.desiredOn');
expect(wrapper.text()).toContain('system.network.keeperActive');
});
it('keeps unsupported Keeper actions visible-disabled with exact reasons', () => {
mount(NetworkList);
const actions = mocks.tableOptions.rowActions;
const enable = actions.find((item: any) => item.key === 'keeper-enable');
const probe = actions.find((item: any) => item.key === 'probe');
const tcp = createRow({ protocol: 'tcp' });
const splitPort = createRow({ internalPort: 32_000 });
const disabledKeeper = createRow({ keeperDesiredEnabled: false });
expect(enable.rowVisible(tcp)).toBe(true);
expect(enable.disabled(tcp)).toBe(true);
expect(enable.disabledReason(tcp)).toBe(
'system.network.tcpKeeperUnsupported',
);
expect(probe.disabledReason(splitPort)).toBe(
'system.network.udpSamePortRequired',
);
expect(probe.disabledReason(disabledKeeper)).toBe(
'system.network.enableKeeperFirst',
);
});
it('does not use Agent offline state to disable valid desired mutations', () => {
mount(NetworkList);
const actions = mocks.tableOptions.rowActions;
const row = createRow({ keeperDesiredEnabled: false });
const edit = actions.find((item: any) => item.key === 'edit');
const enable = actions.find((item: any) => item.key === 'keeper-enable');
expect(edit.disabled(row)).toBe(false);
expect(enable.disabled(row)).toBe(false);
});
it('makes deleting rows immutable while preserving retry', () => {
mount(NetworkList);
const actions = mocks.tableOptions.rowActions;
const row = createRow({
desiredPresence: 'absent',
syncStatus: 'deleting',
});
expect(isDeleting(row)).toBe(true);
expect(actions.find((item: any) => item.key === 'edit').disabled(row)).toBe(
true,
);
expect(
actions.find((item: any) => item.key === 'delete').disabled(row),
).toBe(true);
expect(
actions.find((item: any) => item.key === 'retry').disabled(row),
).toBe(false);
});
it('copies only the exact API-approved current endpoint', async () => {
mount(NetworkList);
const action = mocks.tableOptions.rowActions.find(
(item: any) => item.key === 'copy-endpoint',
);
const row = createRow({
currentPublicIpv4: '123.45.67.89',
currentPublicPort: 45_678,
});
expect(getCurrentEndpoint(row)).toBe('123.45.67.89:45678');
expect(action.disabled(row)).toBe(false);
await action.onClick(row);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'123.45.67.89:45678',
);
});
it('serializes polling and clears its timer when the page unmounts', async () => {
let finishReload: (() => void) | undefined;
mocks.tableApi.reload.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishReload = resolve;
}),
);
const wrapper = mount(NetworkList);
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
finishReload?.();
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
finishReload?.();
await flushPromises();
wrapper.unmount();
await vi.advanceTimersByTimeAsync(10_000);
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
});
});
describe('network action constraints', () => {
it('keeps Snowflake IDs and revisions as strings in row fixtures', () => {
const row = createRow();
expect(typeof row.id).toBe('string');
expect(typeof row.desiredRevision).toBe('string');
expect(getKeeperDisabledReason(row, false)).toBeUndefined();
});
});

View File

@ -0,0 +1,667 @@
import type { TableColumnType } from 'antdv-next';
import type { NetworkEndpointHistoryDrawerExposed } from './components/NetworkEndpointHistoryDrawer';
import type { NetworkPortForwardModalExposed } from './components/NetworkPortForwardModal';
import type { SystemNetworkApi } from '#/api/system/network';
import type {
KtTableApi,
KtTableButton,
KtTableRowAction,
} from '#/components/ktTable';
import {
defineComponent,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
} from 'vue';
import { Page } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { message, Space, Tag, Typography } from 'antdv-next';
import {
deleteNetworkPortForward,
disableNetworkPortForwardKeeper,
enableNetworkPortForwardKeeper,
getNetworkAgentStatus,
getNetworkPortForwardList,
probeNetworkPortForward,
retryNetworkPortForward,
} from '#/api/system/network';
import { KtTable, useKtTable } from '#/components/ktTable';
import { $t } from '#/locales';
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
const AKtTable = KtTable as any;
const ATypographyText = Typography.Text as any;
const POLL_INTERVAL_MS = 5000;
const protocolOptions = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
];
const syncStatusOptions = [
{ label: $t('system.network.syncPending'), value: 'pending' },
{ label: $t('system.network.syncSyncing'), value: 'syncing' },
{ label: $t('system.network.syncSynced'), value: 'synced' },
{ label: $t('system.network.syncConflict'), value: 'conflict' },
{ label: $t('system.network.syncFailed'), value: 'failed' },
{ label: $t('system.network.syncDeleting'), value: 'deleting' },
];
const syncStatusColors: Record<SystemNetworkApi.SyncStatus, string> = {
conflict: 'warning',
deleting: 'default',
failed: 'error',
pending: 'processing',
synced: 'success',
syncing: 'processing',
};
const keeperStatusColors: Record<SystemNetworkApi.KeeperStatus, string> = {
active: 'success',
disabled: 'default',
failed: 'error',
stale: 'warning',
starting: 'processing',
};
const syncStatusLabels: Record<SystemNetworkApi.SyncStatus, string> = {
conflict: $t('system.network.syncConflict'),
deleting: $t('system.network.syncDeleting'),
failed: $t('system.network.syncFailed'),
pending: $t('system.network.syncPending'),
synced: $t('system.network.syncSynced'),
syncing: $t('system.network.syncSyncing'),
};
const keeperStatusLabels: Record<SystemNetworkApi.KeeperStatus, string> = {
active: $t('system.network.keeperActive'),
disabled: $t('system.network.keeperDisabled'),
failed: $t('system.network.keeperFailed'),
stale: $t('system.network.keeperStale'),
starting: $t('system.network.keeperStarting'),
};
export default defineComponent({
name: 'SystemNetworkList',
/**
* Builds the generic persisted network-resource table and serialized poller.
*/
setup() {
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
const agentStatusUnknown = ref(true);
const busyRowIds = ref<Set<string>>(new Set());
const modalRef = ref<NetworkPortForwardModalExposed>();
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
let pollActive = false;
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let refreshInFlight: Promise<void> | undefined;
let refreshQueued = false;
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
{
dataIndex: 'name',
key: 'name',
title: $t('system.network.name'),
width: 150,
},
{
dataIndex: 'protocol',
key: 'protocol',
title: $t('system.network.protocol'),
width: 80,
},
{
dataIndex: 'externalPort',
key: 'externalPort',
title: $t('system.network.externalPort'),
width: 100,
},
{
key: 'internalTarget',
title: $t('system.network.internalTarget'),
width: 190,
},
{
dataIndex: 'syncStatus',
key: 'syncStatus',
title: $t('system.network.syncStatus'),
width: 105,
},
{
key: 'keeper',
title: $t('system.network.keeperState'),
width: 190,
},
{
key: 'publicEndpoint',
title: $t('system.network.publicEndpoint'),
width: 185,
},
{
key: 'lastObserved',
title: $t('system.network.lastObserved'),
width: 210,
},
{
key: 'summary',
title: $t('system.network.summary'),
width: 240,
},
{
dataIndex: 'updateTime',
key: 'updateTime',
title: $t('system.network.updateTime'),
width: 180,
},
];
const api: KtTableApi<SystemNetworkApi.PortForward> = {
/** Loads the latest API fact-source page for KtTable. */
list: async (params) => await getNetworkPortForwardList(params),
};
const buttons: Array<KtTableButton<SystemNetworkApi.PortForward>> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: $t('system.network.createAction'),
onClick: openCreate,
permissionCodes: ['System:Network:PortForward:Create'],
type: 'primary',
},
];
const rowActions: Array<KtTableRowAction<SystemNetworkApi.PortForward>> = [
{
disabled: (row) => isRowBusy(row) || isDeleting(row),
disabledReason: (row) =>
isRowBusy(row)
? $t('system.network.operationInProgress')
: getWriteDisabledReason(row),
key: 'edit',
label: $t('system.network.editAction'),
onClick: openEdit,
permissionCodes: ['System:Network:PortForward:Update'],
},
{
disabled: (row) =>
isRowBusy(row) ||
!['conflict', 'deleting', 'failed'].includes(row.syncStatus),
disabledReason: (row) =>
isRowBusy(row)
? $t('system.network.operationInProgress')
: $t('system.network.retryNotRequired'),
key: 'retry',
label: $t('system.network.retryAction'),
onClick: async (row) => {
await runRowMutation(
row,
retryNetworkPortForward,
$t('system.network.retrySubmitted'),
);
},
permissionCodes: ['System:Network:PortForward:Retry'],
},
{
disabled: (row) =>
isRowBusy(row) || !!getKeeperDisabledReason(row, false),
disabledReason: (row) =>
isRowBusy(row)
? $t('system.network.operationInProgress')
: getKeeperDisabledReason(row, false),
key: 'keeper-enable',
label: $t('system.network.enableKeeperAction'),
onClick: async (row) => {
await runRowMutation(
row,
enableNetworkPortForwardKeeper,
$t('system.network.keeperEnableSubmitted'),
);
},
permissionCodes: ['System:Network:PortForward:Keeper'],
rowVisible: (row) => !row.keeperDesiredEnabled,
},
{
disabled: (row) => isRowBusy(row) || isDeleting(row),
disabledReason: (row) =>
isRowBusy(row)
? $t('system.network.operationInProgress')
: getWriteDisabledReason(row),
key: 'keeper-disable',
label: $t('system.network.disableKeeperAction'),
onClick: async (row) => {
await runRowMutation(
row,
disableNetworkPortForwardKeeper,
$t('system.network.keeperDisableSubmitted'),
);
},
permissionCodes: ['System:Network:PortForward:Keeper'],
rowVisible: (row) => row.keeperDesiredEnabled,
},
{
disabled: (row) =>
isRowBusy(row) || !!getKeeperDisabledReason(row, true),
disabledReason: (row) =>
isRowBusy(row)
? $t('system.network.operationInProgress')
: getKeeperDisabledReason(row, true),
key: 'probe',
label: $t('system.network.probeAction'),
onClick: async (row) => {
await runRowMutation(
row,
probeNetworkPortForward,
$t('system.network.probeSubmitted'),
);
},
permissionCodes: ['System:Network:PortForward:Probe'],
},
{
disabled: (row) => !getCurrentEndpoint(row),
disabledReason: $t('system.network.noCurrentEndpoint'),
key: 'copy-endpoint',
label: $t('system.network.copyEndpointAction'),
onClick: copyEndpoint,
permissionCodes: ['System:Network:PortForward:List'],
},
{
key: 'history',
label: $t('system.network.historyAction'),
onClick: openHistory,
permissionCodes: ['System:Network:PortForward:History'],
},
{
confirm: (row) => $t('system.network.deleteConfirm', [row.name]),
danger: true,
disabled: (row) => isRowBusy(row) || isDeleting(row),
disabledReason: (row) =>
isRowBusy(row)
? $t('system.network.operationInProgress')
: getWriteDisabledReason(row),
key: 'delete',
label: $t('system.network.deleteAction'),
onClick: async (row) => {
await runRowMutation(
row,
deleteNetworkPortForward,
$t('system.network.deleteSubmitted'),
);
},
permissionCodes: ['System:Network:PortForward:Delete'],
},
];
const [registerTable, tableApi] = useKtTable<SystemNetworkApi.PortForward>({
api,
buttons,
columns,
formOptions: {
schema: [
{
component: 'Input',
componentProps: { allowClear: true },
fieldName: 'name',
label: $t('system.network.name'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: protocolOptions,
},
fieldName: 'protocol',
label: $t('system.network.protocol'),
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: syncStatusOptions,
},
fieldName: 'syncStatus',
label: $t('system.network.syncStatus'),
},
],
},
immediate: false,
rowActions,
rowActionVisibleCount: 2,
rowKey: 'id',
tableTitle: $t('system.network.portForwardTitle'),
});
/** Opens the create modal using the server-reported fixed target address. */
function openCreate() {
const rowTarget = tableApi.getRows()[0]?.targetIpv4 || '';
void modalRef.value?.openCreate(
agentStatus.value?.targetIpv4 || rowTarget,
);
}
/** Opens one mutable row in the shared CRUD modal. */
function openEdit(row: SystemNetworkApi.PortForward) {
if (!isRowBusy(row) && !isDeleting(row)) {
void modalRef.value?.openEdit(row);
}
}
/** Opens the append-only endpoint history drawer for one row. */
function openHistory(row: SystemNetworkApi.PortForward) {
historyDrawerRef.value?.open(row);
}
/** Reloads after a modal save while preserving serialized request order. */
function handleModalSaved() {
void requestRefresh();
}
/** Copies an API-approved current endpoint without logging it. */
async function copyEndpoint(row: SystemNetworkApi.PortForward) {
const endpoint = getCurrentEndpoint(row);
if (!endpoint) return;
await navigator.clipboard.writeText(endpoint);
message.success($t('system.network.endpointCopied'));
}
/**
* Runs one row mutation with per-row duplicate suppression and fact-source reload.
* @param row - Selected persisted mapping.
* @param mutation - API operation accepting the stable record ID.
* @param successMessage - Accurate asynchronous-success wording.
*/
async function runRowMutation(
row: SystemNetworkApi.PortForward,
mutation: (id: string) => Promise<unknown>,
successMessage: string,
) {
if (isRowBusy(row)) return;
setRowBusy(row.id, true);
try {
await mutation(row.id);
message.success(successMessage);
await requestRefresh();
} finally {
setRowBusy(row.id, false);
}
}
/** Updates the reactive per-row in-flight set without mutating it in place. */
function setRowBusy(id: string, busy: boolean) {
const next = new Set(busyRowIds.value);
if (busy) next.add(id);
else next.delete(id);
busyRowIds.value = next;
}
/** Returns whether one row already owns an in-flight write request. */
function isRowBusy(row: SystemNetworkApi.PortForward): boolean {
return busyRowIds.value.has(row.id);
}
/** Loads Agent connectivity without translating refresh failure into offline. */
async function loadAgentStatus() {
try {
agentStatus.value = await getNetworkAgentStatus();
agentStatusUnknown.value = false;
} catch {
agentStatusUnknown.value = true;
}
}
/**
* Serializes table and Agent polling so stale responses cannot overtake writes.
*/
async function requestRefresh(): Promise<void> {
clearPollTimer();
if (refreshInFlight) {
refreshQueued = true;
await refreshInFlight;
return;
}
refreshInFlight = Promise.allSettled([
tableApi.reload(),
loadAgentStatus(),
]).then(() => undefined);
try {
await refreshInFlight;
} finally {
refreshInFlight = undefined;
}
if (refreshQueued) {
refreshQueued = false;
await requestRefresh();
return;
}
schedulePoll();
}
/** Schedules the next non-overlapping refresh only while the page is active. */
function schedulePoll() {
clearPollTimer();
if (!pollActive) return;
pollTimer = setTimeout(() => {
void requestRefresh();
}, POLL_INTERVAL_MS);
}
/** Starts polling and immediately requests fresh table and Agent facts. */
function startPolling() {
if (pollActive) return;
pollActive = true;
void requestRefresh();
}
/** Stops future polling without changing any locally displayed fact. */
function stopPolling() {
pollActive = false;
clearPollTimer();
}
/** Clears the current one-shot polling timer when present. */
function clearPollTimer() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = undefined;
}
}
/** Renders independent Agent connectivity and revision convergence controls. */
function renderAgentControls() {
const status = agentStatus.value;
return (
<Space wrap>
<Tag color={getAgentStatusColor(status, agentStatusUnknown.value)}>
{$t('system.network.agentLabel')}:{' '}
{getAgentStatusLabel(status, agentStatusUnknown.value)}
</Tag>
<ATypographyText type="secondary">
{$t('system.network.revisionLabel')}{' '}
{String(status?.appliedRevision ?? '-')}/
{String(status?.desiredRevision ?? '-')}
</ATypographyText>
<ATypographyText type="secondary">
{$t('system.network.targetLabel')} {status?.targetIpv4 || '-'}
</ATypographyText>
</Space>
);
}
/** Renders cells without collapsing independent synchronization dimensions. */
function renderBodyCell({ column, record }: any) {
const row = record as SystemNetworkApi.PortForward;
if (column.key === 'protocol') {
return (
<Tag color={row.protocol === 'udp' ? 'blue' : 'purple'}>
{row.protocol.toUpperCase()}
</Tag>
);
}
if (column.key === 'internalTarget') {
return `${row.targetIpv4}:${row.internalPort}`;
}
if (column.key === 'syncStatus') {
return (
<Tag color={syncStatusColors[row.syncStatus]}>
{syncStatusLabels[row.syncStatus]}
</Tag>
);
}
if (column.key === 'keeper') {
return (
<Space size={4}>
<Tag color={row.keeperDesiredEnabled ? 'blue' : 'default'}>
{row.keeperDesiredEnabled
? $t('system.network.desiredOn')
: $t('system.network.desiredOff')}
</Tag>
<Tag color={keeperStatusColors[row.keeperStatus]}>
{keeperStatusLabels[row.keeperStatus]}
</Tag>
</Space>
);
}
if (column.key === 'publicEndpoint') {
return getCurrentEndpoint(row) || '-';
}
if (column.key === 'lastObserved') {
const lastEndpoint =
row.lastObservedIpv4 && row.lastObservedPort
? `${row.lastObservedIpv4}:${row.lastObservedPort}`
: '-';
return `${lastEndpoint} · ${row.lastObservedAt || '-'}`;
}
if (column.key === 'summary') {
return getWaitingOrErrorSummary(row, agentStatus.value);
}
return undefined;
}
onMounted(startPolling);
onActivated(startPolling);
onDeactivated(stopPolling);
onBeforeUnmount(stopPolling);
return () => (
<Page autoContentHeight>
<AKtTable
onRegister={registerTable}
v-slots={{
bodyCell: renderBodyCell,
headerControls: renderAgentControls,
}}
/>
<NetworkPortForwardModal onSaved={handleModalSaved} ref={modalRef} />
<NetworkEndpointHistoryDrawer ref={historyDrawerRef} />
</Page>
);
},
});
/**
* Returns whether a mapping is immutable pending confirmed asynchronous deletion.
* @param row - Persisted port-forward row.
* @returns True for absent tombstones, deleting state, or confirmed deletion.
*/
export function isDeleting(row: SystemNetworkApi.PortForward): boolean {
return (
row.isDeleted ||
row.desiredPresence === 'absent' ||
row.syncStatus === 'deleting'
);
}
/**
* Resolves the Keeper or probe disabled reason without consulting Agent online state.
* @param row - Persisted port-forward row.
* @param requireEnabled - Whether immediate probe additionally requires desired Keeper.
* @returns Readable reason, or undefined when the action is valid.
*/
export function getKeeperDisabledReason(
row: SystemNetworkApi.PortForward,
requireEnabled: boolean,
): string | undefined {
if (isDeleting(row)) return $t('system.network.deletingImmutable');
if (row.protocol === 'tcp') {
return $t('system.network.tcpKeeperUnsupported');
}
if (row.externalPort !== row.internalPort) {
return $t('system.network.udpSamePortRequired');
}
if (requireEnabled && !row.keeperDesiredEnabled) {
return $t('system.network.enableKeeperFirst');
}
return undefined;
}
/**
* Formats the current endpoint already lease-filtered by the API.
* @param row - Persisted port-forward row.
* @returns Exact public IPv4 and port, or undefined when the API withdrew it.
*/
export function getCurrentEndpoint(
row: SystemNetworkApi.PortForward,
): string | undefined {
return row.currentPublicIpv4 && row.currentPublicPort
? `${row.currentPublicIpv4}:${row.currentPublicPort}`
: undefined;
}
/**
* Produces the row summary without treating status-refresh failure as Agent offline.
* @param row - Persisted mapping with redacted stable error fields.
* @param status - Last successfully loaded Agent status, when available.
* @returns Error, offline wait, pending wait, or an empty marker.
*/
export function getWaitingOrErrorSummary(
row: SystemNetworkApi.PortForward,
status?: SystemNetworkApi.AgentStatus,
): string {
if (row.lastErrorMessage) return row.lastErrorMessage;
if (status && !status.online && row.syncStatus !== 'synced') {
return $t('system.network.waitingForAgent');
}
if (row.syncStatus === 'pending' || row.syncStatus === 'syncing') {
return $t('system.network.waitingForSync');
}
return '-';
}
/**
* Resolves the independent Agent status color without treating refresh failure as offline.
* @param status - Last successfully loaded Agent status.
* @param unknown - Whether the latest status request failed.
* @returns Ant Design semantic color.
*/
function getAgentStatusColor(
status: SystemNetworkApi.AgentStatus | undefined,
unknown: boolean,
): string {
if (unknown) return 'default';
return status?.online ? 'success' : 'warning';
}
/**
* Resolves the independent Agent status label shown in the table header.
* @param status - Last successfully loaded Agent status.
* @param unknown - Whether the latest status request failed.
* @returns Localized unknown, online, or offline label.
*/
function getAgentStatusLabel(
status: SystemNetworkApi.AgentStatus | undefined,
unknown: boolean,
): string {
if (unknown) return $t('system.network.agentUnknown');
return status?.online
? $t('system.network.agentOnline')
: $t('system.network.agentOffline');
}
/** Returns the immutable-row explanation used by CRUD write actions. */
function getWriteDisabledReason(
row: SystemNetworkApi.PortForward,
): string | undefined {
return isDeleting(row) ? $t('system.network.deletingImmutable') : undefined;
}