RSS Git Download  Clone
Raw Blame History 28kB 574 lines
<script setup lang="ts">
/**
 * TimeSeries key type renderer — exact port of React KeyTimeseries.tsx.
 * uPlot chart, range controls, data table with virtual scroll, TS.INFO with alter mode.
 */
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { storeToRefs } from 'pinia'
import P3xrAccordion from '../../../components/P3xrAccordion.vue'
import P3xrButton from '../../../components/P3xrButton.vue'
import KeyNewOrSetDialog from '../../../dialogs/KeyNewOrSetDialog.vue'
import { useI18nStore } from '../../../stores/i18n'
import { useRedisStateStore } from '../../../stores/redis-state'
import { useCommonStore } from '../../../stores/common'
import { useOverlayStore } from '../../../stores/overlay'
import { useThemeStore } from '../../../stores/theme'
import { request } from '../../../stores/socket.service'
import { str } from './key-type-base'
import 'uplot/dist/uPlot.min.css'

interface DataPoint { timestamp: number; value: number }

const aggregationTypes = ['avg', 'min', 'max', 'sum', 'count', 'first', 'last', 'range', 'std.p', 'std.s', 'var.p', 'var.s']
const SERIES_COLORS = ['#1976d2', '#9c27b0', '#f44336', '#4caf50', '#ff9800', '#00bcd4', '#e91e63', '#8bc34a']

const props = defineProps<{ response: any; value: any; valueBuffer: any; keyName: string; valueFormat: string }>()
const emit = defineEmits<{ refresh: [] }>()

const i18n = useI18nStore()
const strings = computed(() => i18n.strings)
const state = useRedisStateStore()
const common = useCommonStore()
const overlay = useOverlayStore()
const { themeKey } = storeToRefs(useThemeStore())
const isDark = computed(() => ['dark', 'darkNeu', 'darkoBluo', 'matrix'].includes(themeKey.value))
const isReadonly = computed(() => state.connection?.readonly === true)

// State
const tsInfo = ref<any>({})
const rangeData = ref<DataPoint[]>([])
const rangeFrom = ref('')
const rangeTo = ref('')
const aggregationType = ref('')
const aggregationItems = computed(() => [
    { title: str(strings.value?.page?.key?.timeseries?.none), value: '' },
    ...aggregationTypes.map(a => ({ title: a, value: a })),
])
const aggregationBucket = ref('')
const addTimestamp = ref('*')
const addValue = ref('')
const autoRefresh = ref(false)
const alterMode = ref(false)
const alterRetention = ref(0)
const alterDuplicatePolicy = ref('LAST')
const alterLabels = ref('')
const overlayKeysInput = ref('')
const mrangeFilter = ref('')
const overlaySeries = ref<Array<{ key: string; data: DataPoint[] }>>([])
const editDialogOpen = ref(false)
const editDialogData = ref<any>(null)
const chartReady = ref(false)

// Refs
const chartEl = ref<HTMLDivElement>()
const dataParentEl = ref<HTMLDivElement>()
let uPlotLib: any = null
let plot: any = null
let resizeObs: ResizeObserver | null = null
let autoRefreshTimer: any = null
let debounceTimer: any = null

// Virtual scrolling — same pattern as DatabaseTree.vue
const virtualizer = useVirtualizer(computed(() => ({
    count: rangeData.value.length,
    getScrollElement: () => dataParentEl.value,
    estimateSize: () => 40,
    overscan: 10,
})))

// Computed
const infoLabels = computed(() => {
    if (!tsInfo.value) return []
    const skip = new Set(['labels', 'rules', 'sourceKey', 'chunks'])
    return Object.entries(tsInfo.value).filter(([k]) => !skip.has(k)).map(([key, val]) => ({ key, value: val }))
})

const tsLabels = computed(() => {
    const labels = tsInfo.value?.labels
    if (!labels || typeof labels !== 'object') return []
    return Object.entries(labels).map(([key, val]) => ({ key, value: String(val) }))
})

