️ perf(redis-monitor): 暂停后台页签轮询

This commit is contained in:
Syngnat
2026-07-22 08:45:11 +08:00
parent 62b7ea9aa9
commit 1fe1e51791
3 changed files with 355 additions and 29 deletions

View 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} />',
);
});
});

View File

@@ -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>

View File

@@ -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') {