RSS Git Download  Clone
Raw Blame History 22kB 434 lines
<script setup lang="ts">
/**
 * KeyNewOrSetDialog — exact port of React KeyNewOrSetDialog.tsx
 * Multi-type form for creating or editing Redis keys.
 */
import { ref, computed, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useI18nStore } from '../stores/i18n'
import { useRedisStateStore } from '../stores/redis-state'
import { useSettingsStore } from '../stores/settings'
import { useCommonStore } from '../stores/common'
import { useOverlayStore } from '../stores/overlay'
import { request } from '../stores/socket.service'
import { str } from '../pages/database/key/key-type-base'
import P3xrDialog from '../components/P3xrDialog.vue'
import JsonViewDialog from './JsonViewDialog.vue'
import JsonEditorDialog from './JsonEditorDialog.vue'
import DiffDialog from './DiffDialog.vue'

export interface KeyNewOrSetData {
    type: 'add' | 'edit' | 'append'
    node?: any
    model?: any
}

const props = defineProps<{
    open: boolean
    data: KeyNewOrSetData | null
}>()

const emit = defineEmits<{
    close: [result?: any]
}>()

const i18n = useI18nStore()
const state = useRedisStateStore()
const settings = useSettingsStore()
const common = useCommonStore()
const overlay = useOverlayStore()
const { width } = useDisplay()

const strings = computed(() => i18n.strings)
const isWide = computed(() => width.value >= 720)
const isReadonly = computed(() => state.connection?.readonly === true)

const validateJson = ref(false)
const jsonViewOpen = ref(false)
const jsonEditorOpen = ref(false)
const fileInputRef = ref<HTMLInputElement>()

// Diff dialog
const diffOpen = ref(false)
const diffOldValue = ref('')
const diffNewValue = ref('')
const diffFieldName = ref('')
let diffResolve: ((v: boolean) => void) | null = null

// Model
const model = ref<any>({})

const isProbabilistic = computed(() => ['bloom', 'cuckoo', 'topk', 'cms', 'tdigest'].includes(model.value.type))
const isVectorset = computed(() => model.value.type === 'vectorset')

const types = computed(() => {
    const base = ['string', 'list', 'hash', 'set', 'zset', 'stream']
    if (state.hasTimeSeries) base.push('timeseries')
    if (state.hasReJSON) base.push('json')
    if (state.hasBloom) base.push('bloom', 'cuckoo', 'topk', 'cms', 'tdigest')
    base.push('vectorset')
    return base
})

watch(() => props.open, (v) => {
    if (!v || !props.data) return
    const divider = settings.redisTreeDivider
    const m: any = {
        type: 'string',
        key: props.data.node?.key ? props.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: undefined,
        bloomErrorRate: 0.01,
        bloomCapacity: 100,
        cuckooCapacity: 1024,
        topkK: 10,
        topkWidth: 2000,
        topkDepth: 7,
        topkDecay: 0.9,
        cmsWidth: 2000,
        cmsDepth: 7,
        tdigestCompression: 100,
        vectorElement: '',
        vectorValues: '',
    }
    if (props.data.model) Object.assign(m, props.data.model)
    model.value = m
    validateJson.value = false
})

const isAdd = computed(() => props.data?.type === 'add')

const title = computed(() => {
    if (props.data?.type === 'edit') return str(strings.value?.form?.key?.label?.formName?.edit)
    if (props.data?.type === 'append') return str(strings.value?.form?.key?.label?.formName?.append)
    return str(strings.value?.form?.key?.label?.formName?.add)
})

function set(field: string, value: any) {
    model.value = { ...model.value, [field]: value }
}

// --- Actions ---
function copy() {
    let value = model.value.value
    if (model.value.type === 'timeseries') {
        value = `TS.ADD ${model.value.key} ${model.value.tsTimestamp} ${model.value.value}`
    }
    navigator.clipboard.writeText(String(value ?? '')).catch(() => {})
    common.toast(str(strings.value?.status?.dataCopied))
}