const tsRules = computed(() => Array.isArray(tsInfo.value?.rules) ? tsInfo.value.rules : [])

const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : ''

function formatTimestamp(ts: number): string {
    const lang = i18n.currentLang
    return new Date(ts).toLocaleString(lang, {
        year: 'numeric', month: '2-digit', day: '2-digit',
        hour: '2-digit', minute: '2-digit', second: '2-digit',
        fractionalSecondDigits: 3,
    } as any)
}

// --- Chart helpers ---
function getChartColors() {
    const dark = isDark.value
    const style = getComputedStyle(document.body)
    const cssPrimary = style.getPropertyValue('--p3xr-btn-primary-bg').trim()
    return {
        primary: cssPrimary || (dark ? '#90caf9' : '#1976d2'),
        text: dark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)',
        grid: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
    }
}

function buildChartData(data: DataPoint[], overlays: typeof overlaySeries.value): number[][] {
    if (overlays.length === 0) {
        return [data.map(d => d.timestamp / 1000), data.map(d => d.value)]
    }
    const allSeries = [data, ...overlays.map(s => s.data)]
    const tsSet = new Set<number>()
    for (const series of allSeries) for (const d of series) tsSet.add(d.timestamp)
    const sortedTs = Array.from(tsSet).sort((a, b) => a - b)
    const timestamps = sortedTs.map(t => t / 1000)
    const result: number[][] = [timestamps]
    for (const series of allSeries) {
        const valueMap = new Map<number, number>()
        for (const d of series) valueMap.set(d.timestamp, d.value)
        result.push(sortedTs.map(t => valueMap.has(t) ? valueMap.get(t)! : null as any))
    }
    return result
}

function destroyChart() {
    resizeObs?.disconnect()
    resizeObs = null
    plot?.destroy()
    plot = null
}

function initChart() {
    if (!uPlotLib || !chartEl.value) return
    destroyChart()
    const el = chartEl.value
    const w = el.clientWidth || 400
    const colors = getChartColors()
    const lang = i18n.currentLang

    const seriesConfig: any[] = [
        {
            label: str(strings.value?.label?.time),
            value: (_: any, v: number) => v ? new Date(v * 1000).toLocaleString(i18n.currentLang, {
                year: 'numeric', month: '2-digit', day: '2-digit',
                hour: '2-digit', minute: '2-digit', second: '2-digit',
            }) : '',
        },
        { label: props.keyName, stroke: colors.primary, width: 2, fill: colors.primary + '15' },
    ]
    for (let i = 0; i < overlaySeries.value.length; i++) {
        seriesConfig.push({ label: overlaySeries.value[i].key, stroke: SERIES_COLORS[(i + 1) % SERIES_COLORS.length], width: 2 })
    }

    const opts = {
        width: w, height: 400,
        cursor: { show: true, drag: { x: false, y: false } },
        legend: { show: true, live: true },
        scales: { x: { time: true } },
        axes: [
            { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto',
                values: (_: any, ticks: number[]) => ticks.map(t => new Date(t * 1000).toLocaleTimeString(i18n.currentLang, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })) },
            { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto Mono', size: 65 },
        ],
        series: seriesConfig,
    }

    const chartData = buildChartData(rangeData.value, overlaySeries.value)
    plot = new uPlotLib(opts, chartData, el)

    let timer: any
    resizeObs = new ResizeObserver(() => {
        clearTimeout(timer)
        timer = setTimeout(() => { const nw = el.clientWidth; if (nw > 0) plot?.setSize({ width: nw, height: 400 }) }, 50)
    })
    resizeObs.observe(el)
}

function updateChart() {
    if (!uPlotLib || !chartEl.value) return
    const expectedSeries = 2 + overlaySeries.value.length
    if (!plot || plot.series?.length !== expectedSeries) { initChart(); return }
    const chartData = buildChartData(rangeData.value, overlaySeries.value)
    plot.setData(chartData, true)
    if (chartData[0].length > 0) plot.setScale('x', { min: chartData[0][0], max: chartData[0][chartData[0].length - 1] })
}

