import { useState, useEffect, useRef } from 'react' import { TextField, Select, MenuItem, FormControl, InputLabel, Button, Tooltip, Switch, FormControlLabel, Box, useMediaQuery, } from '@mui/material' import { Add, Edit, Upload, Description, FormatLineSpacing, AccountTree, ContentCopy, AutoGraph, } from '@mui/icons-material' import { Cancel } 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 { trackPage } from '../stores/analytics' import { useOverlayStore } from '../stores/overlay.store' import { request } from '../stores/socket.service' import P3xrDialog from '../components/P3xrDialog' import JsonViewDialog from './JsonViewDialog' import DiffDialog from './DiffDialog' import JsonEditorDialog from './JsonEditorDialog' export interface KeyNewOrSetData { type: 'add' | 'edit' | 'append' node?: any model?: any } interface KeyModel { type: string key: string value: any score: string streamTimestamp: string tsTimestamp: string tsRetention: number tsDuplicatePolicy: string tsLabels: string tsBulkMode: boolean tsSpread: number tsFormula: string tsFormulaPoints: number tsFormulaAmplitude: number tsFormulaOffset: number tsEditAll: boolean hashKey: string index: string bloomErrorRate: number bloomCapacity: number cuckooCapacity: number topkK: number topkWidth: number topkDepth: number topkDecay: number cmsWidth: number cmsDepth: number tdigestCompression: number vectorElement: string vectorValues: string } interface Props { open: boolean data: KeyNewOrSetData | null onClose: (result?: any) => void } export default function KeyNewOrSetDialog({ open, data, onClose }: Props) { const strings = useI18nStore(s => s.strings) const hasTimeSeries = useRedisStateStore(s => s.hasTimeSeries) const hasReJSON = useRedisStateStore(s => s.hasReJSON) const hasBloom = useRedisStateStore(s => s.hasBloom) const connection = useRedisStateStore(s => s.connection) const settings = useSettingsStore() const { toast, generalHandleError } = useCommonStore() const overlay = useOverlayStore() const isWide = useMediaQuery('(min-width: 720px)') const fileInputRef = useRef(null) const isReadonly = connection?.readonly === true const [validateJson, setValidateJson] = useState(false) const [jsonViewOpen, setJsonViewOpen] = useState(false) const [jsonEditorOpen, setJsonEditorOpen] = useState(false) const [model, setModel] = useState({ type: 'string', key: '', value: '', score: '', streamTimestamp: '*', tsTimestamp: '*', tsRetention: 0, tsDuplicatePolicy: 'LAST', tsLabels: '', tsBulkMode: false, tsSpread: 60000, tsFormula: '', tsFormulaPoints: 25, tsFormulaAmplitude: 100, tsFormulaOffset: 0, tsEditAll: false, hashKey: '', index: '', bloomErrorRate: 0.01, bloomCapacity: 100, cuckooCapacity: 1024, topkK: 10, topkWidth: 2000, topkDepth: 7, topkDecay: 0.9, cmsWidth: 2000, cmsDepth: 7, tdigestCompression: 100, vectorElement: '', vectorValues: '', }) const isProbabilistic = ['bloom', 'cuckoo', 'topk', 'cms', 'tdigest'].includes(model.type) const isVectorset = model.type === 'vectorset' const types = (() => { const base = ['string', 'list', 'hash', 'set', 'zset', 'stream'] if (hasTimeSeries) base.push('timeseries') if (hasReJSON) base.push('json') if (hasBloom) base.push('bloom', 'cuckoo', 'topk', 'cms', 'tdigest') base.push('vectorset') return base })() useEffect(() => { if (!open || !data) return const divider = settings.redisTreeDivider const m: KeyModel = { type: 'string', key: data.node?.key ? data.node.key + divider : '', value: '', score: '', streamTimestamp: '*', tsTimestamp: '*', tsRetention: 0, tsDuplicatePolicy: 'LAST', tsLabels: '', tsBulkMode: false, tsSpread: 60000, tsFormula: '', tsFormulaPoints: 25, tsFormulaAmplitude: 100, tsFormulaOffset: 0, tsEditAll: false, hashKey: '', index: '', } if (data.model) Object.assign(m, data.model) setModel(m) setValidateJson(false) }, [open, data]) const set = (field: keyof KeyModel, value: any) => setModel(m => ({ ...m, [field]: value })) const getTitle = () => { if (data?.type === 'edit') return strings?.form?.key?.label?.formName?.edit if (data?.type === 'append') return strings?.form?.key?.label?.formName?.append return strings?.form?.key?.label?.formName?.add } const copy = async () => { let value = model.value if (model.type === 'timeseries') value = `TS.ADD ${model.key} ${model.tsTimestamp} ${model.value}` try { await navigator.clipboard.writeText(String(value)) } catch {} toast(strings?.status?.dataCopied) } const formatJson = () => { try { set('value', JSON.stringify(JSON.parse(model.value), null, settings.jsonFormat || 2)) } catch { toast(strings?.label?.jsonViewNotParsable) } } const onFileSelected = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return try { await useCommonStore.getState().confirm({ message: strings?.confirm?.uploadBuffer }) const buf = await file.arrayBuffer() set('value', buf) toast(strings?.confirm?.uploadBufferDone) } catch {} event.target.value = '' } const generateFormula = () => { const points = Math.min(Math.max(parseInt(String(model.tsFormulaPoints)) || 25, 1), 10000) const amplitude = parseFloat(String(model.tsFormulaAmplitude)) || 100 const offset = parseFloat(String(model.tsFormulaOffset)) || 0 const formula = model.tsFormula const lines: string[] = [] for (let i = 0; i < points; i++) { const x = i / points let v: number switch (formula) { case 'sin': v = Math.sin(x * Math.PI * 2) * amplitude + offset; break case 'cos': v = Math.cos(x * Math.PI * 2) * amplitude + offset; break case 'linear': v = x * amplitude + offset; break case 'random': v = Math.random() * amplitude + offset; break case 'sawtooth': v = (x % 0.25) * 4 * amplitude + offset; break default: v = offset } lines.push(`* ${parseFloat(v.toFixed(4))}`) } set('value', lines.join('\n')) } const [diffOpen, setDiffOpen] = useState(false) const [diffData, setDiffData] = useState({ oldValue: '', newValue: '', fieldName: '' }) const diffResolveRef = useRef<((v: boolean) => void) | null>(null) const submit = async () => { if (!model.key?.trim()) { toast(strings?.form?.key?.error?.key); return } if (validateJson) { try { JSON.parse(model.value) } catch { toast(strings?.label?.jsonViewNotParsable); return } } // Show diff for edits (not new keys) if (data?.model?.value !== undefined && data.model.value !== model.value) { const settings = useSettingsStore.getState() if (settings.showDiffBeforeSave) { setDiffData({ oldValue: String(data.model.value), newValue: String(model.value), fieldName: model.hashKey || '' }) setDiffOpen(true) const confirmed = await new Promise(resolve => { diffResolveRef.current = resolve }) if (!confirmed) return } } try { overlay.show({ message: strings?.label?.saving }) const response = await request({ action: 'key/new-or-set', payload: { type: data?.type, originalValue: data?.model?.value, originalHashKey: data?.model?.hashKey, model: structuredClone(model), }, }) trackPage('/key-new-or-set') toast(strings?.status?.set) onClose(response) } catch (e) { generalHandleError(e) } finally { overlay.hide() } } if (!open || !data) return null const isAdd = data.type === 'add' return ( <> onClose()} title={getTitle()} actions={ <> {!isReadonly && ( )} }> {/* Key */} set('key', e.target.value)} disabled={!isAdd} /> {/* Type */} {strings?.form?.key?.field?.type} {/* Type-specific fields */} {model.type === 'list' && ( <> set('index', e.target.value)} /> {strings?.label?.redisListIndexInfo} )} {model.type === 'hash' && ( set('hashKey', e.target.value)} /> )} {model.type === 'zset' && ( set('score', e.target.value)} /> )} {model.type === 'stream' && ( <> set('streamTimestamp', e.target.value)} /> {strings?.label?.streamTimestampId} )} {model.type === 'timeseries' && isAdd && ( <> set('tsRetention', e.target.value)} helperText={strings?.page?.key?.timeseries?.retentionHint} /> {strings?.page?.key?.timeseries?.duplicatePolicy} )} {model.type === 'timeseries' && ( <> set('tsLabels', e.target.value)} helperText={strings?.page?.key?.timeseries?.labelsHint} /> {!model.tsBulkMode && ( set('tsTimestamp', e.target.value)} disabled={model.originalTimestamp !== undefined} helperText={strings?.page?.key?.timeseries?.timestampHint} /> )} {model.originalTimestamp === undefined && ( set('tsBulkMode', v)} />} label={strings?.page?.key?.timeseries?.bulkMode} /> )} )} {/* Probabilistic type fields */} {model.type === 'bloom' && ( set('bloomErrorRate', parseFloat(e.target.value))} placeholder="0.01 = 1%" sx={{ flex: 1, minWidth: 140 }} /> set('bloomCapacity', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 140 }} /> )} {model.type === 'cuckoo' && ( set('cuckooCapacity', parseInt(e.target.value))} /> )} {model.type === 'topk' && ( set('topkK', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> set('topkWidth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> set('topkDepth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> set('topkDecay', parseFloat(e.target.value))} sx={{ flex: 1, minWidth: 100 }} /> )} {model.type === 'cms' && ( set('cmsWidth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 140 }} /> set('cmsDepth', parseInt(e.target.value))} sx={{ flex: 1, minWidth: 140 }} /> )} {model.type === 'tdigest' && ( set('tdigestCompression', parseInt(e.target.value))} /> )} {model.type === 'vectorset' && ( set('vectorElement', e.target.value)} sx={{ flex: 1, minWidth: 200 }} /> set('vectorValues', e.target.value)} sx={{ flex: 1, minWidth: 200 }} /> )} {/* Action buttons */} {model.type !== 'stream' && model.type !== 'timeseries' && !isProbabilistic && !isVectorset && ( )} {model.type !== 'timeseries' && !isProbabilistic && !isVectorset && ( <> )} {model.type !== 'timeseries' && !isProbabilistic && !isVectorset && ( setValidateJson(v)} />} label={strings?.label?.validateJson} /> )} {/* Timeseries formula generator */} {model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode) && ( <> {strings?.page?.key?.timeseries?.autoSpread} {strings?.page?.key?.timeseries?.formula} {model.tsFormula && ( set('tsFormulaPoints', e.target.value)} slotProps={{ htmlInput: { min: 1, max: 10000 } }} /> set('tsFormulaAmplitude', e.target.value)} /> set('tsFormulaOffset', e.target.value)} /> )} )} {/* Value field */} {isProbabilistic || isVectorset ? null : model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode) ? ( set('value', e.target.value)} helperText={strings?.page?.key?.timeseries?.editAllHint} slotProps={{ input: { sx: { fontFamily: "'Roboto Mono', monospace", fontSize: 13 } } }} /> ) : model.type === 'timeseries' && !model.tsBulkMode ? ( set('value', e.target.value)} /> ) : ( <> {model.type === 'stream' && ( {strings?.label?.streamValue} )} set('value', e.target.value)} /> )} setJsonViewOpen(false)} /> { setJsonEditorOpen(false); if (result?.obj) set('value', result.obj) }} /> { setDiffOpen(false); diffResolveRef.current?.(true) }} onCancel={() => { setDiffOpen(false); diffResolveRef.current?.(false) }} /> ) }