mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-13 10:04:18 +08:00
⚡️ perf(redis-monitor): 暂停后台页签轮询
This commit is contained in:
265
frontend/src/components/RedisMonitor.polling.test.tsx
Normal file
265
frontend/src/components/RedisMonitor.polling.test.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import RedisMonitor from './RedisMonitor';
|
||||
|
||||
const redisApi = vi.hoisted(() => ({
|
||||
RedisGetServerInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const connection = {
|
||||
id: 'redis-connection',
|
||||
name: 'Redis',
|
||||
config: {},
|
||||
};
|
||||
|
||||
const serverInfoResponse = (connectedClients = '2') => ({
|
||||
success: true,
|
||||
data: {
|
||||
instantaneous_ops_per_sec: '1',
|
||||
used_memory: '1024',
|
||||
used_memory_rss: '2048',
|
||||
connected_clients: connectedClients,
|
||||
},
|
||||
});
|
||||
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => redisApi);
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: unknown) => unknown) => selector({
|
||||
connections: [connection],
|
||||
theme: 'light',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/connectionRpcConfig', () => ({
|
||||
buildRpcConnectionConfig: (config: unknown, options: unknown) => ({ config, options }),
|
||||
}));
|
||||
|
||||
vi.mock('../i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('../i18n/provider', () => ({
|
||||
useOptionalI18n: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const React = await import('react');
|
||||
const passthrough = ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children);
|
||||
return {
|
||||
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
|
||||
React.createElement('button', { onClick }, children)
|
||||
),
|
||||
Card: passthrough,
|
||||
Col: passthrough,
|
||||
Row: passthrough,
|
||||
Spin: passthrough,
|
||||
Statistic: ({ value }: { value?: React.ReactNode }) => React.createElement(
|
||||
'output',
|
||||
{ 'data-statistic-value': String(value ?? '') },
|
||||
value,
|
||||
),
|
||||
Tag: passthrough,
|
||||
Typography: {
|
||||
Text: passthrough,
|
||||
Title: passthrough,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('recharts', async () => {
|
||||
const React = await import('react');
|
||||
const passthrough = ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children);
|
||||
return {
|
||||
Area: passthrough,
|
||||
AreaChart: passthrough,
|
||||
CartesianGrid: passthrough,
|
||||
Legend: passthrough,
|
||||
Line: passthrough,
|
||||
LineChart: passthrough,
|
||||
ResponsiveContainer: passthrough,
|
||||
Tooltip: passthrough,
|
||||
XAxis: passthrough,
|
||||
YAxis: passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@ant-design/icons', async () => {
|
||||
const React = await import('react');
|
||||
const Icon = () => React.createElement('span');
|
||||
return {
|
||||
ApiOutlined: Icon,
|
||||
DashboardOutlined: Icon,
|
||||
DesktopOutlined: Icon,
|
||||
HddOutlined: Icon,
|
||||
PauseCircleOutlined: Icon,
|
||||
PlayCircleOutlined: Icon,
|
||||
ReloadOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
const renderMonitor = (isActive: boolean) => (
|
||||
<RedisMonitor connectionId={connection.id} redisDB={0} isActive={isActive} />
|
||||
);
|
||||
const workbenchSource = readFileSync(new URL('./WorkbenchTabContent.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('RedisMonitor polling', () => {
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
redisApi.RedisGetServerInfo.mockReset().mockResolvedValue(serverInfoResponse());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
renderer = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts immediately only when active and stops polling as soon as it becomes inactive', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(renderMonitor(false));
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(renderMonitor(true));
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(renderMonitor(false));
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps manual pause state across activity changes while allowing an explicit refresh', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(renderMonitor(true));
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findAllByType('button')[0].props.onClick();
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findAllByType('button')[1].props.onClick();
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(renderMonitor(false));
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.update(renderMonitor(true));
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('discards a pending manual refresh after deactivation and lets a new generation run', async () => {
|
||||
const staleRefresh = createDeferred<ReturnType<typeof serverInfoResponse>>();
|
||||
redisApi.RedisGetServerInfo
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(serverInfoResponse())
|
||||
.mockImplementationOnce(() => staleRefresh.promise)
|
||||
.mockResolvedValueOnce(serverInfoResponse());
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(renderMonitor(true));
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findAllByType('button')[0].props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findAllByType('button')[1].props.onClick();
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(renderMonitor(false));
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.update(renderMonitor(true));
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findAllByType('button')[0].props.onClick();
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(3);
|
||||
|
||||
await act(async () => {
|
||||
staleRefresh.resolve(serverInfoResponse('99'));
|
||||
await staleRefresh.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(renderer!.root.findAllByProps({ 'data-statistic-value': '99' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('waits for a slow request to finish before scheduling the next poll', async () => {
|
||||
const firstRequest = createDeferred<ReturnType<typeof serverInfoResponse>>();
|
||||
redisApi.RedisGetServerInfo
|
||||
.mockReset()
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockResolvedValue(serverInfoResponse());
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(renderMonitor(true));
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findAllByType('button')[1].props.onClick();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
firstRequest.resolve(serverInfoResponse());
|
||||
await firstRequest.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
expect(redisApi.RedisGetServerInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('receives the active workbench state from the tab host', () => {
|
||||
expect(workbenchSource).toContain(
|
||||
'<RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} isActive={isActive} />',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Card, Row, Col, Statistic, Button, Tag, Typography, Spin } from 'antd';
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ const { Title, Text } = Typography;
|
||||
interface RedisMonitorProps {
|
||||
connectionId: string;
|
||||
redisDB: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// Data point for charts
|
||||
@@ -38,14 +39,18 @@ interface MetricPoint {
|
||||
}
|
||||
|
||||
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
|
||||
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB, isActive = true }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
const i18n = useOptionalI18n();
|
||||
const i18nLanguage = i18n?.language;
|
||||
const tr = (key: string, params?: I18nParams) => t(key, params, i18nLanguage);
|
||||
const tr = useCallback(
|
||||
(key: string, params?: I18nParams) => t(key, params, i18nLanguage),
|
||||
[i18nLanguage],
|
||||
);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -55,22 +60,30 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
|
||||
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
|
||||
|
||||
// Ref to track if component is mounted to prevent state updates after unmount
|
||||
const mountedRef = useRef(true);
|
||||
// Interval ref
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const monitorActiveRef = useRef(false);
|
||||
const autoRefreshEnabledRef = useRef(false);
|
||||
const pollingGenerationRef = useRef(0);
|
||||
const inFlightGenerationsRef = useRef(new Set<number>());
|
||||
const nextPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const requestMetricsRef = useRef<(generation: number) => Promise<void>>(async () => undefined);
|
||||
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
|
||||
const formatFetchError = (detail?: unknown) => tr('redis_monitor.message.fetch_failed', {
|
||||
const formatFetchError = useCallback((detail?: unknown) => tr('redis_monitor.message.fetch_failed', {
|
||||
detail: String(detail || tr('common.unknown')),
|
||||
});
|
||||
}), [tr]);
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
const fetchMetrics = useCallback(async (generation: number) => {
|
||||
if (!connection) return;
|
||||
|
||||
try {
|
||||
const config = buildRpcConnectionConfig(connection.config, { redisDB });
|
||||
const res = await RedisGetServerInfo(config);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
if (
|
||||
!mountedRef.current
|
||||
|| !monitorActiveRef.current
|
||||
|| generation !== pollingGenerationRef.current
|
||||
) return;
|
||||
|
||||
if (!res.success) {
|
||||
setError(formatFetchError(res.message));
|
||||
@@ -124,38 +137,83 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
|
||||
return next;
|
||||
});
|
||||
|
||||
if (loading) setLoading(false);
|
||||
setLoading(false);
|
||||
|
||||
} catch (err: any) {
|
||||
if (mountedRef.current) {
|
||||
if (
|
||||
mountedRef.current
|
||||
&& monitorActiveRef.current
|
||||
&& generation === pollingGenerationRef.current
|
||||
) {
|
||||
setError(formatFetchError(err?.message || err));
|
||||
if (loading) setLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [connection, formatFetchError, redisDB]);
|
||||
|
||||
const clearNextPollTimer = useCallback(() => {
|
||||
if (nextPollTimerRef.current === null) return;
|
||||
clearTimeout(nextPollTimerRef.current);
|
||||
nextPollTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const requestMetrics = useCallback(async (generation: number) => {
|
||||
if (
|
||||
!monitorActiveRef.current
|
||||
|| generation !== pollingGenerationRef.current
|
||||
|| inFlightGenerationsRef.current.has(generation)
|
||||
) return;
|
||||
|
||||
clearNextPollTimer();
|
||||
inFlightGenerationsRef.current.add(generation);
|
||||
try {
|
||||
await fetchMetrics(generation);
|
||||
} finally {
|
||||
inFlightGenerationsRef.current.delete(generation);
|
||||
if (
|
||||
mountedRef.current
|
||||
&& monitorActiveRef.current
|
||||
&& autoRefreshEnabledRef.current
|
||||
&& generation === pollingGenerationRef.current
|
||||
) {
|
||||
nextPollTimerRef.current = setTimeout(() => {
|
||||
nextPollTimerRef.current = null;
|
||||
void requestMetricsRef.current(generation);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}, [clearNextPollTimer, fetchMetrics]);
|
||||
requestMetricsRef.current = requestMetrics;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchMetrics(); // initial fetch
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
monitorActiveRef.current = false;
|
||||
autoRefreshEnabledRef.current = false;
|
||||
pollingGenerationRef.current += 1;
|
||||
inFlightGenerationsRef.current.clear();
|
||||
clearNextPollTimer();
|
||||
};
|
||||
}, []);
|
||||
}, [clearNextPollTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
|
||||
}
|
||||
|
||||
clearNextPollTimer();
|
||||
const generation = ++pollingGenerationRef.current;
|
||||
const monitorActive = isActive && Boolean(connection);
|
||||
monitorActiveRef.current = monitorActive;
|
||||
autoRefreshEnabledRef.current = monitorActive && isRunning;
|
||||
if (autoRefreshEnabledRef.current) void requestMetricsRef.current(generation);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (pollingGenerationRef.current !== generation) return;
|
||||
pollingGenerationRef.current += 1;
|
||||
monitorActiveRef.current = false;
|
||||
autoRefreshEnabledRef.current = false;
|
||||
inFlightGenerationsRef.current.delete(generation);
|
||||
clearNextPollTimer();
|
||||
};
|
||||
}, [isRunning, connectionId, redisDB, connection, i18nLanguage]);
|
||||
}, [clearNextPollTimer, connection, isActive, isRunning, requestMetrics]);
|
||||
|
||||
if (!connection) {
|
||||
return <div style={{ padding: 20 }}>{tr('redis_monitor.state.connection_not_found')}</div>;
|
||||
@@ -207,7 +265,10 @@ const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) =>
|
||||
>
|
||||
{isRunning ? tr('redis_monitor.action.pause_refresh') : tr('redis_monitor.action.resume_refresh')}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { void requestMetricsRef.current(pollingGenerationRef.current); }}
|
||||
>
|
||||
{tr('redis_monitor.action.refresh_now')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@ export const WorkbenchTabContent: React.FC<WorkbenchTabContentProps> = React.mem
|
||||
} else if (tab.type === 'redis-command') {
|
||||
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
} else if (tab.type === 'redis-monitor') {
|
||||
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} isActive={isActive} />;
|
||||
} else if (tab.type === 'trigger') {
|
||||
content = <TriggerViewer tab={tab} />;
|
||||
} else if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def' || tab.type === 'sequence-def' || tab.type === 'package-def') {
|
||||
|
||||
Reference in New Issue
Block a user