// --- Load range ---
async function loadRange() {
    try {
        const payload: any = { key: props.keyName }
        if (rangeFrom.value) payload.from = rangeFrom.value
        if (rangeTo.value) payload.to = rangeTo.value
        if (aggregationType.value && aggregationBucket.value) {
            payload.aggregation = { type: aggregationType.value, timeBucket: parseInt(aggregationBucket.value, 10) }
        }
        const resp = await request({ action: 'timeseries/range', payload })
        rangeData.value = resp.data || []

        // Overlay keys
        const newOverlays: typeof overlaySeries.value = []
        const overlayKeys = overlayKeysInput.value.split(',').map(k => k.trim()).filter(k => k.length > 0)
        for (const ok of overlayKeys) {
            try {
                const op: any = { key: ok }
                if (rangeFrom.value) op.from = rangeFrom.value
                if (rangeTo.value) op.to = rangeTo.value
                if (aggregationType.value && aggregationBucket.value) op.aggregation = { type: aggregationType.value, timeBucket: parseInt(aggregationBucket.value, 10) }
                const or = await request({ action: 'timeseries/range', payload: op })
                newOverlays.push({ key: ok, data: or.data || [] })
            } catch { /* skip */ }
        }

        // MRANGE by label filter
        if (mrangeFilter.value.trim().length > 0) {
            try {
                const mp: any = { filter: mrangeFilter.value.trim() }
                if (rangeFrom.value) mp.from = rangeFrom.value
                if (rangeTo.value) mp.to = rangeTo.value
                if (aggregationType.value && aggregationBucket.value) mp.aggregation = { type: aggregationType.value, timeBucket: parseInt(aggregationBucket.value, 10) }
                const mr = await request({ action: 'timeseries/mrange', payload: mp })
                for (const entry of (mr.data || [])) { if (entry.key !== props.keyName) newOverlays.push({ key: entry.key, data: entry.data }) }
            } catch { /* skip */ }
        }

        overlaySeries.value = newOverlays
        nextTick(() => updateChart())
    } catch (e: any) { common.generalHandleError(e) }
}

function debouncedLoadRange() {
    clearTimeout(debounceTimer)
    debounceTimer = setTimeout(() => loadRange(), 500)
}

// --- Actions ---
async function addDataPoint() {
    if (!addValue.value) return
    try {
        await request({ action: 'timeseries/add', payload: { key: props.keyName, timestamp: addTimestamp.value, value: parseFloat(addValue.value) } })
        common.toast(str(strings.value?.status?.added))
        addValue.value = ''
        emit('refresh')
    } catch (e: any) { common.generalHandleError(e) }
}

async function deleteDataPoint(point: DataPoint) {
    try {
        await common.confirm({ message: str(strings.value?.confirm?.delete) })
        await request({ action: 'timeseries/del', payload: { key: props.keyName, from: point.timestamp, to: point.timestamp } })
        common.toast(str(strings.value?.status?.deleted))
        emit('refresh')
    } catch (e: any) { if (e !== undefined && e !== null) common.generalHandleError(e) }
}

function editDataPoint(point: DataPoint) {
    editDialogData.value = { type: 'edit', model: { type: 'timeseries', key: props.keyName, tsTimestamp: String(point.timestamp), value: point.value, originalTimestamp: point.timestamp } }
    editDialogOpen.value = true
}

function editAllDataPoints() {
    const allPoints = rangeData.value.map(p => `${p.timestamp} ${p.value}`).join('\n')
    const currentLabels = tsLabels.value.map(l => `${l.key} ${l.value}`).join(' ') || `key ${props.keyName}`
    editDialogData.value = { type: 'edit', model: { type: 'timeseries', key: props.keyName, value: allPoints, tsEditAll: true, tsLabels: currentLabels } }
    editDialogOpen.value = true
}

