LightRAG/lightrag_webui/src/features/DocumentManager.tsx
yangdx 45f27fccc3 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"
2025-07-31 01:37:24 +08:00

1070 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import Button from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/Table'
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card'
import EmptyCard from '@/components/ui/EmptyCard'
import Checkbox from '@/components/ui/Checkbox'
import UploadDocumentsDialog from '@/components/documents/UploadDocumentsDialog'
import ClearDocumentsDialog from '@/components/documents/ClearDocumentsDialog'
import DeleteDocumentsDialog from '@/components/documents/DeleteDocumentsDialog'
import DeselectDocumentsDialog from '@/components/documents/DeselectDocumentsDialog'
import PaginationControls from '@/components/ui/PaginationControls'
import {
scanNewDocuments,
getDocumentsPaginated,
DocsStatusesResponse,
DocStatus,
DocStatusResponse,
DocumentsRequest,
PaginationInfo
} from '@/api/lightrag'
import { errorMessage } from '@/lib/utils'
import { toast } from 'sonner'
import { useBackendState } from '@/stores/state'
import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon } from 'lucide-react'
import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog'
type StatusFilter = DocStatus | 'all';
const getDisplayFileName = (doc: DocStatusResponse, maxLength: number = 20): string => {
// Check if file_path exists and is a non-empty string
if (!doc.file_path || typeof doc.file_path !== 'string' || doc.file_path.trim() === '') {
return doc.id;
}
// Try to extract filename from path
const parts = doc.file_path.split('/');
const fileName = parts[parts.length - 1];
// Ensure extracted filename is valid
if (!fileName || fileName.trim() === '') {
return doc.id;
}
// If filename is longer than maxLength, truncate it and add ellipsis
return fileName.length > maxLength
? fileName.slice(0, maxLength) + '...'
: fileName;
};
const pulseStyle = `
/* Tooltip styles */
.tooltip-container {
position: relative;
overflow: visible !important;
}
.tooltip {
position: fixed; /* Use fixed positioning to escape overflow constraints */
z-index: 9999; /* Ensure tooltip appears above all other elements */
max-width: 600px;
white-space: normal;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
background-color: rgba(0, 0, 0, 0.95);
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
pointer-events: none; /* Prevent tooltip from interfering with mouse events */
opacity: 0;
visibility: hidden;
transition: opacity 0.15s, visibility 0.15s;
}
.tooltip.visible {
opacity: 1;
visibility: visible;
}
.dark .tooltip {
background-color: rgba(255, 255, 255, 0.95);
color: black;
}
/* Position tooltip helper class */
.tooltip-helper {
position: absolute;
visibility: hidden;
pointer-events: none;
top: 0;
left: 0;
width: 100%;
height: 0;
}
@keyframes pulse {
0% {
background-color: rgb(255 0 0 / 0.1);
border-color: rgb(255 0 0 / 0.2);
}
50% {
background-color: rgb(255 0 0 / 0.2);
border-color: rgb(255 0 0 / 0.4);
}
100% {
background-color: rgb(255 0 0 / 0.1);
border-color: rgb(255 0 0 / 0.2);
}
}
.dark .pipeline-busy {
animation: dark-pulse 2s infinite;
}
@keyframes dark-pulse {
0% {
background-color: rgb(255 0 0 / 0.2);
border-color: rgb(255 0 0 / 0.4);
}
50% {
background-color: rgb(255 0 0 / 0.3);
border-color: rgb(255 0 0 / 0.6);
}
100% {
background-color: rgb(255 0 0 / 0.2);
border-color: rgb(255 0 0 / 0.4);
}
}
.pipeline-busy {
animation: pulse 2s infinite;
border: 1px solid;
}
`;
// Type definitions for sort field and direction
type SortField = 'created_at' | 'updated_at' | 'id' | 'file_path';
type SortDirection = 'asc' | 'desc';
export default function DocumentManager() {
// Track component mount status
const isMountedRef = useRef(true);
// Set up mount/unmount status tracking
useEffect(() => {
isMountedRef.current = true;
// Handle page reload/unload
const handleBeforeUnload = () => {
isMountedRef.current = false;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
isMountedRef.current = false;
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
const [showPipelineStatus, setShowPipelineStatus] = useState(false)
const { t, i18n } = useTranslation()
const health = useBackendState.use.health()
const pipelineBusy = useBackendState.use.pipelineBusy()
// Legacy state for backward compatibility
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
const currentTab = useSettingsStore.use.currentTab()
const showFileName = useSettingsStore.use.showFileName()
const setShowFileName = useSettingsStore.use.setShowFileName()
const documentsPageSize = useSettingsStore.use.documentsPageSize()
const setDocumentsPageSize = useSettingsStore.use.setDocumentsPageSize()
// New pagination state
const [, setCurrentPageDocs] = useState<DocStatusResponse[]>([])
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
page_size: documentsPageSize,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
})
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({ all: 0 })
const [isRefreshing, setIsRefreshing] = useState(false)
// Sort state
const [sortField, setSortField] = useState<SortField>('updated_at')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
// State for document status filter
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
// State to store page number for each status filter
const [pageByStatus, setPageByStatus] = useState<Record<StatusFilter, number>>({
all: 1,
processed: 1,
processing: 1,
pending: 1,
failed: 1,
});
// State for document selection
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([])
const isSelectionMode = selectedDocIds.length > 0
// Handle checkbox change for individual documents
const handleDocumentSelect = useCallback((docId: string, checked: boolean) => {
setSelectedDocIds(prev => {
if (checked) {
return [...prev, docId]
} else {
return prev.filter(id => id !== docId)
}
})
}, [])
// Handle deselect all documents
const handleDeselectAll = useCallback(() => {
setSelectedDocIds([])
}, [])
// Handle sort column click
const handleSort = (field: SortField) => {
let actualField = field;
// When clicking the first column, determine the actual sort field based on showFileName
if (field === 'id') {
actualField = showFileName ? 'file_path' : 'id';
}
const newDirection = (sortField === actualField && sortDirection === 'desc') ? 'asc' : 'desc';
setSortField(actualField);
setSortDirection(newDirection);
// Reset page to 1 when sorting changes
setPagination(prev => ({ ...prev, page: 1 }));
// Reset all status filters' page memory since sorting affects all
setPageByStatus({
all: 1,
processed: 1,
processing: 1,
pending: 1,
failed: 1,
});
};
// Sort documents based on current sort field and direction
const sortDocuments = useCallback((documents: DocStatusResponse[]) => {
return [...documents].sort((a, b) => {
let valueA, valueB;
// Special handling for ID field based on showFileName setting
if (sortField === 'id' && showFileName) {
valueA = getDisplayFileName(a);
valueB = getDisplayFileName(b);
} else if (sortField === 'id') {
valueA = a.id;
valueB = b.id;
} else {
// Date fields
valueA = new Date(a[sortField]).getTime();
valueB = new Date(b[sortField]).getTime();
}
// Apply sort direction
const sortMultiplier = sortDirection === 'asc' ? 1 : -1;
// Compare values
if (typeof valueA === 'string' && typeof valueB === 'string') {
return sortMultiplier * valueA.localeCompare(valueB);
} else {
return sortMultiplier * (valueA > valueB ? 1 : valueA < valueB ? -1 : 0);
}
});
}, [sortField, sortDirection, showFileName]);
// Define a new type that includes status information
type DocStatusWithStatus = DocStatusResponse & { status: DocStatus };
const filteredAndSortedDocs = useMemo(() => {
if (!docs) return null;
// Create a flat array of documents with status information
const allDocuments: DocStatusWithStatus[] = [];
if (statusFilter === 'all') {
// When filter is 'all', include documents from all statuses
Object.entries(docs.statuses).forEach(([status, documents]) => {
documents.forEach(doc => {
allDocuments.push({
...doc,
status: status as DocStatus
});
});
});
} else {
// When filter is specific status, only include documents from that status
const documents = docs.statuses[statusFilter] || [];
documents.forEach(doc => {
allDocuments.push({
...doc,
status: statusFilter
});
});
}
// Sort all documents together if sort field and direction are specified
if (sortField && sortDirection) {
return sortDocuments(allDocuments);
}
return allDocuments;
}, [docs, sortField, sortDirection, statusFilter, sortDocuments]);
// Calculate document counts for each status
const documentCounts = useMemo(() => {
if (!docs) return { all: 0 } as Record<string, number>;
const counts: Record<string, number> = { all: 0 };
Object.entries(docs.statuses).forEach(([status, documents]) => {
counts[status as DocStatus] = documents.length;
counts.all += documents.length;
});
return counts;
}, [docs]);
// Store previous status counts
const prevStatusCounts = useRef({
processed: 0,
processing: 0,
pending: 0,
failed: 0
})
// Add pulse style to document
useEffect(() => {
const style = document.createElement('style')
style.textContent = pulseStyle
document.head.appendChild(style)
return () => {
document.head.removeChild(style)
}
}, [])
// Reference to the card content element
const cardContentRef = useRef<HTMLDivElement>(null);
// Add tooltip position adjustment for fixed positioning
useEffect(() => {
if (!docs) return;
// Function to position tooltips
const positionTooltips = () => {
// Get all tooltip containers
const containers = document.querySelectorAll<HTMLElement>('.tooltip-container');
containers.forEach(container => {
const tooltip = container.querySelector<HTMLElement>('.tooltip');
if (!tooltip) return;
// Skip tooltips that aren't visible
if (!tooltip.classList.contains('visible')) return;
// Get container position
const rect = container.getBoundingClientRect();
// Position tooltip above the container
tooltip.style.left = `${rect.left}px`;
tooltip.style.top = `${rect.top - 5}px`;
tooltip.style.transform = 'translateY(-100%)';
});
};
// Set up event listeners
const handleMouseOver = (e: MouseEvent) => {
// Check if target or its parent is a tooltip container
const target = e.target as HTMLElement;
const container = target.closest('.tooltip-container');
if (!container) return;
// Find tooltip and make it visible
const tooltip = container.querySelector<HTMLElement>('.tooltip');
if (tooltip) {
tooltip.classList.add('visible');
// Position immediately without delay
positionTooltips();
}
};
const handleMouseOut = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const container = target.closest('.tooltip-container');
if (!container) return;
const tooltip = container.querySelector<HTMLElement>('.tooltip');
if (tooltip) {
tooltip.classList.remove('visible');
}
};
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('mouseout', handleMouseOut);
return () => {
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('mouseout', handleMouseOut);
};
}, [docs]);
// New paginated data fetching function
const fetchPaginatedDocuments = useCallback(async (
page: number,
pageSize: number,
statusFilter: StatusFilter
) => {
try {
if (!isMountedRef.current) return;
setIsRefreshing(true);
// Prepare request parameters
const request: DocumentsRequest = {
status_filter: statusFilter === 'all' ? null : statusFilter,
page,
page_size: pageSize,
sort_field: sortField,
sort_direction: sortDirection
};
const response = await getDocumentsPaginated(request);
if (!isMountedRef.current) return;
// Update pagination state
setPagination(response.pagination);
setCurrentPageDocs(response.documents);
setStatusCounts(response.status_counts);
// Update legacy docs state for backward compatibility
const legacyDocs: DocsStatusesResponse = {
statuses: {
processed: response.documents.filter(doc => doc.status === 'processed'),
processing: response.documents.filter(doc => doc.status === 'processing'),
pending: response.documents.filter(doc => doc.status === 'pending'),
failed: response.documents.filter(doc => doc.status === 'failed')
}
};
if (response.pagination.total_count > 0) {
setDocs(legacyDocs);
} else {
setDocs(null);
}
} catch (err) {
if (isMountedRef.current) {
toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }));
}
} finally {
if (isMountedRef.current) {
setIsRefreshing(false);
}
}
}, [sortField, sortDirection, t]);
// Legacy fetchDocuments function for backward compatibility
const fetchDocuments = useCallback(async () => {
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 {
// Check if component is still mounted before starting the request
if (!isMountedRef.current) return;
const { status, message, track_id: _track_id } = await scanNewDocuments(); // eslint-disable-line @typescript-eslint/no-unused-vars
// Check again if component is still mounted after the request completes
if (!isMountedRef.current) return;
// 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, startPollingInterval])
// Handle page size change - update state and save to store
const handlePageSizeChange = useCallback((newPageSize: number) => {
if (newPageSize === pagination.page_size) return;
// Save the new page size to the store
setDocumentsPageSize(newPageSize);
// Reset all status filters to page 1 when page size changes
setPageByStatus({
all: 1,
processed: 1,
processing: 1,
pending: 1,
failed: 1,
});
setPagination(prev => ({ ...prev, page: 1, page_size: newPageSize }));
}, [pagination.page_size, setDocumentsPageSize]);
// Handle manual refresh with pagination reset logic
const handleManualRefresh = useCallback(async () => {
try {
setIsRefreshing(true);
// Fetch documents from the first page
const request: DocumentsRequest = {
status_filter: statusFilter === 'all' ? null : statusFilter,
page: 1,
page_size: pagination.page_size,
sort_field: sortField,
sort_direction: sortDirection
};
const response = await getDocumentsPaginated(request);
if (!isMountedRef.current) return;
// Check if total count is less than current page size and page size is not already 10
if (response.pagination.total_count < pagination.page_size && pagination.page_size !== 10) {
// Reset page size to 10 which will trigger a new fetch
handlePageSizeChange(10);
} else {
// Update pagination state
setPagination(response.pagination);
setCurrentPageDocs(response.documents);
setStatusCounts(response.status_counts);
// Update legacy docs state for backward compatibility
const legacyDocs: DocsStatusesResponse = {
statuses: {
processed: response.documents.filter(doc => doc.status === 'processed'),
processing: response.documents.filter(doc => doc.status === 'processing'),
pending: response.documents.filter(doc => doc.status === 'pending'),
failed: response.documents.filter(doc => doc.status === 'failed')
}
};
if (response.pagination.total_count > 0) {
setDocs(legacyDocs);
} else {
setDocs(null);
}
}
} catch (err) {
if (isMountedRef.current) {
toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }));
}
} finally {
if (isMountedRef.current) {
setIsRefreshing(false);
}
}
}, [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(() => {
// Only switch if currently sorting by the first column (id or file_path)
if (sortField === 'id' || sortField === 'file_path') {
const newSortField = showFileName ? 'file_path' : 'id';
if (sortField !== newSortField) {
setSortField(newSortField);
}
}
}, [showFileName, sortField]);
// Central effect to handle all data fetching
useEffect(() => {
if (currentTab === 'documents') {
fetchPaginatedDocuments(pagination.page, pagination.page_size, statusFilter);
}
}, [
currentTab,
pagination.page,
pagination.page_size,
statusFilter,
sortField,
sortDirection,
fetchPaginatedDocuments
]);
return (
<Card className="!rounded-none !overflow-hidden flex flex-col h-full min-h-0">
<CardHeader className="py-2 px-6">
<CardTitle className="text-lg">{t('documentPanel.documentManager.title')}</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 overflow-auto">
<div className="flex justify-between items-center gap-2 mb-2">
<div className="flex gap-2">
<Button
variant="outline"
onClick={scanDocuments}
side="bottom"
tooltip={t('documentPanel.documentManager.scanTooltip')}
size="sm"
>
<RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')}
</Button>
<Button
variant="outline"
onClick={() => setShowPipelineStatus(true)}
side="bottom"
tooltip={t('documentPanel.documentManager.pipelineStatusTooltip')}
size="sm"
className={cn(
pipelineBusy && 'pipeline-busy'
)}
>
<ActivityIcon /> {t('documentPanel.documentManager.pipelineStatusButton')}
</Button>
</div>
{/* Pagination Controls in the middle */}
{pagination.total_pages > 1 && (
<PaginationControls
currentPage={pagination.page}
totalPages={pagination.total_pages}
pageSize={pagination.page_size}
totalCount={pagination.total_count}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isRefreshing}
compact={true}
/>
)}
<div className="flex gap-2">
{isSelectionMode && (
<DeleteDocumentsDialog
selectedDocIds={selectedDocIds}
totalCompletedCount={documentCounts.processed || 0}
onDocumentsDeleted={handleDocumentsDeleted}
/>
)}
{isSelectionMode ? (
<DeselectDocumentsDialog
selectedCount={selectedDocIds.length}
onDeselect={handleDeselectAll}
/>
) : (
<ClearDocumentsDialog onDocumentsCleared={handleDocumentsCleared} />
)}
<UploadDocumentsDialog onDocumentsUploaded={fetchDocuments} />
<PipelineStatusDialog
open={showPipelineStatus}
onOpenChange={setShowPipelineStatus}
/>
</div>
</div>
<Card className="flex-1 flex flex-col border rounded-md min-h-0 mb-2">
<CardHeader className="flex-none py-2 px-4">
<div className="flex justify-between items-center">
<CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle>
<div className="flex items-center gap-2">
<div className="flex gap-1" dir={i18n.dir()}>
<Button
size="sm"
variant={statusFilter === 'all' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('all')}
disabled={isRefreshing}
className={cn(
statusFilter === 'all' && 'bg-gray-100 dark:bg-gray-900 font-medium border border-gray-400 dark:border-gray-500 shadow-sm'
)}
>
{t('documentPanel.documentManager.status.all')} ({statusCounts.all || documentCounts.all})
</Button>
<Button
size="sm"
variant={statusFilter === 'processed' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('processed')}
disabled={isRefreshing}
className={cn(
(statusCounts.PROCESSED || statusCounts.processed || documentCounts.processed) > 0 ? 'text-green-600' : 'text-gray-500',
statusFilter === 'processed' && 'bg-green-100 dark:bg-green-900/30 font-medium border border-green-400 dark:border-green-600 shadow-sm'
)}
>
{t('documentPanel.documentManager.status.completed')} ({statusCounts.PROCESSED || statusCounts.processed || 0})
</Button>
<Button
size="sm"
variant={statusFilter === 'processing' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('processing')}
disabled={isRefreshing}
className={cn(
(statusCounts.PROCESSING || statusCounts.processing || documentCounts.processing) > 0 ? 'text-blue-600' : 'text-gray-500',
statusFilter === 'processing' && 'bg-blue-100 dark:bg-blue-900/30 font-medium border border-blue-400 dark:border-blue-600 shadow-sm'
)}
>
{t('documentPanel.documentManager.status.processing')} ({statusCounts.PROCESSING || statusCounts.processing || 0})
</Button>
<Button
size="sm"
variant={statusFilter === 'pending' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('pending')}
disabled={isRefreshing}
className={cn(
(statusCounts.PENDING || statusCounts.pending || documentCounts.pending) > 0 ? 'text-yellow-600' : 'text-gray-500',
statusFilter === 'pending' && 'bg-yellow-100 dark:bg-yellow-900/30 font-medium border border-yellow-400 dark:border-yellow-600 shadow-sm'
)}
>
{t('documentPanel.documentManager.status.pending')} ({statusCounts.PENDING || statusCounts.pending || 0})
</Button>
<Button
size="sm"
variant={statusFilter === 'failed' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('failed')}
disabled={isRefreshing}
className={cn(
(statusCounts.FAILED || statusCounts.failed || documentCounts.failed) > 0 ? 'text-red-600' : 'text-gray-500',
statusFilter === 'failed' && 'bg-red-100 dark:bg-red-900/30 font-medium border border-red-400 dark:border-red-600 shadow-sm'
)}
>
{t('documentPanel.documentManager.status.failed')} ({statusCounts.FAILED || statusCounts.failed || 0})
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleManualRefresh}
disabled={isRefreshing}
side="bottom"
tooltip={t('documentPanel.documentManager.refreshTooltip')}
>
<RotateCcwIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="toggle-filename-btn"
className="text-sm text-gray-500"
>
{t('documentPanel.documentManager.fileNameLabel')}
</label>
<Button
id="toggle-filename-btn"
variant="outline"
size="sm"
onClick={() => setShowFileName(!showFileName)}
className="border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{showFileName
? t('documentPanel.documentManager.hideButton')
: t('documentPanel.documentManager.showButton')
}
</Button>
</div>
</div>
<CardDescription aria-hidden="true" className="hidden">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex-1 relative p-0" ref={cardContentRef}>
{!docs && (
<div className="absolute inset-0 p-0">
<EmptyCard
title={t('documentPanel.documentManager.emptyTitle')}
description={t('documentPanel.documentManager.emptyDescription')}
/>
</div>
)}
{docs && (
<div className="absolute inset-0 flex flex-col p-0">
<div className="absolute inset-[-1px] flex flex-col p-0 border rounded-md border-gray-200 dark:border-gray-700 overflow-hidden">
<Table className="w-full">
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow className="border-b bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75 shadow-[inset_0_-1px_0_rgba(0,0,0,0.1)]">
<TableHead
onClick={() => handleSort('id')}
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none"
>
<div className="flex items-center">
{showFileName
? t('documentPanel.documentManager.columns.fileName')
: t('documentPanel.documentManager.columns.id')
}
{((sortField === 'id' && !showFileName) || (sortField === 'file_path' && showFileName)) && (
<span className="ml-1">
{sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />}
</span>
)}
</div>
</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.summary')}</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead>
<TableHead
onClick={() => handleSort('created_at')}
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none"
>
<div className="flex items-center">
{t('documentPanel.documentManager.columns.created')}
{sortField === 'created_at' && (
<span className="ml-1">
{sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />}
</span>
)}
</div>
</TableHead>
<TableHead
onClick={() => handleSort('updated_at')}
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none"
>
<div className="flex items-center">
{t('documentPanel.documentManager.columns.updated')}
{sortField === 'updated_at' && (
<span className="ml-1">
{sortDirection === 'asc' ? <ArrowUpIcon size={14} /> : <ArrowDownIcon size={14} />}
</span>
)}
</div>
</TableHead>
<TableHead className="w-16 text-center">
{t('documentPanel.documentManager.columns.select')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-sm overflow-auto">
{filteredAndSortedDocs && filteredAndSortedDocs.map((doc) => (
<TableRow key={doc.id}>
<TableCell className="truncate font-mono overflow-visible max-w-[250px]">
{showFileName ? (
<>
<div className="group relative overflow-visible tooltip-container">
<div className="truncate">
{getDisplayFileName(doc, 30)}
</div>
<div className="invisible group-hover:visible tooltip">
{doc.file_path}
</div>
</div>
<div className="text-xs text-gray-500">{doc.id}</div>
</>
) : (
<div className="group relative overflow-visible tooltip-container">
<div className="truncate">
{doc.id}
</div>
<div className="invisible group-hover:visible tooltip">
{doc.file_path}
</div>
</div>
)}
</TableCell>
<TableCell className="max-w-xs min-w-45 truncate overflow-visible">
<div className="group relative overflow-visible tooltip-container">
<div className="truncate">
{doc.content_summary}
</div>
<div className="invisible group-hover:visible tooltip">
{doc.content_summary}
</div>
</div>
</TableCell>
<TableCell>
{doc.status === 'processed' && (
<span className="text-green-600">{t('documentPanel.documentManager.status.completed')}</span>
)}
{doc.status === 'processing' && (
<span className="text-blue-600">{t('documentPanel.documentManager.status.processing')}</span>
)}
{doc.status === 'pending' && (
<span className="text-yellow-600">{t('documentPanel.documentManager.status.pending')}</span>
)}
{doc.status === 'failed' && (
<span className="text-red-600">{t('documentPanel.documentManager.status.failed')}</span>
)}
{doc.error_msg && (
<span className="ml-2 text-red-500" title={doc.error_msg}>
</span>
)}
</TableCell>
<TableCell>{doc.content_length ?? '-'}</TableCell>
<TableCell>{doc.chunks_count ?? '-'}</TableCell>
<TableCell className="truncate">
{new Date(doc.created_at).toLocaleString()}
</TableCell>
<TableCell className="truncate">
{new Date(doc.updated_at).toLocaleString()}
</TableCell>
<TableCell className="text-center">
<Checkbox
checked={selectedDocIds.includes(doc.id)}
onCheckedChange={(checked) => handleDocumentSelect(doc.id, checked === true)}
// disabled={doc.status !== 'processed'}
className="mx-auto"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
</Card>
</CardContent>
</Card>
)
}