From 45f27fccc3f4d2d1465638c2bcd1d030395c112b Mon Sep 17 00:00:00 2001 From: yangdx Date: Thu, 31 Jul 2025 01:37:24 +0800 Subject: [PATCH] feat(webui): Implement intelligent polling and responsive health checks - Relocate the health check functionality from aap.tsx to state.ts to enable initialization by other components. - Replaced the fixed 5-second polling with a dynamic interval. The polling interval is extended to 30 seconds when the no files in pending an processing state. - Data refresh is triggered instantly when `pipelineBusy` state changed - Health check is triggered after clicking "Scan New Documents" or "Clear Documents" --- lightrag_webui/src/App.tsx | 33 ++- .../src/features/DocumentManager.tsx | 224 +++++++++++------- lightrag_webui/src/stores/state.ts | 43 +++- 3 files changed, 203 insertions(+), 97 deletions(-) diff --git a/lightrag_webui/src/App.tsx b/lightrag_webui/src/App.tsx index 55e6268a..e2228359 100644 --- a/lightrag_webui/src/App.tsx +++ b/lightrag_webui/src/App.tsx @@ -3,7 +3,7 @@ import ThemeProvider from '@/components/ThemeProvider' import TabVisibilityProvider from '@/contexts/TabVisibilityProvider' import ApiKeyAlert from '@/components/ApiKeyAlert' import StatusIndicator from '@/components/status/StatusIndicator' -import { healthCheckInterval, SiteInfo, webuiPrefix } from '@/lib/constants' +import { SiteInfo, webuiPrefix } from '@/lib/constants' import { useBackendState, useAuthStore } from '@/stores/state' import { useSettingsStore } from '@/stores/settings' import { getAuthStatus } from '@/api/lightrag' @@ -56,9 +56,6 @@ function App() { // Health check - can be disabled useEffect(() => { - // Only execute if health check is enabled and ApiKeyAlert is closed - if (!enableHealthCheck || apiKeyAlertOpen) return; - // Health check function const performHealthCheck = async () => { try { @@ -71,17 +68,27 @@ function App() { } }; - // On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false, - // perform an immediate health check - if (!healthCheckInitializedRef.current) { - healthCheckInitializedRef.current = true; - // Immediate health check on first load - performHealthCheck(); + // Set health check function in the store + useBackendState.getState().setHealthCheckFunction(performHealthCheck); + + if (!enableHealthCheck || apiKeyAlertOpen) { + useBackendState.getState().clearHealthCheckTimer(); + return; } - // Set interval for periodic execution - const interval = setInterval(performHealthCheck, healthCheckInterval * 1000); - return () => clearInterval(interval); + // On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false, + // perform an immediate health check and start the timer + if (!healthCheckInitializedRef.current) { + healthCheckInitializedRef.current = true; + } + + // Start/reset the health check timer using the store + useBackendState.getState().resetHealthCheckTimer(); + + // Component unmount cleanup + return () => { + useBackendState.getState().clearHealthCheckTimer(); + }; }, [enableHealthCheck, apiKeyAlertOpen]); // Version check - independent and executed only once diff --git a/lightrag_webui/src/features/DocumentManager.tsx b/lightrag_webui/src/features/DocumentManager.tsx index 7b78df13..0bb1ac0a 100644 --- a/lightrag_webui/src/features/DocumentManager.tsx +++ b/lightrag_webui/src/features/DocumentManager.tsx @@ -485,6 +485,36 @@ export default function DocumentManager() { await fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter); }, [fetchPaginatedDocuments, pagination.page, pagination.page_size, statusFilter]); + // Add refs to track previous pipelineBusy state and current interval + const prevPipelineBusyRef = useRef(undefined); + const pollingIntervalRef = useRef | null>(null); + + // Function to clear current polling interval + const clearPollingInterval = useCallback(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }, []); + + // Function to start polling with given interval + const startPollingInterval = useCallback((intervalMs: number) => { + clearPollingInterval(); + + pollingIntervalRef.current = setInterval(async () => { + try { + // Only perform fetch if component is still mounted + if (isMountedRef.current) { + await fetchDocuments() + } + } catch (err) { + // Only show error if component is still mounted + if (isMountedRef.current) { + toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) })) + } + } + }, intervalMs); + }, [fetchDocuments, t, clearPollingInterval]); const scanDocuments = useCallback(async () => { try { @@ -498,73 +528,19 @@ export default function DocumentManager() { // Note: _track_id is available for future use (e.g., progress tracking) toast.message(message || status); + + // Reset health check timer with 1 second delay to avoid race condition + useBackendState.getState().resetHealthCheckTimerDelayed(1000); + + // Schedule a health check 2 seconds after successful scan + startPollingInterval(2000); } catch (err) { // Only show error if component is still mounted if (isMountedRef.current) { toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) })); } } - }, [t]) - - // Set up polling when the documents tab is active and health is good - useEffect(() => { - if (currentTab !== 'documents' || !health) { - return - } - - const interval = setInterval(async () => { - try { - // Only perform fetch if component is still mounted - if (isMountedRef.current) { - await fetchDocuments() - } - } catch (err) { - // Only show error if component is still mounted - if (isMountedRef.current) { - toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) })) - } - } - }, 5000) - - return () => { - clearInterval(interval) - } - }, [health, fetchDocuments, t, currentTab]) - - // Monitor docs changes to check status counts and trigger health check if needed - useEffect(() => { - if (!docs) return; - - // Get new status counts - const newStatusCounts = { - processed: docs?.statuses?.processed?.length || 0, - processing: docs?.statuses?.processing?.length || 0, - pending: docs?.statuses?.pending?.length || 0, - failed: docs?.statuses?.failed?.length || 0 - } - - // Check if any status count has changed - const hasStatusCountChange = (Object.keys(newStatusCounts) as Array).some( - status => newStatusCounts[status] !== prevStatusCounts.current[status] - ) - - // Trigger health check if changes detected and component is still mounted - if (hasStatusCountChange && isMountedRef.current) { - useBackendState.getState().check() - } - - // Update previous status counts - prevStatusCounts.current = newStatusCounts - }, [docs]); - - // Handle page change - only update state - const handlePageChange = useCallback((newPage: number) => { - if (newPage === pagination.page) return; - - // Save the new page for current status filter - setPageByStatus(prev => ({ ...prev, [statusFilter]: newPage })); - setPagination(prev => ({ ...prev, page: newPage })); - }, [pagination.page, statusFilter]); + }, [t, startPollingInterval]) // Handle page size change - update state and save to store const handlePageSizeChange = useCallback((newPageSize: number) => { @@ -585,27 +561,6 @@ export default function DocumentManager() { setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize })); }, [pagination.page_size, setDocumentsPageSize]); - // Handle status filter change - only update state - const handleStatusFilterChange = useCallback((newStatusFilter: StatusFilter) => { - if (newStatusFilter === statusFilter) return; - - // Save current page for the current status filter - setPageByStatus(prev => ({ ...prev, [statusFilter]: pagination.page })); - - // Get the saved page for the new status filter - const newPage = pageByStatus[newStatusFilter]; - - // Update status filter and restore the saved page - setStatusFilter(newStatusFilter); - setPagination(prev => ({ ...prev, page: newPage })); - }, [statusFilter, pagination.page, pageByStatus]); - - // Handle documents deleted callback - const handleDocumentsDeleted = useCallback(async () => { - setSelectedDocIds([]) - await fetchDocuments() - }, [fetchDocuments]) - // Handle manual refresh with pagination reset logic const handleManualRefresh = useCallback(async () => { try { @@ -662,6 +617,109 @@ export default function DocumentManager() { } }, [statusFilter, pagination.page_size, sortField, sortDirection, handlePageSizeChange, t]); + // Monitor pipelineBusy changes and trigger immediate refresh with timer reset + useEffect(() => { + // Skip the first render when prevPipelineBusyRef is undefined + if (prevPipelineBusyRef.current !== undefined && prevPipelineBusyRef.current !== pipelineBusy) { + // pipelineBusy state has changed, trigger immediate refresh + if (currentTab === 'documents' && health && isMountedRef.current) { + handleManualRefresh(); + + // Reset polling timer after manual refresh + const hasActiveDocuments = (statusCounts.processing || 0) > 0 || (statusCounts.pending || 0) > 0; + const pollingInterval = hasActiveDocuments ? 5000 : 30000; + startPollingInterval(pollingInterval); + } + } + // Update the previous state + prevPipelineBusyRef.current = pipelineBusy; + }, [pipelineBusy, currentTab, health, handleManualRefresh, statusCounts.processing, statusCounts.pending, startPollingInterval]); + + // Set up intelligent polling with dynamic interval based on document status + useEffect(() => { + if (currentTab !== 'documents' || !health) { + clearPollingInterval(); + return + } + + // Determine polling interval based on document status + const hasActiveDocuments = (statusCounts.processing || 0) > 0 || (statusCounts.pending || 0) > 0; + const pollingInterval = hasActiveDocuments ? 5000 : 30000; // 5s if active, 30s if idle + + startPollingInterval(pollingInterval); + + return () => { + clearPollingInterval(); + } + }, [health, t, currentTab, statusCounts.processing, statusCounts.pending, startPollingInterval, clearPollingInterval]) + + // Monitor docs changes to check status counts and trigger health check if needed + useEffect(() => { + if (!docs) return; + + // Get new status counts + const newStatusCounts = { + processed: docs?.statuses?.processed?.length || 0, + processing: docs?.statuses?.processing?.length || 0, + pending: docs?.statuses?.pending?.length || 0, + failed: docs?.statuses?.failed?.length || 0 + } + + // Check if any status count has changed + const hasStatusCountChange = (Object.keys(newStatusCounts) as Array).some( + status => newStatusCounts[status] !== prevStatusCounts.current[status] + ) + + // Trigger health check if changes detected and component is still mounted + if (hasStatusCountChange && isMountedRef.current) { + useBackendState.getState().check() + } + + // Update previous status counts + prevStatusCounts.current = newStatusCounts + }, [docs]); + + // Handle page change - only update state + const handlePageChange = useCallback((newPage: number) => { + if (newPage === pagination.page) return; + + // Save the new page for current status filter + setPageByStatus(prev => ({ ...prev, [statusFilter]: newPage })); + setPagination(prev => ({ ...prev, page: newPage })); + }, [pagination.page, statusFilter]); + + // Handle status filter change - only update state + const handleStatusFilterChange = useCallback((newStatusFilter: StatusFilter) => { + if (newStatusFilter === statusFilter) return; + + // Save current page for the current status filter + setPageByStatus(prev => ({ ...prev, [statusFilter]: pagination.page })); + + // Get the saved page for the new status filter + const newPage = pageByStatus[newStatusFilter]; + + // Update status filter and restore the saved page + setStatusFilter(newStatusFilter); + setPagination(prev => ({ ...prev, page: newPage })); + }, [statusFilter, pagination.page, pageByStatus]); + + // Handle documents deleted callback + const handleDocumentsDeleted = useCallback(async () => { + setSelectedDocIds([]) + + // Reset health check timer with 1 second delay to avoid race condition + useBackendState.getState().resetHealthCheckTimerDelayed(1000) + + // Schedule a health check 2 seconds after successful clear + startPollingInterval(2000) + }, [startPollingInterval]) + + // Handle documents cleared callback with delayed health check timer reset + const handleDocumentsCleared = useCallback(async () => { + // Schedule a health check 0.5 seconds after successful clear + startPollingInterval(500) + }, [startPollingInterval]) + // Handle showFileName change - switch sort field if currently sorting by first column useEffect(() => { @@ -748,7 +806,7 @@ export default function DocumentManager() { onDeselect={handleDeselectAll} /> ) : ( - + )} | null + healthCheckFunction: (() => void) | null + healthCheckIntervalValue: number check: () => Promise clear: () => void setErrorMessage: (message: string, messageTitle: string) => void setPipelineBusy: (busy: boolean) => void + setHealthCheckFunction: (fn: () => void) => void + resetHealthCheckTimer: () => void + resetHealthCheckTimerDelayed: (delayMs: number) => void + clearHealthCheckTimer: () => void } interface AuthState { @@ -32,13 +40,16 @@ interface AuthState { setCustomTitle: (webuiTitle: string | null, webuiDescription: string | null) => void; } -const useBackendStateStoreBase = create()((set) => ({ +const useBackendStateStoreBase = create()((set, get) => ({ health: true, message: null, messageTitle: null, lastCheckTime: Date.now(), status: null, pipelineBusy: false, + healthCheckIntervalId: null, + healthCheckFunction: null, + healthCheckIntervalValue: healthCheckInterval * 1000, // Use constant from lib/constants check: async () => { const health = await checkHealth() @@ -108,6 +119,36 @@ const useBackendStateStoreBase = create()((set) => ({ setPipelineBusy: (busy: boolean) => { set({ pipelineBusy: busy }) + }, + + setHealthCheckFunction: (fn: () => void) => { + set({ healthCheckFunction: fn }) + }, + + resetHealthCheckTimer: () => { + const { healthCheckIntervalId, healthCheckFunction, healthCheckIntervalValue } = get() + if (healthCheckIntervalId) { + clearInterval(healthCheckIntervalId) + } + if (healthCheckFunction) { + healthCheckFunction() // run health check immediately + const newIntervalId = setInterval(healthCheckFunction, healthCheckIntervalValue) + set({ healthCheckIntervalId: newIntervalId }) + } + }, + + resetHealthCheckTimerDelayed: (delayMs: number) => { + setTimeout(() => { + get().resetHealthCheckTimer() + }, delayMs) + }, + + clearHealthCheckTimer: () => { + const { healthCheckIntervalId } = get() + if (healthCheckIntervalId) { + clearInterval(healthCheckIntervalId) + set({ healthCheckIntervalId: null }) + } } }))