function formatJson() {
    try {
        set('value', JSON.stringify(JSON.parse(model.value.value), null, settings.jsonFormat || 2))
    } catch {
        common.toast(str(strings.value?.label?.jsonViewNotParsable))
    }
}

function onFileSelected(event: Event) {
    const input = event.target as HTMLInputElement
    const file = input.files?.[0]
    if (!file) return
    const reader = new FileReader()
    reader.onload = async (e: any) => {
        try {
            await common.confirm({ message: str(strings.value?.confirm?.uploadBuffer) })
            set('value', e.target.result)
            common.toast(str(strings.value?.confirm?.uploadBufferDone))
        } catch {}
    }
    reader.readAsArrayBuffer(file)
    input.value = ''
}

function generateFormula() {
    const points = Math.min(Math.max(parseInt(String(model.value.tsFormulaPoints)) || 25, 1), 10000)
    const amplitude = parseFloat(String(model.value.tsFormulaAmplitude)) || 100
    const offset = parseFloat(String(model.value.tsFormulaOffset)) || 0
    const formula = model.value.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'))
}

function handleJsonEditorClose(result?: { obj: string } | null) {
    jsonEditorOpen.value = false
    if (result?.obj) set('value', result.obj)
}

async function submit() {
    if (!model.value.key?.trim()) {
        common.toast(str(strings.value?.form?.key?.error?.key))
        return
    }
    if (validateJson.value) {
        try { JSON.parse(model.value.value) }
        catch { common.toast(str(strings.value?.label?.jsonViewNotParsable)); return }
    }
    // Diff for edits
    if (props.data?.model?.value !== undefined && props.data.model.value !== model.value.value) {
        if (settings.showDiffBeforeSave) {
            diffOldValue.value = String(props.data.model.value)
            diffNewValue.value = String(model.value.value)
            diffFieldName.value = model.value.hashKey || ''
            diffOpen.value = true
            const confirmed = await new Promise<boolean>(resolve => { diffResolve = resolve })
            if (!confirmed) return
        }
    }
    try {
        overlay.show({ message: str(strings.value?.label?.saving) })
        const response = await request({
            action: 'key/new-or-set',
            payload: {
                type: props.data?.type,
                originalValue: props.data?.model?.value,
                originalHashKey: props.data?.model?.hashKey,
                model: structuredClone(model.value),
            },
        })
        common.toast(str(strings.value?.status?.set))
        emit('close', response)
    } catch (e) {
        common.generalHandleError(e)
    } finally {
        overlay.hide()
    }
}

function cancel() {
    emit('close')
}
</script>

