diff --git a/frontend/src/components/RedisMonitor.polling.test.tsx b/frontend/src/components/RedisMonitor.polling.test.tsx new file mode 100644 index 00000000..bc957c64 --- /dev/null +++ b/frontend/src/components/RedisMonitor.polling.test.tsx @@ -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 = () => { + let resolve!: (value: T) => void; + const promise = new Promise((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) => ( + +); +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>(); + 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>(); + 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( + '', + ); + }); +}); diff --git a/frontend/src/components/RedisMonitor.tsx b/frontend/src/components/RedisMonitor.tsx index daba348e..059c3bf5 100644 --- a/frontend/src/components/RedisMonitor.tsx +++ b/frontend/src/components/RedisMonitor.tsx @@ -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 = ({ connectionId, redisDB }) => { +const RedisMonitor: React.FC = ({ 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 = ({ connectionId, redisDB }) => const [currentInfo, setCurrentInfo] = useState>({}); // Ref to track if component is mounted to prevent state updates after unmount - const mountedRef = useRef(true); - // Interval ref - const intervalRef = useRef | null>(null); + const mountedRef = useRef(false); + const monitorActiveRef = useRef(false); + const autoRefreshEnabledRef = useRef(false); + const pollingGenerationRef = useRef(0); + const inFlightGenerationsRef = useRef(new Set()); + const nextPollTimerRef = useRef | null>(null); + const requestMetricsRef = useRef<(generation: number) => Promise>(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 = ({ 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
{tr('redis_monitor.state.connection_not_found')}
; @@ -207,7 +265,10 @@ const RedisMonitor: React.FC = ({ connectionId, redisDB }) => > {isRunning ? tr('redis_monitor.action.pause_refresh') : tr('redis_monitor.action.resume_refresh')} - diff --git a/frontend/src/components/WorkbenchTabContent.tsx b/frontend/src/components/WorkbenchTabContent.tsx index a6bda1b2..7545be2a 100644 --- a/frontend/src/components/WorkbenchTabContent.tsx +++ b/frontend/src/components/WorkbenchTabContent.tsx @@ -111,7 +111,7 @@ export const WorkbenchTabContent: React.FC = React.mem } else if (tab.type === 'redis-command') { content = ; } else if (tab.type === 'redis-monitor') { - content = ; + content = ; } else if (tab.type === 'trigger') { content = ; } else if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def' || tab.type === 'sequence-def' || tab.type === 'package-def') {