diff --git a/lightrag/api/lightrag_server.py b/lightrag/api/lightrag_server.py index 3428a63b..d7d6ffe9 100644 --- a/lightrag/api/lightrag_server.py +++ b/lightrag/api/lightrag_server.py @@ -1065,10 +1065,13 @@ def create_app(args): app.include_router(create_graph_routes(rag, api_key)) # Register table routes if all storages are PostgreSQL - if (args.kv_storage == "PGKVStorage" and + all_postgres_storages = ( + args.kv_storage == "PGKVStorage" and args.doc_status_storage == "PGDocStatusStorage" and args.graph_storage == "PGGraphStorage" and - args.vector_storage == "PGVectorStorage"): + args.vector_storage == "PGVectorStorage" + ) + if all_postgres_storages: app.include_router(create_table_routes(rag, api_key), prefix="/tables") # Add Ollama API routes diff --git a/lightrag/api/routers/table_routes.py b/lightrag/api/routers/table_routes.py index 07887cfa..2dd34c28 100644 --- a/lightrag/api/routers/table_routes.py +++ b/lightrag/api/routers/table_routes.py @@ -7,8 +7,6 @@ from lightrag.api.utils_api import get_combined_auth_dependency from lightrag.kg.postgres_impl import TABLES from lightrag.utils import logger -router = APIRouter(tags=["Tables"]) - def get_order_clause(ddl: str) -> str: """Determine the best ORDER BY clause based on available columns in DDL.""" ddl_lower = ddl.lower() @@ -25,6 +23,7 @@ def get_order_clause(ddl: str) -> str: return "" def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRouter: + router = APIRouter(tags=["Tables"]) combined_auth = get_combined_auth_dependency(api_key) def get_workspace_from_request(request: Request) -> Optional[str]: @@ -81,12 +80,22 @@ def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRout # 1. Get total count count_sql = f"SELECT COUNT(*) as count FROM {table_name} WHERE workspace = $1" count_res = await db.query(count_sql, [target_workspace]) - total = count_res['count'] if count_res else 0 + + total = 0 + if isinstance(count_res, dict): + total = count_res.get('count', 0) + elif isinstance(count_res, list) and len(count_res) > 0: + first_row = count_res[0] + if isinstance(first_row, dict): + total = first_row.get('count', 0) # 2. Get data # Try to determine order column - ddl = TABLES[table_name]['ddl'] - order_clause = get_order_clause(ddl) + if 'ddl' in TABLES[table_name]: + ddl = TABLES[table_name]['ddl'] + order_clause = get_order_clause(ddl) + else: + order_clause = "" sql = f"SELECT * FROM {table_name} WHERE workspace = $1 {order_clause} LIMIT $2 OFFSET $3" rows = await db.query(sql, [target_workspace, page_size, offset], multirows=True) @@ -103,4 +112,4 @@ def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRout logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") from e - return router + return router \ No newline at end of file diff --git a/lightrag_webui/bun.lock b/lightrag_webui/bun.lock index b1cd828b..99ad39a6 100644 --- a/lightrag_webui/bun.lock +++ b/lightrag_webui/bun.lock @@ -28,7 +28,7 @@ "@react-sigma/minimap": "^5.0.5", "@sigma/edge-curve": "^3.1.0", "@sigma/node-border": "^3.0.0", - "@tanstack/react-query": "^5.60.0", + "@tanstack/react-query": "^5.87.1", "@tanstack/react-table": "^8.21.3", "axios": "^1.12.2", "class-variance-authority": "^0.7.1", diff --git a/lightrag_webui/package.json b/lightrag_webui/package.json index e3e34bc0..c35af9e3 100644 --- a/lightrag_webui/package.json +++ b/lightrag_webui/package.json @@ -40,7 +40,7 @@ "@react-sigma/minimap": "^5.0.5", "@sigma/edge-curve": "^3.1.0", "@sigma/node-border": "^3.0.0", - "@tanstack/react-query": "^5.60.0", + "@tanstack/react-query": "^5.87.1", "@tanstack/react-table": "^8.21.3", "axios": "^1.12.2", "class-variance-authority": "^0.7.1", diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index b20fbdf1..4b47dafd 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -886,6 +886,8 @@ const mockTableData: Record = { ] } +const SAFE_TABLE_NAME_REGEX = /^[a-zA-Z0-9_.-]+$/ + export const getTableList = async (): Promise => { if (import.meta.env.DEV) { return mockTables @@ -898,6 +900,9 @@ export const getTableSchema = async (tableName: string): Promise => if (!tableName || typeof tableName !== 'string') { throw new Error('Invalid table name') } + if (!SAFE_TABLE_NAME_REGEX.test(tableName)) { + throw new Error('Invalid table name: contains forbidden characters') + } if (import.meta.env.DEV) { return { ddl: mockSchemas[tableName] || `-- Schema not available for ${tableName}` } } @@ -909,7 +914,10 @@ export const getTableData = async (tableName: string, page: number, pageSize: nu if (!tableName || typeof tableName !== 'string') { throw new Error('Invalid table name') } - if (page < 1 || pageSize < 1 || pageSize > 1000) { + if (!SAFE_TABLE_NAME_REGEX.test(tableName)) { + throw new Error('Invalid table name: contains forbidden characters') + } + if (!Number.isInteger(page) || !Number.isInteger(pageSize) || page < 1 || pageSize < 1 || pageSize > 1000) { throw new Error('Page must be >= 1 and page size must be between 1 and 1000') } diff --git a/lightrag_webui/src/features/DocumentManager.tsx b/lightrag_webui/src/features/DocumentManager.tsx index 88c8bc40..a0c17699 100644 --- a/lightrag_webui/src/features/DocumentManager.tsx +++ b/lightrag_webui/src/features/DocumentManager.tsx @@ -32,7 +32,7 @@ import { errorMessage } from '@/lib/utils' import { toast } from 'sonner' import { useBackendState } from '@/stores/state' -import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info, FileText, CheckCircle2, Loader2, Clock, AlertCircle, Brain, Shell } from 'lucide-react' +import { RefreshCwIcon, ArrowUpIcon, ArrowDownIcon, CheckSquareIcon, XIcon, AlertTriangle, Info, FileText, CheckCircle2, Loader2, AlertCircle, Brain, Shell } from 'lucide-react' import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog' type StatusFilter = DocStatus | 'all'; @@ -495,6 +495,14 @@ export default function DocumentManager() { color: "text-emerald-500", activeColor: "text-emerald-600 dark:text-emerald-500" }, + { + id: 'preprocessed' as StatusFilter, + label: t('documentPanel.documentManager.status.preprocessed'), + icon: Brain, + count: preprocessedCount, + color: "text-purple-500", + activeColor: "text-purple-600 dark:text-purple-500" + }, { id: 'processing' as StatusFilter, label: t('documentPanel.documentManager.status.processing'), @@ -521,7 +529,7 @@ export default function DocumentManager() { color: "text-red-500", activeColor: "text-red-600 dark:text-red-500" } - ], [t, statusCounts, documentCounts, processedCount, processingCount, pendingCount, failedCount]); + ], [t, statusCounts, documentCounts, processedCount, preprocessedCount, processingCount, pendingCount, failedCount]); // Store previous status counts const prevStatusCounts = useRef({ @@ -1175,6 +1183,15 @@ export default function DocumentManager() {
handleStatusFilterChange(item.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleStatusFilterChange(item.id); + } + }} + role="button" + tabIndex={0} + aria-pressed={isActive} className={cn( "flex-1 min-w-[140px] flex flex-col gap-1 p-3 rounded-lg transition-all cursor-pointer border select-none", isActive diff --git a/lightrag_webui/src/features/SiteHeader.tsx b/lightrag_webui/src/features/SiteHeader.tsx index 48dd840a..af1fabf6 100644 --- a/lightrag_webui/src/features/SiteHeader.tsx +++ b/lightrag_webui/src/features/SiteHeader.tsx @@ -35,7 +35,7 @@ function shouldShowTableExplorer(storageConfig: any) { // Always show for now - TODO: fix storageConfig state propagation from health check return true - // Original logic: + // Original logic (storageConfig not being populated from health endpoint): // if (import.meta.env.DEV) return true // return ( // storageConfig && @@ -80,7 +80,6 @@ export default function SiteHeader() { const { t } = useTranslation() const { isGuestMode, username, webuiTitle, webuiDescription } = useAuthStore() const enableHealthCheck = useSettingsStore.use.enableHealthCheck() - const storageConfig = useSettingsStore.use.storageConfig() const handleLogout = () => { navigationService.navigateToLogin(); diff --git a/lightrag_webui/src/features/TableExplorer.tsx b/lightrag_webui/src/features/TableExplorer.tsx index 77d9b560..96622f8f 100644 --- a/lightrag_webui/src/features/TableExplorer.tsx +++ b/lightrag_webui/src/features/TableExplorer.tsx @@ -48,23 +48,28 @@ function isJsonLike(value: any): boolean { // Copy to clipboard helper async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - // Fallback for older browsers - const textarea = document.createElement('textarea') - textarea.value = text - textarea.style.position = 'fixed' - textarea.style.opacity = '0' - document.body.appendChild(textarea) - textarea.select() + if (navigator.clipboard?.writeText) { try { - document.execCommand('copy') + await navigator.clipboard.writeText(text) return true } catch { - return false - } finally { + // Fall through to legacy approach + } + } + // Fallback for older browsers + const textarea = document.createElement('textarea') + textarea.value = text + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + try { + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + return true + } catch { + return false + } finally { + if (textarea.parentNode) { document.body.removeChild(textarea) } } @@ -114,7 +119,13 @@ function RowDetailModal({ if (!row) return null const entries = Object.entries(row) - const fullRowJson = JSON.stringify(row, null, 2) + const fullRowJson = useMemo(() => { + try { + return JSON.stringify(row, null, 2) + } catch { + return '[Unable to serialize row]' + } + }, [row]) return ( @@ -197,6 +208,9 @@ export default function TableExplorer() { setModalOpen(true) }, []) + // Columns to hide from UI (exist in schema but not populated) + const HIDDEN_COLUMNS = ['meta'] + // Generate columns dynamically from data const columns = useMemo[]>(() => { const cols: ColumnDef[] = [] @@ -206,7 +220,8 @@ export default function TableExplorer() { Object.keys(row).forEach(key => allKeys.add(key)) }) - allKeys.forEach((key) => { + Array.from(allKeys).sort().forEach((key) => { + if (HIDDEN_COLUMNS.includes(key)) return // Skip hidden columns cols.push({ accessorKey: key, header: () => ( diff --git a/lightrag_webui/src/stores/settings.ts b/lightrag_webui/src/stores/settings.ts index be6636c6..3f767469 100644 --- a/lightrag_webui/src/stores/settings.ts +++ b/lightrag_webui/src/stores/settings.ts @@ -4,6 +4,13 @@ import { createSelectors } from '@/lib/utils' import { defaultQueryLabel } from '@/lib/constants' import { Message, QueryRequest } from '@/api/lightrag' +const DEV_STORAGE_CONFIG = import.meta.env.DEV ? { + kv_storage: 'PGKVStorage', + doc_status_storage: 'PGDocStatusStorage', + graph_storage: 'PGGraphStorage', + vector_storage: 'PGVectorStorage' +} : null + type Theme = 'dark' | 'light' | 'system' type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW' type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' | 'table-explorer' @@ -118,12 +125,7 @@ const useSettingsStoreBase = create()( apiKey: null, // In dev mode, mock PG storage config so Tables tab is visible - storageConfig: import.meta.env.DEV ? { - kv_storage: 'PGKVStorage', - doc_status_storage: 'PGDocStatusStorage', - graph_storage: 'PGGraphStorage', - vector_storage: 'PGVectorStorage' - } : null, + storageConfig: DEV_STORAGE_CONFIG, currentTab: 'documents', showFileName: false, @@ -365,12 +367,7 @@ const useSettingsStoreBase = create()( } if (version < 21) { // Reset storageConfig to pick up dev mode default - state.storageConfig = import.meta.env.DEV ? { - kv_storage: 'PGKVStorage', - doc_status_storage: 'PGDocStatusStorage', - graph_storage: 'PGGraphStorage', - vector_storage: 'PGVectorStorage' - } : null + state.storageConfig = DEV_STORAGE_CONFIG } return state }