import { useState, useEffect, useCallback, useRef } from 'react' import { useParams } from 'react-router-dom' import { Box, Button, Tooltip, CircularProgress, ToggleButtonGroup, ToggleButton, useMediaQuery, } from '@mui/material' import { useTheme } from '@mui/material' import { Add, Delete, Timer, Fingerprint, Refresh } from '@mui/icons-material' import { useI18nStore } from '../../stores/i18n.store' import { useRedisStateStore } from '../../stores/redis-state.store' import { useSettingsStore } from '../../stores/settings.store' import { useCommonStore } from '../../stores/common.store' import { useMainCommandStore, onCommandEvent } from '../../stores/main-command.store' import { useThemeStore } from '../../stores/theme.store' import { request } from '../../stores/socket.service' import { navigateTo } from '../../stores/navigation.store' import { isDarkTheme } from '../../themes' import humanizeDuration from 'humanize-duration' import KeyString from './key/KeyString' import KeyList from './key/KeyList' import KeyHash from './key/KeyHash' import KeySet from './key/KeySet' import KeyZset from './key/KeyZset' import KeyStream from './key/KeyStream' import KeyJson from './key/KeyJson' import KeyTimeseries from './key/KeyTimeseries' import TtlDialog from '../../dialogs/TtlDialog' const TextDecoder_ = typeof TextDecoder !== 'undefined' ? TextDecoder : class { decode(b: any) { return String(b) } } function decodeValueBuffer(response: any, jsonFormat: number): void { const td = new TextDecoder_() const { type, valueBuffer } = response switch (type) { case 'string': response.value = td.decode(valueBuffer) break case 'list': case 'set': response.value = valueBuffer.map((buf: any) => td.decode(buf)) break case 'hash': response.value = {} Object.entries(valueBuffer).forEach(([key, buf]: [string, any]) => { response.value[key] = td.decode(buf) }) break case 'zset': response.value = [] for (let i = 0; i < valueBuffer.length; i += 2) { response.value.push(td.decode(valueBuffer[i])) response.value.push(td.decode(valueBuffer[i + 1])) } break case 'json': { const rawJson = td.decode(valueBuffer) try { const parsed = JSON.parse(rawJson) const unwrapped = Array.isArray(parsed) ? parsed[0] : parsed response.value = JSON.stringify(unwrapped, null, jsonFormat ?? 2) } catch { response.value = rawJson } break } case 'stream': { const decodeEntry = (entry: any): any => { return entry.map((item: any) => { if (Array.isArray(item)) return decodeEntry(item) if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer) return td.decode(item) return item }) } response.value = valueBuffer.map((entry: any) => decodeEntry(entry)) break } case 'timeseries': try { response.value = JSON.parse(td.decode(valueBuffer)) } catch { response.value = {} } break } } function calculateSize(response: any): void { response.size = 0 if (response.type !== 'stream') { if (typeof response.valueBuffer === 'object' && !Array.isArray(response.valueBuffer) && response.length > 0) { for (const k of Object.keys(response.valueBuffer)) response.size += response.valueBuffer[k]?.byteLength ?? 0 } else if (Array.isArray(response.valueBuffer)) { for (const buf of response.valueBuffer) response.size += buf?.byteLength ?? 0 } else if (response.valueBuffer) { response.size = response.valueBuffer.byteLength ?? 0 } } else { const sumBytes = (arr: any[]): number => { let total = 0 const process = (el: any) => { if (ArrayBuffer.isView(el) || el instanceof ArrayBuffer) total += el.byteLength else if (Array.isArray(el)) el.forEach(process) } arr.forEach(process) return total } response.size = sumBytes(response.valueBuffer ?? []) } } export default function DatabaseKeyPage() { const { key: rawKey } = useParams() const key = decodeURIComponent(rawKey ?? '') const strings = useI18nStore(s => s.strings) const connection = useRedisStateStore(s => s.connection) const themeKey = useThemeStore(s => s.themeKey) const jsonFormat = useSettingsStore(s => s.jsonFormat) const muiTheme = useTheme() const { confirm, toast, generalHandleError } = useCommonStore() const isGtSm = useMediaQuery('(min-width: 960px)') const isReadonly = connection?.readonly === true const isDark = muiTheme.palette.mode === 'dark' const [loading, setLoading] = useState(true) const [response, setResponse] = useState(null) const [valueFormat, setValueFormat] = useState<'raw' | 'json' | 'hex' | 'base64'>('raw') const [ttlDialogOpen, setTtlDialogOpen] = useState(false) const ttlIntervalRef = useRef(null) const wasExpiringRef = useRef(false) // Border color matching Angular SCSS const borderColor = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)' const hoverBg = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' // Highlight the selected key in the tree useEffect(() => { const dark = isDarkTheme(themeKey) const bg = dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)' const color = dark ? 'white' : 'black' const style = document.createElement('style') style.id = 'p3xr-theme-styles-tree-key' style.textContent = `[data-p3xr-tree-key="${key.replace(/"/g, '\\"')}"] .p3xr-database-tree-node-label { background-color: ${bg} !important; color: ${color} !important; padding: 2px; }` document.getElementById('p3xr-theme-styles-tree-key')?.remove() document.head.appendChild(style) return () => { document.getElementById('p3xr-theme-styles-tree-key')?.remove() } }, [key, themeKey]) // Load key data const loadKey = useCallback(async () => { clearInterval(ttlIntervalRef.current) setLoading(true) try { const resp = await request({ action: 'key-get', payload: { key } }) if (resp.ttl === -2) { toast(strings?.status?.keyIsNotExisting) navigateTo('database.statistics') return } resp.size = 0 decodeValueBuffer(resp, jsonFormat ? 2 : 0) calculateSize(resp) if (resp.ttl > -1) wasExpiringRef.current = true setResponse(resp) if (resp.ttl > -1) { ttlIntervalRef.current = setInterval(() => { setResponse((prev: any) => { if (!prev) return prev const newTtl = prev.ttl - 1 if (newTtl < -1 || (wasExpiringRef.current && newTtl < 1)) { clearInterval(ttlIntervalRef.current) toast(strings?.status?.keyIsNotExisting) useRedisStateStore.setState({ redisChanged: true }) navigateTo('database.statistics') return prev } return { ...prev, ttl: newTtl } }) }, 1000) } } catch (e: any) { console.error(e) if (e?.message === 'Connection is closed.') { useRedisStateStore.setState({ connection: undefined }) useCommonStore.getState().confirm({ message: e.message, disableCancel: true }).catch(() => {}) } else { const fn = strings?.label?.unableToLoadKey const msg = typeof fn === 'function' ? fn({ key }) : String(e) useCommonStore.getState().confirm({ message: msg, disableCancel: true }).catch(() => {}) } navigateTo('database.statistics') } finally { setLoading(false) } }, [key, strings, jsonFormat, toast]) useEffect(() => { loadKey(); return () => clearInterval(ttlIntervalRef.current) }, [key]) useEffect(() => { return onCommandEvent('refresh-key', () => loadKey()) }, [loadKey]) // --- Actions --- const addKey = useCallback((e: React.MouseEvent) => { e.stopPropagation() useMainCommandStore.getState().addKey({ event: e.nativeEvent, node: { key } }) }, [key]) const deleteKey = useCallback(async (e: React.MouseEvent) => { 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 useMainCommandStore.getState().refresh({ withoutParent: false, force: true }) } catch (err) { generalHandleError(err) } }, [key, strings, confirm, toast, generalHandleError]) const renameKey = useCallback(async (e: React.MouseEvent) => { e.stopPropagation() try { const newKey = await useCommonStore.getState().prompt({ title: strings?.confirm?.rename?.title, placeholder: strings?.confirm?.rename?.placeholder, initialValue: key, okLabel: strings?.intention?.rename, cancelLabel: strings?.intention?.cancel, }) await request({ action: 'rename', payload: { key, keyNew: newKey } }) navigateTo('database.key', { key: newKey }) toast(strings?.status?.renamedKey) await useMainCommandStore.getState().refresh({ withoutParent: false, force: true }) } catch (err) { generalHandleError(err) } }, [key, strings, toast, generalHandleError]) const setTtl = useCallback((e: React.MouseEvent) => { e.stopPropagation() setTtlDialogOpen(true) }, []) const handleTtlClose = useCallback(async (result?: { model: { ttl: number } }) => { setTtlDialogOpen(false) if (!result) return try { const ttlVal = result.model.ttl const ttlStr = String(ttlVal).trim() if (ttlStr === '' || ttlVal == null) { await request({ action: 'persist', payload: { key } }) toast(strings?.status?.persisted) } else if (!/^-?\d+$/.test(ttlStr)) { toast(strings?.status?.notInteger) return } else { await request({ action: 'expire', payload: { key, ttl: parseInt(ttlStr) } }) toast(strings?.status?.ttlChanged) } await loadKey() } catch (err) { generalHandleError(err) } }, [key, strings, toast, loadKey, generalHandleError]) const refreshKey = useCallback(async () => { await loadKey() }, [loadKey]) // --- Responsive button --- const ActionBtn = ({ icon, label, color, onClick }: { icon: React.ReactNode; label: string; color: 'primary' | 'secondary' | 'error'; onClick: (e: React.MouseEvent) => void }) => isGtSm ? ( ) : ( ) // --- Render --- if (loading) { return ( ) } if (!response) return null const hdOpts = useSettingsStore.getState().getHumanizeDurationOptions() return ( {/* Action buttons — right-aligned, matching .p3xr-database-key-actions */} {!isReadonly && ( <> } label={strings?.intention?.addKey} color="secondary" onClick={addKey} /> } label={strings?.intention?.delete} color="error" onClick={deleteKey} /> } label={strings?.intention?.ttl} color="primary" onClick={setTtl} /> } label={strings?.intention?.rename} color="primary" onClick={renameKey} /> )} } label={strings?.intention?.reloadKey} color="secondary" onClick={refreshKey} /> {/* Key info — matching .p3xr-database-key-info */} {/* Key name — clickable */} {strings?.page?.key?.label?.key}: {key} {/* TTL — clickable */} {strings?.page?.key?.label?.ttl}: {response.ttl === -1 ? ( {strings?.page?.key?.label?.ttlNotExpire} ) : ( {response.ttl} {humanizeDuration(response.ttl * 1000, { ...hdOpts, delimiter: ' ' })} )} {/* Type */} {strings?.page?.key?.label?.type}: {strings?.redisTypes?.[response.type]} {/* Encoding */} {strings?.page?.key?.label?.encoding}: {response.encoding} {/* Compression */} {response.compression && ( {strings?.page?.key?.label?.compression}: {response.compression.algorithm.toUpperCase()} = 0 ? 'success.main' : 'error.main', color: response.compression.ratio >= 0 ? 'success.contrastText' : 'error.contrastText', }}> {response.compression.ratio >= 0 ? '' : '-'}{Math.abs(response.compression.ratio)}% )} {/* Length/Size */} {strings?.page?.key?.label?.length}: {response.size >= 1024 ? `(${useSettingsStore.getState().prettyBytes(response.size)})` : ''}  {response.size} {strings?.page?.key?.label?.lengthString} {response.length ? , {response.length} {strings?.page?.key?.label?.lengthItem} : null} {/* Format toggle */} {response.type !== 'timeseries' && response.type !== 'json' && ( {strings?.label?.format}: v && setValueFormat(v)} sx={{ borderRadius: '4px', overflow: 'hidden', '& .MuiToggleButton-root': { height: 32, fontSize: 13, px: 1.5, borderRadius: '0 !important', textTransform: 'none', bgcolor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', }, '& .MuiToggleButton-root.Mui-selected': { bgcolor: `${muiTheme.p3xr.matSysPrimary}40 !important`, color: muiTheme.p3xr.matSysPrimary, }, '& .MuiToggleButton-root:first-of-type': { borderRadius: '4px 0 0 4px !important' }, '& .MuiToggleButton-root:last-of-type': { borderRadius: '0 4px 4px 0 !important' }, }}> Raw JSON Hex Base64 )} {/* Type-specific renderer */} {response.type === 'string' && ( )} {response.type === 'hash' && ( )} {response.type === 'zset' && ( )} {response.type === 'set' && ( )} {response.type === 'list' && ( )} {response.type === 'stream' && ( )} {response.type === 'json' && ( )} {response.type === 'timeseries' && ( )} {response.type !== 'string' && response.type !== 'hash' && response.type !== 'set' && response.type !== 'zset' && response.type !== 'list' && response.type !== 'stream' && response.type !== 'json' && response.type !== 'timeseries' && ( {typeof response.value === 'string' ? response.value : JSON.stringify(response.value, null, 2)} )} ) }