function handleEditClose(result?: any) {
    editDialogOpen.value = false
    editDialogData.value = null
    if (result) { emit('refresh'); loadRange() }
}

function toggleAlterMode() {
    alterMode.value = !alterMode.value
    if (alterMode.value) {
        alterRetention.value = tsInfo.value?.retentionTime || 0
        alterDuplicatePolicy.value = (tsInfo.value?.duplicatePolicy).toUpperCase()
        const labels = tsLabels.value.map(l => `${l.key} ${l.value}`).join(' ')
        alterLabels.value = labels || `key ${props.keyName}`
    }
}

async function saveAlter() {
    try {
        const labels = alterLabels.value.trim().length > 0 ? alterLabels.value : `key ${props.keyName}`
        await request({ action: 'timeseries/alter', payload: { key: props.keyName, retention: alterRetention.value, duplicatePolicy: alterDuplicatePolicy.value, labels } })
        common.toast(str(strings.value?.status?.saved))
        alterMode.value = false
        emit('refresh')
    } catch (e: any) { common.generalHandleError(e) }
}

function exportChartPng() {
    if (!plot || !chartEl.value) return
    const chartCanvas = chartEl.value.querySelector('canvas') as HTMLCanvasElement
    if (!chartCanvas) return
    const dark = isDark.value
    const bgColor = dark ? '#1e1e1e' : '#ffffff'
    const textColor = dark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)'
    const padding = 20; const titleHeight = 30; const legendHeight = 30
    const totalWidth = chartCanvas.width + padding * 2
    const totalHeight = chartCanvas.height + padding * 2 + titleHeight + legendHeight
    const exportCanvas = document.createElement('canvas')
    exportCanvas.width = totalWidth; exportCanvas.height = totalHeight
    const ctx = exportCanvas.getContext('2d')!
    ctx.fillStyle = bgColor; ctx.fillRect(0, 0, totalWidth, totalHeight)
    ctx.fillStyle = textColor; ctx.font = 'bold 14px Roboto, sans-serif'
    ctx.fillText(props.keyName, padding, padding + 16)
    ctx.drawImage(chartCanvas, padding, padding + titleHeight)
    const allKeys = [props.keyName, ...overlaySeries.value.map(s => s.key)]
    const colors = [getChartColors().primary, ...overlaySeries.value.map((_, i) => SERIES_COLORS[(i + 1) % SERIES_COLORS.length])]
    let legendX = padding; const legendY = padding + titleHeight + chartCanvas.height + 16
    ctx.font = '12px Roboto, sans-serif'
    for (let i = 0; i < allKeys.length; i++) {
        ctx.fillStyle = colors[i]; ctx.fillRect(legendX, legendY - 8, 12, 12)
        ctx.fillStyle = textColor; ctx.fillText(allKeys[i], legendX + 16, legendY + 2)
        legendX += ctx.measureText(allKeys[i]).width + 32
    }
    const url = exportCanvas.toDataURL('image/png')
    const a = document.createElement('a'); a.href = url; a.download = `${props.keyName}-chart.png`; a.click()
}

// --- Lifecycle ---
onMounted(async () => {
    tsInfo.value = props.value || {}
    // Load uPlot
    try {
        const mod = await import('uplot')
        uPlotLib = mod.default
        chartReady.value = true
    } catch (e) { console.error('Failed to load uPlot', e) }
    loadRange()
    // Ensure default label
    if (!isReadonly.value) {
        const labels = props.value?.labels
        const labelCount = labels && typeof labels === 'object' ? Object.keys(labels).length : 0
        if (labelCount === 0) {
            try {
                await request({ action: 'timeseries/alter', payload: { key: props.keyName, labels: `key ${props.keyName}` } })
                tsInfo.value = { ...tsInfo.value, labels: { key: props.keyName } }
            } catch { /* ignore */ }
        }
    }
})

