feat: add Table Explorer feature with dynamic table data fetching and schema display

- Implemented Table Explorer component to allow users to select and view database tables.
- Added API calls for fetching table list, schema, and paginated data.
- Introduced row detail modal for displaying and copying row data.
- Enhanced DataTable component to support row click events.
- Updated UI components for better user experience and accessibility.
- Added mock data for development mode to facilitate testing.
- Updated localization files to include new terms related to tables.
- Modified settings store to include storage configuration for conditional UI rendering.
- Improved styling and layout for various components to align with new design standards.
This commit is contained in:
clssck 2025-11-27 18:27:14 +01:00
parent 48c7732edc
commit a9edadef45
18 changed files with 809 additions and 148 deletions

View file

@ -2,8 +2,6 @@
LightRAG FastAPI Server LightRAG FastAPI Server
""" """
from __future__ import annotations
from fastapi import FastAPI, Depends, HTTPException, Request from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -53,6 +51,7 @@ from lightrag.api.routers.document_routes import (
) )
from lightrag.api.routers.query_routes import create_query_routes from lightrag.api.routers.query_routes import create_query_routes
from lightrag.api.routers.graph_routes import create_graph_routes from lightrag.api.routers.graph_routes import create_graph_routes
from lightrag.api.routers.table_routes import create_table_routes
from lightrag.api.routers.ollama_api import OllamaAPI from lightrag.api.routers.ollama_api import OllamaAPI
from lightrag.utils import logger, set_verbose_debug from lightrag.utils import logger, set_verbose_debug
@ -644,7 +643,7 @@ def create_app(args):
raise Exception(f"Failed to import {binding} options: {e}") raise Exception(f"Failed to import {binding} options: {e}")
return {} return {}
def create_entity_resolution_config(args) -> object | None: def create_entity_resolution_config(args) -> "EntityResolutionConfig | None":
""" """
Create EntityResolutionConfig from command line/env arguments. Create EntityResolutionConfig from command line/env arguments.
Returns None if entity resolution is disabled. Returns None if entity resolution is disabled.
@ -1065,6 +1064,13 @@ def create_app(args):
app.include_router(create_query_routes(rag, api_key, args.top_k)) app.include_router(create_query_routes(rag, api_key, args.top_k))
app.include_router(create_graph_routes(rag, api_key)) app.include_router(create_graph_routes(rag, api_key))
# Register table routes if all storages are PostgreSQL
if (args.kv_storage == "PGKVStorage" and
args.doc_status_storage == "PGDocStatusStorage" and
args.graph_storage == "PGGraphStorage" and
args.vector_storage == "PGVectorStorage"):
app.include_router(create_table_routes(rag, api_key), prefix="/tables")
# Add Ollama API routes # Add Ollama API routes
ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key) ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key)
app.include_router(ollama_api.router, prefix="/api") app.include_router(ollama_api.router, prefix="/api")

View file

@ -0,0 +1,106 @@
from typing import Optional, Any
import traceback
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from lightrag import LightRAG
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()
if "update_time" in ddl_lower:
return "ORDER BY update_time DESC"
elif "updated_at" in ddl_lower:
return "ORDER BY updated_at DESC"
elif "create_time" in ddl_lower:
return "ORDER BY create_time DESC"
elif "created_at" in ddl_lower:
return "ORDER BY created_at DESC"
elif "id" in ddl_lower:
return "ORDER BY id ASC"
return ""
def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRouter:
combined_auth = get_combined_auth_dependency(api_key)
def get_workspace_from_request(request: Request) -> Optional[str]:
workspace = request.headers.get("LIGHTRAG-WORKSPACE", "").strip()
return workspace if workspace else None
@router.get("/list", dependencies=[Depends(combined_auth)])
async def list_tables() -> list[str]:
"""List all available LightRAG tables."""
try:
return list(TABLES.keys())
except Exception as e:
logger.error(f"Error listing tables: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Error listing tables: {str(e)}") from e
@router.get("/{table_name}/schema", dependencies=[Depends(combined_auth)])
async def get_table_schema(table_name: str) -> dict[str, Any]:
"""Get DDL/schema for a specific table."""
if table_name not in TABLES:
raise HTTPException(status_code=404, detail=f"Table {table_name} not found")
return TABLES[table_name]
@router.get("/{table_name}/data", dependencies=[Depends(combined_auth)])
async def get_table_data(
request: Request,
table_name: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
workspace: Optional[str] = None
) -> dict[str, Any]:
"""Get paginated data from a table."""
# Strict validation: table name must be alphanumeric + underscores only
if not re.match(r'^[a-zA-Z0-9_]+$', table_name):
raise HTTPException(status_code=400, detail="Invalid table name format")
if table_name not in TABLES:
raise HTTPException(status_code=404, detail=f"Table {table_name} not found")
try:
req_workspace = get_workspace_from_request(request)
# Priority: query param > header > rag default > "default"
target_workspace = workspace or req_workspace or rag.workspace or "default"
# Access the database connection from an initialized storage
# full_docs is a KV storage that has the db connection when using PostgreSQL
if not hasattr(rag.full_docs, 'db') or rag.full_docs.db is None:
raise HTTPException(status_code=500, detail="PostgreSQL storage not available")
db = rag.full_docs.db
offset = (page - 1) * page_size
# 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
# 2. Get data
# Try to determine order column
ddl = TABLES[table_name]['ddl']
order_clause = get_order_clause(ddl)
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)
return {
"data": rows or [],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0
}
except Exception as e:
logger.error(f"Error fetching table data for {table_name}: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") from e
return router

