RSS Git Download  Clone
Raw Blame History 35kB 714 lines
/**
 * TimeSeries key type renderer — exact port of Angular key-timeseries.component.
 * uPlot chart, range controls, data table with virtual scroll, TS.INFO with alter mode.
 */
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
    Box, Button, Tooltip, TextField, MenuItem, Select, FormControl, InputLabel,
    List, ListItem, Divider, useMediaQuery, useTheme,
} from '@mui/material'
import {
    Edit, Image, CheckBox, CheckBoxOutlineBlank, Refresh, Add, Delete, Save,
} from '@mui/icons-material'
import { useVirtualizer } from '@tanstack/react-virtual'
import 'uplot/dist/uPlot.min.css'
import { useI18nStore } from '../../../stores/i18n.store'
import { useRedisStateStore } from '../../../stores/redis-state.store'
import { useCommonStore } from '../../../stores/common.store'
import { useOverlayStore } from '../../../stores/overlay.store'
import { request } from '../../../stores/socket.service'
import { KeyTypeProps } from './key-type-base'
import P3xrAccordion from '../../../components/P3xrAccordion'
import P3xrButton from '../../../components/P3xrButton'
import KeyNewOrSetDialog from '../../../dialogs/KeyNewOrSetDialog'

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']

export default function KeyTimeseries({ response, value, keyName, onRefresh }: KeyTypeProps) {
    const strings = useI18nStore(s => s.strings)
    const currentLang = useI18nStore(s => s.currentLang)
    const connection = useRedisStateStore(s => s.connection)
    const { toast, confirm, generalHandleError } = useCommonStore()
    const overlay = useOverlayStore()
    const muiTheme = useTheme()
    const isGtSm = useMediaQuery('(min-width: 960px)')
    const isReadonly = connection?.readonly === true
    const isDark = muiTheme.palette.mode === 'dark'

    const [tsInfo, setTsInfo] = useState<any>(() => value || {})
    const [rangeData, setRangeData] = useState<DataPoint[]>([])
    const [rangeFrom, setRangeFrom] = useState('')
    const [rangeTo, setRangeTo] = useState('')
    const [aggregationType, setAggregationType] = useState('')
    const [aggregationBucket, setAggregationBucket] = useState('')
    const [addTimestamp, setAddTimestamp] = useState('*')
    const [addValue, setAddValue] = useState('')
    const [autoRefresh, setAutoRefresh] = useState(false)
    const [alterMode, setAlterMode] = useState(false)
    const [alterRetention, setAlterRetention] = useState(0)
    const [alterDuplicatePolicy, setAlterDuplicatePolicy] = useState('LAST')
    const [alterLabels, setAlterLabels] = useState('')
    const [overlayKeysInput, setOverlayKeysInput] = useState('')
    const [mrangeFilter, setMrangeFilter] = useState('')
    const [overlaySeries, setOverlaySeries] = useState<Array<{ key: string; data: DataPoint[] }>>([])
    const [editDialogOpen, setEditDialogOpen] = useState(false)
    const [editDialogData, setEditDialogData] = useState<any>(null)
    const [chartReady, setChartReady] = useState(false)

    const chartRef = useRef<HTMLDivElement>(null)
    const plotRef = useRef<any>(null)
    const uPlotRef = useRef<any>(null)
    const resizeObserverRef = useRef<ResizeObserver | null>(null)
    const autoRefreshRef = useRef<any>(null)
    const debounceRef = useRef<any>(null)
    const dataParentRef = useRef<HTMLDivElement>(null)
    // Keep latest data in refs for chart operations (avoids stale closures)
    const rangeDataRef = useRef<DataPoint[]>([])
    const overlaySeriesRef = useRef<typeof overlaySeries>([])
    rangeDataRef.current = rangeData
    overlaySeriesRef.current = overlaySeries

    // Virtual scrolling for data table
    const virtualizer = useVirtualizer({
        count: rangeData.length,
        getScrollElement: () => dataParentRef.current,
        estimateSize: () => 40,
        overscan: 10,
    })

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

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

    const tsRules = useMemo(() => Array.isArray(tsInfo?.rules) ? tsInfo.rules : [], [tsInfo])

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

    const formatTimestamp = useCallback((ts: number) => {
        return new Date(ts).toLocaleString(currentLang || 'en', {
            year: 'numeric', month: '2-digit', day: '2-digit',
            hour: '2-digit', minute: '2-digit', second: '2-digit',
            fractionalSecondDigits: 3,
        } as any)
    }, [currentLang])

    // --- Chart helpers ---
    // Use a ref so initChart always reads the latest theme colors (no stale closures)
    const themeRef = useRef({ primary: muiTheme.palette.primary.main, isDark })
    themeRef.current = { primary: muiTheme.palette.primary.main, isDark }
    const langRef = useRef(currentLang)
    langRef.current = currentLang

    const getChartColors = useCallback(() => {
        const { primary, isDark: dark } = themeRef.current
        return {
            primary: primary || (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)',
        }
    }, [])

    const buildChartData = useCallback((data: DataPoint[], overlays: typeof overlaySeries): 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
    }, [])

    const destroyChart = useCallback(() => {
        resizeObserverRef.current?.disconnect()
        resizeObserverRef.current = null
        plotRef.current?.destroy()
        plotRef.current = null
    }, [])

    const initChart = useCallback(() => {
        if (!uPlotRef.current || !chartRef.current) return
        destroyChart()

        const el = chartRef.current
        const w = el.clientWidth || 400
        const data = rangeDataRef.current
        const overlays = overlaySeriesRef.current
        const colors = getChartColors()
        const lang = langRef.current || 'en'

        const seriesConfig: any[] = [
            {
                label: strings?.label?.time,
                value: (_: any, v: number) => {
                    if (!v) return ''
                    return new Date(v * 1000).toLocaleString(langRef.current || 'en', {
                        year: 'numeric', month: '2-digit', day: '2-digit',
                        hour: '2-digit', minute: '2-digit', second: '2-digit',
                    })
                },
            },
            { label: keyName, stroke: colors.primary, width: 2, fill: colors.primary + '15' },
        ]
        for (let i = 0; i < overlays.length; i++) {
            seriesConfig.push({
                label: overlays[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(langRef.current || 'en', { 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(data, overlays)
        plotRef.current = new uPlotRef.current(opts, chartData, el)

        let timer: any
        resizeObserverRef.current = new ResizeObserver(() => {
            clearTimeout(timer)
            timer = setTimeout(() => {
                const nw = el.clientWidth
                if (nw > 0) plotRef.current?.setSize({ width: nw, height: 400 })
            }, 50)
        })
        resizeObserverRef.current.observe(el)
    }, [destroyChart, getChartColors, buildChartData]) // reads theme/lang/data from refs

    const updateChart = useCallback(() => {
        if (!uPlotRef.current || !chartRef.current) return
        const data = rangeDataRef.current
        const overlays = overlaySeriesRef.current
        const expectedSeries = 2 + overlays.length
        if (!plotRef.current || plotRef.current.series?.length !== expectedSeries) {
            initChart()
            return
        }
        const chartData = buildChartData(data, overlays)
        plotRef.current.setData(chartData, true)
        if (chartData[0].length > 0) {
            plotRef.current.setScale('x', { min: chartData[0][0], max: chartData[0][chartData[0].length - 1] })
        }
    }, [initChart, buildChartData])

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

            // Overlay keys
            const newOverlays: typeof overlaySeries = []
            const overlayKeys = overlayKeysInput.split(',').map(k => k.trim()).filter(k => k.length > 0)
            for (const ok of overlayKeys) {
                try {
                    const op: any = { key: ok }
                    if (rangeFrom) op.from = rangeFrom
                    if (rangeTo) op.to = rangeTo
                    if (aggregationType && aggregationBucket) {
                        op.aggregation = { type: aggregationType, timeBucket: parseInt(aggregationBucket, 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.trim().length > 0) {
                try {
                    const mp: any = { filter: mrangeFilter.trim() }
                    if (rangeFrom) mp.from = rangeFrom
                    if (rangeTo) mp.to = rangeTo
                    if (aggregationType && aggregationBucket) {
                        mp.aggregation = { type: aggregationType, timeBucket: parseInt(aggregationBucket, 10) }
                    }
                    const mr = await request({ action: 'timeseries/mrange', payload: mp })
                    for (const entry of (mr.data || [])) {
                        if (entry.key !== keyName) newOverlays.push({ key: entry.key, data: entry.data })
                    }
                } catch { /* skip */ }
            }

            setOverlaySeries(newOverlays)
            overlaySeriesRef.current = newOverlays
            // Update chart after state is set
            setTimeout(() => updateChart(), 0)
        } catch (e: any) { generalHandleError(e) }
    }, [keyName, rangeFrom, rangeTo, aggregationType, aggregationBucket, overlayKeysInput, mrangeFilter, updateChart, generalHandleError])

    const debouncedLoadRange = useCallback(() => {
        clearTimeout(debounceRef.current)
        debounceRef.current = setTimeout(() => loadRange(), 500)
    }, [loadRange])

    // --- Init: load uPlot module ---
    useEffect(() => {
        let cancelled = false
        import('uplot').then(mod => {
            if (cancelled) return
            uPlotRef.current = mod.default
            setChartReady(true)
        }).catch(e => console.error('Failed to load uPlot', e))
        return () => {
            cancelled = true
            destroyChart()
        }
    }, []) // eslint-disable-line react-hooks/exhaustive-deps

    // --- Init: load data on mount / value change ---
    useEffect(() => {
        setTsInfo(value || {})
        loadRange()
        // Ensure default label
        if (!isReadonly) {
            const labels = (value as any)?.labels
            const labelCount = labels && typeof labels === 'object' ? Object.keys(labels).length : 0
            if (labelCount === 0) {
                request({
                    action: 'timeseries/alter',
                    payload: { key: keyName, labels: `key ${keyName}` },
                }).then(() => {
                    setTsInfo((prev: any) => ({ ...prev, labels: { key: keyName } }))
                }).catch(() => {})
            }
        }
    }, [value]) // eslint-disable-line react-hooks/exhaustive-deps

    // --- Init chart when uPlot is loaded or data first arrives ---
    useEffect(() => {
        if (!chartReady) return
        const t = setTimeout(() => {
            if (plotRef.current) {
                updateChart()
            } else {
                initChart()
            }
        }, 150)
        return () => clearTimeout(t)
    }, [chartReady, rangeData]) // eslint-disable-line react-hooks/exhaustive-deps

    // --- Full re-init chart on theme or language change (colors/labels change) ---
    const primaryColor = muiTheme.palette.primary.main
    useEffect(() => {
        if (!chartReady) return
        const t = setTimeout(() => {
            destroyChart()
            initChart()
        }, 100)
        return () => clearTimeout(t)
    }, [isDark, currentLang, primaryColor]) // eslint-disable-line react-hooks/exhaustive-deps

    // Auto-refresh
    useEffect(() => {
        if (autoRefresh) {
            autoRefreshRef.current = setInterval(() => loadRange(), 10000)
        } else {
            clearInterval(autoRefreshRef.current)
        }
        return () => clearInterval(autoRefreshRef.current)
    }, [autoRefresh, loadRange])

    // --- Actions ---
    const addDataPoint = useCallback(async () => {
        if (!addValue) return
        try {
            await request({ action: 'timeseries/add', payload: { key: keyName, timestamp: addTimestamp || '*', value: parseFloat(addValue) } })
            toast(strings?.status?.added)
            setAddValue('')
            onRefresh()
        } catch (e: any) { generalHandleError(e) }
    }, [keyName, addTimestamp, addValue, strings, toast, onRefresh, generalHandleError])

    const deleteDataPoint = useCallback(async (point: DataPoint) => {
        try {
            await confirm({ message: strings?.confirm?.delete })
            await request({ action: 'timeseries/del', payload: { key: keyName, from: point.timestamp, to: point.timestamp } })
            toast(strings?.status?.deleted)
            onRefresh()
        } catch (e: any) { if (e !== undefined && e !== null) generalHandleError(e) }
    }, [keyName, strings, confirm, toast, onRefresh, generalHandleError])

    const editDataPoint = useCallback((point: DataPoint) => {
        setEditDialogData({
            type: 'edit',
            model: { type: 'timeseries', key: keyName, tsTimestamp: String(point.timestamp), value: point.value, originalTimestamp: point.timestamp },
        })
        setEditDialogOpen(true)
    }, [keyName])

    const editAllDataPoints = useCallback(() => {
        const allPoints = rangeData.map(p => `${p.timestamp} ${p.value}`).join('\n')
        const currentLabels = tsLabels.map(l => `${l.key} ${l.value}`).join(' ') || `key ${keyName}`
        setEditDialogData({
            type: 'edit',
            model: { type: 'timeseries', key: keyName, value: allPoints, tsEditAll: true, tsLabels: currentLabels },
        })
        setEditDialogOpen(true)
    }, [rangeData, tsLabels, keyName])

    const handleEditClose = useCallback((result?: any) => {
        setEditDialogOpen(false)
        setEditDialogData(null)
        if (result) {
            onRefresh()
            loadRange()
        }
    }, [onRefresh, loadRange])

    const toggleAlterMode = useCallback(() => {
        setAlterMode(prev => {
            if (!prev) {
                setAlterRetention(tsInfo?.retentionTime || 0)
                setAlterDuplicatePolicy((tsInfo?.duplicatePolicy || 'LAST').toUpperCase())
                const labels = tsLabels.map(l => `${l.key} ${l.value}`).join(' ')
                setAlterLabels(labels || `key ${keyName}`)
            }
            return !prev
        })
    }, [tsInfo, tsLabels, keyName])

    const saveAlter = useCallback(async () => {
        try {
            const labels = alterLabels.trim().length > 0 ? alterLabels : `key ${keyName}`
            await request({
                action: 'timeseries/alter',
                payload: { key: keyName, retention: alterRetention, duplicatePolicy: alterDuplicatePolicy, labels },
            })
            toast(strings?.status?.saved)
            setAlterMode(false)
            onRefresh()
        } catch (e: any) { generalHandleError(e) }
    }, [keyName, alterRetention, alterDuplicatePolicy, alterLabels, strings, toast, onRefresh, generalHandleError])

    const exportChartPng = useCallback(() => {
        if (!plotRef.current || !chartRef.current) return
        const chartCanvas = chartRef.current.querySelector('canvas') as HTMLCanvasElement
        if (!chartCanvas) return

        const bgColor = isDark ? '#1e1e1e' : '#ffffff'
        const textColor = isDark ? '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(keyName, padding, padding + 16)
        ctx.drawImage(chartCanvas, padding, padding + titleHeight)

        const allSeriesKeys = [keyName, ...overlaySeries.map(s => s.key)]
        const colors = [getChartColors().primary, ...overlaySeries.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 < allSeriesKeys.length; i++) {
            ctx.fillStyle = colors[i]
            ctx.fillRect(legendX, legendY - 8, 12, 12)
            ctx.fillStyle = textColor
            ctx.fillText(allSeriesKeys[i], legendX + 16, legendY + 2)
            legendX += ctx.measureText(allSeriesKeys[i]).width + 32
        }

        const url = exportCanvas.toDataURL('image/png')
        const a = document.createElement('a')
        a.href = url; a.download = `${keyName}-chart.png`; a.click()
    }, [keyName, overlaySeries, isDark, getChartColors])

    const hoverBg = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
    const oddBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'
    const listBorder = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'
    const iconSx = (color: string) => ({ fontSize: 18, cursor: 'pointer', mx: '2px', opacity: 0.7, color, '&:hover': { opacity: 1 } })
    const fieldSx = { minWidth: 140, maxWidth: 200, '& .MuiInputBase-input': { fontSize: 13 }, '& .MuiInputLabel-root': { fontSize: 13 } }

    return (
        <Box className="p3xr-key-type-content">
            {/* Chart accordion */}
            <br />
            <P3xrAccordion
                title={strings?.page?.key?.timeseries?.chart}
                accordionKey="ts-chart"
                actions={<>
                    {!isReadonly && (
                        <P3xrButton icon={<Edit sx={{ fontSize: 18 }} />} label={strings?.intention?.edit}
                            breakpoint={1280} color="inherit"
                            onClick={(e) => { e.stopPropagation(); editAllDataPoints() }} />
                    )}
                    <P3xrButton icon={<Image sx={{ fontSize: 18 }} />} label={strings?.page?.key?.timeseries?.exportChart}
                        breakpoint={1280} color="inherit"
                        onClick={(e) => { e.stopPropagation(); exportChartPng() }} />
                    <P3xrButton icon={autoRefresh ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
                        label={strings?.label?.autoRefresh} breakpoint={1280} color="inherit"
                        onClick={(e) => { e.stopPropagation(); setAutoRefresh(v => !v) }} />
                    {!autoRefresh && (
                        <P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />} label={strings?.intention?.refresh}
                            breakpoint={1280} color="inherit"
                            onClick={(e) => { e.stopPropagation(); loadRange() }} />
                    )}
                </>}
            >
                <Box sx={{ p: 2 }}>
                    {/* Range controls */}
                    <Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 1, py: 1 }}>
                        <TextField size="small" sx={fieldSx} label={strings?.page?.key?.timeseries?.from} placeholder="-"
                            value={rangeFrom} onChange={e => { setRangeFrom(e.target.value); debouncedLoadRange() }} />
                        <TextField size="small" sx={fieldSx} label={strings?.page?.key?.timeseries?.to} placeholder="+"
                            value={rangeTo} onChange={e => { setRangeTo(e.target.value); debouncedLoadRange() }} />
                        <FormControl size="small" sx={fieldSx}>
                            <InputLabel sx={{ fontSize: 13 }}>{strings?.page?.key?.timeseries?.aggregation}</InputLabel>
                            <Select value={aggregationType} label={strings?.page?.key?.timeseries?.aggregation}
                                onChange={e => { setAggregationType(e.target.value); setTimeout(() => loadRange(), 0) }}
                                sx={{ fontSize: 13 }}>
                                <MenuItem value="">{strings?.page?.key?.timeseries?.none}</MenuItem>
                                {aggregationTypes.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}
                            </Select>
                        </FormControl>
                        {aggregationType && (
                            <TextField size="small" sx={fieldSx} type="number"
                                label={strings?.page?.key?.timeseries?.timeBucket} placeholder="5000"
                                value={aggregationBucket} onChange={e => { setAggregationBucket(e.target.value); debouncedLoadRange() }} />
                        )}
                        <TextField size="small" sx={{ ...fieldSx, minWidth: 200 }}
                            label={strings?.page?.key?.timeseries?.overlay}
                            placeholder={strings?.page?.key?.timeseries?.overlayHint}
                            value={overlayKeysInput} onChange={e => { setOverlayKeysInput(e.target.value); debouncedLoadRange() }} />
                        <TextField size="small" sx={{ ...fieldSx, minWidth: 180 }}
                            label={strings?.page?.key?.timeseries?.mrangeFilter}
                            placeholder={strings?.page?.key?.timeseries?.mrangeHint}
                            value={mrangeFilter} onChange={e => { setMrangeFilter(e.target.value); debouncedLoadRange() }} />
                    </Box>

                    {/* Data points count */}
                    <Box sx={{ py: '4px', opacity: 0.6, fontSize: 13 }}>
                        {rangeData.length} {strings?.page?.key?.timeseries?.dataPoints}
                    </Box>

                    {/* Chart container */}
                    <Box ref={chartRef} sx={{ width: '100%', minHeight: 400 }} />

                    {/* Add data point */}
                    {!isReadonly && (
                        <Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 1, mt: 2 }}>
                            <TextField size="small" sx={fieldSx}
                                label={strings?.page?.key?.timeseries?.timestamp}
                                placeholder="* (auto)" value={addTimestamp}
                                onChange={e => setAddTimestamp(e.target.value)} />
                            <TextField size="small" sx={fieldSx} type="number"
                                label={strings?.page?.key?.timeseries?.value}
                                value={addValue} onChange={e => setAddValue(e.target.value)}
                                onKeyDown={e => { if (e.key === 'Enter') addDataPoint() }} />
                            <P3xrButton icon={<Add fontSize="small" />} label={strings?.intention?.add}
                                raised color="primary" onClick={() => addDataPoint()} disabled={!addValue} />
                        </Box>
                    )}
                </Box>
            </P3xrAccordion>

            {/* Data table accordion */}
            {rangeData.length > 0 && (
                <>
                    <br />
                    <P3xrAccordion
                        title={capitalize(strings?.page?.key?.timeseries?.dataPoints) + ` (${rangeData.length})`}
                        accordionKey="ts-data"
                    >
                        <Box>
                            {/* Header */}
                            <Box sx={{
                                display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 1, fontWeight: 'bold',
                                bgcolor: muiTheme.palette.primary.main, color: muiTheme.palette.primary.contrastText,
                                borderBottom: `2px solid ${listBorder}`,
                            }}>
                                <Box component="span" sx={{ flex: 1 }}>{strings?.page?.key?.timeseries?.timestamp}</Box>
                                <Box component="span">{strings?.page?.key?.timeseries?.value}</Box>
                                {!isReadonly && <Box component="span" sx={{ minWidth: 52 }} />}
                            </Box>

                            {/* Virtual scroll */}
                            <Box ref={dataParentRef} sx={{ height: 600, overflow: 'auto' }}>
                                <Box sx={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
                                    {virtualizer.getVirtualItems().map(vRow => {
                                        const point = rangeData[vRow.index]
                                        return (
                                            <Box key={vRow.key} sx={{
                                                position: 'absolute', top: 0, left: 0, width: '100%',
                                                transform: `translateY(${vRow.start}px)`,
                                                height: vRow.size,
                                                display: 'flex', alignItems: 'center', gap: 1, px: 2,
                                                borderBottom: `1px solid ${listBorder}`,
                                                bgcolor: vRow.index % 2 === 0 ? oddBg : 'transparent',
                                                '&:hover': { bgcolor: `${hoverBg} !important` },
                                            }}>
                                                <Box component="span" sx={{ flex: 1, fontSize: 13 }}>{formatTimestamp(point.timestamp)}</Box>
                                                <Box component="span" sx={{ fontSize: 13, fontFamily: "'Roboto Mono', monospace" }}>{point.value}</Box>
                                                {!isReadonly && (
                                                    <Box component="span" sx={{ display: 'flex', alignItems: 'center' }}>
                                                        <Tooltip title={strings?.intention?.delete ?? 'Delete'}>
                                                            <Delete sx={iconSx('error.main')} onClick={() => deleteDataPoint(point)} />
                                                        </Tooltip>
                                                        <Tooltip title={strings?.intention?.edit ?? 'Edit'}>
                                                            <Edit sx={iconSx('primary.main')} onClick={() => editDataPoint(point)} />
                                                        </Tooltip>
                                                    </Box>
                                                )}
                                            </Box>
                                        )
                                    })}
                                </Box>
                            </Box>
                        </Box>
                    </P3xrAccordion>
                </>
            )}

            {/* TS.INFO accordion */}
            <br />
            <P3xrAccordion
                title={strings?.page?.key?.timeseries?.info}
                accordionKey="ts-info"
                actions={!isReadonly ? (
                    <P3xrButton icon={alterMode ? <CheckBox sx={{ fontSize: 18 }} /> : <Edit sx={{ fontSize: 18 }} />}
                        label={strings?.intention?.edit} color="inherit"
                        onClick={(e) => { e.stopPropagation(); toggleAlterMode() }} />
                ) : undefined}
            >
                {alterMode && (
                    <Box sx={{ p: 2 }}>
                        <Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 1 }}>
                            <TextField size="small" type="number" sx={{ flex: 1, minWidth: 150 }}
                                label={`${strings?.page?.key?.timeseries?.retention} (ms)`}
                                helperText={strings?.page?.key?.timeseries?.retentionHint}
                                value={alterRetention} onChange={e => setAlterRetention(Number(e.target.value))} />
                            <FormControl size="small" sx={{ flex: 1, minWidth: 150 }}>
                                <InputLabel>{strings?.page?.key?.timeseries?.duplicatePolicy}</InputLabel>
                                <Select value={alterDuplicatePolicy}
                                    label={strings?.page?.key?.timeseries?.duplicatePolicy}
                                    onChange={e => setAlterDuplicatePolicy(e.target.value)}>
                                    {['LAST', 'FIRST', 'MIN', 'MAX', 'SUM', 'BLOCK'].map(p =>
                                        <MenuItem key={p} value={p}>{p}</MenuItem>
                                    )}
                                </Select>
                            </FormControl>
                            <TextField size="small" sx={{ flex: 1, minWidth: 200 }}
                                label={strings?.page?.key?.timeseries?.labels}
                                helperText={strings?.page?.key?.timeseries?.labelsHint}
                                value={alterLabels} onChange={e => setAlterLabels(e.target.value)} />
                            <P3xrButton icon={<Save fontSize="small" />} label={strings?.intention?.save}
                                raised color="primary" onClick={() => saveAlter()} />
                        </Box>
                    </Box>
                )}

                <List disablePadding>
                    {infoLabels.map(item => (
                        <Box key={item.key}>
                            <ListItem sx={{ px: 2, py: 1 }}>
                                <Box sx={{ display: 'flex', width: '100%' }}>
                                    <Box component="span" sx={{ flex: 1, fontWeight: 500 }}>{item.key}</Box>
                                    <Box component="span" sx={{ wordBreak: 'break-all' }}>{String(item.value)}</Box>
                                </Box>
                            </ListItem>
                            <Divider />
                        </Box>
                    ))}

                    {tsLabels.length > 0 && (
                        <>
                            <ListItem sx={{ px: 2, py: 1 }}><strong>{strings?.page?.key?.timeseries?.labels}</strong></ListItem>
                            <Divider />
                            {tsLabels.map(label => (
                                <Box key={label.key}>
                                    <ListItem sx={{ px: 2, py: 1 }}>
                                        <Box sx={{ display: 'flex', width: '100%' }}>
                                            <Box component="span" sx={{ flex: 1, fontWeight: 500 }}>{label.key}</Box>
                                            <Box component="span" sx={{ wordBreak: 'break-all' }}>{label.value}</Box>
                                        </Box>
                                    </ListItem>
                                    <Divider />
                                </Box>
                            ))}
                        </>
                    )}

                    {tsRules.length > 0 && (
                        <>
                            <ListItem sx={{ px: 2, py: 1 }}><strong>{strings?.page?.key?.timeseries?.rules}</strong></ListItem>
                            <Divider />
                            {tsRules.map((rule: any) => (
                                <Box key={rule.destKey}>
                                    <ListItem sx={{ px: 2, py: 1 }}>
                                        <Box sx={{ display: 'flex', width: '100%' }}>
                                            <Box component="span" sx={{ flex: 1, fontWeight: 500 }}>{rule.destKey}</Box>
                                            <Box component="span">{rule.aggregationType} / {rule.bucketDuration}ms</Box>
                                        </Box>
                                    </ListItem>
                                    <Divider />
                                </Box>
                            ))}
                        </>
                    )}
                </List>
            </P3xrAccordion>

            <KeyNewOrSetDialog open={editDialogOpen} data={editDialogData} onClose={handleEditClose} />
        </Box>
    )
}