onUnmounted(() => {
    destroyChart()
    clearInterval(autoRefreshTimer)
    clearTimeout(debounceTimer)
})

// Re-init chart when data or chart readiness changes
watch([chartReady, rangeData], () => {
    if (!chartReady.value) return
    setTimeout(() => { plot ? updateChart() : initChart() }, 150)
}, { flush: 'post' })

// Re-init chart on theme/language/primary color change
watch([isDark, () => i18n.currentLang, themeKey], () => {
    if (!chartReady.value) return
    setTimeout(() => { destroyChart(); initChart() }, 100)
})

// Value change
watch(() => props.value, () => {
    tsInfo.value = props.value || {}
    loadRange()
})

// Auto-refresh
watch(autoRefresh, (v) => {
    clearInterval(autoRefreshTimer)
    if (v) autoRefreshTimer = setInterval(() => loadRange(), 10000)
})

// Row styling
const listBorder = computed(() => isDark.value ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)')
const oddBg = computed(() => isDark.value ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)')
const hoverBg = computed(() => isDark.value ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)')
</script>

<template>
    <div class="p3xr-key-type-content">

        <!-- Chart accordion -->
        <br />
        <P3xrAccordion :title="str(strings?.page?.key?.timeseries?.chart)" accordion-key="ts-chart">
            <template #actions>
                <P3xrButton v-if="!isReadonly" icon="mdi-pencil" :label="str(strings?.intention?.edit)" :breakpoint="1280" color="inherit" @click.stop="editAllDataPoints()" />
                <P3xrButton icon="mdi-image" :label="str(strings?.page?.key?.timeseries?.exportChart)" :breakpoint="1280" color="inherit" @click.stop="exportChartPng()" />
                <P3xrButton :icon="autoRefresh ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'" :label="str(strings?.label?.autoRefresh)" :breakpoint="1280" color="inherit" @click.stop="autoRefresh = !autoRefresh" />
                <P3xrButton v-if="!autoRefresh" icon="mdi-refresh" :label="str(strings?.intention?.refresh)" :breakpoint="1280" color="inherit" @click.stop="loadRange()" />
            </template>

            <div style="padding: 16px;">
                <!-- Range controls -->
                <div class="p3xr-ts-controls">
                    <v-text-field density="compact" variant="outlined" hide-details class="p3xr-ts-field"
                        :label="str(strings?.page?.key?.timeseries?.from)" placeholder="-"
                        v-model="rangeFrom" @update:model-value="debouncedLoadRange()" />
                    <v-text-field density="compact" variant="outlined" hide-details class="p3xr-ts-field"
                        :label="str(strings?.page?.key?.timeseries?.to)" placeholder="+"
                        v-model="rangeTo" @update:model-value="debouncedLoadRange()" />
                    <v-select density="compact" variant="outlined" hide-details class="p3xr-ts-field"
                        :label="str(strings?.page?.key?.timeseries?.aggregation)"
                        v-model="aggregationType" :items="aggregationItems" item-title="title" item-value="value"
                        @update:model-value="loadRange()" />
                    <v-text-field v-if="aggregationType" density="compact" variant="outlined" hide-details class="p3xr-ts-field"
                        type="number" :label="str(strings?.page?.key?.timeseries?.timeBucket)" placeholder="5000"
                        v-model="aggregationBucket" @update:model-value="debouncedLoadRange()" />
                    <v-text-field density="compact" variant="outlined" hide-details class="p3xr-ts-field" style="min-width: 200px;"
                        :label="str(strings?.page?.key?.timeseries?.overlay)"
                        :placeholder="str(strings?.page?.key?.timeseries?.overlayHint)"
                        v-model="overlayKeysInput" @update:model-value="debouncedLoadRange()" />
                    <v-text-field density="compact" variant="outlined" hide-details class="p3xr-ts-field" style="min-width: 180px;"
                        :label="str(strings?.page?.key?.timeseries?.mrangeFilter)"
                        :placeholder="str(strings?.page?.key?.timeseries?.mrangeHint)"
                        v-model="mrangeFilter" @update:model-value="debouncedLoadRange()" />
                </div>

                <div style="padding: 4px 0; opacity: 0.6; font-size: 13px;">
                    {{ rangeData.length }} {{ str(strings?.page?.key?.timeseries?.dataPoints) }}
                </div>

                <!-- Chart container -->
                <div ref="chartEl" style="width: 100%; min-height: 400px;"></div>

                <!-- Add data point -->
                <div v-if="!isReadonly" class="p3xr-ts-controls" style="margin-top: 16px;">
                    <v-text-field density="compact" variant="outlined" hide-details class="p3xr-ts-field"
                        :label="str(strings?.page?.key?.timeseries?.timestamp)" placeholder="* (auto)"
                        v-model="addTimestamp" />
                    <v-text-field density="compact" variant="outlined" hide-details class="p3xr-ts-field"
                        type="number" :label="str(strings?.page?.key?.timeseries?.value)"
                        v-model="addValue" @keydown.enter="addDataPoint()" />
                    <P3xrButton icon="mdi-plus" :label="str(strings?.intention?.add)" raised color="primary" :disabled="!addValue" @click="addDataPoint()" />
                </div>
            </div>
        </P3xrAccordion>

        <!-- Data table accordion -->
        <template v-if="rangeData.length > 0">
            <br />
            <P3xrAccordion :title="capitalize(str(strings?.page?.key?.timeseries?.dataPoints)) + ` (${rangeData.length})`" accordion-key="ts-data">
                <!-- Header -->
                <div class="p3xr-key-table-header">
                    <span style="flex: 1;">{{ str(strings?.page?.key?.timeseries?.timestamp) }}</span>
                    <span>{{ str(strings?.page?.key?.timeseries?.value) }}</span>
                    <span v-if="!isReadonly" style="min-width: 52px;"></span>
                </div>

                <!-- Virtual scroll -->
                <div ref="dataParentEl" style="height: 600px; overflow: auto;">
                    <div :style="{ height: virtualizer.getTotalSize() + 'px', width: '100%', position: 'relative' }">
                        <div v-for="vRow in virtualizer.getVirtualItems()" :key="vRow.key"
                            :style="{
                                position: 'absolute', top: 0, left: 0, width: '100%',
                                transform: `translateY(${vRow.start}px)`,
                                height: vRow.size + 'px',
                                display: 'flex', alignItems: 'center', gap: '8px', padding: '0 16px',
                                borderBottom: `1px solid ${listBorder}`,
                                backgroundColor: vRow.index % 2 === 0 ? oddBg : 'transparent',
                            }"
                            class="p3xr-ts-data-row">
                            <span style="flex: 1; font-size: 13px;">{{ formatTimestamp(rangeData[vRow.index].timestamp) }}</span>
                            <span style="font-size: 13px; font-family: 'Roboto Mono', monospace;">{{ rangeData[vRow.index].value }}</span>
                            <span v-if="!isReadonly" style="display: flex; align-items: center;">
                                <v-tooltip :text="str(strings?.intention?.delete)" location="top">
                                    <template #activator="{ props: tp }"><v-icon v-bind="tp" size="24" class="p3xr-key-icon" style="color:rgb(var(--v-theme-error));" @click="deleteDataPoint(rangeData[vRow.index])">mdi-delete</v-icon></template>
                                </v-tooltip>
                                <v-tooltip :text="str(strings?.intention?.edit)" location="top">
                                    <template #activator="{ props: tp }"><v-icon v-bind="tp" size="24" class="p3xr-key-icon" style="color:rgb(var(--v-theme-primary));" @click="editDataPoint(rangeData[vRow.index])">mdi-pencil</v-icon></template>
                                </v-tooltip>
                            </span>
                        </div>
                    </div>
                </div>
            </P3xrAccordion>
        </template>

        <!-- TS.INFO accordion -->
        <br />
        <P3xrAccordion :title="str(strings?.page?.key?.timeseries?.info)" accordion-key="ts-info">
            <template v-if="!isReadonly" #actions>
                <P3xrButton :icon="alterMode ? 'mdi-checkbox-marked' : 'mdi-pencil'" :label="str(strings?.intention?.edit)" color="inherit" @click.stop="toggleAlterMode()" />
            </template>

            <!-- Alter mode -->
            <div v-if="alterMode" style="padding: 16px;">
                <div class="p3xr-ts-controls">
                    <v-text-field density="compact" variant="outlined" type="number" style="flex: 1; min-width: 150px;"
                        :label="`${str(strings?.page?.key?.timeseries?.retention)} (ms)`"
                        :hint="str(strings?.page?.key?.timeseries?.retentionHint)" persistent-hint
                        v-model.number="alterRetention" />
                    <v-select density="compact" variant="outlined" style="flex: 1; min-width: 150px;"
                        :label="str(strings?.page?.key?.timeseries?.duplicatePolicy)"
                        v-model="alterDuplicatePolicy" :items="['LAST', 'FIRST', 'MIN', 'MAX', 'SUM', 'BLOCK']" />
                    <v-text-field density="compact" variant="outlined" style="flex: 1; min-width: 200px;"
                        :label="str(strings?.page?.key?.timeseries?.labels)"
                        :hint="str(strings?.page?.key?.timeseries?.labelsHint)" persistent-hint
                        v-model="alterLabels" />
                    <P3xrButton icon="mdi-content-save" :label="str(strings?.intention?.save)" raised color="primary" @click="saveAlter()" />
                </div>
            </div>

            <!-- Info list -->
            <v-list density="compact">
                <template v-for="item in infoLabels" :key="item.key">
                    <v-list-item>
                        <div style="display: flex; width: 100%;">
                            <span style="flex: 1; font-weight: 500;">{{ item.key }}</span>
                            <span style="word-break: break-all;">{{ item.value }}</span>
                        </div>
                    </v-list-item>
                    <v-divider />
                </template>

                <template v-if="tsLabels.length > 0">
                    <v-list-item><strong>{{ str(strings?.page?.key?.timeseries?.labels) }}</strong></v-list-item>
                    <v-divider />
                    <template v-for="label in tsLabels" :key="label.key">
                        <v-list-item>
                            <div style="display: flex; width: 100%;">
                                <span style="flex: 1; font-weight: 500;">{{ label.key }}</span>
                                <span style="word-break: break-all;">{{ label.value }}</span>
                            </div>
                        </v-list-item>
                        <v-divider />
                    </template>
                </template>

                <template v-if="tsRules.length > 0">
                    <v-list-item><strong>{{ str(strings?.page?.key?.timeseries?.rules) }}</strong></v-list-item>
                    <v-divider />
                    <template v-for="rule in tsRules" :key="rule.destKey">
                        <v-list-item>
                            <div style="display: flex; width: 100%;">
                                <span style="flex: 1; font-weight: 500;">{{ rule.destKey }}</span>
                                <span>{{ rule.aggregationType }} / {{ rule.bucketDuration }}ms</span>
                            </div>
                        </v-list-item>
                        <v-divider />
                    </template>
                </template>
            </v-list>
        </P3xrAccordion>

        <KeyNewOrSetDialog :open="editDialogOpen" :data="editDialogData" @close="handleEditClose" />
    </div>
</template>

<style scoped>
.p3xr-key-type-content { padding: 8px 16px 24px; }
.p3xr-ts-controls { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; padding: 8px 0; }
.p3xr-ts-field { min-width: 140px; max-width: 200px; }
.p3xr-ts-data-row:hover { background-color: rgba(var(--v-theme-on-surface), 0.1) !important; }
.p3xr-key-table-header {
    display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-weight: bold;
    background-color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-on-primary));
    border-bottom: 2px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>