import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Box, Tooltip, useTheme } from '@mui/material' import { Schedule, Delete, Add } from '@mui/icons-material' import { useI18nStore } from '../../stores/i18n.store' import { useRedisStateStore, getPaginatedKeys } from '../../stores/redis-state.store' import { useSettingsStore } from '../../stores/settings.store' import { useCommonStore, onTreeEvent, emitTreeEvent } from '../../stores/common.store' import { useMainCommandStore, onCommandEvent } from '../../stores/main-command.store' import { request } from '../../stores/socket.service' import { navigateTo } from '../../stores/navigation.store' import { keysToTreeControl } from '../../stores/tree-builder' import KeyNewOrSetDialog from '../../dialogs/KeyNewOrSetDialog' import humanizeDuration from 'humanize-duration' const ROW_HEIGHT = 28 const INDENT_PX = 20 export interface FlatTreeNode { label: string key: string level: number expandable: boolean type: 'folder' | 'element' childCount: number keysInfo?: { type: string; length: number; ttl?: number } _sourceNode?: any } // Type icon map const typeIcons: Record = { hash: 'fas fa-hashtag', list: 'fas fa-list-ol', set: 'fas fa-list', string: 'fas fa-ellipsis-h', zset: 'fas fa-chart-line', stream: 'fas fa-stream', json: 'fas fa-code', timeseries: 'fas fa-chart-area', } export default function DatabaseTree({ resizeSignal }: { resizeSignal?: any }) { const strings = useI18nStore(s => s.strings) const keysRaw = useRedisStateStore(s => s.keysRaw) const keysInfo = useRedisStateStore(s => s.keysInfo) const page = useRedisStateStore(s => s.page) const search = useRedisStateStore(s => s.search) const connection = useRedisStateStore(s => s.connection) const redisTreeDivider = useSettingsStore(s => s.redisTreeDivider) const muiTheme = useTheme() const { confirm, toast, generalHandleError } = useCommonStore() const { refresh } = useMainCommandStore() const isReadonly = connection?.readonly === true const divider = redisTreeDivider || ':' const [expandedKeys, setExpandedKeys] = useState>(new Set()) const [keyNewDialogOpen, setKeyNewDialogOpen] = useState(false) const [keyNewDialogData, setKeyNewDialogData] = useState(null) const [hierarchicalNodes, setHierarchicalNodes] = useState([]) const [, setTick] = useState(0) // for TTL repaints const parentRef = useRef(null) // Build tree when keys, divider, or page change useEffect(() => { const paginatedKeys = getPaginatedKeys() keysToTreeControl({ keys: paginatedKeys, divider, keysInfo: keysInfo ?? {}, }).then(({ nodes }) => { setHierarchicalNodes(nodes) }) }, [keysRaw, keysInfo, divider, page, search]) // Flatten visible nodes const dataSource = useMemo(() => { const result: FlatTreeNode[] = [] const flatten = (nodes: any[], level: number) => { for (const node of nodes) { result.push({ label: node.label, key: node.key, level, expandable: node.type === 'folder', type: node.type, childCount: node.childCount ?? 0, keysInfo: node.keysInfo, _sourceNode: node, }) if (node.type === 'folder' && expandedKeys.has(node.key) && node.children?.length > 0) { flatten(node.children, level + 1) } } } flatten(hierarchicalNodes, 0) return result }, [hierarchicalNodes, expandedKeys]) // Virtual scrolling const virtualizer = useVirtualizer({ count: dataSource.length, getScrollElement: () => parentRef.current, estimateSize: () => ROW_HEIGHT, overscan: 10, }) // Re-measure when container resizes (console expand/collapse) useEffect(() => { virtualizer.measure() }, [resizeSignal]) // Toggle expand/collapse const toggleExpand = useCallback((key: string) => { setExpandedKeys(prev => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) }, []) // Select a key node const selectNode = useCallback((node: FlatTreeNode) => { navigateTo('database.key', { key: node.key }) }, []) // Listen for key-new events from add buttons useEffect(() => { return onCommandEvent('key-new', (data: any) => { setKeyNewDialogData({ type: 'add', node: data?.node }) setKeyNewDialogOpen(true) }) }, []) const handleKeyNewClose = useCallback(async (result?: any) => { setKeyNewDialogOpen(false) setKeyNewDialogData(null) if (result?.key) { await useMainCommandStore.getState().refresh({ withoutParent: false, force: true }) navigateTo('database.key', { key: result.key }) } }, []) // Tree command event subscriptions useEffect(() => { const unsubs = [ onTreeEvent('expand-all', () => { const allKeys = new Set() const collect = (nodes: any[]) => { for (const node of nodes) { if (node.type === 'folder') { allKeys.add(node.key) collect(node.children ?? []) } } } collect(hierarchicalNodes) setExpandedKeys(allKeys) }), onTreeEvent('collapse-all', () => { setExpandedKeys(new Set()) }), ...[1, 2, 3, 4, 5].map(level => onTreeEvent(`expand-level-${level}`, () => { const keys = new Set() const collect = (nodes: any[], depth: number) => { for (const node of nodes) { if (node.type === 'folder') { if (depth < level) keys.add(node.key) collect(node.children ?? [], depth + 1) } } } collect(hierarchicalNodes, 0) setExpandedKeys(keys) }) ), ] return () => unsubs.forEach(fn => fn()) }, [hierarchicalNodes]) // TTL adaptive repaint useEffect(() => { let timer: any const tick = () => { let minTtl = Infinity let hasExpired = false const fetchedAt = useRedisStateStore.getState().keysInfoFetchedAt ?? Date.now() const now = Date.now() for (const node of dataSource) { if (node.type === 'folder') continue const serverTtl = node.keysInfo?.ttl if (!serverTtl || serverTtl <= 0) continue const elapsed = Math.floor((now - fetchedAt) / 1000) const remaining = serverTtl - elapsed if (remaining <= 0) hasExpired = true else if (remaining < minTtl) minTtl = remaining } if (hasExpired) { refresh() timer = setTimeout(tick, 3000) return } setTick(t => t + 1) let interval: number if (minTtl <= 30) interval = 1000 else if (minTtl <= 300) interval = 5000 else interval = 30000 timer = setTimeout(tick, interval) } timer = setTimeout(tick, 1000) return () => clearTimeout(timer) }, [dataSource]) // --- TTL helpers --- const getRemainingTtl = useCallback((node: FlatTreeNode): number => { const ttl = node.keysInfo?.ttl if (!ttl || ttl <= 0) return -1 const fetchedAt = useRedisStateStore.getState().keysInfoFetchedAt ?? Date.now() const elapsed = Math.floor((Date.now() - fetchedAt) / 1000) const remaining = ttl - elapsed return remaining > 0 ? remaining : -1 }, []) const formatTtl = useCallback((node: FlatTreeNode): string => { const remaining = getRemainingTtl(node) if (remaining <= 0) return '' const hdOpts = useSettingsStore.getState().getHumanizeDurationOptions() return humanizeDuration(remaining * 1000, { ...hdOpts, largest: 2, round: true, delimiter: ' ', }) }, [getRemainingTtl]) const getTtlColor = useCallback((node: FlatTreeNode): string => { const remaining = getRemainingTtl(node) if (remaining <= 0) return '' if (remaining < 300) return '#f44336' // red if (remaining < 3600) return '#ff9800' // yellow/orange return '#4caf50' // green }, [getRemainingTtl]) const isTtlPulsing = useCallback((node: FlatTreeNode): boolean => { return getRemainingTtl(node) > 0 && getRemainingTtl(node) < 30 }, [getRemainingTtl]) // --- Node actions --- const deleteKey = useCallback(async (e: React.MouseEvent, key: string) => { e.preventDefault() e.stopPropagation() try { await confirm({ message: strings?.confirm?.deleteKey }) await request({ action: 'delete', payload: { key } }) navigateTo('database.statistics') toast(typeof strings?.status?.deletedKey === 'function' ? strings.status.deletedKey({ key }) : '') await refresh() } catch (err) { generalHandleError(err) } }, [strings, confirm, toast, refresh, generalHandleError]) const deleteTree = useCallback(async (e: React.MouseEvent, node: FlatTreeNode) => { e.stopPropagation() try { const msg = typeof strings?.confirm?.deleteAllKeys === 'function' ? strings.confirm.deleteAllKeys({ key: node.key }) : '' await confirm({ message: msg }) await request({ action: 'key-del-tree', payload: { key: node.key, redisTreeDivider: divider }, }) const toastMsg = typeof strings?.status?.treeDeleted === 'function' ? strings.status.treeDeleted({ key: node.key }) : '' toast(toastMsg) await refresh() } catch (err) { generalHandleError(err) } }, [strings, divider, confirm, toast, refresh, generalHandleError]) const addKey = useCallback((e: React.MouseEvent, node: FlatTreeNode) => { e.stopPropagation() useMainCommandStore.getState().addKey({ event: e.nativeEvent, node: node._sourceNode ?? { key: node.key } }) }, []) // --- Tooltip helpers --- const nodeTooltip = useCallback((node: FlatTreeNode): string => { if (node.type !== 'folder' && node.keysInfo) { const typeName = strings?.redisTypes?.[node.keysInfo.type] ?? node.keysInfo.type return typeName + ' - ' + node.key } return node.key }, [strings]) const dialogEl = if (dataSource.length === 0) { return ( {strings?.label?.noKeys} {dialogEl} ) } return ( {virtualizer.getVirtualItems().map(virtualRow => { const node = dataSource[virtualRow.index] const remaining = getRemainingTtl(node) const ttlColor = getTtlColor(node) const pulsing = isTtlPulsing(node) return ( {/* Folder icon (no spacer for elements — matches Angular) */} {node.expandable && ( { e.stopPropagation(); toggleExpand(node.key) }} sx={{ display: 'inline-block', fontFamily: "'Font Awesome 5 Free'", fontWeight: 900, fontSize: 24, lineHeight: '28px', width: 28, textAlign: 'center', mr: '4px', cursor: 'pointer', color: muiTheme.p3xr.treeBranchColor, '&::before': { content: expandedKeys.has(node.key) ? '"\\f07c"' : '"\\f07b"', }, }} /> )} {/* Node label area */} node.expandable ? toggleExpand(node.key) : selectNode(node)} sx={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', height: ROW_HEIGHT, whiteSpace: 'nowrap', }} > {/* Type icon — same box size as folder icon for alignment */} {node.type !== 'folder' && node.keysInfo && typeIcons[node.keysInfo.type] && (