style(lightrag_webui): fix indentation, color palette, and component optimization

- Fix inconsistent indentation in App.tsx (66 → 68 chars)
- Refactor GraphControl reducer logic: cache selection/theme in refs to prevent expensive re-renders on every hover/selection change; extract nodeReducer and edgeReducer to useCallback with stable dependencies
- Improve GraphViewer performance: extract FocusSync and GraphSearchWithSelection components to prevent re-renders from unrelated store updates
- Remove unused imports (X icon, ZapIcon, i18n)
- Remove unused function parameter (storageConfig)
- Standardize dark theme colors: improve contrast and visual hierarchy (hsl values); update scrollbar colors for better visibility
- Normalize quote style: double quotes → single quotes in className attributes
- Fix form element styling: improve dark mode button hover states (gray-800/900 → gray-700/800, red-900 → red-800)
- Optimize dropdown menu colors: dark mode backgrounds (gray-900/gray-800)
- Relocate HIDDEN_COLUMNS constant to module level in TableExplorer
- Optimize RowDetailModal: move entries computation to useMemo for perf
- Fix useLightragGraph dependency array: add missing minDegree and includeOrphans dependencies
This commit is contained in:
clssck 2025-11-30 20:15:27 +01:00
parent 9f5948650e
commit 4e58da3583
12 changed files with 314 additions and 315 deletions

View file

