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 放进地址栏。
|
||||
- 系统管理 / 菜单管理维护后端 `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 操作保持可见且禁用。
|
||||
- 系统管理 / 网络管理使用 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 / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`。
|
||||
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/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,
|
||||
enableNetworkPortForwardKeeper,
|
||||
getNetworkAgentStatus,
|
||||
getNetworkManagementEventsUrl,
|
||||
getNetworkPortForwardEndpointHistory,
|
||||
getNetworkPortForwardList,
|
||||
probeNetworkPortForward,
|
||||
@ -19,6 +20,7 @@ vi.mock('#/api/request', () => ({
|
||||
requestClient: {
|
||||
delete: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getBaseUrl: vi.fn(() => '/api'),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
@ -102,4 +104,13 @@ describe('system network api', () => {
|
||||
{ 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'
|
||||
| 'starting';
|
||||
export type Protocol = 'tcp' | 'udp';
|
||||
export type StateChangeSource = 'events' | 'reported' | 'status';
|
||||
export type Revision = string;
|
||||
export type SyncStatus =
|
||||
| 'conflict'
|
||||
@ -96,6 +97,12 @@ export namespace SystemNetworkApi {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface StateChangeEvent {
|
||||
eventId: string;
|
||||
observedAt: string;
|
||||
source: StateChangeSource;
|
||||
}
|
||||
|
||||
export type PortForwardItem = PortForward;
|
||||
export type PortForwardPayload = PortForwardInput;
|
||||
|
||||
@ -233,3 +240,32 @@ export function getNetworkAgentStatus() {
|
||||
'/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 */
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@ -12,8 +12,10 @@ import NetworkPortForwardModal from './NetworkPortForwardModal';
|
||||
const mocks = vi.hoisted(() => {
|
||||
const modalApi = {
|
||||
close: vi.fn(async () => {}),
|
||||
getData: vi.fn(),
|
||||
lock: vi.fn(),
|
||||
open: vi.fn(),
|
||||
setData: vi.fn(),
|
||||
unlock: vi.fn(),
|
||||
};
|
||||
const formApi = {
|
||||
@ -97,6 +99,15 @@ vi.mock('#/locales', () => ({
|
||||
describe('network port-forward modal', () => {
|
||||
beforeEach(() => {
|
||||
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.update.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', () => {
|
||||
mount(NetworkPortForwardModal);
|
||||
const schema = mocks.formOptions.schema as Array<{
|
||||
|
||||
@ -15,8 +15,12 @@ import {
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export interface NetworkPortForwardModalExposed {
|
||||
openCreate: (targetIpv4: string) => Promise<void>;
|
||||
openEdit: (row: SystemNetworkApi.PortForward) => Promise<void>;
|
||||
openCreate: (targetIpv4: string) => void;
|
||||
openEdit: (row: SystemNetworkApi.PortForward) => void;
|
||||
}
|
||||
|
||||
interface NetworkPortForwardModalData {
|
||||
values: Partial<SystemNetworkApi.PortForwardInput>;
|
||||
}
|
||||
|
||||
const protocolOptions = [
|
||||
@ -53,40 +57,52 @@ export default defineComponent({
|
||||
async onConfirm() {
|
||||
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.
|
||||
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
|
||||
*/
|
||||
async function openCreate(fixedTargetIpv4: string) {
|
||||
function openCreate(fixedTargetIpv4: string) {
|
||||
editingRow.value = undefined;
|
||||
targetIpv4.value = fixedTargetIpv4;
|
||||
await resetForm({
|
||||
modalApi
|
||||
.setData({
|
||||
values: {
|
||||
externalPort: undefined,
|
||||
internalPort: undefined,
|
||||
name: '',
|
||||
protocol: 'udp',
|
||||
remark: '',
|
||||
});
|
||||
modalApi.open();
|
||||
},
|
||||
} satisfies NetworkPortForwardModalData)
|
||||
.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) {
|
||||
function openEdit(row: SystemNetworkApi.PortForward) {
|
||||
editingRow.value = row;
|
||||
targetIpv4.value = row.targetIpv4;
|
||||
await resetForm({
|
||||
modalApi
|
||||
.setData({
|
||||
values: {
|
||||
externalPort: row.externalPort,
|
||||
internalPort: row.internalPort,
|
||||
name: row.name,
|
||||
protocol: row.protocol,
|
||||
remark: row.remark || '',
|
||||
});
|
||||
modalApi.open();
|
||||
},
|
||||
} 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,
|
||||
} 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(() => ({
|
||||
api: {
|
||||
deleteMapping: vi.fn(),
|
||||
@ -28,6 +68,8 @@ const mocks = vi.hoisted(() => ({
|
||||
retry: vi.fn(),
|
||||
},
|
||||
messageSuccess: vi.fn(),
|
||||
modalOpenCreate: vi.fn(),
|
||||
modalOpenEdit: vi.fn(),
|
||||
tableApi: {
|
||||
getRows: vi.fn(() => []),
|
||||
reload: vi.fn(),
|
||||
@ -99,7 +141,10 @@ vi.mock('./components/NetworkPortForwardModal', () => ({
|
||||
default: defineComponent({
|
||||
name: 'MockNetworkPortForwardModal',
|
||||
setup(_, { expose }) {
|
||||
expose({ openCreate: vi.fn(), openEdit: vi.fn() });
|
||||
expose({
|
||||
openCreate: mocks.modalOpenCreate,
|
||||
openEdit: mocks.modalOpenEdit,
|
||||
});
|
||||
return () => h('div');
|
||||
},
|
||||
}),
|
||||
@ -120,6 +165,9 @@ vi.mock('#/api/system/network', () => ({
|
||||
disableNetworkPortForwardKeeper: mocks.api.disableKeeper,
|
||||
enableNetworkPortForwardKeeper: mocks.api.enableKeeper,
|
||||
getNetworkAgentStatus: mocks.api.getAgentStatus,
|
||||
getNetworkManagementEventsUrl: vi.fn(
|
||||
() => '/api/system/network/events/stream',
|
||||
),
|
||||
getNetworkPortForwardList: mocks.api.getList,
|
||||
probeNetworkPortForward: mocks.api.probe,
|
||||
retryNetworkPortForward: mocks.api.retry,
|
||||
@ -163,6 +211,8 @@ describe('system network persisted list', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
FakeEventSource.instances = [];
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
mocks.tableOptions = undefined;
|
||||
mocks.tableApi.getRows.mockReturnValue([]);
|
||||
mocks.tableApi.reload.mockResolvedValue(undefined);
|
||||
@ -181,6 +231,7 @@ describe('system network persisted list', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@ -283,6 +334,20 @@ describe('system network persisted list', () => {
|
||||
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', () => {
|
||||
mount(NetworkList);
|
||||
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;
|
||||
mocks.tableApi.reload.mockImplementation(
|
||||
() =>
|
||||
@ -332,18 +397,38 @@ describe('system network persisted list', () => {
|
||||
const wrapper = mount(NetworkList);
|
||||
await flushPromises();
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
|
||||
finishReload?.();
|
||||
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);
|
||||
|
||||
finishReload?.();
|
||||
await flushPromises();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,11 +38,10 @@ import { $t } from '#/locales';
|
||||
|
||||
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
|
||||
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
|
||||
import { useNetworkManagementStream } from './composables/useNetworkManagementStream';
|
||||
|
||||
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' },
|
||||
@ -89,7 +88,7 @@ const keeperStatusLabels: Record<SystemNetworkApi.KeeperStatus, string> = {
|
||||
export default defineComponent({
|
||||
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() {
|
||||
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
|
||||
@ -97,10 +96,13 @@ export default defineComponent({
|
||||
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 pageActive = false;
|
||||
let refreshInFlight: Promise<void> | undefined;
|
||||
let refreshQueued = false;
|
||||
const managementStream = useNetworkManagementStream({
|
||||
onSnapshotRequired: handleStreamRefresh,
|
||||
onStateChanged: handleStreamRefresh,
|
||||
});
|
||||
|
||||
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> {
|
||||
clearPollTimer();
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true;
|
||||
await refreshInFlight;
|
||||
@ -433,39 +434,26 @@ export default defineComponent({
|
||||
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);
|
||||
/** Refreshes persisted facts only for a committed topic event or replay gap. */
|
||||
function handleStreamRefresh() {
|
||||
if (pageActive) void requestRefresh();
|
||||
}
|
||||
|
||||
/** Starts polling and immediately requests fresh table and Agent facts. */
|
||||
function startPolling() {
|
||||
if (pollActive) return;
|
||||
pollActive = true;
|
||||
/** Opens the stream before the one initial page snapshot to avoid a subscription gap. */
|
||||
function activatePage() {
|
||||
if (pageActive) return;
|
||||
pageActive = true;
|
||||
managementStream.start();
|
||||
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;
|
||||
}
|
||||
/** Closes the route-owned stream while preserving its replay cursor. */
|
||||
function deactivatePage() {
|
||||
pageActive = false;
|
||||
managementStream.close();
|
||||
}
|
||||
|
||||
/** Renders independent Agent connectivity and revision convergence controls. */
|
||||
@ -539,10 +527,10 @@ export default defineComponent({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onMounted(startPolling);
|
||||
onActivated(startPolling);
|
||||
onDeactivated(stopPolling);
|
||||
onBeforeUnmount(stopPolling);
|
||||
onMounted(activatePage);
|
||||
onActivated(activatePage);
|
||||
onDeactivated(deactivatePage);
|
||||
onBeforeUnmount(deactivatePage);
|
||||
|
||||
return () => (
|
||||
<Page autoContentHeight>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user