<template>
    <P3xrDialog v-if="open && data" :open="true" :title="title" @close="cancel">
        <!-- Key -->
        <v-text-field v-model="model.key" :label="str(strings?.form?.key?.field?.key)" required :disabled="!isAdd"
            variant="outlined" density="comfortable" hide-details class="mb-3" />

        <!-- Type -->
        <v-select v-model="model.type" :label="str(strings?.form?.key?.field?.type)" :items="types" :disabled="!isAdd"
            variant="outlined" density="comfortable" hide-details class="mb-3">
            <template #item="{ item, props: itemProps }">
                <v-list-item v-bind="itemProps" :title="strings?.redisTypes?.[item.value] || item.value" />
            </template>
            <template #selection="{ item }">
                {{ strings?.redisTypes?.[item.value] || item.value }}
            </template>
        </v-select>

        <!-- Type-specific fields -->
        <v-text-field v-if="model.type === 'list'" v-model="model.index" :label="str(strings?.form?.key?.field?.index)"
            type="number" variant="outlined" density="comfortable"
            :hint="str(strings?.label?.redisListIndexInfo)" persistent-hint class="mb-3" />

        <v-text-field v-if="model.type === 'hash'" v-model="model.hashKey" :label="str(strings?.form?.key?.field?.hashKey)"
            required variant="outlined" density="comfortable" hide-details class="mb-3" />

        <v-text-field v-if="model.type === 'zset'" v-model="model.score" :label="str(strings?.form?.key?.field?.score)"
            type="number" required variant="outlined" density="comfortable" hide-details class="mb-3" />

        <template v-if="model.type === 'stream'">
            <v-text-field v-model="model.streamTimestamp" :label="str(strings?.form?.key?.field?.streamTimestamp)"
                required variant="outlined" density="comfortable"
                :hint="str(strings?.label?.streamTimestampId)" persistent-hint class="mb-3" />
        </template>

        <!-- Timeseries create fields -->
        <template v-if="model.type === 'timeseries' && isAdd">
            <v-text-field v-model.number="model.tsRetention" :label="`${str(strings?.page?.key?.timeseries?.retention)} (ms)`"
                type="number" variant="outlined" density="comfortable"
                :hint="str(strings?.page?.key?.timeseries?.retentionHint)" persistent-hint class="mb-3" />
            <v-select v-model="model.tsDuplicatePolicy" :label="str(strings?.page?.key?.timeseries?.duplicatePolicy)"
                :items="['LAST', 'FIRST', 'MIN', 'MAX', 'SUM', 'BLOCK']"
                variant="outlined" density="comfortable" hide-details class="mb-3" />
        </template>

        <!-- Timeseries common fields -->
        <template v-if="model.type === 'timeseries'">
            <v-text-field v-model="model.tsLabels" :label="str(strings?.page?.key?.timeseries?.labels)"
                variant="outlined" density="comfortable"
                :hint="str(strings?.page?.key?.timeseries?.labelsHint)" persistent-hint class="mb-3" />
            <v-text-field v-if="!model.tsBulkMode" v-model="model.tsTimestamp" :label="str(strings?.page?.key?.timeseries?.timestamp)"
                :disabled="model.originalTimestamp !== undefined" variant="outlined" density="comfortable"
                :hint="str(strings?.page?.key?.timeseries?.timestampHint)" persistent-hint class="mb-3" />
            <v-switch v-if="model.originalTimestamp === undefined" v-model="model.tsBulkMode"
                :label="str(strings?.page?.key?.timeseries?.bulkMode)" density="compact" hide-details class="mb-3" />
        </template>

        <!-- Probabilistic fields — all hide-details for clean flex alignment -->
        <div v-if="model.type === 'bloom'" class="p3xr-flex-row">
            <v-text-field v-model.number="model.bloomErrorRate" :label="str(strings?.form?.key?.field?.errorRate)"
                type="number" step="0.001" placeholder="0.01 = 1%" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:140px;" />
            <v-text-field v-model.number="model.bloomCapacity" :label="str(strings?.form?.key?.field?.capacity)"
                type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:140px;" />
        </div>
        <v-text-field v-if="model.type === 'cuckoo'" v-model.number="model.cuckooCapacity"
            :label="str(strings?.form?.key?.field?.capacity)" type="number" variant="outlined" density="comfortable" hide-details class="mb-3" />
        <div v-if="model.type === 'topk'" class="p3xr-flex-row">
            <v-text-field v-model.number="model.topkK" label="Top K" type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:100px;" />
            <v-text-field v-model.number="model.topkWidth" :label="str(strings?.form?.key?.field?.width)" type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:100px;" />
            <v-text-field v-model.number="model.topkDepth" :label="str(strings?.form?.key?.field?.depth)" type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:100px;" />
            <v-text-field v-model.number="model.topkDecay" :label="str(strings?.form?.key?.field?.decay)" type="number" step="0.1" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:100px;" />
        </div>
        <div v-if="model.type === 'cms'" class="p3xr-flex-row">
            <v-text-field v-model.number="model.cmsWidth" :label="str(strings?.form?.key?.field?.width)" type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:140px;" />
            <v-text-field v-model.number="model.cmsDepth" :label="str(strings?.form?.key?.field?.depth)" type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:140px;" />
        </div>
        <v-text-field v-if="model.type === 'tdigest'" v-model.number="model.tdigestCompression"
            :label="str(strings?.form?.key?.field?.compression)" type="number" variant="outlined" density="comfortable" hide-details class="mb-3" />
        <div v-if="model.type === 'vectorset'" class="p3xr-flex-row">
            <v-text-field v-model="model.vectorElement" :label="str(strings?.page?.key?.vectorset?.elementName)" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:200px;" />
            <v-text-field v-model="model.vectorValues" :label="str(strings?.page?.key?.vectorset?.vectorValues)" placeholder="0.1, 0.2, 0.3" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:200px;" />
        </div>

        <!-- Action buttons -->
        <input ref="fileInputRef" type="file" style="display: none;" @change="onFileSelected" />
        <div style="display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0;">
            <v-btn v-if="model.type !== 'stream' && model.type !== 'timeseries' && !isProbabilistic && !isVectorset"
                variant="flat" color="primary" size="small" @click="fileInputRef?.click()" style="gap: 3px;">
                <v-icon size="small">mdi-upload</v-icon>
                <span v-if="isWide">{{ str(strings?.intention?.setBuffer) }}</span>
            </v-btn>
            <template v-if="model.type !== 'timeseries' && !isProbabilistic && !isVectorset">
                <v-btn variant="flat" color="primary" size="small" @click="jsonEditorOpen = true" style="gap: 3px;">
                    <v-icon size="small">mdi-file-document-outline</v-icon>
                    <span v-if="isWide">{{ str(strings?.intention?.jsonViewEditor) }}</span>
                </v-btn>
                <v-btn variant="flat" color="primary" size="small" @click="formatJson()" style="gap: 3px;">
                    <v-icon size="small">mdi-format-line-spacing</v-icon>
                    <span v-if="isWide">{{ str(strings?.intention?.formatJson) }}</span>
                </v-btn>
                <v-btn variant="flat" color="secondary" size="small" @click="jsonViewOpen = true" style="gap: 3px;">
                    <v-icon size="small">mdi-file-tree</v-icon>
                    <span v-if="isWide">{{ str(strings?.intention?.jsonViewShow) }}</span>
                </v-btn>
            </template>
            <v-btn variant="flat" color="secondary" size="small" @click="copy()" style="gap: 3px;">
                <v-icon size="small">mdi-content-copy</v-icon>
                <span v-if="isWide">{{ str(strings?.intention?.copy) }}</span>
            </v-btn>
        </div>

        <!-- Validate JSON -->
        <v-switch v-if="model.type !== 'timeseries' && !isProbabilistic && !isVectorset"
            v-model="validateJson" :label="str(strings?.label?.validateJson)" color="secondary" density="compact" hide-details class="mb-3" />

        <!-- Timeseries formula generator -->
        <template v-if="model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode)">
            <div class="p3xr-flex-row">
                <v-select v-model="model.tsSpread" :label="str(strings?.page?.key?.timeseries?.autoSpread)"
                    :items="[
                        { value: 1000, title: `1 ${str(strings?.time?.second)}` },
                        { value: 30000, title: `30 ${str(strings?.time?.seconds)}` },
                        { value: 60000, title: `1 ${str(strings?.time?.minute)}` },
                        { value: 1800000, title: `30 ${str(strings?.time?.minutes)}` },
                        { value: 3600000, title: `1 ${str(strings?.time?.hour)}` },
                        { value: 86400000, title: `24 ${str(strings?.time?.hours)}` },
                    ]" item-value="value" item-title="title"
                    variant="outlined" density="comfortable" hide-details style="flex:1;min-width:140px;" />
                <v-select v-model="model.tsFormula" :label="str(strings?.page?.key?.timeseries?.formula)"
                    :items="[
                        { value: '', title: str(strings?.page?.key?.timeseries?.none) },
                        { value: 'sin', title: 'sin' },
                        { value: 'cos', title: 'cos' },
                        { value: 'linear', title: str(strings?.page?.key?.timeseries?.formulaLinear) },
                        { value: 'random', title: str(strings?.page?.key?.timeseries?.formulaRandom) },
                        { value: 'sawtooth', title: str(strings?.page?.key?.timeseries?.formulaSawtooth) },
                    ]" item-value="value" item-title="title"
                    variant="outlined" density="comfortable" hide-details style="flex:1;min-width:120px;" />
            </div>
            <div v-if="model.tsFormula" class="p3xr-flex-row">
                <v-text-field v-model.number="model.tsFormulaPoints" :label="str(strings?.page?.key?.timeseries?.formulaPoints)"
                    type="number" :min="1" :max="10000" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:80px;" />
                <v-text-field v-model.number="model.tsFormulaAmplitude" :label="str(strings?.page?.key?.timeseries?.formulaAmplitude)"
                    type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:80px;" />
                <v-text-field v-model.number="model.tsFormulaOffset" :label="str(strings?.page?.key?.timeseries?.formulaOffset)"
                    type="number" variant="outlined" density="comfortable" hide-details style="flex:1;min-width:80px;" />
                <v-btn variant="flat" color="secondary" size="small" @click="generateFormula()" style="gap: 3px;">
                    <v-icon size="small">mdi-chart-line</v-icon>
                    <span v-if="isWide">{{ str(strings?.page?.key?.timeseries?.generate) }}</span>
                </v-btn>
            </div>
        </template>

        <!-- Value field -->
        <template v-if="!isProbabilistic && !isVectorset">
            <template v-if="model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode)">
                <v-textarea v-model="model.value" :label="str(strings?.page?.key?.timeseries?.dataPoints)" required rows="10"
                    :hint="str(strings?.page?.key?.timeseries?.editAllHint)" persistent-hint
                    variant="outlined" density="comfortable" style="font-family: 'Roboto Mono', monospace; font-size: 13px;" />
            </template>
            <template v-else-if="model.type === 'timeseries' && !model.tsBulkMode">
                <v-text-field v-model="model.value" :label="str(strings?.page?.key?.timeseries?.value)"
                    type="number" required variant="outlined" density="comfortable" hide-details />
            </template>
            <template v-else>
                <div v-if="model.type === 'stream'" style="opacity: 0.5; font-size: 12px; margin-bottom: 8px;">
                    {{ str(strings?.label?.streamValue) }}
                </div>
                <v-textarea v-model="model.value" :label="str(strings?.form?.key?.field?.value)" required rows="5"
                    variant="outlined" density="comfortable" hide-details />
            </template>
        </template>

        <template #actions>
            <v-btn variant="flat" color="error" size="small" @click="cancel">
                <v-icon class="mr-1">mdi-close-circle</v-icon>
                <span>{{ str(strings?.intention?.cancel) }}</span>
            </v-btn>
            <v-btn v-if="!isReadonly" variant="flat" color="primary" size="small" @click="submit">
                <v-icon class="mr-1">{{ data?.type === 'edit' ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
                <span>{{ data?.type === 'edit' ? str(strings?.intention?.save) : str(strings?.intention?.add) }}</span>
            </v-btn>
        </template>
    </P3xrDialog>

    <!-- Sub-dialogs -->
    <JsonViewDialog :open="jsonViewOpen" :value="String(model.value ?? '')" @close="jsonViewOpen = false" />
    <JsonEditorDialog :open="jsonEditorOpen" :value="String(model.value ?? '')" @close="handleJsonEditorClose" />
    <DiffDialog :open="diffOpen" :key-name="model.key || ''" :field-name="diffFieldName || undefined"
        :old-value="diffOldValue" :new-value="diffNewValue"
        @confirm="diffOpen = false; diffResolve?.(true)"
        @cancel="diffOpen = false; diffResolve?.(false)" />
</template>

<style>
/* Shared flex row for multi-field layouts (probabilistic, timeseries formula) */
.p3xr-flex-row {
    display: flex;
    gap: 16px;
    flex-wrap: wrap;
    align-items: center;
    margin-bottom: 12px;
}
</style>