@ -63,7 +63,7 @@ function App() {
if (isMountedRef.current) {
const status = await useBackendState.getState().check();
if (status && 'status' in status && status.status === 'healthy' && status.configuration) {
useSettingsStore.getState().setStorageConfig(status.configuration);
useSettingsStore.getState().setStorageConfig(status.configuration);
}
}
} catch (error) {

View file

@ -2,7 +2,7 @@ import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import { AbstractGraph } from 'graphology-types'
// import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
@ -212,158 +212,191 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
}
}, [sigma, sigmaGraph, minEdgeSize, maxEdgeSize])
// Cache selection state in refs so we don't trigger expensive reducer recreation on hover/selection changes
const selectionRef = useRef({
selectedNode: null as string | null,
focusedNode: null as string | null,
selectedEdge: null as string | null,
focusedEdge: null as string | null,
hideUnselectedEdges
})
const focusedNeighborsRef = useRef<{ key: string | null; neighbors: Set<string> | null }>(
{ key: null, neighbors: null }
)
useEffect(() => {
selectionRef.current = {
selectedNode,
focusedNode,
selectedEdge,
focusedEdge,
hideUnselectedEdges
}
// Invalidate neighbor cache when focused node changes
if (focusedNeighborsRef.current.key !== (focusedNode || null)) {
focusedNeighborsRef.current = { key: focusedNode || null, neighbors: null }
}
}, [selectedNode, focusedNode, selectedEdge, focusedEdge, hideUnselectedEdges])
// Theme values used inside reducers; kept in refs to avoid re-creating reducer functions
const themeRef = useRef({
isDarkTheme: false,
labelColor: undefined as string | undefined,
edgeColor: undefined as string | undefined
})
/**
* When component mount or hovered node change
* => Setting the sigma reducers
*/
useEffect(() => {
// Check if dark mode is actually applied (handles both 'dark' theme and 'system' theme when OS is dark)
const isDarkTheme = theme === 'dark' ||
(theme === 'system' && window.document.documentElement.classList.contains('dark'))
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
// Update all dynamic settings directly without recreating the sigma container
themeRef.current = {
isDarkTheme,
labelColor: isDarkTheme ? Constants.labelColorDarkTheme : undefined,
edgeColor: isDarkTheme ? Constants.edgeColorDarkTheme : undefined
}
}, [theme, systemThemeIsDark])
// Helper to lazily compute focused node neighbors and reuse across reducer calls
const getFocusedNeighbors = useCallback((graph: AbstractGraph, nodeId: string): Set<string> => {
if (focusedNeighborsRef.current.key === nodeId && focusedNeighborsRef.current.neighbors) {
return focusedNeighborsRef.current.neighbors
}
const neighbors = new Set(graph.neighbors(nodeId))
focusedNeighborsRef.current = { key: nodeId, neighbors }
return neighbors
}, [])
const nodeReducer = useCallback((node: string, data: NodeType) => {
const graph = sigma.getGraph()
const { selectedNode, focusedNode, selectedEdge, focusedEdge } = selectionRef.current
const { isDarkTheme, labelColor } = themeRef.current
if (!graph.hasNode(node)) {
return { ...data, highlighted: false, labelColor }
}
const newData: NodeType & { labelColor?: string; borderColor?: string; borderSize?: number } = {
...data,
highlighted: data.highlighted || false,
labelColor
}
// Hidden connections indicator
const dbDegree = graph.getNodeAttribute(node, 'db_degree') || 0
const visualDegree = graph.degree(node)
if (dbDegree > visualDegree) {
newData.borderColor = Constants.nodeBorderColorHiddenConnections
newData.borderSize = 1.5
}
if (disableHoverEffect) {
return newData
}
const targetNode = focusedNode || selectedNode
const targetEdge = focusedEdge || selectedEdge
if (targetNode && graph.hasNode(targetNode)) {
try {
const neighbors = getFocusedNeighbors(graph, targetNode)
if (node === targetNode || neighbors.has(node)) {
newData.highlighted = true
if (node === selectedNode) {
newData.borderColor = Constants.nodeBorderColorSelected
}
}
} catch (error) {
console.error('Error in nodeReducer:', error)
return { ...data, highlighted: false, labelColor }
}
} else if (targetEdge && graph.hasEdge(targetEdge)) {
try {
if (graph.extremities(targetEdge).includes(node)) {
newData.highlighted = true
newData.size = 3
}
} catch (error) {
console.error('Error accessing edge extremities in nodeReducer:', error)
return { ...data, highlighted: false, labelColor }
}
}
if (newData.highlighted) {
if (isDarkTheme) {
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
}
} else {
newData.color = Constants.nodeColorDisabled
}
return newData
}, [sigma, disableHoverEffect, getFocusedNeighbors])
const edgeReducer = useCallback((edge: string, data: EdgeType) => {
const graph = sigma.getGraph()
const { selectedNode, focusedNode, selectedEdge, focusedEdge, hideUnselectedEdges } = selectionRef.current
const { isDarkTheme, labelColor, edgeColor } = themeRef.current
if (!graph.hasEdge(edge)) {
return { ...data, hidden: false, labelColor, color: edgeColor }
}
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
if (disableHoverEffect) {
return newData
}
const targetNode = focusedNode || selectedNode
const edgeHighlightColor = isDarkTheme
? Constants.edgeColorHighlightedDarkTheme
: Constants.edgeColorHighlightedLightTheme
if (targetNode && graph.hasNode(targetNode)) {
try {
const includesNode = graph.extremities(edge).includes(targetNode)
if (hideUnselectedEdges && !includesNode) {
newData.hidden = true
} else if (includesNode) {
newData.color = edgeHighlightColor
}
} catch (error) {
console.error('Error in edgeReducer:', error)
return { ...data, hidden: false, labelColor, color: edgeColor }
}
} else {
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null
if (_selectedEdge || _focusedEdge) {
if (edge === _selectedEdge) {
newData.color = Constants.edgeColorSelected
} else if (edge === _focusedEdge) {
newData.color = edgeHighlightColor
} else if (hideUnselectedEdges) {
newData.hidden = true
}
}
}
return newData
}, [sigma, disableHoverEffect])
/**
* Keep sigma reducers stable; selection/theme changes are read from refs to avoid
* re-registering reducers on every hover and maintain frame budget.
*/
useEffect(() => {
setSettings({
// Update display settings
enableEdgeEvents,
renderEdgeLabels,
renderLabels,
// Node reducer for node appearance
nodeReducer: (node, data) => {
const graph = sigma.getGraph()
// Add defensive check for node existence during theme switching
if (!graph.hasNode(node)) {
console.warn(`Node ${node} not found in graph during theme switch, returning default data`)
return { ...data, highlighted: false, labelColor }
}
const newData: NodeType & {
labelColor?: string
borderColor?: string
borderSize?: number
} = { ...data, highlighted: data.highlighted || false, labelColor }
// Check for hidden connections (db_degree > visual_degree)
const dbDegree = graph.getNodeAttribute(node, 'db_degree') || 0
const visualDegree = graph.degree(node)
if (dbDegree > visualDegree) {
newData.borderColor = Constants.nodeBorderColorHiddenConnections
newData.borderSize = 1.5
}
if (!disableHoverEffect) {
newData.highlighted = false
const _focusedNode = focusedNode || selectedNode
const _focusedEdge = focusedEdge || selectedEdge
if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
newData.highlighted = true
if (node === selectedNode) {
newData.borderColor = Constants.nodeBorderColorSelected
}
}
} catch (error) {
console.error('Error in nodeReducer:', error);
return { ...data, highlighted: false, labelColor }
}
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
try {
if (graph.extremities(_focusedEdge).includes(node)) {
newData.highlighted = true
newData.size = 3
}
} catch (error) {
console.error('Error accessing edge extremities in nodeReducer:', error);
return { ...data, highlighted: false, labelColor }
}
} else {
return newData
}
if (newData.highlighted) {
if (isDarkTheme) {
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
}
} else {
newData.color = Constants.nodeColorDisabled
}
}
return newData
},
// Edge reducer for edge appearance
edgeReducer: (edge, data) => {
const graph = sigma.getGraph()
// Add defensive check for edge existence during theme switching
if (!graph.hasEdge(edge)) {
console.warn(`Edge ${edge} not found in graph during theme switch, returning default data`)
return { ...data, hidden: false, labelColor, color: edgeColor }
}
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
if (!disableHoverEffect) {
const _focusedNode = focusedNode || selectedNode
// Choose edge highlight color based on theme
const edgeHighlightColor = isDarkTheme
? Constants.edgeColorHighlightedDarkTheme
: Constants.edgeColorHighlightedLightTheme
if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (hideUnselectedEdges) {
if (!graph.extremities(edge).includes(_focusedNode)) {
newData.hidden = true
}
} else {
if (graph.extremities(edge).includes(_focusedNode)) {
newData.color = edgeHighlightColor
}
}
} catch (error) {
console.error('Error in edgeReducer:', error);
return { ...data, hidden: false, labelColor, color: edgeColor }
}
} else {
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
if (_selectedEdge || _focusedEdge) {
if (edge === _selectedEdge) {
newData.color = Constants.edgeColorSelected
} else if (edge === _focusedEdge) {
newData.color = edgeHighlightColor
} else if (hideUnselectedEdges) {
newData.hidden = true
}
}
}
}
return newData
}
nodeReducer,
edgeReducer
})
}, [
selectedNode,
focusedNode,
selectedEdge,
focusedEdge,
setSettings,
sigma,
disableHoverEffect,
theme,
systemThemeIsDark,
hideUnselectedEdges,
enableEdgeEvents,
renderEdgeLabels,
renderLabels
])
}, [setSettings, enableEdgeEvents, renderEdgeLabels, renderLabels, nodeReducer, edgeReducer])
return null
}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AlignLeft, AlignCenter, AlignRight, Link, Loader2, CheckCircle2, AlertCircle, X, Play, Square } from 'lucide-react'
import { AlignLeft, AlignCenter, AlignRight, Link, Loader2, CheckCircle2, AlertCircle, Play, Square } from 'lucide-react'
import {
Dialog,

View file

@ -20,7 +20,7 @@ const StatusIndicator = ({ className }: { className?: string }) => {
}, [lastCheckTime])
return (
<div className={cn("flex items-center gap-2 opacity-80 select-none", className)}>
<div className={cn('flex items-center gap-2 opacity-80 select-none', className)}>
<div
className="flex cursor-pointer items-center gap-2"
onClick={() => setDialogOpen(true)}

View file

@ -150,7 +150,7 @@ export default function UserPromptInputWithHistory({
<button
type="button"
onClick={handleInputClick}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
className="absolute right-2 top-1/2 -translate-y-1/2 p-0 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
tabIndex={-1}
>
<ChevronDown
@ -165,13 +165,13 @@ export default function UserPromptInputWithHistory({
{/* Dropdown */}
{isOpen && history.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-0.5 bg-gray-100 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-96 overflow-auto min-w-0">
<div className="absolute top-full left-0 right-0 z-50 mt-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-96 overflow-auto min-w-0">
{history.map((prompt, index) => (
<div
key={index}
className={cn(
'flex items-center justify-between pl-3 pr-1 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors',
'border-b border-gray-100 dark:border-gray-700 last:border-b-0',
'border-b border-gray-100 dark:border-gray-600 last:border-b-0',
'focus-within:bg-gray-100 dark:focus-within:bg-gray-700',
selectedIndex === index && 'bg-gray-100 dark:bg-gray-700'
)}
@ -188,7 +188,7 @@ export default function UserPromptInputWithHistory({
<button
type="button"
onClick={(e) => handleDeleteHistoryItem(index, e)}
className="flex-shrink-0 p-0 rounded hover:bg-red-100 dark:hover:bg-red-900 transition-colors focus:outline-none ml-auto"
className="flex-shrink-0 p-0 rounded hover:bg-red-100 dark:hover:bg-red-800 transition-colors focus:outline-none ml-auto"
title="Delete this history item"
>
<X className="h-3 w-3 text-gray-400 hover:text-red-500" />

View file

@ -219,7 +219,7 @@ export default function DocumentManager() {
}, []);
const [showPipelineStatus, setShowPipelineStatus] = useState(false)
const { t, i18n } = useTranslation()
const { t } = useTranslation()
const health = useBackendState.use.health()
const pipelineBusy = useBackendState.use.pipelineBusy()
@ -484,32 +484,32 @@ export default function DocumentManager() {
label: t('documentPanel.documentManager.status.all'),
icon: FileText,
count: statusCounts.all || documentCounts.all || 0,
color: "text-muted-foreground",
activeColor: "text-foreground"
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"
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"
color: 'text-purple-500',
activeColor: 'text-purple-600 dark:text-purple-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",
color: 'text-blue-500',
activeColor: 'text-blue-600 dark:text-blue-500',
spin: true
},
{
@ -517,8 +517,8 @@ export default function DocumentManager() {
label: t('documentPanel.documentManager.status.pending'),
icon: Loader2,
count: pendingCount,
color: "text-amber-500",
activeColor: "text-amber-600 dark:text-amber-500",
color: 'text-amber-500',
activeColor: 'text-amber-600 dark:text-amber-500',
spin: true
},
{
@ -526,8 +526,8 @@ export default function DocumentManager() {
label: t('documentPanel.documentManager.status.failed'),
icon: AlertCircle,
count: failedCount,
color: "text-red-500",
activeColor: "text-red-600 dark:text-red-500"
color: 'text-red-500',
activeColor: 'text-red-600 dark:text-red-500'
}
], [t, statusCounts, documentCounts, processedCount, preprocessedCount, processingCount, pendingCount, failedCount]);
@ -941,63 +941,6 @@ export default function DocumentManager() {
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'),
preprocessed: response.documents.filter(doc => doc.status === 'preprocessed'),
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
@ -1193,27 +1136,27 @@ export default function DocumentManager() {
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",
'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"
? '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")}>
<span className={cn('text-xs font-medium', isActive ? 'text-foreground' : 'text-muted-foreground')}>
{item.label}
</span>
{showIcon && (
<Icon
className={cn(
"h-4 w-4",
'h-4 w-4',
isActive ? item.activeColor : item.color,
item.spin && item.count > 0 && "animate-spin"
item.spin && item.count > 0 && 'animate-spin'
)}
/>
)}
</div>
<div className={cn("text-2xl font-bold", isActive ? "text-foreground" : "text-muted-foreground/80")}>
<div className={cn('text-2xl font-bold', isActive ? 'text-foreground' : 'text-muted-foreground/80')}>
{item.count}
</div>
</div>
@ -1266,10 +1209,10 @@ export default function DocumentManager() {
size="sm"
onClick={() => setShowFileName(!showFileName)}
className={cn(
"transition-all border",
'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"
? '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')}

View file

@ -62,6 +62,41 @@ const createSigmaSettings = (isDarkTheme: boolean): Partial<SigmaSettings> => ({
// labelFont: 'Lato, sans-serif'
})
// Keep focus logic isolated to avoid re-rendering the whole viewer during hover/selection churn
const FocusSync = () => {
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
const autoFocusedNode = useMemo(() => focusedNode ?? selectedNode, [focusedNode, selectedNode])
return <FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
}
// Keep GraphSearch value derivation local to avoid bubbling re-renders
const GraphSearchWithSelection = ({
onFocus,
onSelect
}: {
onFocus: (value: GraphSearchOption | null) => void
onSelect: (value: GraphSearchOption | null) => void
}) => {
const selectedNode = useGraphStore.use.selectedNode()
const searchInitSelectedNode = useMemo(
(): OptionItem | null => (selectedNode ? { type: 'nodes', id: selectedNode } : null),
[selectedNode]
)
return (
<GraphSearch
value={searchInitSelectedNode}
onFocus={onFocus}
onChange={onSelect}
/>
)
}
const GraphEvents = () => {
const registerEvents = useRegisterEvents()
const sigma = useSigma()
@ -113,9 +148,6 @@ const GraphViewer = () => {
const sigmaRef = useRef<any>(null)
const prevTheme = useRef<string>('')
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
const isFetching = useGraphStore.use.isFetching()
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
@ -186,12 +218,6 @@ const GraphViewer = () => {
}
}, [])
const autoFocusedNode = useMemo(() => focusedNode ?? selectedNode, [focusedNode, selectedNode])
const searchInitSelectedNode = useMemo(
(): OptionItem | null => (selectedNode ? { type: 'nodes', id: selectedNode } : null),
[selectedNode]
)
// Always render SigmaContainer but control its visibility with CSS
return (
<div className="relative h-full w-full overflow-hidden">
@ -204,15 +230,14 @@ const GraphViewer = () => {
{enableNodeDrag && <GraphEvents />}
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
<FocusSync />
<div className="absolute top-2 left-2 flex items-start gap-2">
<GraphLabels />
{showNodeSearchBar && !isThemeSwitching && (
<GraphSearch
value={searchInitSelectedNode}
<GraphSearchWithSelection
onFocus={onSearchFocus}
onChange={onSearchSelect}
onSelect={onSearchSelect}
/>
)}
</div>

View file

@ -145,9 +145,9 @@ const LoginPage = () => {
}
return (
<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="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-fuchsia-50 to-purple-100 dark:from-gray-800 dark:to-gray-700">
<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-700/30 backdrop-blur-sm rounded-md" />
</div>
<Card className="w-full max-w-[480px] shadow-lg mx-4">
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">

View file

@ -8,7 +8,7 @@ import { useAuthStore } from '@/stores/state'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { navigationService } from '@/services/navigation'
import { ZapIcon, LogOutIcon, BrainIcon } from 'lucide-react'
import { LogOutIcon, BrainIcon } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
interface NavigationTabProps {
@ -31,7 +31,7 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
)
}
function shouldShowTableExplorer(storageConfig: any) {
function shouldShowTableExplorer() {
// Always show for now - TODO: fix storageConfig state propagation from health check
return true
@ -48,7 +48,6 @@ function shouldShowTableExplorer(storageConfig: any) {
function TabsNavigation() {
const currentTab = useSettingsStore.use.currentTab()
const storageConfig = useSettingsStore.use.storageConfig()
const { t } = useTranslation()
return (
@ -66,7 +65,7 @@ function TabsNavigation() {
<NavigationTab value="api" currentTab={currentTab}>
{t('header.api')}
</NavigationTab>
{shouldShowTableExplorer(storageConfig) && (
{shouldShowTableExplorer() && (
<NavigationTab value="table-explorer" currentTab={currentTab}>
{t('header.tables')}
</NavigationTab>

View file

@ -10,6 +10,8 @@ import Button from '@/components/ui/Button'
import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon, CopyIcon, CheckIcon } from 'lucide-react'
import { toast } from 'sonner'
const HIDDEN_COLUMNS = ['meta']
// Truncate long values for display
function truncateValue(value: any, maxLength = 50): string {
if (value === null || value === undefined) return ''
@ -116,9 +118,7 @@ function RowDetailModal({
open: boolean
onOpenChange: (open: boolean) => void
}) {
if (!row) return null
const entries = Object.entries(row)
const entries = useMemo(() => (row ? Object.entries(row) : []), [row])
const fullRowJson = useMemo(() => {
try {
return JSON.stringify(row, null, 2)
@ -127,6 +127,8 @@ function RowDetailModal({
}
}, [row])
if (!row) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
@ -208,9 +210,6 @@ 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<ColumnDef<any>[]>(() => {
const cols: ColumnDef<any>[] = []
@ -260,7 +259,7 @@ export default function TableExplorer() {
<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"} />
<SelectValue placeholder={tableList && tableList.length > 0 ? 'Select a table' : 'No tables available'} />
</SelectTrigger>
<SelectContent>
{tableList && tableList.length > 0 ? (
@ -284,39 +283,39 @@ export default function TableExplorer() {
</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>
<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>
)}
{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">

View file

@ -461,7 +461,7 @@ const useLightrangeGraph = () => {
state.setLastSuccessfulQueryLabel('') // Clear last successful query label on error
})
}
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t, graphDataVersion])
}, [queryLabel, maxQueryDepth, maxNodes, minDegree, includeOrphans, isFetching, t, graphDataVersion])
// Handle node expansion
useEffect(() => {

View file

@ -47,40 +47,40 @@
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
--background: hsl(220 8% 12%);
--foreground: hsl(0 0% 95%);
--card: hsl(220 8% 16%);
--card-foreground: hsl(0 0% 96%);
--popover: hsl(220 8% 16%);
--popover-foreground: hsl(0 0% 96%);
--primary: hsl(0 0% 96%);
--primary-foreground: hsl(220 8% 14%);
--secondary: hsl(220 7% 22%);
--secondary-foreground: hsl(0 0% 96%);
--muted: hsl(220 6% 26%);
--muted-foreground: hsl(220 12% 72%);
--accent: hsl(220 7% 24%);
--accent-foreground: hsl(0 0% 96%);
--destructive: hsl(0 65% 52%);
--destructive-foreground: hsl(0 0% 96%);
--border: hsl(220 6% 30%);
--input: hsl(220 6% 30%);
--ring: hsl(220 15% 65%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--plum: #9d5ba3;
--plum-foreground: hsl(0 0% 98%);
--sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--plum: #b06ab6;
--plum-foreground: hsl(0 0% 96%);
--sidebar-background: hsl(220 8% 13%);
--sidebar-foreground: hsl(0 0% 94%);
--sidebar-primary: hsl(224.3 70% 62%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(220 7% 21%);
--sidebar-accent-foreground: hsl(0 0% 94%);
--sidebar-border: hsl(220 7% 28%);
--sidebar-ring: hsl(220 15% 62%);
}
@theme inline {
@ -152,11 +152,11 @@
.dark {
::-webkit-scrollbar-thumb {
background-color: hsl(0 0% 90%);
background-color: hsl(0 0% 70%);
}
::-webkit-scrollbar-track {
background-color: hsl(0 0% 0%);
background-color: hsl(220 8% 18%);
}
}