View file

@ -28,6 +28,7 @@
"@react-sigma/minimap": "^5.0.5", "@react-sigma/minimap": "^5.0.5",
"@sigma/edge-curve": "^3.1.0", "@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0", "@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -476,6 +477,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.11", "", {}, "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.11", "", { "dependencies": { "@tanstack/query-core": "5.90.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],

View file

@ -40,6 +40,7 @@
"@react-sigma/minimap": "^5.0.5", "@react-sigma/minimap": "^5.0.5",
"@sigma/edge-curve": "^3.1.0", "@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0", "@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View file

@ -14,6 +14,7 @@ import GraphViewer from '@/features/GraphViewer'
import DocumentManager from '@/features/DocumentManager' import DocumentManager from '@/features/DocumentManager'
import RetrievalTesting from '@/features/RetrievalTesting' import RetrievalTesting from '@/features/RetrievalTesting'
import ApiSite from '@/features/ApiSite' import ApiSite from '@/features/ApiSite'
import TableExplorer from '@/features/TableExplorer'
import { Tabs, TabsContent } from '@/components/ui/Tabs' import { Tabs, TabsContent } from '@/components/ui/Tabs'
@ -60,7 +61,10 @@ function App() {
try { try {
// Only perform health check if component is still mounted // Only perform health check if component is still mounted
if (isMountedRef.current) { if (isMountedRef.current) {
await useBackendState.getState().check(); const status = await useBackendState.getState().check();
if (status && 'status' in status && status.status === 'healthy' && status.configuration) {
useSettingsStore.getState().setStorageConfig(status.configuration);
}
} }
} catch (error) { } catch (error) {
console.error('Health check error:', error); console.error('Health check error:', error);
@ -171,7 +175,7 @@ function App() {
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur"> <header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
<div className="min-w-[200px] w-auto flex items-center"> <div className="min-w-[200px] w-auto flex items-center">
<a href={webuiPrefix} className="flex items-center gap-2"> <a href={webuiPrefix} className="flex items-center gap-2">
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" /> <ZapIcon className="size-4 text-plum" aria-hidden="true" />
<span className="font-bold md:inline-block">{SiteInfo.name}</span> <span className="font-bold md:inline-block">{SiteInfo.name}</span>
</a> </a>
</div> </div>
@ -215,6 +219,9 @@ function App() {
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden"> <TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<ApiSite /> <ApiSite />
</TabsContent> </TabsContent>
<TabsContent value="table-explorer" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<TableExplorer />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} /> <ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />

View file

@ -1,6 +1,7 @@
import '@/lib/extensions'; // Import all global extensions import '@/lib/extensions'; // Import all global extensions
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom' import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useAuthStore } from '@/stores/state' import { useAuthStore } from '@/stores/state'
import { navigationService } from '@/services/navigation' import { navigationService } from '@/services/navigation'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
@ -8,6 +9,17 @@ import App from './App'
import LoginPage from '@/features/LoginPage' import LoginPage from '@/features/LoginPage'
import ThemeProvider from '@/components/ThemeProvider' import ThemeProvider from '@/components/ThemeProvider'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
})
const AppContent = () => { const AppContent = () => {
const [initializing, setInitializing] = useState(true) const [initializing, setInitializing] = useState(true)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
@ -78,17 +90,19 @@ const AppContent = () => {
const AppRouter = () => { const AppRouter = () => {
return ( return (
<ThemeProvider> <QueryClientProvider client={queryClient}>
<Router> <ThemeProvider>
<AppContent /> <Router>
<Toaster <AppContent />
position="bottom-center" <Toaster
theme="system" position="bottom-center"
closeButton theme="system"
richColors closeButton
/> richColors
</Router> />
</ThemeProvider> </Router>
</ThemeProvider>
</QueryClientProvider>
) )
} }

View file

@ -815,3 +815,117 @@ export const getDocumentStatusCounts = async (): Promise<StatusCountsResponse> =
const response = await axiosInstance.get('/documents/status_counts') const response = await axiosInstance.get('/documents/status_counts')
return response.data return response.data
} }
export type TableSchema = {
ddl: string
}
export type TableDataResponse = {
data: Record<string, any>[]
total: number
page: number
page_size: number
total_pages: number
}
// Mock data for dev mode
const mockTables = [
'lightrag_doc_status',
'lightrag_doc_chunks',
'lightrag_entities',
'lightrag_relations',
'lightrag_entity_aliases',
'lightrag_llm_cache'
]
const mockSchemas: Record<string, string> = {
'lightrag_doc_status': `CREATE TABLE lightrag_doc_status (
id VARCHAR(255) PRIMARY KEY,
workspace VARCHAR(255) NOT NULL,
content_summary TEXT,
content_length INTEGER,
status VARCHAR(50),
created_at TIMESTAMP,
updated_at TIMESTAMP
);`,
'lightrag_entities': `CREATE TABLE lightrag_entities (
id SERIAL PRIMARY KEY,
workspace VARCHAR(255) NOT NULL,
entity_name VARCHAR(500) NOT NULL,
entity_type VARCHAR(100),
description TEXT,
source_chunk_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
);`,
'lightrag_entity_aliases': `CREATE TABLE lightrag_entity_aliases (
id SERIAL PRIMARY KEY,
workspace VARCHAR(255) NOT NULL,
alias VARCHAR(500) NOT NULL,
canonical_entity VARCHAR(500) NOT NULL,
method VARCHAR(50),
confidence FLOAT,
created_at TIMESTAMP DEFAULT NOW()
);`
}
const mockTableData: Record<string, any[]> = {
'lightrag_doc_status': [
{ id: 'doc_001', workspace: 'default', content_summary: 'Research paper on AI...', content_length: 15420, status: 'processed', created_at: '2024-01-15T10:30:00Z' },
{ id: 'doc_002', workspace: 'default', content_summary: 'Technical documentation...', content_length: 8750, status: 'processed', created_at: '2024-01-16T14:22:00Z' },
{ id: 'doc_003', workspace: 'default', content_summary: 'Meeting notes from Q4...', content_length: 3200, status: 'pending', created_at: '2024-01-17T09:15:00Z' },
],
'lightrag_entities': [
{ id: 1, workspace: 'default', entity_name: 'OpenAI', entity_type: 'Organization', description: 'AI research company', created_at: '2024-01-15T10:30:00Z' },
{ id: 2, workspace: 'default', entity_name: 'GPT-4', entity_type: 'Product', description: 'Large language model', created_at: '2024-01-15T10:31:00Z' },
{ id: 3, workspace: 'default', entity_name: 'San Francisco', entity_type: 'Location', description: 'City in California', created_at: '2024-01-15T10:32:00Z' },
],
'lightrag_entity_aliases': [
{ id: 1, workspace: 'default', alias: 'openai', canonical_entity: 'OpenAI', method: 'exact', confidence: 1.0, created_at: '2024-01-15T10:30:00Z' },
{ id: 2, workspace: 'default', alias: 'gpt4', canonical_entity: 'GPT-4', method: 'fuzzy', confidence: 0.92, created_at: '2024-01-15T10:31:00Z' },
{ id: 3, workspace: 'default', alias: 'SF', canonical_entity: 'San Francisco', method: 'llm', confidence: 0.85, created_at: '2024-01-15T10:32:00Z' },
]
}
export const getTableList = async (): Promise<string[]> => {
if (import.meta.env.DEV) {
return mockTables
}
const response = await axiosInstance.get('/tables/list')
return response.data
}
export const getTableSchema = async (tableName: string): Promise<TableSchema> => {
if (!tableName || typeof tableName !== 'string') {
throw new Error('Invalid table name')
}
if (import.meta.env.DEV) {
return { ddl: mockSchemas[tableName] || `-- Schema not available for ${tableName}` }
}
const response = await axiosInstance.get(`/tables/${encodeURIComponent(tableName)}/schema`)
return response.data
}
export const getTableData = async (tableName: string, page: number, pageSize: number): Promise<TableDataResponse> => {
if (!tableName || typeof tableName !== 'string') {
throw new Error('Invalid table name')
}
if (page < 1 || pageSize < 1 || pageSize > 1000) {
throw new Error('Page must be >= 1 and page size must be between 1 and 1000')
}
if (import.meta.env.DEV) {
const data = mockTableData[tableName] || []
const start = (page - 1) * pageSize
const end = start + pageSize
const paginatedData = data.slice(start, end)
return {
data: paginatedData,
total: data.length,
page: page,
page_size: pageSize,
total_pages: Math.ceil(data.length / pageSize)
}
}
const response = await axiosInstance.get(`/tables/${encodeURIComponent(tableName)}/data`, { params: { page, page_size: pageSize } })
return response.data
}

View file

@ -360,7 +360,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3> <h3 className="text-md pl-1 font-bold tracking-wide text-plum">{t('graphPanel.propertiesView.edge.title')}</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} /> <PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />} {edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}

View file

@ -12,9 +12,10 @@ import {
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[] columns: ColumnDef<TData, TValue>[]
data: TData[] data: TData[]
onRowClick?: (row: TData) => void
} }
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { export default function DataTable<TData, TValue>({ columns, data, onRowClick }: DataTableProps<TData, TValue>) {
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@ -42,7 +43,12 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}> <TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? 'cursor-pointer hover:bg-muted/50' : ''}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}

View file

@ -20,19 +20,23 @@ export default function EmptyCard({
return ( return (
<Card <Card
className={cn( className={cn(
'flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16', 'flex w-full h-full flex-col items-center justify-center space-y-6 bg-transparent p-16 border-none shadow-none',
className className
)} )}
{...props} {...props}
> >
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4"> <div className="shrink-0 rounded-2xl bg-gradient-to-br from-muted/80 to-muted/40 p-6 ring-1 ring-border/50">
<Icon className="text-muted-foreground size-8" aria-hidden="true" /> <Icon className="text-muted-foreground/70 size-10" aria-hidden="true" />
</div> </div>
<div className="flex flex-col items-center gap-1.5 text-center"> <div className="flex flex-col items-center gap-2 text-center max-w-sm">
<CardTitle>{title}</CardTitle> <CardTitle className="text-lg font-semibold">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null} {description ? (
<CardDescription className="text-sm text-muted-foreground/80">
{description}
</CardDescription>
) : null}
</div> </div>
{action ? action : null} {action ? <div className="mt-2">{action}</div> : null}
</Card> </Card>
) )
} }

