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:
parent
93dede163d
commit
45f27fccc3
3 changed files with 203 additions and 97 deletions
|
|
@ -3,7 +3,7 @@ import ThemeProvider from '@/components/ThemeProvider'
|
||||||
import TabVisibilityProvider from '@/contexts/TabVisibilityProvider'
|
import TabVisibilityProvider from '@/contexts/TabVisibilityProvider'
|
||||||
import ApiKeyAlert from '@/components/ApiKeyAlert'
|
import ApiKeyAlert from '@/components/ApiKeyAlert'
|
||||||
import StatusIndicator from '@/components/status/StatusIndicator'
|
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 { useBackendState, useAuthStore } from '@/stores/state'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { getAuthStatus } from '@/api/lightrag'
|
import { getAuthStatus } from '@/api/lightrag'
|
||||||
|
|
@ -56,9 +56,6 @@ function App() {
|
||||||
|
|
||||||
// Health check - can be disabled
|
// Health check - can be disabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only execute if health check is enabled and ApiKeyAlert is closed
|
|
||||||
if (!enableHealthCheck || apiKeyAlertOpen) return;
|
|
||||||
|
|
||||||
// Health check function
|
// Health check function
|
||||||
const performHealthCheck = async () => {
|
const performHealthCheck = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -71,17 +68,27 @@ function App() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false,
|
// Set health check function in the store
|
||||||
// perform an immediate health check
|
useBackendState.getState().setHealthCheckFunction(performHealthCheck);
|
||||||
if (!healthCheckInitializedRef.current) {
|
|
||||||
healthCheckInitializedRef.current = true;
|
if (!enableHealthCheck || apiKeyAlertOpen) {
|
||||||
// Immediate health check on first load
|
useBackendState.getState().clearHealthCheckTimer();
|
||||||
performHealthCheck();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set interval for periodic execution
|
// On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false,
|
||||||
const interval = setInterval(performHealthCheck, healthCheckInterval * 1000);
|
// perform an immediate health check and start the timer
|
||||||
return () => clearInterval(interval);
|
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]);
|
}, [enableHealthCheck, apiKeyAlertOpen]);
|
||||||
|
|
||||||
// Version check - independent and executed only once
|
// Version check - independent and executed only once
|
||||||
|
|
|
||||||
|
|
@ -485,6 +485,36 @@ export default function DocumentManager() {
|
||||||
await fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter);
|
await fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter);
|
||||||
}, [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 () => {
|
const scanDocuments = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -498,73 +528,19 @@ export default function DocumentManager() {
|
||||||
|
|
||||||
// Note: _track_id is available for future use (e.g., progress tracking)
|
// Note: _track_id is available for future use (e.g., progress tracking)
|
||||||
toast.message(message || status);
|
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) {
|
} catch (err) {
|
||||||
// Only show error if component is still mounted
|
// Only show error if component is still mounted
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }));
|
toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [t, startPollingInterval])
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Handle page size change - update state and save to store
|
// Handle page size change - update state and save to store
|
||||||
const handlePageSizeChange = useCallback((newPageSize: number) => {
|
const handlePageSizeChange = useCallback((newPageSize: number) => {
|
||||||
|
|
@ -585,27 +561,6 @@ export default function DocumentManager() {
|
||||||
setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize }));
|
setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize }));
|
||||||
}, [pagination.page_size, setDocumentsPageSize]);
|
}, [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
|
// Handle manual refresh with pagination reset logic
|
||||||
const handleManualRefresh = useCallback(async () => {
|
const handleManualRefresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -662,6 +617,109 @@ export default function DocumentManager() {
|
||||||
}
|
}
|
||||||
}, [statusFilter, pagination.page_size, sortField, sortDirection, handlePageSizeChange, t]);
|
}, [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
|
// Handle showFileName change - switch sort field if currently sorting by first column
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -748,7 +806,7 @@ export default function DocumentManager() {
|
||||||
onDeselect={handleDeselectAll}
|
onDeselect={handleDeselectAll}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ClearDocumentsDialog onDocumentsCleared={fetchDocuments} />
|
<ClearDocumentsDialog onDocumentsCleared={handleDocumentsCleared} />
|
||||||
)}
|
)}
|
||||||
<UploadDocumentsDialog onDocumentsUploaded={fetchDocuments} />
|
<UploadDocumentsDialog onDocumentsUploaded={fetchDocuments} />
|
||||||
<PipelineStatusDialog
|
<PipelineStatusDialog
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||||
import { createSelectors } from '@/lib/utils'
|
import { createSelectors } from '@/lib/utils'
|
||||||
import { checkHealth, LightragStatus } from '@/api/lightrag'
|
import { checkHealth, LightragStatus } from '@/api/lightrag'
|
||||||
import { useSettingsStore } from './settings'
|
import { useSettingsStore } from './settings'
|
||||||
|
import { healthCheckInterval } from '@/lib/constants'
|
||||||
|
|
||||||
interface BackendState {
|
interface BackendState {
|
||||||
health: boolean
|
health: boolean
|
||||||
|
|
@ -10,11 +11,18 @@ interface BackendState {
|
||||||
status: LightragStatus | null
|
status: LightragStatus | null
|
||||||
lastCheckTime: number
|
lastCheckTime: number
|
||||||
pipelineBusy: boolean
|
pipelineBusy: boolean
|
||||||
|
healthCheckIntervalId: ReturnType<typeof setInterval> | null
|
||||||
|
healthCheckFunction: (() => void) | null
|
||||||
|
healthCheckIntervalValue: number
|
||||||
|
|
||||||
check: () => Promise<boolean>
|
check: () => Promise<boolean>
|
||||||
clear: () => void
|
clear: () => void
|
||||||
setErrorMessage: (message: string, messageTitle: string) => void
|
setErrorMessage: (message: string, messageTitle: string) => void
|
||||||
setPipelineBusy: (busy: boolean) => void
|
setPipelineBusy: (busy: boolean) => void
|
||||||
|
setHealthCheckFunction: (fn: () => void) => void
|
||||||
|
resetHealthCheckTimer: () => void
|
||||||
|
resetHealthCheckTimerDelayed: (delayMs: number) => void
|
||||||
|
clearHealthCheckTimer: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|
@ -32,13 +40,16 @@ interface AuthState {
|
||||||
setCustomTitle: (webuiTitle: string | null, webuiDescription: string | null) => void;
|
setCustomTitle: (webuiTitle: string | null, webuiDescription: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useBackendStateStoreBase = create<BackendState>()((set) => ({
|
const useBackendStateStoreBase = create<BackendState>()((set, get) => ({
|
||||||
health: true,
|
health: true,
|
||||||
message: null,
|
message: null,
|
||||||
messageTitle: null,
|
messageTitle: null,
|
||||||
lastCheckTime: Date.now(),
|
lastCheckTime: Date.now(),
|
||||||
status: null,
|
status: null,
|
||||||
pipelineBusy: false,
|
pipelineBusy: false,
|
||||||
|
healthCheckIntervalId: null,
|
||||||
|
healthCheckFunction: null,
|
||||||
|
healthCheckIntervalValue: healthCheckInterval * 1000, // Use constant from lib/constants
|
||||||
|
|
||||||
check: async () => {
|
check: async () => {
|
||||||
const health = await checkHealth()
|
const health = await checkHealth()
|
||||||
|
|
@ -108,6 +119,36 @@ const useBackendStateStoreBase = create<BackendState>()((set) => ({
|
||||||
|
|
||||||
setPipelineBusy: (busy: boolean) => {
|
setPipelineBusy: (busy: boolean) => {
|
||||||
set({ pipelineBusy: busy })
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue