/** * 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(() => value || {}) const [rangeData, setRangeData] = useState([]) 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>([]) const [editDialogOpen, setEditDialogOpen] = useState(false) const [editDialogData, setEditDialogData] = useState(null) const [chartReady, setChartReady] = useState(false) const chartRef = useRef(null) const plotRef = useRef(null) const uPlotRef = useRef(null) const resizeObserverRef = useRef(null) const autoRefreshRef = useRef(null) const debounceRef = useRef(null) const dataParentRef = useRef(null) // Keep latest data in refs for chart operations (avoids stale closures) const rangeDataRef = useRef([]) const overlaySeriesRef = useRef([]) 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() 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() 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 || '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 || '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 || 'Delete?' }) await request({ action: 'timeseries-del', payload: { key: keyName, from: point.timestamp, to: point.timestamp } }) toast(strings?.status?.deleted || '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 || 'Updated') 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 ( {/* Chart accordion */} {!isReadonly && ( } label={strings?.intention?.edit || 'Edit'} breakpoint={1280} color="inherit" onClick={(e) => { e.stopPropagation(); editAllDataPoints() }} /> )} } label={strings?.page?.key?.timeseries?.exportChart || 'Export PNG'} breakpoint={1280} color="inherit" onClick={(e) => { e.stopPropagation(); exportChartPng() }} /> : } label={strings?.label?.autoRefresh || 'Auto'} breakpoint={1280} color="inherit" onClick={(e) => { e.stopPropagation(); setAutoRefresh(v => !v) }} /> {!autoRefresh && ( } label={strings?.intention?.refresh || 'Refresh'} breakpoint={1280} color="inherit" onClick={(e) => { e.stopPropagation(); loadRange() }} /> )} } > {/* Range controls */} { setRangeFrom(e.target.value); debouncedLoadRange() }} /> { setRangeTo(e.target.value); debouncedLoadRange() }} /> {strings?.page?.key?.timeseries?.aggregation || 'Aggregation'} {aggregationType && ( { setAggregationBucket(e.target.value); debouncedLoadRange() }} /> )} { setOverlayKeysInput(e.target.value); debouncedLoadRange() }} /> { setMrangeFilter(e.target.value); debouncedLoadRange() }} /> {/* Data points count */} {rangeData.length} {strings?.page?.key?.timeseries?.dataPoints || 'data points'} {/* Chart container */} {/* Add data point */} {!isReadonly && ( setAddTimestamp(e.target.value)} /> setAddValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addDataPoint() }} /> } label={strings?.intention?.add || 'Add'} raised color="primary" onClick={() => addDataPoint()} disabled={!addValue} /> )} {/* Data table accordion */} {rangeData.length > 0 && ( <> {/* Header */} {strings?.page?.key?.timeseries?.timestamp || 'Timestamp'} {strings?.page?.key?.timeseries?.value || 'Value'} {!isReadonly && } {/* Virtual scroll */} {virtualizer.getVirtualItems().map(vRow => { const point = rangeData[vRow.index] return ( {formatTimestamp(point.timestamp)} {point.value} {!isReadonly && ( deleteDataPoint(point)} /> editDataPoint(point)} /> )} ) })} )} {/* TS.INFO accordion */} : } label={strings?.intention?.edit || 'Edit'} color="inherit" onClick={(e) => { e.stopPropagation(); toggleAlterMode() }} /> ) : undefined} > {alterMode && ( setAlterRetention(Number(e.target.value))} /> {strings?.page?.key?.timeseries?.duplicatePolicy || 'Duplicate policy'} setAlterLabels(e.target.value)} /> } label={strings?.intention?.save || 'Save'} raised color="primary" onClick={() => saveAlter()} /> )} {infoLabels.map(item => ( {item.key} {String(item.value)} ))} {tsLabels.length > 0 && ( <> {strings?.page?.key?.timeseries?.labels || 'Labels'} {tsLabels.map(label => ( {label.key} {label.value} ))} )} {tsRules.length > 0 && ( <> {strings?.page?.key?.timeseries?.rules || 'Rules'} {tsRules.map((rule: any) => ( {rule.destKey} {rule.aggregationType} / {rule.bucketDuration}ms ))} )} ) }