fix: 修复网络管理弹窗并改为事件刷新
This commit is contained in:
parent
bba71812b8
commit
f5165b238c
@ -50,7 +50,7 @@ pnpm run build:antdv-next
|
|||||||
- Blog 左栏“管理”入口通过 `/#/auth/login?sso=1&redirect=%2Fblog%2Farticle` 进入 Admin SSO bootstrap。Admin 只在自身域内调用 `/api/auth/refresh` 恢复 HttpOnly refresh Cookie;成功后进入文章管理,失败则移除 `sso` 并显示登录表单,且只保留固定内部回跳 `/blog/article`。该链路不接受外部 return URL,也不把 access token 放进地址栏。
|
- Blog 左栏“管理”入口通过 `/#/auth/login?sso=1&redirect=%2Fblog%2Farticle` 进入 Admin SSO bootstrap。Admin 只在自身域内调用 `/api/auth/refresh` 恢复 HttpOnly refresh Cookie;成功后进入文章管理,失败则移除 `sso` 并显示登录表单,且只保留固定内部回跳 `/blog/article`。该链路不接受外部 return URL,也不把 access token 放进地址栏。
|
||||||
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
|
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
|
||||||
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
|
||||||
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。
|
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。首屏读取一次 HTTP 快照,后续只在 API SSE 状态事件或主动操作后刷新;心跳不刷新页面,不使用定时轮询。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入,TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败,STUN 操作保持可见且禁用。
|
||||||
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API,新设备验证二维码和腾讯验证码分开展示;行操作“运行态”打开只读抽屉,展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
|
||||||
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||||
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现,iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview` 与 `BlogArticlePreviewButton`。
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
disableNetworkPortForwardKeeper,
|
disableNetworkPortForwardKeeper,
|
||||||
enableNetworkPortForwardKeeper,
|
enableNetworkPortForwardKeeper,
|
||||||
getNetworkAgentStatus,
|
getNetworkAgentStatus,
|
||||||
|
getNetworkManagementEventsUrl,
|
||||||
getNetworkPortForwardEndpointHistory,
|
getNetworkPortForwardEndpointHistory,
|
||||||
getNetworkPortForwardList,
|
getNetworkPortForwardList,
|
||||||
probeNetworkPortForward,
|
probeNetworkPortForward,
|
||||||
@ -19,6 +20,7 @@ vi.mock('#/api/request', () => ({
|
|||||||
requestClient: {
|
requestClient: {
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
|
getBaseUrl: vi.fn(() => '/api'),
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
},
|
},
|
||||||
@ -102,4 +104,13 @@ describe('system network api', () => {
|
|||||||
{ params: { pageNo: 2, pageSize: 10 } },
|
{ params: { pageNo: 2, pageSize: 10 } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds the credentialed SSE URL with an optional replay cursor', () => {
|
||||||
|
expect(getNetworkManagementEventsUrl()).toBe(
|
||||||
|
'/api/system/network/events/stream',
|
||||||
|
);
|
||||||
|
expect(getNetworkManagementEventsUrl('network-event-1')).toBe(
|
||||||
|
'/api/system/network/events/stream?lastEventId=network-event-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export namespace SystemNetworkApi {
|
|||||||
| 'stale'
|
| 'stale'
|
||||||
| 'starting';
|
| 'starting';
|
||||||
export type Protocol = 'tcp' | 'udp';
|
export type Protocol = 'tcp' | 'udp';
|
||||||
|
export type StateChangeSource = 'events' | 'reported' | 'status';
|
||||||
export type Revision = string;
|
export type Revision = string;
|
||||||
export type SyncStatus =
|
export type SyncStatus =
|
||||||
| 'conflict'
|
| 'conflict'
|
||||||
@ -96,6 +97,12 @@ export namespace SystemNetworkApi {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StateChangeEvent {
|
||||||
|
eventId: string;
|
||||||
|
observedAt: string;
|
||||||
|
source: StateChangeSource;
|
||||||
|
}
|
||||||
|
|
||||||
export type PortForwardItem = PortForward;
|
export type PortForwardItem = PortForward;
|
||||||
export type PortForwardPayload = PortForwardInput;
|
export type PortForwardPayload = PortForwardInput;
|
||||||
|
|
||||||
@ -233,3 +240,32 @@ export function getNetworkAgentStatus() {
|
|||||||
'/system/network/agent/status',
|
'/system/network/agent/status',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the credentialed EventSource URL for committed network-state changes.
|
||||||
|
* @param lastEventId - Optional replay cursor retained by the current route instance.
|
||||||
|
* @returns Browser-ready SSE URL using the configured API base path.
|
||||||
|
*/
|
||||||
|
export function getNetworkManagementEventsUrl(lastEventId?: string) {
|
||||||
|
const query = lastEventId
|
||||||
|
? `?lastEventId=${encodeURIComponent(lastEventId)}`
|
||||||
|
: '';
|
||||||
|
return buildApiUrl(`/system/network/events/stream${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins one network API path with the request client's configured base URL.
|
||||||
|
* @param path - Relative API route for the EventSource connection.
|
||||||
|
* @returns Absolute or proxy-relative URL matching normal Admin requests.
|
||||||
|
*/
|
||||||
|
function buildApiUrl(path: string) {
|
||||||
|
const getBaseUrl = (requestClient as unknown as { getBaseUrl?: () => string })
|
||||||
|
.getBaseUrl;
|
||||||
|
const baseUrl = getBaseUrl?.() || '';
|
||||||
|
if (!baseUrl) return path;
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
|
if (/^https?:\/\//i.test(baseUrl)) {
|
||||||
|
return new URL(path, baseUrl).toString();
|
||||||
|
}
|
||||||
|
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
|
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, h } from 'vue';
|
import { defineComponent, h } from 'vue';
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@ -12,8 +12,10 @@ import NetworkPortForwardModal from './NetworkPortForwardModal';
|
|||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const modalApi = {
|
const modalApi = {
|
||||||
close: vi.fn(async () => {}),
|
close: vi.fn(async () => {}),
|
||||||
|
getData: vi.fn(),
|
||||||
lock: vi.fn(),
|
lock: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
|
setData: vi.fn(),
|
||||||
unlock: vi.fn(),
|
unlock: vi.fn(),
|
||||||
};
|
};
|
||||||
const formApi = {
|
const formApi = {
|
||||||
@ -97,6 +99,15 @@ vi.mock('#/locales', () => ({
|
|||||||
describe('network port-forward modal', () => {
|
describe('network port-forward modal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mocks.formApi.resetForm.mockResolvedValue(undefined);
|
||||||
|
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.create.mockResolvedValue({});
|
||||||
mocks.update.mockResolvedValue({});
|
mocks.update.mockResolvedValue({});
|
||||||
mocks.formApi.getValues.mockResolvedValue({
|
mocks.formApi.getValues.mockResolvedValue({
|
||||||
@ -127,6 +138,43 @@ describe('network port-forward modal', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'create',
|
||||||
|
(wrapper: ReturnType<typeof mount>) =>
|
||||||
|
(wrapper.vm as any).openCreate('192.168.31.224'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'edit',
|
||||||
|
(wrapper: ReturnType<typeof mount>) =>
|
||||||
|
(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',
|
||||||
|
syncStatus: 'synced',
|
||||||
|
targetIpv4: '192.168.31.224',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])('opens the modal before restoring the %s form', async (_, openModal) => {
|
||||||
|
mocks.formApi.resetForm.mockImplementation(async () => {
|
||||||
|
expect(mocks.modalApi.open).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
const wrapper = mount(NetworkPortForwardModal);
|
||||||
|
|
||||||
|
await openModal(wrapper);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.modalApi.open).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.formApi.resetForm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('matches the API name and remark length contract', () => {
|
it('matches the API name and remark length contract', () => {
|
||||||
mount(NetworkPortForwardModal);
|
mount(NetworkPortForwardModal);
|
||||||
const schema = mocks.formOptions.schema as Array<{
|
const schema = mocks.formOptions.schema as Array<{
|
||||||
|
|||||||
@ -15,8 +15,12 @@ import {
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
export interface NetworkPortForwardModalExposed {
|
export interface NetworkPortForwardModalExposed {
|
||||||
openCreate: (targetIpv4: string) => Promise<void>;
|
openCreate: (targetIpv4: string) => void;
|
||||||
openEdit: (row: SystemNetworkApi.PortForward) => Promise<void>;
|
openEdit: (row: SystemNetworkApi.PortForward) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkPortForwardModalData {
|
||||||
|
values: Partial<SystemNetworkApi.PortForwardInput>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocolOptions = [
|
const protocolOptions = [
|
||||||
@ -53,40 +57,52 @@ export default defineComponent({
|
|||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
await submit();
|
await submit();
|
||||||
},
|
},
|
||||||
|
/** Resets values only after destroy-on-close content has mounted. */
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const { values } = modalApi.getData<NetworkPortForwardModalData>();
|
||||||
|
void resetForm(values);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a blank form for one new desired mapping.
|
* Opens a blank form for one new desired mapping.
|
||||||
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
|
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
|
||||||
*/
|
*/
|
||||||
async function openCreate(fixedTargetIpv4: string) {
|
function openCreate(fixedTargetIpv4: string) {
|
||||||
editingRow.value = undefined;
|
editingRow.value = undefined;
|
||||||
targetIpv4.value = fixedTargetIpv4;
|
targetIpv4.value = fixedTargetIpv4;
|
||||||
await resetForm({
|
modalApi
|
||||||
externalPort: undefined,
|
.setData({
|
||||||
internalPort: undefined,
|
values: {
|
||||||
name: '',
|
externalPort: undefined,
|
||||||
protocol: 'udp',
|
internalPort: undefined,
|
||||||
remark: '',
|
name: '',
|
||||||
});
|
protocol: 'udp',
|
||||||
modalApi.open();
|
remark: '',
|
||||||
|
},
|
||||||
|
} satisfies NetworkPortForwardModalData)
|
||||||
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens an existing row for editing without copying reported/runtime fields.
|
* Opens an existing row for editing without copying reported/runtime fields.
|
||||||
* @param row - Persisted desired record selected from KtTable.
|
* @param row - Persisted desired record selected from KtTable.
|
||||||
*/
|
*/
|
||||||
async function openEdit(row: SystemNetworkApi.PortForward) {
|
function openEdit(row: SystemNetworkApi.PortForward) {
|
||||||
editingRow.value = row;
|
editingRow.value = row;
|
||||||
targetIpv4.value = row.targetIpv4;
|
targetIpv4.value = row.targetIpv4;
|
||||||
await resetForm({
|
modalApi
|
||||||
externalPort: row.externalPort,
|
.setData({
|
||||||
internalPort: row.internalPort,
|
values: {
|
||||||
name: row.name,
|
externalPort: row.externalPort,
|
||||||
protocol: row.protocol,
|
internalPort: row.internalPort,
|
||||||
remark: row.remark || '',
|
name: row.name,
|
||||||
});
|
protocol: row.protocol,
|
||||||
modalApi.open();
|
remark: row.remark || '',
|
||||||
|
},
|
||||||
|
} satisfies NetworkPortForwardModalData)
|
||||||
|
.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
import type { SystemNetworkApi } from '#/api/system/network';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { getNetworkManagementEventsUrl } from '#/api/system/network';
|
||||||
|
|
||||||
|
export interface UseNetworkManagementStreamOptions {
|
||||||
|
onSnapshotRequired: () => void;
|
||||||
|
onStateChanged: (event: SystemNetworkApi.StateChangeEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges API SSE updates into the network page without exposing MQTT credentials.
|
||||||
|
* @param options - Page-owned callbacks for committed changes and replay gaps.
|
||||||
|
* @returns Idempotent start/close controls for route keep-alive lifecycle hooks.
|
||||||
|
*/
|
||||||
|
export function useNetworkManagementStream(
|
||||||
|
options: UseNetworkManagementStreamOptions,
|
||||||
|
) {
|
||||||
|
const lastEventId = ref<string>();
|
||||||
|
let source: EventSource | undefined;
|
||||||
|
|
||||||
|
/** Starts one EventSource and relies on native reconnect while the page is active. */
|
||||||
|
function start() {
|
||||||
|
if (source) return;
|
||||||
|
source = new EventSource(getNetworkManagementEventsUrl(lastEventId.value), {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
source.addEventListener('network-state-changed', handleStateChanged);
|
||||||
|
source.addEventListener('snapshot-required', handleSnapshotRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the active route stream and removes its typed listeners. */
|
||||||
|
function close() {
|
||||||
|
if (!source) return;
|
||||||
|
source.removeEventListener('network-state-changed', handleStateChanged);
|
||||||
|
source.removeEventListener('snapshot-required', handleSnapshotRequired);
|
||||||
|
source.close();
|
||||||
|
source = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one committed MQTT-derived state event exactly once.
|
||||||
|
* @param event - Browser SSE message containing a safe state-change envelope.
|
||||||
|
*/
|
||||||
|
function handleStateChanged(event: Event) {
|
||||||
|
const payload = parseStateChange(event);
|
||||||
|
if (!payload || payload.eventId === lastEventId.value) return;
|
||||||
|
lastEventId.value = payload.eventId;
|
||||||
|
options.onStateChanged(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests one fresh snapshot only when the API cannot replay a missed topic event. */
|
||||||
|
function handleSnapshotRequired() {
|
||||||
|
options.onSnapshotRequired();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the minimum browser payload before it reaches page state.
|
||||||
|
* @param event - Typed EventSource message with JSON data.
|
||||||
|
* @returns Parsed state event, or undefined for malformed/unexpected data.
|
||||||
|
*/
|
||||||
|
function parseStateChange(
|
||||||
|
event: Event,
|
||||||
|
): SystemNetworkApi.StateChangeEvent | undefined {
|
||||||
|
const data = (event as MessageEvent<string>).data;
|
||||||
|
if (!data) return undefined;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
data,
|
||||||
|
) as Partial<SystemNetworkApi.StateChangeEvent>;
|
||||||
|
if (
|
||||||
|
!payload.eventId ||
|
||||||
|
!payload.observedAt ||
|
||||||
|
!['events', 'reported', 'status'].includes(payload.source || '')
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return payload as SystemNetworkApi.StateChangeEvent;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { close, start };
|
||||||
|
}
|
||||||
@ -17,6 +17,46 @@ import NetworkList, {
|
|||||||
isDeleting,
|
isDeleting,
|
||||||
} from './list';
|
} from './list';
|
||||||
|
|
||||||
|
type FakeEventSourceListener = (event: Event) => void;
|
||||||
|
|
||||||
|
class FakeEventSource {
|
||||||
|
static instances: FakeEventSource[] = [];
|
||||||
|
|
||||||
|
closed = false;
|
||||||
|
readonly listeners = new Map<string, Set<FakeEventSourceListener>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly url: string,
|
||||||
|
readonly options?: EventSourceInit,
|
||||||
|
) {
|
||||||
|
FakeEventSource.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers one typed SSE listener for the page test. */
|
||||||
|
addEventListener(type: string, listener: FakeEventSourceListener) {
|
||||||
|
const listeners = this.listeners.get(type) || new Set();
|
||||||
|
listeners.add(listener);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes this fake stream and prevents later dispatches. */
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatches one JSON SSE payload to currently registered listeners. */
|
||||||
|
dispatch(type: string, data: Record<string, unknown>) {
|
||||||
|
if (this.closed) return;
|
||||||
|
const event = new MessageEvent(type, { data: JSON.stringify(data) });
|
||||||
|
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes one typed SSE listener from the page test. */
|
||||||
|
removeEventListener(type: string, listener: FakeEventSourceListener) {
|
||||||
|
this.listeners.get(type)?.delete(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
api: {
|
api: {
|
||||||
deleteMapping: vi.fn(),
|
deleteMapping: vi.fn(),
|
||||||
@ -28,6 +68,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
retry: vi.fn(),
|
retry: vi.fn(),
|
||||||
},
|
},
|
||||||
messageSuccess: vi.fn(),
|
messageSuccess: vi.fn(),
|
||||||
|
modalOpenCreate: vi.fn(),
|
||||||
|
modalOpenEdit: vi.fn(),
|
||||||
tableApi: {
|
tableApi: {
|
||||||
getRows: vi.fn(() => []),
|
getRows: vi.fn(() => []),
|
||||||
reload: vi.fn(),
|
reload: vi.fn(),
|
||||||
@ -99,7 +141,10 @@ vi.mock('./components/NetworkPortForwardModal', () => ({
|
|||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'MockNetworkPortForwardModal',
|
name: 'MockNetworkPortForwardModal',
|
||||||
setup(_, { expose }) {
|
setup(_, { expose }) {
|
||||||
expose({ openCreate: vi.fn(), openEdit: vi.fn() });
|
expose({
|
||||||
|
openCreate: mocks.modalOpenCreate,
|
||||||
|
openEdit: mocks.modalOpenEdit,
|
||||||
|
});
|
||||||
return () => h('div');
|
return () => h('div');
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -120,6 +165,9 @@ vi.mock('#/api/system/network', () => ({
|
|||||||
disableNetworkPortForwardKeeper: mocks.api.disableKeeper,
|
disableNetworkPortForwardKeeper: mocks.api.disableKeeper,
|
||||||
enableNetworkPortForwardKeeper: mocks.api.enableKeeper,
|
enableNetworkPortForwardKeeper: mocks.api.enableKeeper,
|
||||||
getNetworkAgentStatus: mocks.api.getAgentStatus,
|
getNetworkAgentStatus: mocks.api.getAgentStatus,
|
||||||
|
getNetworkManagementEventsUrl: vi.fn(
|
||||||
|
() => '/api/system/network/events/stream',
|
||||||
|
),
|
||||||
getNetworkPortForwardList: mocks.api.getList,
|
getNetworkPortForwardList: mocks.api.getList,
|
||||||
probeNetworkPortForward: mocks.api.probe,
|
probeNetworkPortForward: mocks.api.probe,
|
||||||
retryNetworkPortForward: mocks.api.retry,
|
retryNetworkPortForward: mocks.api.retry,
|
||||||
@ -163,6 +211,8 @@ describe('system network persisted list', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
FakeEventSource.instances = [];
|
||||||
|
vi.stubGlobal('EventSource', FakeEventSource);
|
||||||
mocks.tableOptions = undefined;
|
mocks.tableOptions = undefined;
|
||||||
mocks.tableApi.getRows.mockReturnValue([]);
|
mocks.tableApi.getRows.mockReturnValue([]);
|
||||||
mocks.tableApi.reload.mockResolvedValue(undefined);
|
mocks.tableApi.reload.mockResolvedValue(undefined);
|
||||||
@ -181,6 +231,7 @@ describe('system network persisted list', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -283,6 +334,20 @@ describe('system network persisted list', () => {
|
|||||||
expect(enable.disabled(row)).toBe(false);
|
expect(enable.disabled(row)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the shared modal from both create and edit table actions', async () => {
|
||||||
|
mount(NetworkList);
|
||||||
|
await flushPromises();
|
||||||
|
const row = createRow();
|
||||||
|
|
||||||
|
await mocks.tableOptions.buttons[0].onClick({});
|
||||||
|
await mocks.tableOptions.rowActions
|
||||||
|
.find((item: any) => item.key === 'edit')
|
||||||
|
.onClick(row, {});
|
||||||
|
|
||||||
|
expect(mocks.modalOpenCreate).toHaveBeenCalledWith('192.168.31.224');
|
||||||
|
expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row);
|
||||||
|
});
|
||||||
|
|
||||||
it('makes deleting rows immutable while preserving retry', () => {
|
it('makes deleting rows immutable while preserving retry', () => {
|
||||||
mount(NetworkList);
|
mount(NetworkList);
|
||||||
const actions = mocks.tableOptions.rowActions;
|
const actions = mocks.tableOptions.rowActions;
|
||||||
@ -321,7 +386,7 @@ describe('system network persisted list', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes polling and clears its timer when the page unmounts', async () => {
|
it('refreshes only for subscribed topic events and closes the stream on unmount', async () => {
|
||||||
let finishReload: (() => void) | undefined;
|
let finishReload: (() => void) | undefined;
|
||||||
mocks.tableApi.reload.mockImplementation(
|
mocks.tableApi.reload.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@ -332,18 +397,38 @@ describe('system network persisted list', () => {
|
|||||||
const wrapper = mount(NetworkList);
|
const wrapper = mount(NetworkList);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(FakeEventSource.instances).toHaveLength(1);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(10_000);
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||||
finishReload?.();
|
finishReload?.();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
await vi.advanceTimersByTimeAsync(5000);
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
|
||||||
|
FakeEventSource.instances[0]?.dispatch('heartbeat', {
|
||||||
|
observedAt: '2026-07-23T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||||
|
eventId: 'network-event-1',
|
||||||
|
observedAt: '2026-07-23T00:00:01.000Z',
|
||||||
|
source: 'reported',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
finishReload?.();
|
finishReload?.();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
await vi.advanceTimersByTimeAsync(10_000);
|
expect(FakeEventSource.instances[0]?.closed).toBe(true);
|
||||||
|
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
|
||||||
|
eventId: 'network-event-2',
|
||||||
|
observedAt: '2026-07-23T00:00:02.000Z',
|
||||||
|
source: 'status',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -38,11 +38,10 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
||||||
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
||||||
|
import { useNetworkManagementStream } from './composables/useNetworkManagementStream';
|
||||||
|
|
||||||
const AKtTable = KtTable as any;
|
const AKtTable = KtTable as any;
|
||||||
const ATypographyText = Typography.Text as any;
|
const ATypographyText = Typography.Text as any;
|
||||||
const POLL_INTERVAL_MS = 5000;
|
|
||||||
|
|
||||||
const protocolOptions = [
|
const protocolOptions = [
|
||||||
{ label: 'TCP', value: 'tcp' },
|
{ label: 'TCP', value: 'tcp' },
|
||||||
{ label: 'UDP', value: 'udp' },
|
{ label: 'UDP', value: 'udp' },
|
||||||
@ -89,7 +88,7 @@ const keeperStatusLabels: Record<SystemNetworkApi.KeeperStatus, string> = {
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'SystemNetworkList',
|
name: 'SystemNetworkList',
|
||||||
/**
|
/**
|
||||||
* Builds the generic persisted network-resource table and serialized poller.
|
* Builds the generic persisted network-resource table and event-driven refresh stream.
|
||||||
*/
|
*/
|
||||||
setup() {
|
setup() {
|
||||||
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
|
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
|
||||||
@ -97,10 +96,13 @@ export default defineComponent({
|
|||||||
const busyRowIds = ref<Set<string>>(new Set());
|
const busyRowIds = ref<Set<string>>(new Set());
|
||||||
const modalRef = ref<NetworkPortForwardModalExposed>();
|
const modalRef = ref<NetworkPortForwardModalExposed>();
|
||||||
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
|
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
|
||||||
let pollActive = false;
|
let pageActive = false;
|
||||||
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let refreshInFlight: Promise<void> | undefined;
|
let refreshInFlight: Promise<void> | undefined;
|
||||||
let refreshQueued = false;
|
let refreshQueued = false;
|
||||||
|
const managementStream = useNetworkManagementStream({
|
||||||
|
onSnapshotRequired: handleStreamRefresh,
|
||||||
|
onStateChanged: handleStreamRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
|
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
|
||||||
{
|
{
|
||||||
@ -411,10 +413,9 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes table and Agent polling so stale responses cannot overtake writes.
|
* Serializes table and Agent refreshes so stale responses cannot overtake writes.
|
||||||
*/
|
*/
|
||||||
async function requestRefresh(): Promise<void> {
|
async function requestRefresh(): Promise<void> {
|
||||||
clearPollTimer();
|
|
||||||
if (refreshInFlight) {
|
if (refreshInFlight) {
|
||||||
refreshQueued = true;
|
refreshQueued = true;
|
||||||
await refreshInFlight;
|
await refreshInFlight;
|
||||||
@ -433,39 +434,26 @@ export default defineComponent({
|
|||||||
if (refreshQueued) {
|
if (refreshQueued) {
|
||||||
refreshQueued = false;
|
refreshQueued = false;
|
||||||
await requestRefresh();
|
await requestRefresh();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
schedulePoll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schedules the next non-overlapping refresh only while the page is active. */
|
/** Refreshes persisted facts only for a committed topic event or replay gap. */
|
||||||
function schedulePoll() {
|
function handleStreamRefresh() {
|
||||||
clearPollTimer();
|
if (pageActive) void requestRefresh();
|
||||||
if (!pollActive) return;
|
|
||||||
pollTimer = setTimeout(() => {
|
|
||||||
void requestRefresh();
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts polling and immediately requests fresh table and Agent facts. */
|
/** Opens the stream before the one initial page snapshot to avoid a subscription gap. */
|
||||||
function startPolling() {
|
function activatePage() {
|
||||||
if (pollActive) return;
|
if (pageActive) return;
|
||||||
pollActive = true;
|
pageActive = true;
|
||||||
|
managementStream.start();
|
||||||
void requestRefresh();
|
void requestRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stops future polling without changing any locally displayed fact. */
|
/** Closes the route-owned stream while preserving its replay cursor. */
|
||||||
function stopPolling() {
|
function deactivatePage() {
|
||||||
pollActive = false;
|
pageActive = false;
|
||||||
clearPollTimer();
|
managementStream.close();
|
||||||
}
|
|
||||||
|
|
||||||
/** 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. */
|
/** Renders independent Agent connectivity and revision convergence controls. */
|
||||||
@ -539,10 +527,10 @@ export default defineComponent({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(startPolling);
|
onMounted(activatePage);
|
||||||
onActivated(startPolling);
|
onActivated(activatePage);
|
||||||
onDeactivated(stopPolling);
|
onDeactivated(deactivatePage);
|
||||||
onBeforeUnmount(stopPolling);
|
onBeforeUnmount(deactivatePage);
|
||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<Page autoContentHeight>
|
<Page autoContentHeight>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user