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<HTMLInputElement>(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<KeyModel>({
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<HTMLInputElement>) => {
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<boolean>(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 (
<>
<P3xrDialog open onClose={() => onClose()} title={getTitle()}
actions={
<>
<Button variant="contained" color="error" size="small" onClick={() => onClose()}>
<Cancel fontSize="small" /><span style={{ marginLeft: 3 }}>{strings?.intention?.cancel}</span>
</Button>
{!isReadonly && (
<Button variant="contained" color="primary" size="small" onClick={submit}>
{data.type === 'edit' ? <Edit fontSize="small" /> : <Add fontSize="small" />}
<span style={{ marginLeft: 3 }}>{data.type === 'edit' ? strings?.intention?.save : strings?.intention?.add}</span>
</Button>
)}
</>
}>
{/* Key */}
<TextField fullWidth margin="dense" required label={strings?.form?.key?.field?.key}
value={model.key} onChange={e => set('key', e.target.value)} disabled={!isAdd} />
{/* Type */}
<FormControl fullWidth margin="dense">
<InputLabel>{strings?.form?.key?.field?.type}</InputLabel>
<Select value={model.type} label={strings?.form?.key?.field?.type}
onChange={e => set('type', e.target.value)} disabled={!isAdd}>
{types.map(t => <MenuItem key={t} value={t}>{strings?.redisTypes?.[t] ?? t}</MenuItem>)}
</Select>
</FormControl>
{/* Type-specific fields */}
{model.type === 'list' && (
<>
<TextField fullWidth margin="dense" type="number" label={strings?.form?.key?.field?.index}
value={model.index} onChange={e => set('index', e.target.value)} />
<Box sx={{ opacity: 0.5, fontSize: 12, mb: 1 }}>{strings?.label?.redisListIndexInfo}</Box>
</>
)}
{model.type === 'hash' && (
<TextField fullWidth margin="dense" required label={strings?.form?.key?.field?.hashKey}
value={model.hashKey} onChange={e => set('hashKey', e.target.value)} />
)}
{model.type === 'zset' && (
<TextField fullWidth margin="dense" type="number" required label={strings?.form?.key?.field?.score}
value={model.score} onChange={e => set('score', e.target.value)} />
)}
{model.type === 'stream' && (
<>
<TextField fullWidth margin="dense" required label={strings?.form?.key?.field?.streamTimestamp}
value={model.streamTimestamp} onChange={e => set('streamTimestamp', e.target.value)} />
<Box sx={{ opacity: 0.5, fontSize: 12, mb: 1 }}>{strings?.label?.streamTimestampId}</Box>
</>
)}
{model.type === 'timeseries' && isAdd && (
<>
<TextField fullWidth margin="dense" type="number"
label={`${strings?.page?.key?.timeseries?.retention} (ms)`}
value={model.tsRetention} onChange={e => set('tsRetention', e.target.value)}
helperText={strings?.page?.key?.timeseries?.retentionHint} />
<FormControl fullWidth margin="dense">
<InputLabel>{strings?.page?.key?.timeseries?.duplicatePolicy}</InputLabel>
<Select value={model.tsDuplicatePolicy} label={strings?.page?.key?.timeseries?.duplicatePolicy}
onChange={e => set('tsDuplicatePolicy', e.target.value)}>
{['LAST', 'FIRST', 'MIN', 'MAX', 'SUM', 'BLOCK'].map(p =>
<MenuItem key={p} value={p}>{p}</MenuItem>)}
</Select>
</FormControl>
</>
)}
{model.type === 'timeseries' && (
<>
<TextField fullWidth margin="dense" label={strings?.page?.key?.timeseries?.labels}
value={model.tsLabels} onChange={e => set('tsLabels', e.target.value)}
helperText={strings?.page?.key?.timeseries?.labelsHint} />
{!model.tsBulkMode && (
<TextField fullWidth margin="dense" label={strings?.page?.key?.timeseries?.timestamp}
value={model.tsTimestamp} onChange={e => set('tsTimestamp', e.target.value)}
disabled={model.originalTimestamp !== undefined}
helperText={strings?.page?.key?.timeseries?.timestampHint} />
)}
{model.originalTimestamp === undefined && (
<FormControlLabel sx={{ display: 'block', my: 1 }}
control={<Switch checked={model.tsBulkMode} onChange={(_, v) => set('tsBulkMode', v)} />}
label={strings?.page?.key?.timeseries?.bulkMode} />
)}
</>
)}
{/* Probabilistic type fields */}
{model.type === 'bloom' && (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField margin="dense" type="number" slotProps={{ htmlInput: { step: 0.001 } }}
label={strings?.form?.key?.field?.errorRate}
value={model.bloomErrorRate} onChange={e => set('bloomErrorRate', parseFloat(e.target.value))}
placeholder="0.01 = 1%" sx={{ flex: 1, minWidth: 140 }} />
<TextField margin="dense" type="number"
label={strings?.form?.key?.field?.capacity}
value={model.bloomCapacity} onChange={e => set('bloomCapacity', parseInt(e.target.value))}
sx={{ flex: 1, minWidth: 140 }} />
</Box>
)}
{model.type === 'cuckoo' && (
<TextField fullWidth margin="dense" type="number"
label={strings?.form?.key?.field?.capacity}
value={model.cuckooCapacity} onChange={e => set('cuckooCapacity', parseInt(e.target.value))} />
)}
{model.type === 'topk' && (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField margin="dense" type="number" label="Top K"
value={model.topkK} onChange={e => set('topkK', parseInt(e.target.value))}
sx={{ flex: 1, minWidth: 100 }} />
<TextField margin="dense" type="number" label={strings?.form?.key?.field?.width}
value={model.topkWidth} onChange={e => set('topkWidth', parseInt(e.target.value))}
sx={{ flex: 1, minWidth: 100 }} />
<TextField margin="dense" type="number" label={strings?.form?.key?.field?.depth}
value={model.topkDepth} onChange={e => set('topkDepth', parseInt(e.target.value))}
sx={{ flex: 1, minWidth: 100 }} />
<TextField margin="dense" type="number" slotProps={{ htmlInput: { step: 0.1 } }}
label={strings?.form?.key?.field?.decay}
value={model.topkDecay} onChange={e => set('topkDecay', parseFloat(e.target.value))}
sx={{ flex: 1, minWidth: 100 }} />
</Box>
)}
{model.type === 'cms' && (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField margin="dense" type="number" label={strings?.form?.key?.field?.width}
value={model.cmsWidth} onChange={e => set('cmsWidth', parseInt(e.target.value))}
sx={{ flex: 1, minWidth: 140 }} />
<TextField margin="dense" type="number" label={strings?.form?.key?.field?.depth}
value={model.cmsDepth} onChange={e => set('cmsDepth', parseInt(e.target.value))}
sx={{ flex: 1, minWidth: 140 }} />
</Box>
)}
{model.type === 'tdigest' && (
<TextField fullWidth margin="dense" type="number"
label={strings?.form?.key?.field?.compression}
value={model.tdigestCompression} onChange={e => set('tdigestCompression', parseInt(e.target.value))} />
)}
{model.type === 'vectorset' && (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField margin="dense"
label={strings?.page?.key?.vectorset?.elementName}
value={model.vectorElement} onChange={e => set('vectorElement', e.target.value)}
sx={{ flex: 1, minWidth: 200 }} />
<TextField margin="dense"
label={strings?.page?.key?.vectorset?.vectorValues}
placeholder="0.1, 0.2, 0.3"
value={model.vectorValues} onChange={e => set('vectorValues', e.target.value)}
sx={{ flex: 1, minWidth: 200 }} />
</Box>
)}
{/* Action buttons */}
<input type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={onFileSelected} />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, my: 1 }}>
{model.type !== 'stream' && model.type !== 'timeseries' && !isProbabilistic && !isVectorset && (
<Button variant="contained" color="primary" size="small" onClick={() => fileInputRef.current?.click()}>
<Upload fontSize="small" />
{isWide && <span style={{ marginLeft: 3 }}>{strings?.intention?.setBuffer}</span>}
</Button>
)}
{model.type !== 'timeseries' && !isProbabilistic && !isVectorset && (
<>
<Button variant="contained" color="primary" size="small" onClick={() => setJsonEditorOpen(true)}>
<Description fontSize="small" />
{isWide && <span style={{ marginLeft: 3 }}>{strings?.intention?.jsonViewEditor}</span>}
</Button>
<Button variant="contained" color="primary" size="small" onClick={formatJson}>
<FormatLineSpacing fontSize="small" />
{isWide && <span style={{ marginLeft: 3 }}>{strings?.intention?.formatJson}</span>}
</Button>
<Button variant="contained" color="secondary" size="small" onClick={() => setJsonViewOpen(true)}>
<AccountTree fontSize="small" />
{isWide && <span style={{ marginLeft: 3 }}>{strings?.intention?.jsonViewShow}</span>}
</Button>
</>
)}
<Button variant="contained" color="secondary" size="small" onClick={copy}>
<ContentCopy fontSize="small" />
{isWide && <span style={{ marginLeft: 3 }}>{strings?.intention?.copy}</span>}
</Button>
</Box>
{model.type !== 'timeseries' && !isProbabilistic && !isVectorset && (
<FormControlLabel sx={{ display: 'block', my: 1 }}
control={<Switch checked={validateJson} onChange={(_, v) => setValidateJson(v)} />}
label={strings?.label?.validateJson} />
)}
{/* Timeseries formula generator */}
{model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode) && (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center', mb: 1 }}>
<FormControl sx={{ minWidth: 140, flex: 1 }} size="small">
<InputLabel>{strings?.page?.key?.timeseries?.autoSpread}</InputLabel>
<Select value={model.tsSpread} label={strings?.page?.key?.timeseries?.autoSpread}
onChange={e => set('tsSpread', e.target.value)}>
<MenuItem value={1000}>1 {strings?.time?.second}</MenuItem>
<MenuItem value={30000}>30 {strings?.time?.seconds}</MenuItem>
<MenuItem value={60000}>1 {strings?.time?.minute}</MenuItem>
<MenuItem value={1800000}>30 {strings?.time?.minutes}</MenuItem>
<MenuItem value={3600000}>1 {strings?.time?.hour}</MenuItem>
<MenuItem value={86400000}>24 {strings?.time?.hours}</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 120, flex: 1 }} size="small">
<InputLabel>{strings?.page?.key?.timeseries?.formula}</InputLabel>
<Select value={model.tsFormula} label={strings?.page?.key?.timeseries?.formula}
onChange={e => set('tsFormula', e.target.value)}>
<MenuItem value="">{strings?.page?.key?.timeseries?.none}</MenuItem>
<MenuItem value="sin">sin</MenuItem>
<MenuItem value="cos">cos</MenuItem>
<MenuItem value="linear">{strings?.page?.key?.timeseries?.formulaLinear}</MenuItem>
<MenuItem value="random">{strings?.page?.key?.timeseries?.formulaRandom}</MenuItem>
<MenuItem value="sawtooth">{strings?.page?.key?.timeseries?.formulaSawtooth}</MenuItem>
</Select>
</FormControl>
</Box>
{model.tsFormula && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center', mb: 1 }}>
<TextField sx={{ minWidth: 80, flex: 1 }} size="small" type="number"
label={strings?.page?.key?.timeseries?.formulaPoints}
value={model.tsFormulaPoints} onChange={e => set('tsFormulaPoints', e.target.value)}
slotProps={{ htmlInput: { min: 1, max: 10000 } }} />
<TextField sx={{ minWidth: 80, flex: 1 }} size="small" type="number"
label={strings?.page?.key?.timeseries?.formulaAmplitude}
value={model.tsFormulaAmplitude} onChange={e => set('tsFormulaAmplitude', e.target.value)} />
<TextField sx={{ minWidth: 80, flex: 1 }} size="small" type="number"
label={strings?.page?.key?.timeseries?.formulaOffset}
value={model.tsFormulaOffset} onChange={e => set('tsFormulaOffset', e.target.value)} />
<Button variant="contained" color="secondary" size="small" onClick={generateFormula}>
<AutoGraph fontSize="small" />
{isWide && <span style={{ marginLeft: 3 }}>{strings?.page?.key?.timeseries?.generate}</span>}
</Button>
</Box>
)}
</>
)}
{/* Value field */}
{isProbabilistic || isVectorset ? null
: model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode) ? (
<TextField fullWidth margin="dense" required multiline rows={10}
label={strings?.page?.key?.timeseries?.dataPoints}
value={model.value} onChange={e => 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 ? (
<TextField fullWidth margin="dense" type="number" required
label={strings?.page?.key?.timeseries?.value}
value={model.value} onChange={e => set('value', e.target.value)} />
) : (
<>
{model.type === 'stream' && (
<Box sx={{ opacity: 0.5, fontSize: 12, mb: 1 }}>{strings?.label?.streamValue}</Box>
)}
<TextField fullWidth margin="dense" required multiline rows={5}
label={strings?.form?.key?.field?.value}
value={model.value ?? ''} onChange={e => set('value', e.target.value)} />
</>
)}
<JsonViewDialog open={jsonViewOpen} value={String(model.value ?? '')} onClose={() => setJsonViewOpen(false)} />
<JsonEditorDialog open={jsonEditorOpen} value={String(model.value ?? '')}
onClose={(result) => { setJsonEditorOpen(false); if (result?.obj) set('value', result.obj) }} />
</P3xrDialog>
<DiffDialog open={diffOpen} keyName={model.key}
fieldName={diffData.fieldName || undefined}
oldValue={diffData.oldValue} newValue={diffData.newValue}
onConfirm={() => { setDiffOpen(false); diffResolveRef.current?.(true) }}
onCancel={() => { setDiffOpen(false); diffResolveRef.current?.(false) }} />
</>
)
}