View file

@ -32,7 +32,7 @@ import { errorMessage } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info } from 'lucide-react' import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info, FileText, CheckCircle2, Loader2, Clock, AlertCircle, Brain, Shell } from 'lucide-react'
import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog' import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog'
type StatusFilter = DocStatus | 'all'; type StatusFilter = DocStatus | 'all';
@ -477,6 +477,52 @@ export default function DocumentManager() {
const pendingCount = getCountValue(statusCounts, 'PENDING', 'pending') || documentCounts.pending || 0; const pendingCount = getCountValue(statusCounts, 'PENDING', 'pending') || documentCounts.pending || 0;
const failedCount = getCountValue(statusCounts, 'FAILED', 'failed') || documentCounts.failed || 0; const failedCount = getCountValue(statusCounts, 'FAILED', 'failed') || documentCounts.failed || 0;
// Stats items configuration for modern dashboard
const statItems = useMemo(() => [
{
id: 'all' as StatusFilter,
label: t('documentPanel.documentManager.status.all'),
icon: FileText,
count: statusCounts.all || documentCounts.all || 0,
color: "text-muted-foreground",
activeColor: "text-foreground"
},
{
id: 'processed' as StatusFilter,
label: t('documentPanel.documentManager.status.completed'),
icon: CheckCircle2,
count: processedCount,
color: "text-emerald-500",
activeColor: "text-emerald-600 dark:text-emerald-500"
},
{
id: 'processing' as StatusFilter,
label: t('documentPanel.documentManager.status.processing'),
icon: Loader2,
count: processingCount,
color: "text-blue-500",
activeColor: "text-blue-600 dark:text-blue-500",
spin: true
},
{
id: 'pending' as StatusFilter,
label: t('documentPanel.documentManager.status.pending'),
icon: Loader2,
count: pendingCount,
color: "text-amber-500",
activeColor: "text-amber-600 dark:text-amber-500",
spin: true
},
{
id: 'failed' as StatusFilter,
label: t('documentPanel.documentManager.status.failed'),
icon: AlertCircle,
count: failedCount,
color: "text-red-500",
activeColor: "text-red-600 dark:text-red-500"
}
], [t, statusCounts, documentCounts, processedCount, processingCount, pendingCount, failedCount]);
// Store previous status counts // Store previous status counts
const prevStatusCounts = useRef({ const prevStatusCounts = useRef({
processed: 0, processed: 0,
@ -1114,10 +1160,50 @@ export default function DocumentManager() {
return ( return (
<Card className="!rounded-none !overflow-hidden flex flex-col h-full min-h-0"> <Card className="!rounded-none !overflow-hidden flex flex-col h-full min-h-0">
<CardHeader className="py-2 px-6"> <CardHeader className="py-4 px-6 border-b border-border/50">
<CardTitle className="text-lg">{t('documentPanel.documentManager.title')}</CardTitle> <CardTitle className="text-xl font-semibold tracking-tight">{t('documentPanel.documentManager.title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 overflow-auto"> <CardContent className="flex-1 flex flex-col min-h-0 overflow-auto">
{/* Modern Interactive Stats Bar */}
<div className="bg-muted/20 p-2 rounded-xl flex flex-wrap gap-2 mb-6">
{statItems.map((item) => {
const isActive = statusFilter === item.id;
const Icon = item.icon;
const showIcon = item.count > 0 || item.id === 'all';
return (
<div
key={item.id}
onClick={() => handleStatusFilterChange(item.id)}
className={cn(
"flex-1 min-w-[140px] flex flex-col gap-1 p-3 rounded-lg transition-all cursor-pointer border select-none",
isActive
? "bg-background shadow-sm border-border ring-1 ring-black/5 dark:ring-white/5"
: "bg-background/80 border-border/40 hover:bg-background hover:border-border/60"
)}
>
<div className="flex items-center justify-between">
<span className={cn("text-xs font-medium", isActive ? "text-foreground" : "text-muted-foreground")}>
{item.label}
</span>
{showIcon && (
<Icon
className={cn(
"h-4 w-4",
isActive ? item.activeColor : item.color,
item.spin && item.count > 0 && "animate-spin"
)}
/>
)}
</div>
<div className={cn("text-2xl font-bold", isActive ? "text-foreground" : "text-muted-foreground/80")}>
{item.count}
</div>
</div>
);
})}
</div>
<div className="flex justify-between items-center gap-2 mb-2"> <div className="flex justify-between items-center gap-2 mb-2">
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -1139,7 +1225,7 @@ export default function DocumentManager() {
pipelineBusy && 'pipeline-busy' pipelineBusy && 'pipeline-busy'
)} )}
> >
<ActivityIcon /> {t('documentPanel.documentManager.pipelineStatusButton')} <Shell className="h-4 w-4" /> {t('documentPanel.documentManager.pipelineStatusButton')}
</Button> </Button>
</div> </div>
@ -1157,7 +1243,27 @@ export default function DocumentManager() {
/> />
)} )}
<div className="flex gap-2"> <div className="flex gap-2 items-center">
<Button
variant="outline"
size="sm"
onClick={() => setShowFileName(!showFileName)}
className={cn(
"transition-all border",
showFileName
? "bg-primary text-primary-foreground hover:bg-primary/90 border-primary shadow-sm"
: "text-muted-foreground border-dashed border-border/60 hover:border-solid hover:text-foreground hover:bg-accent/50"
)}
side="bottom"
tooltip={showFileName ? t('documentPanel.documentManager.hideButton') : t('documentPanel.documentManager.showButton')}
>
{showFileName ? (
<CheckCircle2 className="h-4 w-4 mr-2" />
) : (
<FileText className="h-4 w-4 mr-2" />
)}
{t('documentPanel.documentManager.columns.fileName')}
</Button>
{isSelectionMode && ( {isSelectionMode && (
<DeleteDocumentsDialog <DeleteDocumentsDialog
selectedDocIds={selectedDocIds} selectedDocIds={selectedDocIds}
@ -1196,111 +1302,6 @@ export default function DocumentManager() {
<CardHeader className="flex-none py-2 px-4"> <CardHeader className="flex-none py-2 px-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle> <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(
processedCount > 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')} ({processedCount})
</Button>
<Button
size="sm"
variant={statusFilter === 'preprocessed' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('preprocessed')}
disabled={isRefreshing}
className={cn(
preprocessedCount > 0 ? 'text-purple-600' : 'text-gray-500',
statusFilter === 'preprocessed' && 'bg-purple-100 dark:bg-purple-900/30 font-medium border border-purple-400 dark:border-purple-600 shadow-sm'
)}
>
{t('documentPanel.documentManager.status.preprocessed')} ({preprocessedCount})
</Button>
<Button
size="sm"
variant={statusFilter === 'processing' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('processing')}
disabled={isRefreshing}
className={cn(
processingCount > 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')} ({processingCount})
</Button>
<Button
size="sm"
variant={statusFilter === 'pending' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('pending')}
disabled={isRefreshing}
className={cn(
pendingCount > 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')} ({pendingCount})
</Button>
<Button
size="sm"
variant={statusFilter === 'failed' ? 'secondary' : 'outline'}
onClick={() => handleStatusFilterChange('failed')}
disabled={isRefreshing}
className={cn(
failedCount > 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')} ({failedCount})
</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> </div>
<CardDescription aria-hidden="true" className="hidden">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription> <CardDescription aria-hidden="true" className="hidden">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>
</CardHeader> </CardHeader>

View file

@ -145,7 +145,7 @@ const LoginPage = () => {
} }
return ( return (
<div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800"> <div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-fuchsia-50 to-purple-100 dark:from-gray-900 dark:to-gray-800">
<div className="absolute top-4 right-4 flex items-center gap-2"> <div className="absolute top-4 right-4 flex items-center gap-2">
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" /> <AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
</div> </div>
@ -154,7 +154,7 @@ const LoginPage = () => {
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" /> <img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" />
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" /> <ZapIcon className="size-10 text-plum" aria-hidden="true" />
</div> </div>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1> <h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>

View file

@ -8,7 +8,7 @@ import { useAuthStore } from '@/stores/state'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { navigationService } from '@/services/navigation' import { navigationService } from '@/services/navigation'
import { ZapIcon, LogOutIcon } from 'lucide-react' import { ZapIcon, LogOutIcon, BrainIcon } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
interface NavigationTabProps { interface NavigationTabProps {
@ -23,7 +23,7 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
value={value} value={value}
className={cn( className={cn(
'cursor-pointer px-2 py-1 transition-all', 'cursor-pointer px-2 py-1 transition-all',
currentTab === value ? '!bg-emerald-400 !text-zinc-50' : 'hover:bg-background/60' currentTab === value ? '!bg-plum !text-plum-foreground' : 'hover:bg-background/60'
)} )}
> >
{children} {children}
@ -31,8 +31,24 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
) )
} }
function shouldShowTableExplorer(storageConfig: any) {
// Always show for now - TODO: fix storageConfig state propagation from health check
return true
// Original logic:
// if (import.meta.env.DEV) return true
// return (
// storageConfig &&
// storageConfig.kv_storage === 'PGKVStorage' &&
// storageConfig.doc_status_storage === 'PGDocStatusStorage' &&
// storageConfig.graph_storage === 'PGGraphStorage' &&
// storageConfig.vector_storage === 'PGVectorStorage'
// )
}
function TabsNavigation() { function TabsNavigation() {
const currentTab = useSettingsStore.use.currentTab() const currentTab = useSettingsStore.use.currentTab()
const storageConfig = useSettingsStore.use.storageConfig()
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -50,6 +66,11 @@ function TabsNavigation() {
<NavigationTab value="api" currentTab={currentTab}> <NavigationTab value="api" currentTab={currentTab}>
{t('header.api')} {t('header.api')}
</NavigationTab> </NavigationTab>
{shouldShowTableExplorer(storageConfig) && (
<NavigationTab value="table-explorer" currentTab={currentTab}>
{t('header.tables')}
</NavigationTab>
)}
</TabsList> </TabsList>
</div> </div>
) )
@ -59,6 +80,7 @@ export default function SiteHeader() {
const { t } = useTranslation() const { t } = useTranslation()
const { isGuestMode, username, webuiTitle, webuiDescription } = useAuthStore() const { isGuestMode, username, webuiTitle, webuiDescription } = useAuthStore()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck() const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
const storageConfig = useSettingsStore.use.storageConfig()
const handleLogout = () => { const handleLogout = () => {
navigationService.navigateToLogin(); navigationService.navigateToLogin();
@ -68,7 +90,7 @@ export default function SiteHeader() {
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur"> <header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
<div className="min-w-[200px] w-auto flex items-center"> <div className="min-w-[200px] w-auto flex items-center">
<a href={webuiPrefix} className="flex items-center gap-2"> <a href={webuiPrefix} className="flex items-center gap-2">
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" /> <BrainIcon className="size-4 text-plum" aria-hidden="true" />
</a> </a>
{webuiTitle && ( {webuiTitle && (
<div className="flex items-center"> <div className="flex items-center">
@ -93,11 +115,6 @@ export default function SiteHeader() {
<div className="flex h-10 flex-1 items-center justify-center"> <div className="flex h-10 flex-1 items-center justify-center">
<TabsNavigation /> <TabsNavigation />
{isGuestMode && (
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
{t('login.guestMode', 'Guest Mode')}
</div>
)}
</div> </div>
<nav className="w-[200px] flex items-center justify-end"> <nav className="w-[200px] flex items-center justify-end">

View file

@ -0,0 +1,350 @@
import { useState, useMemo, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getTableList, getTableSchema, getTableData } from '@/api/lightrag'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/Dialog'
import DataTable from '@/components/ui/DataTable'
import { ColumnDef } from '@tanstack/react-table'
import Button from '@/components/ui/Button'
import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon, CopyIcon, CheckIcon } from 'lucide-react'
import { toast } from 'sonner'
// Truncate long values for display
function truncateValue(value: any, maxLength = 50): string {
if (value === null || value === undefined) return ''
let strValue: string
if (typeof value === 'object') {
strValue = JSON.stringify(value)
} else {
strValue = String(value)
}
if (strValue.length <= maxLength) return strValue
return strValue.slice(0, maxLength) + '...'
}
// Format value for display in modal
function formatValue(value: any): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
return String(value)
}
// Check if value is JSON-like (object or array)
function isJsonLike(value: any): boolean {
return typeof value === 'object' && value !== null
}
// Copy to clipboard helper
async function copyToClipboard(text: string): Promise<boolean> {
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()
try {
document.execCommand('copy')
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
// Copy button component with feedback
function CopyButton({ text, label }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
const success = await copyToClipboard(text)
if (success) {
setCopied(true)
toast.success(label ? `${label} copied` : 'Copied to clipboard')
setTimeout(() => setCopied(false), 2000)
} else {
toast.error('Failed to copy')
}
}
return (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleCopy}
>
{copied ? (
<CheckIcon className="h-3 w-3 text-green-500" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</Button>
)
}
// Row Detail Modal
function RowDetailModal({
row,
open,
onOpenChange
}: {
row: Record<string, any> | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
if (!row) return null
const entries = Object.entries(row)
const fullRowJson = JSON.stringify(row, null, 2)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Row Details
<CopyButton text={fullRowJson} label="Full row" />
</DialogTitle>
<DialogDescription>
Click the copy icon next to any field to copy its value
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto space-y-3 pr-2">
{entries.map(([key, value]) => (
<div key={key} className="border rounded-lg p-3 bg-muted/30">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm text-muted-foreground">{key}</span>
<CopyButton text={formatValue(value)} label={key} />
</div>
<div className={`text-sm ${isJsonLike(value) ? 'font-mono' : ''}`}>
{isJsonLike(value) ? (
<pre className="whitespace-pre-wrap break-all bg-muted p-2 rounded text-xs overflow-auto max-h-[200px]">
{formatValue(value)}
</pre>
) : (
<div className="whitespace-pre-wrap break-all">
{formatValue(value)}
</div>
)}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
)
}
export default function TableExplorer() {
const [selectedTable, setSelectedTable] = useState<string>('')
const [page, setPage] = useState(1)
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const pageSize = 20
// Fetch table list
const { data: tableList } = useQuery({
queryKey: ['tables', 'list'],
queryFn: getTableList,
})
// Derive effective selection: use state if set, otherwise default to first table
const effectiveSelectedTable = selectedTable || (tableList?.[0] ?? '')
// Reset page when table changes
const handleTableChange = (value: string) => {
setSelectedTable(value)
setPage(1)
}
// Fetch schema
const { data: schema } = useQuery({
queryKey: ['tables', effectiveSelectedTable, 'schema'],
queryFn: () => getTableSchema(effectiveSelectedTable),
enabled: !!effectiveSelectedTable,
})
// Fetch data
const { data: tableData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['tables', effectiveSelectedTable, 'data', page],
queryFn: () => getTableData(effectiveSelectedTable, page, pageSize),
enabled: !!effectiveSelectedTable,
})
// Handle row click
const handleRowClick = useCallback((row: Record<string, any>) => {
setSelectedRow(row)
setModalOpen(true)
}, [])
// Generate columns dynamically from data
const columns = useMemo<ColumnDef<any>[]>(() => {
const cols: ColumnDef<any>[] = []
if (tableData?.data && tableData.data.length > 0) {
const allKeys = new Set<string>()
tableData.data.forEach((row: any) => {
Object.keys(row).forEach(key => allKeys.add(key))
})
allKeys.forEach((key) => {
cols.push({
accessorKey: key,
header: () => (
<div className="font-semibold text-xs truncate max-w-[150px]" title={key}>
{key}
</div>
),
cell: ({ row }) => {
const value = row.getValue(key)
const displayValue = truncateValue(value, 50)
const isLong = typeof value === 'object' || (typeof value === 'string' && value.length > 50)
return (
<div
className={`text-xs max-w-[200px] truncate ${isLong ? 'cursor-pointer hover:text-primary' : ''}`}
title={isLong ? 'Click row to see full value' : displayValue}
>
{displayValue}
</div>
)
},
})
})
}
return cols
}, [tableData?.data])
const totalPages = tableData?.total_pages || 0
return (
<div className="h-full flex flex-col p-4 gap-4 overflow-hidden">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-medium">Table Explorer</CardTitle>
<div className="flex items-center gap-2">
<Select value={effectiveSelectedTable} onValueChange={handleTableChange}>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder={tableList && tableList.length > 0 ? "Select a table" : "No tables available"} />
</SelectTrigger>
<SelectContent>
{tableList && tableList.length > 0 ? (
tableList.map((table) => (
<SelectItem key={table} value={table}>
{table}
</SelectItem>
))
) : (
<SelectItem value="no-tables" disabled>
No tables found
</SelectItem>
)}
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => refetch()}>
<RefreshCwIcon className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
{schema && (
<CardContent className="pb-2">
<details className="text-xs text-muted-foreground cursor-pointer">
<summary>Show Schema (DDL)</summary>
<pre className="mt-2 p-2 bg-muted rounded overflow-auto max-h-[200px] font-mono text-xs">
{schema.ddl}
</pre>
</details>
</CardContent>
)}
</Card>
<Card className="flex-1 overflow-hidden flex flex-col">
<CardContent className="flex-1 p-0 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<RefreshCwIcon className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : isError ? (
<div className="flex flex-col items-center justify-center h-full text-destructive gap-2">
<p className="font-medium">Failed to load table data</p>
<p className="text-sm text-muted-foreground">{error instanceof Error ? error.message : 'Unknown error'}</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-2">
Retry
</Button>
</div>
) : (
<div className="h-full">
<DataTable
columns={columns}
data={tableData?.data || []}
onRowClick={handleRowClick}
/>
</div>
)}
</CardContent>
<div className="border-t p-2 flex items-center justify-between bg-muted/20">
<div className="text-sm text-muted-foreground">
{tableData?.total ? (
<>
Showing {((page - 1) * pageSize) + 1} to {Math.min(page * pageSize, tableData.total)} of {tableData.total} rows
</>
) : (
'No results'
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1 || isLoading}
>
<ChevronLeftIcon className="h-4 w-4 mr-1" />
Previous
</Button>
<span className="text-sm font-medium min-w-[3rem] text-center">
{page} / {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || isLoading}
>
Next
<ChevronRightIcon className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</Card>
<RowDetailModal
row={selectedRow}
open={modalOpen}
onOpenChange={setModalOpen}
/>
</div>
)
}

View file

@ -33,6 +33,8 @@
--chart-3: hsl(197 37% 24%); --chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%); --chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%); --chart-5: hsl(27 87% 67%);
--plum: #734079;
--plum-foreground: hsl(0 0% 98%);
--radius: 0.6rem; --radius: 0.6rem;
--sidebar-background: hsl(0 0% 98%); --sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%); --sidebar-foreground: hsl(240 5.3% 26.1%);
@ -69,6 +71,8 @@
--chart-3: hsl(30 80% 55%); --chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%); --chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%); --chart-5: hsl(340 75% 55%);
--plum: #9d5ba3;
--plum-foreground: hsl(0 0% 98%);
--sidebar-background: hsl(240 5.9% 10%); --sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%); --sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%); --sidebar-primary: hsl(224.3 76.3% 48%);
@ -104,6 +108,8 @@
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-plum: var(--plum);
--color-plum-foreground: var(--plum-foreground);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);

View file

@ -11,6 +11,7 @@
"knowledgeGraph": "Knowledge Graph", "knowledgeGraph": "Knowledge Graph",
"retrieval": "Retrieval", "retrieval": "Retrieval",
"api": "API", "api": "API",
"tables": "Tables",
"projectRepository": "Project Repository", "projectRepository": "Project Repository",
"logout": "Logout", "logout": "Logout",
"frontendNeedsRebuild": "Frontend needs rebuild", "frontendNeedsRebuild": "Frontend needs rebuild",

View file

@ -11,6 +11,7 @@
"knowledgeGraph": "知识图谱", "knowledgeGraph": "知识图谱",
"retrieval": "检索", "retrieval": "检索",
"api": "API", "api": "API",
"tables": "数据表",
"projectRepository": "项目仓库", "projectRepository": "项目仓库",
"logout": "退出登录", "logout": "退出登录",
"frontendNeedsRebuild": "前端代码需重新构建", "frontendNeedsRebuild": "前端代码需重新构建",

View file

@ -6,7 +6,7 @@ import { Message, QueryRequest } from '@/api/lightrag'
type Theme = 'dark' | 'light' | 'system' type Theme = 'dark' | 'light' | 'system'
type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW' type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' | 'table-explorer'
interface SettingsState { interface SettingsState {
// Document manager settings // Document manager settings
@ -66,6 +66,10 @@ interface SettingsState {
apiKey: string | null apiKey: string | null
setApiKey: (key: string | null) => void setApiKey: (key: string | null) => void
// Storage configuration (for conditional UI)
storageConfig: Record<string, string> | null
setStorageConfig: (config: Record<string, string>) => void
// App settings // App settings
theme: Theme theme: Theme
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
@ -113,6 +117,13 @@ const useSettingsStoreBase = create<SettingsState>()(
enableHealthCheck: true, enableHealthCheck: true,
apiKey: null, 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,
currentTab: 'documents', currentTab: 'documents',
showFileName: false, showFileName: false,
@ -184,6 +195,8 @@ const useSettingsStoreBase = create<SettingsState>()(
setApiKey: (apiKey: string | null) => set({ apiKey }), setApiKey: (apiKey: string | null) => set({ apiKey }),
setStorageConfig: (config: Record<string, string>) => set({ storageConfig: config }),
setCurrentTab: (tab: Tab) => set({ currentTab: tab }), setCurrentTab: (tab: Tab) => set({ currentTab: tab }),
setRetrievalHistory: (history: Message[]) => set({ retrievalHistory: history }), setRetrievalHistory: (history: Message[]) => set({ retrievalHistory: history }),
@ -238,7 +251,7 @@ const useSettingsStoreBase = create<SettingsState>()(
{ {
name: 'settings-storage', name: 'settings-storage',
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
version: 20, version: 21,
migrate: (state: any, version: number) => { migrate: (state: any, version: number) => {
if (version < 2) { if (version < 2) {
state.showEdgeLabel = false state.showEdgeLabel = false
@ -350,6 +363,15 @@ const useSettingsStoreBase = create<SettingsState>()(
state.language = 'en' state.language = 'en'
} }
} }
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
}
return state return state
} }
} }