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"
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<boolean | undefined>(undefined);
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | 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<keyof typeof newStatusCounts>).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<keyof typeof newStatusCounts>).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}
|
||||
/>
|
||||
) : (
|
||||
<ClearDocumentsDialog onDocumentsCleared={fetchDocuments} />
|
||||
<ClearDocumentsDialog onDocumentsCleared={handleDocumentsCleared} />
|
||||
)}
|
||||
<UploadDocumentsDialog onDocumentsUploaded={fetchDocuments} />
|
||||
<PipelineStatusDialog
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import { createSelectors } from '@/lib/utils'
|
||||
import { checkHealth, LightragStatus } from '@/api/lightrag'
|
||||
import { useSettingsStore } from './settings'
|
||||
import { healthCheckInterval } from '@/lib/constants'
|
||||
|
||||
interface BackendState {
|
||||
health: boolean
|
||||
@@ -10,11 +11,18 @@ interface BackendState {
|
||||
status: LightragStatus | null
|
||||
lastCheckTime: number
|
||||
pipelineBusy: boolean
|
||||
healthCheckIntervalId: ReturnType<typeof setInterval> | null
|
||||
healthCheckFunction: (() => void) | null
|
||||
healthCheckIntervalValue: number
|
||||
|
||||
check: () => Promise<boolean>
|
||||
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<BackendState>()((set) => ({
|
||||
const useBackendStateStoreBase = create<BackendState>()((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<BackendState>()((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 })
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user