RSS Git Download  Clone
Raw Blame History 73kB 1357 lines
/**
 * Pulse (monitoring overview) — exact port of Angular monitoring.component.
 * Real-time metrics with 4 uPlot charts, slow log, client list, top keys.
 * Charts are theme-aware and language-aware.
 * Export all (ZIP with TXT + charts PNG + PDF) deferred to end.
 */
import { useState, useEffect, useCallback, useRef } from 'react'
import {
    Box, List, ListItem, Divider, useTheme, CircularProgress, Tooltip,
} from '@mui/material'
import {
    Pause, PlayArrow, Download, Archive, Refresh, Close,
    CheckBox, CheckBoxOutlineBlank, HourglassEmpty, DeleteSweep,
} from '@mui/icons-material'
import 'uplot/dist/uPlot.min.css'
import { useI18nStore } from '../../stores/i18n.store'
import { useRedisStateStore } from '../../stores/redis-state.store'
import { parseRedisVersion } from '../../../core/redis-version'
import { useCommonStore } from '../../stores/common.store'
import { request } from '../../stores/socket.service'
import P3xrAccordion from '../../components/P3xrAccordion'
import P3xrButton from '../../components/P3xrButton'

interface MonitorSnapshot {
    timestamp: number
    memory: { used: number; rss: number; peak: number; usedHuman: string; rssHuman: string; peakHuman: string; fragRatio: number }
    stats: { opsPerSec: number; hits: number; misses: number; hitRate: number; inputKbps: number; outputKbps: number; totalCommands: number; expiredKeys: number; evictedKeys: number }
    clients: { connected: number; blocked: number }
    server: { version: string; uptime: number; mode: string }
    keyspace: Record<string, string>
    slowlog: Array<{ id: number; timestamp: number; duration: number; command: string }>
}

const MAX_HISTORY = 120
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })

function formatBytes(bytes: number): string {
    if (bytes < 1024) return bytes + ' B'
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}

function uptimeFormatted(s: number): string {
    const d = Math.floor(s / 86400)
    const h = Math.floor((s % 86400) / 3600)
    const m = Math.floor((s % 3600) / 60)
    return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`
}

function downloadText(content: string, filename: string): void {
    const blob = new Blob([content], { type: 'text/plain' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a'); a.href = url; a.download = filename; a.click()
    URL.revokeObjectURL(url)
}

export default function PulsePage() {
    const strings = useI18nStore(s => s.strings)
    const currentLang = useI18nStore(s => s.currentLang)
    const connection = useRedisStateStore(s => s.connection)
    const info = useRedisStateStore(s => s.info)
    const modules = useRedisStateStore(s => s.modules)
    const { toast, confirm, generalHandleError } = useCommonStore()
    const muiTheme = useTheme()
    const isDark = muiTheme.palette.mode === 'dark'
    const isReadonly = connection?.readonly === true
    const connName = connection?.name || 'redis'

    const [current, setCurrent] = useState<MonitorSnapshot | null>(null)
    const [paused, setPaused] = useState(false)
    const [clientList, setClientList] = useState<any[]>([])
    const [topKeys, setTopKeys] = useState<any[]>([])
    const [slotStats, setSlotStats] = useState<any[]>([])
    const [slotStatsMetric, setSlotStatsMetric] = useState('KEY-COUNT')
    const [slotStatsLoaded, setSlotStatsLoaded] = useState(false)
    const [clientListLoaded, setClientListLoaded] = useState(false)
    const [topKeysLoaded, setTopKeysLoaded] = useState(false)
    const [autoRefreshClients, setAutoRefreshClients] = useState(() => localStorage.getItem('p3xr-monitor-auto-clients') === 'true')
    const [autoRefreshTopKeys, setAutoRefreshTopKeys] = useState(() => localStorage.getItem('p3xr-monitor-auto-topkeys') === 'true')

    const historyRef = useRef<MonitorSnapshot[]>([])
    const uPlotRef = useRef<any>(null)
    const chartsInitRef = useRef(false)
    const memChartRef = useRef<HTMLDivElement>(null)
    const opsChartRef = useRef<HTMLDivElement>(null)
    const cliChartRef = useRef<HTMLDivElement>(null)
    const netChartRef = useRef<HTMLDivElement>(null)
    const memPlotRef = useRef<any>(null)
    const opsPlotRef = useRef<any>(null)
    const cliPlotRef = useRef<any>(null)
    const netPlotRef = useRef<any>(null)
    const resizeObRef = useRef<ResizeObserver | null>(null)
    const pausedRef = useRef(false)
    const autoCliRef = useRef(autoRefreshClients)
    const autoTopRef = useRef(autoRefreshTopKeys)
    pausedRef.current = paused
    autoCliRef.current = autoRefreshClients
    autoTopRef.current = autoRefreshTopKeys

    // Theme colors via ref — always reads latest palette on every render
    const themeRef = useRef({
        primary: muiTheme.palette.primary.main,
        accent: muiTheme.palette.secondary.main,
        warn: muiTheme.palette.error.main,
        text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)',
        grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
    })
    themeRef.current = {
        primary: muiTheme.palette.primary.main,
        accent: muiTheme.palette.secondary.main,
        warn: muiTheme.palette.error.main,
        text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)',
        grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
    }

    const buildChartData = useCallback(() => {
        const h = historyRef.current
        return {
            timestamps: h.map(s => s.timestamp / 1000),
            memUsed: h.map(s => s.memory.used / (1024 * 1024)),
            memRss: h.map(s => s.memory.rss / (1024 * 1024)),
            ops: h.map(s => s.stats.opsPerSec),
            connected: h.map(s => s.clients.connected),
            blocked: h.map(s => s.clients.blocked),
            netIn: h.map(s => s.stats.inputKbps),
            netOut: h.map(s => s.stats.outputKbps),
        }
    }, [])

    const stringsRef = useRef(strings)
    stringsRef.current = strings

    const createOpts = useCallback((width: number, seriesConfig: any[]) => {
        const c = themeRef.current
        return {
            width, height: 180,
            cursor: { show: true, drag: { x: false, y: false } },
            legend: { show: true, live: false },
            scales: { x: { time: true } },
            axes: [
                { stroke: c.text, grid: { stroke: c.grid, width: 1 }, ticks: { stroke: c.grid }, font: '11px Roboto',
                    values: (_: any, ticks: number[]) => ticks.map(t => formatTime(t * 1000)) },
                { stroke: c.text, grid: { stroke: c.grid, width: 1 }, ticks: { stroke: c.grid }, font: '11px Roboto Mono', size: 55 },
            ],
            series: [
                { label: stringsRef.current?.label?.time || 'Time', value: (_: any, v: number) => v ? formatTime(v * 1000) : '' },
                ...seriesConfig,
            ],
        }
    }, []) // reads theme+strings from refs

    const destroyCharts = useCallback(() => {
        resizeObRef.current?.disconnect(); resizeObRef.current = null
        memPlotRef.current?.destroy(); memPlotRef.current = null
        opsPlotRef.current?.destroy(); opsPlotRef.current = null
        cliPlotRef.current?.destroy(); cliPlotRef.current = null
        netPlotRef.current?.destroy(); netPlotRef.current = null
        chartsInitRef.current = false
    }, [])

    const initCharts = useCallback(() => {
        if (!uPlotRef.current || chartsInitRef.current) return
        const memEl = memChartRef.current, opsEl = opsChartRef.current
        const cliEl = cliChartRef.current, netEl = netChartRef.current
        if (!memEl || !opsEl || !cliEl || !netEl) return
        const data = buildChartData()
        if (data.timestamps.length < 2) return
        const c = themeRef.current
        const s = stringsRef.current?.page?.monitor || {} as any

        memPlotRef.current = new uPlotRef.current(createOpts(memEl.offsetWidth || 500, [
            { label: s.memory || 'Memory', stroke: c.primary, width: 2, fill: c.primary + '15' },
            { label: 'RSS', stroke: c.accent, width: 2 },
        ]), [data.timestamps, data.memUsed, data.memRss], memEl)

        opsPlotRef.current = new uPlotRef.current(createOpts(opsEl.offsetWidth || 500, [
            { label: s.opsPerSec || 'Ops/s', stroke: c.primary, width: 2, fill: c.primary + '20' },
        ]), [data.timestamps, data.ops], opsEl)

        cliPlotRef.current = new uPlotRef.current(createOpts(cliEl.offsetWidth || 500, [
            { label: s.clients || 'Connected', stroke: c.primary, width: 2 },
            { label: s.blocked || 'Blocked', stroke: c.warn, width: 2 },
        ]), [data.timestamps, data.connected, data.blocked], cliEl)

        netPlotRef.current = new uPlotRef.current(createOpts(netEl.offsetWidth || 500, [
            { label: '\u2193 In', stroke: c.primary, width: 2, fill: c.primary + '15' },
            { label: '\u2191 Out', stroke: c.accent, width: 2 },
        ]), [data.timestamps, data.netIn, data.netOut], netEl)

        chartsInitRef.current = true

        let rt: any
        resizeObRef.current = new ResizeObserver(() => {
            clearTimeout(rt)
            rt = setTimeout(() => {
                const h = 180
                if (memEl.offsetWidth > 0) memPlotRef.current?.setSize({ width: memEl.offsetWidth, height: h })
                if (opsEl.offsetWidth > 0) opsPlotRef.current?.setSize({ width: opsEl.offsetWidth, height: h })
                if (cliEl.offsetWidth > 0) cliPlotRef.current?.setSize({ width: cliEl.offsetWidth, height: h })
                if (netEl.offsetWidth > 0) netPlotRef.current?.setSize({ width: netEl.offsetWidth, height: h })
            }, 50)
        })
        resizeObRef.current.observe(memEl)
        resizeObRef.current.observe(opsEl)
        resizeObRef.current.observe(cliEl)
        resizeObRef.current.observe(netEl)
    }, [buildChartData, createOpts, destroyCharts]) // reads theme+strings from refs

    const updateCharts = useCallback(() => {
        if (!chartsInitRef.current) return
        const d = buildChartData()
        memPlotRef.current?.setData([d.timestamps, d.memUsed, d.memRss])
        opsPlotRef.current?.setData([d.timestamps, d.ops])
        cliPlotRef.current?.setData([d.timestamps, d.connected, d.blocked])
        netPlotRef.current?.setData([d.timestamps, d.netIn, d.netOut])
    }, [buildChartData])

    // --- Data fetching ---
    const fetchData = useCallback(async () => {
        try {
            const resp = await request({ action: 'monitor/info', payload: {} })
            const data: MonitorSnapshot = resp.data
            setCurrent(data)
            historyRef.current.push(data)
            if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift()
            if (chartsInitRef.current) {
                updateCharts()
            } else if (uPlotRef.current && historyRef.current.length >= 2) {
                initCharts()
            }
        } catch { /* next tick */ }
    }, [updateCharts, initCharts])

    const loadClientList = useCallback(async () => {
        try {
            const resp = await request({ action: 'client/list', payload: {} })
            setClientList(resp.data)
            setClientListLoaded(true)
        } catch { setClientListLoaded(true) }
    }, [])

    const loadTopKeys = useCallback(async () => {
        try {
            const resp = await request({ action: 'memory/top-keys', payload: { topN: 20 } })
            setTopKeys(resp.data)
            setTopKeysLoaded(true)
        } catch { setTopKeysLoaded(true) }
    }, [])

    const loadSlotStats = useCallback(async (metric?: string) => {
        try {
            const resp = await request({ action: 'cluster/slot-stats', payload: { metric: metric || slotStatsMetric, limit: 20 } })
            setSlotStats(resp.slots || [])
            setSlotStatsLoaded(true)
        } catch { setSlotStatsLoaded(true); setSlotStats([]) }
    }, [slotStatsMetric])

    const isCluster = useRedisStateStore.getState().connection?.cluster === true
    const rv = parseRedisVersion(useRedisStateStore.getState().info?.server?.redis_version)

    const [clusterShards, setClusterShards] = useState<any[] | null>(null)
    const [autoRefreshShards, setAutoRefreshShards] = useState(() => localStorage.getItem('p3xr-monitor-auto-shards') === 'true')
    const shardsIntervalRef = useRef<any>(null)

    const loadClusterShards = useCallback(async () => {
        try {
            const resp = await request({ action: 'cluster/shards' })
            setClusterShards(resp.data.shards)
        } catch (e) { generalHandleError(e) }
    }, [generalHandleError])

    const toggleAutoRefreshShards = useCallback(() => {
        setAutoRefreshShards(prev => {
            const next = !prev
            localStorage.setItem('p3xr-monitor-auto-shards', String(next))
            if (next) loadClusterShards()
            return next
        })
    }, [loadClusterShards])

    useEffect(() => {
        if (autoRefreshShards) {
            shardsIntervalRef.current = setInterval(() => loadClusterShards(), 2000)
        } else {
            clearInterval(shardsIntervalRef.current)
        }
        return () => clearInterval(shardsIntervalRef.current)
    }, [autoRefreshShards, loadClusterShards])

    const getSlotCount = (shard: any) => shard.slotRanges.reduce((sum: number, [a, b]: [number, number]) => sum + (b - a + 1), 0)

    const exportClusterSlots = useCallback(() => {
        if (!clusterShards) return
        const lines = clusterShards.map(s => {
            const slots = s.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', ')
            const count = getSlotCount(s)
            const replicas = s.replicas.map((r: any) => `${r.host}:${r.port}`).join(', ')
            return `${s.master.host}:${s.master.port} | ${slots} | ${count} slots | replicas: ${replicas || 'none'}`
        })
        downloadText(lines.join('\n'), `${connName}-cluster-slots.txt`)
    }, [clusterShards, connName])

    // --- Init ---
    useEffect(() => {
        fetchData()
        loadClientList()
        loadTopKeys()
        if (isCluster && rv.isAtLeast(8, 2)) loadSlotStats()

        import('uplot').then(mod => {
            uPlotRef.current = mod.default
            if (historyRef.current.length >= 2) setTimeout(() => initCharts(), 300)
        })

        const interval = setInterval(() => {
            if (!pausedRef.current) {
                fetchData()
                if (autoCliRef.current) loadClientList()
                if (autoTopRef.current) loadTopKeys()
            }
        }, 2000)

        return () => {
            clearInterval(interval)
            destroyCharts()
        }
    }, []) // eslint-disable-line react-hooks/exhaustive-deps

    // Re-init on connection change
    useEffect(() => {
        if (!connection) return
        historyRef.current = []
        destroyCharts()
        setCurrent(null)
        setClientList([])
        setTopKeys([])
        setClientListLoaded(false)
        setTopKeysLoaded(false)
        fetchData()
        loadClientList()
        loadTopKeys()
    }, [connection]) // eslint-disable-line react-hooks/exhaustive-deps

    // Re-init charts on theme/language change — destroy + rebuild with fresh colors from refs
    const primaryColor = muiTheme.palette.primary.main
    useEffect(() => {
        if (!uPlotRef.current || historyRef.current.length < 2) return
        // Always destroy and re-create to pick up new theme colors
        destroyCharts()
        const t = setTimeout(() => initCharts(), 150)
        return () => clearTimeout(t)
    }, [isDark, currentLang, primaryColor]) // eslint-disable-line react-hooks/exhaustive-deps

    // --- Actions ---
    const killClient = useCallback(async (id: string) => {
        try {
            await confirm({ message: strings?.page?.monitor?.confirmKillClient || 'Are you sure to kill this client?' })
            await request({ action: 'client/kill', payload: { id } })
            toast(strings?.page?.monitor?.clientKilled || 'Client killed')
            loadClientList()
        } catch (e: any) { if (e !== undefined) generalHandleError(e) }
    }, [strings, confirm, toast, loadClientList, generalHandleError])

    const toggleAutoClients = () => {
        const next = !autoRefreshClients
        setAutoRefreshClients(next)
        try { localStorage.setItem('p3xr-monitor-auto-clients', String(next)) } catch {}
    }

    const toggleAutoTopKeys = () => {
        const next = !autoRefreshTopKeys
        setAutoRefreshTopKeys(next)
        try { localStorage.setItem('p3xr-monitor-auto-topkeys', String(next)) } catch {}
    }

    const exportOverview = useCallback(() => {
        if (!current) return
        const c = current, mon = strings?.page?.monitor || {} as any
        const lines = [
            `${mon.memory || 'Memory'}: ${c.memory.usedHuman}`, `${mon.rss || 'RSS'}: ${c.memory.rssHuman}`,
            `${mon.peak || 'Peak'}: ${c.memory.peakHuman}`, `${mon.fragmentation || 'Fragmentation'}: ${c.memory.fragRatio}x`,
            `${mon.opsPerSec || 'Ops/sec'}: ${c.stats.opsPerSec}`, `${mon.totalCommands || 'Total'}: ${c.stats.totalCommands}`,
            `${mon.clients || 'Clients'}: ${c.clients.connected}`, `${mon.blocked || 'Blocked'}: ${c.clients.blocked}`,
            `${mon.hitsMisses || 'Hit Rate'}: ${c.stats.hitRate}%`,
            `${mon.hitsAndMisses || 'Hits / Misses'}: ${c.stats.hits} / ${c.stats.misses}`,
            `${mon.networkIo || 'Network I/O'}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`,
            `${mon.expired || 'Expired'}: ${c.stats.expiredKeys}`, `${mon.evicted || 'Evicted'}: ${c.stats.evictedKeys}`,
        ]
        downloadText(lines.join('\n'), `${connName}-overview.txt`)
    }, [current, strings, connName])

    const exportChart = useCallback((ref: React.RefObject<HTMLDivElement | null>, name: string) => {
        const canvas = ref.current?.querySelector('canvas') as HTMLCanvasElement
        if (!canvas) return
        const ec = document.createElement('canvas')
        ec.width = canvas.width; ec.height = canvas.height
        const ctx = ec.getContext('2d')!
        ctx.fillStyle = isDark ? '#1e1e1e' : '#ffffff'
        ctx.fillRect(0, 0, ec.width, ec.height)
        ctx.drawImage(canvas, 0, 0)
        const a = document.createElement('a'); a.href = ec.toDataURL('image/png')
        a.download = `${connName}-${name}.png`; a.click()
    }, [connName, isDark])

    const resetSlowLog = useCallback(async () => {
        try {
            const m = stringsRef.current?.page?.monitor || {}
            await confirm({ message: m.confirmSlowLogReset || 'Are you sure to reset the slow log?' })
            await request({ action: 'monitor/slowlog-reset' })
            toast({ message: m.slowLogResetDone || 'Slow log reset' })
        } catch {}
    }, [confirm, toast])

    const exportSlowLog = useCallback(() => {
        if (!current) return
        const lines = current.slowlog.map(e => `${e.duration}\u00B5s ${e.command}`)
        downloadText(lines.join('\n'), `${connName}-slowlog.txt`)
    }, [current, connName])

    const exportClientList = useCallback(() => {
        const lines = clientList.map(c => `${c.addr} ${c.name || ''} db${c.db} ${c.cmd} idle:${c.idle}s`)
        downloadText(lines.join('\n'), `${connName}-clients.txt`)
    }, [clientList, connName])

    const exportTopKeysFile = useCallback(() => {
        const lines = topKeys.map((e, i) => `#${i + 1} ${e.key} ${formatBytes(e.bytes)}`)
        downloadText(lines.join('\n'), `${connName}-topkeys.txt`)
    }, [topKeys, connName])

    function getExportBackgroundColor(): string {
        return getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (isDark ? '#1e1e1e' : '#ffffff')
    }

    function renderPulseChartsForExport(): Array<{ label: string; canvas: HTMLCanvasElement }> {
        let data: ReturnType<typeof buildChartData>
        if (historyRef.current.length >= 2) {
            data = buildChartData()
        } else if (current) {
            const c = current
            const now = Date.now() / 1000
            data = {
                timestamps: [now - 1, now],
                memUsed: [c.memory.used / (1024 * 1024), c.memory.used / (1024 * 1024)],
                memRss: [c.memory.rss / (1024 * 1024), c.memory.rss / (1024 * 1024)],
                ops: [c.stats.opsPerSec, c.stats.opsPerSec],
                connected: [c.clients.connected, c.clients.connected],
                blocked: [c.clients.blocked, c.clients.blocked],
                netIn: [c.stats.inputKbps, c.stats.inputKbps],
                netOut: [c.stats.outputKbps, c.stats.outputKbps],
            }
        } else {
            return []
        }

        const colors = themeRef.current
        const s = stringsRef.current?.page?.monitor || {} as any

        const chartConfigs: Array<{
            label: string
            series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>
        }> = [
            {
                label: `${s.memory || 'Memory'} (MB)`,
                series: [
                    { label: s.memory || 'Memory', color: colors.primary, values: data.memUsed, fill: true },
                    { label: 'RSS', color: colors.accent, values: data.memRss },
                ],
            },
            {
                label: s.opsPerSec || 'Ops/sec',
                series: [
                    { label: s.opsPerSec || 'Ops/s', color: colors.primary, values: data.ops, fill: true },
                ],
            },
            {
                label: s.clients || 'Clients',
                series: [
                    { label: s.clients || 'Connected', color: colors.primary, values: data.connected },
                    { label: s.blocked || 'Blocked', color: colors.warn, values: data.blocked },
                ],
            },
            {
                label: `${s.networkIo || 'Network I/O'} (KB/s)`,
                series: [
                    { label: '\u2193 In', color: colors.primary, values: data.netIn, fill: true },
                    { label: '\u2191 Out', color: colors.accent, values: data.netOut },
                ],
            },
        ]

        return chartConfigs.map(config => ({
            label: config.label,
            canvas: renderLineChartForExport(data.timestamps, config.series, colors),
        }))
    }

    function renderLineChartForExport(
        timestamps: number[],
        series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>,
        colors: typeof themeRef.current,
    ): HTMLCanvasElement {
        const dpr = 2
        const width = 900
        const height = 260
        const padTop = 32
        const padBottom = 40
        const padLeft = 60
        const padRight = 16
        const legendH = 20
        const chartW = width - padLeft - padRight
        const chartH = height - padTop - padBottom - legendH

        const canvas = document.createElement('canvas')
        canvas.width = width * dpr
        canvas.height = height * dpr
        const ctx = canvas.getContext('2d')!
        ctx.scale(dpr, dpr)

        ctx.fillStyle = getExportBackgroundColor()
        ctx.fillRect(0, 0, width, height)

        const n = timestamps.length
        if (n < 2) return canvas

        let yMin = Infinity
        let yMax = -Infinity
        for (const s of series) {
            for (const v of s.values) {
                if (v < yMin) yMin = v
                if (v > yMax) yMax = v
            }
        }
        if (yMin === yMax) {
            yMin -= 1
            yMax += 1
        }
        const yRange = yMax - yMin
        const tMin = timestamps[0]
        const tMax = timestamps[n - 1]
        const tRange = tMax - tMin || 1

        const toX = (t: number) => padLeft + ((t - tMin) / tRange) * chartW
        const toY = (v: number) => padTop + chartH - ((v - yMin) / yRange) * chartH

        ctx.strokeStyle = colors.grid
        ctx.lineWidth = 1
        const ySteps = 5
        for (let i = 0; i <= ySteps; i++) {
            const gy = padTop + (chartH / ySteps) * i
            ctx.beginPath()
            ctx.moveTo(padLeft, gy)
            ctx.lineTo(padLeft + chartW, gy)
            ctx.stroke()

            const val = yMax - (yRange / ySteps) * i
            ctx.fillStyle = colors.text
            ctx.font = '10px Roboto Mono, monospace'
            ctx.textAlign = 'right'
            ctx.textBaseline = 'middle'
            ctx.fillText(val >= 1000 ? `${(val / 1000).toFixed(1)}k` : val.toFixed(1), padLeft - 6, gy)
        }

        const labelCount = Math.min(6, n)
        ctx.font = '10px Roboto, sans-serif'
        ctx.textAlign = 'center'
        ctx.textBaseline = 'top'
        ctx.fillStyle = colors.text
        for (let i = 0; i < labelCount; i++) {
            const idx = Math.round((i / (labelCount - 1)) * (n - 1))
            const t = timestamps[idx]
            const d = new Date(t * 1000)
            const label = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
            ctx.fillText(label, toX(t), padTop + chartH + 6)
        }

        for (const s of series) {
            ctx.strokeStyle = s.color
            ctx.lineWidth = 2
            ctx.lineJoin = 'round'
            ctx.beginPath()
            for (let i = 0; i < n; i++) {
                const x = toX(timestamps[i])
                const y = toY(s.values[i])
                if (i === 0) ctx.moveTo(x, y)
                else ctx.lineTo(x, y)
            }
            ctx.stroke()

            if (s.fill) {
                ctx.fillStyle = `${s.color}20`
                ctx.beginPath()
                ctx.moveTo(toX(timestamps[0]), toY(s.values[0]))
                for (let i = 1; i < n; i++) ctx.lineTo(toX(timestamps[i]), toY(s.values[i]))
                ctx.lineTo(toX(timestamps[n - 1]), padTop + chartH)
                ctx.lineTo(toX(timestamps[0]), padTop + chartH)
                ctx.closePath()
                ctx.fill()
            }
        }

        let lx = padLeft
        const ly = height - legendH + 4
        ctx.font = '11px Roboto, sans-serif'
        ctx.textAlign = 'left'
        ctx.textBaseline = 'middle'
        for (const s of series) {
            ctx.fillStyle = s.color
            ctx.fillRect(lx, ly - 4, 12, 8)
            ctx.fillStyle = colors.text
            ctx.fillText(s.label, lx + 16, ly)
            lx += ctx.measureText(s.label).width + 32
        }

        return canvas
    }

    // --- Export All (ZIP with TXT + charts PNG + PDF) ---
    const exportAll = useCallback(async () => {
        if (!current) return
        try {
            const JSZip = (await import('jszip')).default
            const zip = new JSZip()
            const c = current
            const sections: string[] = []
            const mon = stringsRef.current?.page?.monitor || {} as any
            const a = stringsRef.current?.page?.analysis || {} as any
            const sanitize = (s: string) => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')

            // === PULSE ===
            sections.push(`============================`, `  PULSE`, `============================`, ``,
                `--- ${mon.title || 'Monitoring'} ---`,
                `Redis ${c.server.version} \u00B7 ${c.server.mode} \u00B7 Uptime: ${uptimeFormatted(c.server.uptime)}`,
                `${mon.memory || 'Memory'}: ${c.memory.usedHuman}`, `${mon.rss || 'RSS'}: ${c.memory.rssHuman}`,
                `${mon.peak || 'Peak'}: ${c.memory.peakHuman}`, `${mon.fragmentation || 'Fragmentation'}: ${c.memory.fragRatio}x`,
                `${mon.opsPerSec || 'Ops/sec'}: ${c.stats.opsPerSec}`, `${mon.totalCommands || 'Total'}: ${c.stats.totalCommands}`,
                `${mon.clients || 'Clients'}: ${c.clients.connected}`, `${mon.blocked || 'Blocked'}: ${c.clients.blocked}`,
                `${mon.hitsMisses || 'Hit Rate'}: ${c.stats.hitRate}%`,
                `${mon.hitsAndMisses || 'Hits / Misses'}: ${c.stats.hits} / ${c.stats.misses}`,
                `${mon.networkIo || 'Network I/O'}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`,
                `${mon.expired || 'Expired'}: ${c.stats.expiredKeys}`, `${mon.evicted || 'Evicted'}: ${c.stats.evictedKeys}`,
            )

            // Dashboard sections
            const storeInfo = useRedisStateStore.getState().info
            const storeMods = useRedisStateStore.getState().modules || []
            if (storeInfo?.server) {
                const si = storeInfo.server, ci = storeInfo.cpu || {}
                sections.push(``, `--- ${mon.serverInfo || 'Server Info'} ---`)
                sections.push(`${mon.os || 'OS'}: ${si.os || ''}`, `${mon.port || 'Port'}: ${si.tcp_port || ''}`, `${mon.pid || 'Process ID'}: ${si.process_id || ''}`)
                if (si.config_file) sections.push(`${mon.configFile || 'Config File'}: ${si.config_file}`)
                sections.push(`${mon.cpuSys || 'System'} CPU: ${ci.used_cpu_sys || '0'}`, `${mon.cpuUser || 'User'} CPU: ${ci.used_cpu_user || '0'}`)
            }
            if (storeInfo?.persistence) {
                const p = storeInfo.persistence
                const lastSaveTs = parseInt(p.rdb_last_save_time, 10)
                const lastSave = lastSaveTs ? new Date(lastSaveTs * 1000).toLocaleString() : 'N/A'
                sections.push(``, `--- ${mon.persistence || 'Persistence'} ---`)
                sections.push(`${mon.rdbLastSave || 'RDB Last Save'}: ${lastSave}`, `${mon.rdbStatus || 'RDB Status'}: ${p.rdb_last_bgsave_status || 'N/A'}`)
                sections.push(`${mon.rdbChanges || 'Changes Since Last Save'}: ${p.rdb_changes_since_last_save ?? 'N/A'}`, `${mon.aofEnabled || 'AOF Enabled'}: ${p.aof_enabled === '1' ? 'Yes' : 'No'}`)
                if (p.aof_enabled === '1') sections.push(`${mon.aofSize || 'AOF Size'}: ${formatBytes(parseInt(p.aof_current_size, 10) || 0)}`)
            }
            if (storeInfo?.replication) {
                const r = storeInfo.replication
                sections.push(``, `--- ${mon.replication || 'Replication'} ---`, `${mon.role || 'Role'}: ${r.role || 'unknown'}`)
                if (r.role === 'master') sections.push(`${mon.replicas || 'Connected Replicas'}: ${r.connected_slaves ?? '0'}`)
                if (r.role === 'slave') { if (r.master_host) sections.push(`${mon.masterHost || 'Master Host'}: ${r.master_host}:${r.master_port}`); if (r.master_link_status) sections.push(`${mon.linkStatus || 'Link Status'}: ${r.master_link_status}`) }
            }
            if (storeInfo?.keyspace) {
                const ks = Object.keys(storeInfo.keyspace).filter((k: string) => k.startsWith('db')).sort((a: string, b: string) => parseInt(a.slice(2), 10) - parseInt(b.slice(2), 10))
                if (ks.length > 0) {
                    sections.push(``, `--- ${mon.keyspace || 'Keyspace'} ---`)
                    sections.push(...ks.map((db: string) => { const e = storeInfo.keyspace[db]; return `${db}: ${mon.keys || 'Keys'}: ${typeof e === 'object' ? e.keys || '0' : '0'}, ${mon.expires || 'Expires'}: ${typeof e === 'object' ? e.expires || '0' : '0'}` }))
                }
            }
            if (storeMods.length > 0) {
                sections.push(``, `--- ${mon.modules || 'Loaded Modules'} ---`)
                sections.push(...storeMods.map((m: any) => `${m.name || 'unknown'} v${m.ver ?? m.version ?? ''}`))
            } else {
                sections.push(``, `--- ${mon.modules || 'Loaded Modules'} ---`, mon.noModules || 'No modules loaded')
            }

            if (c.slowlog.length > 0) {
                sections.push(``, `--- ${mon.slowLog || 'Slow Log'} ---`)
                sections.push(...c.slowlog.map(e => `${e.duration}\u00B5s ${e.command}`))
            }
            if (clientList.length > 0) {
                sections.push(``, `--- ${mon.clientList || 'Client List'} ---`)
                sections.push(...clientList.map(cl => `${cl.addr} ${cl.name || ''} db${cl.db} ${cl.cmd} idle:${cl.idle}s`))
            }
            if (topKeys.length > 0) {
                sections.push(``, `--- ${mon.topKeys || 'Top Keys by Memory'} ---`)
                sections.push(...topKeys.map((e: any, i: number) => `#${i + 1} ${e.key} ${formatBytes(e.bytes)}`))
            }

            // === ANALYSIS ===
            const analysisChartItems: Array<{ name: string; items: Array<{ label: string; value: number }> }> = []
            try {
                const resp = await request({ action: 'memory/analysis', payload: { topN: 20, maxScanKeys: 5000 } })
                const d = resp.data
                if (d) {
                    const m = d.memoryInfo, exp = d.expirationOverview
                    const typeEntries = Object.keys(d.typeDistribution || {}).map((t: string) => ({
                        type: t, count: d.typeDistribution[t], bytes: d.typeMemory?.[t] || 0,
                    })).sort((x: any, y: any) => y.bytes - x.bytes)

                    sections.push(``, ``, `============================`, `  ANALYSIS`, `============================`)
                    sections.push(``, `--- ${a.keysScanned || 'Keys Scanned'} ---`, `${a.keysScanned || 'Keys Scanned'}: ${d.totalScanned} / ${d.dbSize}`)
                    sections.push(``, `--- ${a.memoryBreakdown || 'Memory Breakdown'} ---`)
                    sections.push(`${a.totalMemory || 'Total'}: ${m.usedHuman}`, `${a.rssMemory || 'RSS'}: ${m.rssHuman}`, `${a.peakMemory || 'Peak'}: ${m.peakHuman}`)
                    sections.push(`${a.overheadMemory || 'Overhead'}: ${formatBytes(m.overhead)}`, `${a.datasetMemory || 'Dataset'}: ${formatBytes(m.dataset)}`)
                    sections.push(`${a.luaMemory || 'Lua'}: ${formatBytes(m.lua)}`, `${a.fragmentation || 'Fragmentation'}: ${m.fragRatio}x`, `${a.allocator || 'Allocator'}: ${m.allocator}`)
                    sections.push(``, `--- ${a.typeDistribution || 'Type Distribution'} ---`)
                    sections.push(...typeEntries.map((t: any) => `${t.type}: ${t.count} keys, ${formatBytes(t.bytes)}`))
                    if (d.prefixMemory?.length > 0) {
                        sections.push(``, `--- ${a.prefixMemory || 'Memory by Prefix'} ---`)
                        sections.push(...d.prefixMemory.map((p: any, i: number) => `#${i + 1} ${p.prefix} \u2014 ${p.keyCount} keys, ${formatBytes(p.totalBytes)}`))
                    }
                    sections.push(``, `--- ${a.expirationOverview || 'Key Expiration'} ---`)
                    sections.push(`${a.withTTL || 'With TTL'}: ${exp.withTTL}`, `${a.persistent || 'Persistent'}: ${exp.persistent}`, `${a.avgTTL || 'Average TTL'}: ${exp.avgTTL}s`)

                    analysisChartItems.push(
                        { name: a.typeDistribution || 'Type Distribution', items: typeEntries.map((t: any) => ({ label: t.type, value: t.bytes })) },
                        { name: a.prefixMemory || 'Memory by Prefix', items: (d.prefixMemory || []).slice(0, 20).map((p: any) => ({ label: p.prefix, value: p.totalBytes })) },
                    )
                }
            } catch { /* analysis optional */ }

            // === PROFILER + PUBSUB tail ===
            const tailSections: string[] = []
            const { useMonitoringDataStore } = await import('../../stores/monitoring-data.store')
            const monData = useMonitoringDataStore.getState()
            if (monData.profilerEntries.length > 0) {
                tailSections.push(``, ``, `============================`, `  PROFILER`, `============================`, ``)
                tailSections.push(...monData.profilerEntries.map(e => sanitize(`${e.fullTimestamp} [${e.database} ${e.source}] ${e.command}`)))
            }
            if (monData.pubsubEntries.length > 0) {
                tailSections.push(``, ``, `============================`, `  PUBSUB`, `============================`, ``)
                tailSections.push(...monData.pubsubEntries.map(e => sanitize(`${e.fullTimestamp} ${e.channel} ${e.message}`)))
            }

            // TXT file (UTF-8 with BOM)
            const textContent = [...sections, ...tailSections].join('\n')
            const textBytes = new TextEncoder().encode(textContent)
            const bom = new Uint8Array([0xEF, 0xBB, 0xBF])
            const txtWithBom = new Uint8Array(bom.length + textBytes.length)
            txtWithBom.set(bom)
            txtWithBom.set(textBytes, bom.length)
            zip.file('monitoring.txt', txtWithBom)

            // Charts PNG — collect all pulse chart canvases + analysis bar charts
            const allCanvases: Array<{ label: string; canvas: HTMLCanvasElement }> = []
            allCanvases.push(...renderPulseChartsForExport())
            // Render analysis bar charts
            for (const ci of analysisChartItems) {
                if (ci.items.length === 0) continue
                const canvas = renderBarChartForExport(ci.items)
                if (canvas) allCanvases.push({ label: ci.name, canvas })
            }

            // Stitch all canvases into 1 tall image
            if (allCanvases.length > 0) {
                const blob = await stitchCharts(allCanvases)
                if (blob) zip.file('charts.png', blob)
            }

            // PDF
            try {
                const pdfBlob = await generatePdf(sections, allCanvases, tailSections)
                if (pdfBlob) zip.file('monitoring.pdf', pdfBlob)
            } catch { /* pdf optional */ }

            const content = await zip.generateAsync({ type: 'blob' })
            const url = URL.createObjectURL(content)
            const link = document.createElement('a')
            link.href = url; link.download = `${connName}-monitoring.zip`; link.click()
            URL.revokeObjectURL(url)
        } catch (e) { generalHandleError(e) }
    }, [current, clientList, topKeys, connName, isDark, generalHandleError])

    // --- Export helpers ---
    function renderBarChartForExport(items: Array<{ label: string; value: number }>): HTMLCanvasElement | null {
        if (items.length === 0) return null
        const colors = themeRef.current
        const barColors = [colors.primary, colors.accent, colors.warn,
            isDark ? '#ffb74d' : '#ff9800', isDark ? '#81c784' : '#4caf50',
            isDark ? '#4dd0e1' : '#00bcd4', isDark ? '#a1887f' : '#795548', isDark ? '#90a4ae' : '#607d8b']
        const dpr = 2, width = 800, barHeight = 24, labelWidth = 120, valueWidth = 80
        const chartLeft = labelWidth + 8, chartRight = width - valueWidth - 8, chartWidth = chartRight - chartLeft
        const topPad = 8, height = topPad + items.length * (barHeight + 4) + 8
        const canvas = document.createElement('canvas')
        canvas.width = width * dpr; canvas.height = height * dpr
        const ctx = canvas.getContext('2d')!
        ctx.scale(dpr, dpr)
        ctx.fillStyle = getExportBackgroundColor()
        ctx.fillRect(0, 0, width, height)
        const maxVal = Math.max(...items.map(i => i.value), 1)
        items.forEach((item, i) => {
            const y = topPad + i * (barHeight + 4)
            ctx.fillStyle = colors.text; ctx.font = '12px Roboto, sans-serif'
            ctx.textAlign = 'right'; ctx.textBaseline = 'middle'
            ctx.fillText(item.label.length > 15 ? item.label.substring(0, 14) + '\u2026' : item.label, labelWidth, y + barHeight / 2)
            ctx.fillStyle = colors.grid; ctx.fillRect(chartLeft, y, chartWidth, barHeight)
            ctx.fillStyle = barColors[i % barColors.length]
            ctx.fillRect(chartLeft, y, (item.value / maxVal) * chartWidth, barHeight)
            ctx.fillStyle = colors.text; ctx.font = '11px Roboto Mono, monospace'
            ctx.textAlign = 'left'; ctx.fillText(formatBytes(item.value), chartRight + 8, y + barHeight / 2)
        })
        return canvas
    }

    async function stitchCharts(items: Array<{ label: string; canvas: HTMLCanvasElement }>): Promise<Blob | null> {
        const padding = 32, labelHeight = 60, chartSpacing = 40
        const width = Math.max(2400, ...items.map(i => i.canvas.width))
        let totalHeight = padding
        for (const item of items) {
            totalHeight += labelHeight + item.canvas.height * (width / item.canvas.width) + chartSpacing
        }
        totalHeight += padding
        const stitched = document.createElement('canvas')
        stitched.width = width; stitched.height = totalHeight
        const ctx = stitched.getContext('2d')!
        const colors = themeRef.current
        ctx.fillStyle = getExportBackgroundColor()
        ctx.fillRect(0, 0, width, totalHeight)
        let y = padding
        for (const item of items) {
            ctx.fillStyle = colors.text; ctx.font = 'bold 28px Roboto, sans-serif'
            ctx.textAlign = 'left'; ctx.textBaseline = 'top'
            ctx.fillText(item.label, padding, y); y += labelHeight
            const drawW = width - padding * 2, drawH = item.canvas.height * (drawW / item.canvas.width)
            ctx.drawImage(item.canvas, padding, y, drawW, drawH); y += drawH + chartSpacing
        }
        return new Promise(resolve => stitched.toBlob(b => resolve(b), 'image/png'))
    }

    async function generatePdf(sections: string[], charts: Array<{ label: string; canvas: HTMLCanvasElement }>, tailSections: string[]): Promise<Blob | null> {
        const { jsPDF } = await import('jspdf')
        const bgColor = getExportBackgroundColor()
        const textColor = isDark ? '#e0e0e0' : '#212121'
        const headerColor = isDark ? '#90caf9' : '#1565c0'
        const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
        const pageW = pdf.internal.pageSize.getWidth(), pageH = pdf.internal.pageSize.getHeight()
        const margin = 12, contentW = pageW - margin * 2
        let y = margin
        const fillBg = () => { pdf.setFillColor(bgColor); pdf.rect(0, 0, pageW, pageH, 'F') }
        fillBg()
        const checkPage = (needed: number) => { if (y + needed > pageH - margin) { pdf.addPage(); fillBg(); y = margin } }

        for (const line of sections) {
            if (line.startsWith('====')) continue
            const isTitle = ['PULSE', 'PROFILER', 'PUBSUB', 'ANALYSIS'].includes(line.trim())
            if (isTitle) { checkPage(14); y += 4; pdf.setFontSize(14); pdf.setTextColor(headerColor); pdf.text(line.trim(), margin, y); y += 8; continue }
            if (line.startsWith('---') && line.endsWith('---')) { checkPage(8); y += 2; pdf.setFontSize(10); pdf.setTextColor(headerColor); pdf.text(line.replace(/^-+\s*/, '').replace(/\s*-+$/, ''), margin, y); y += 5; continue }
            if (line === '') { y += 2; continue }
            checkPage(4); pdf.setTextColor(textColor); pdf.setFontSize(8)
            for (const wl of pdf.splitTextToSize(line, contentW)) { checkPage(4); pdf.text(wl, margin, y); y += 3.5 }
        }
        for (const chart of charts) {
            pdf.addPage(); fillBg(); y = margin
            pdf.setFontSize(12); pdf.setTextColor(headerColor); pdf.text(chart.label, margin, y); y += 8
            const imgData = chart.canvas.toDataURL('image/png')
            const ratio = chart.canvas.height / chart.canvas.width
            const availH = pageH - y - margin
            const imgW = contentW
            const imgH = imgW * ratio
            if (imgH > availH) {
                const drawH = availH
                const drawW = drawH / ratio
                pdf.addImage(imgData, 'PNG', margin, y, drawW, drawH)
                y += drawH
            } else {
                pdf.addImage(imgData, 'PNG', margin, y, imgW, imgH)
                y += imgH
            }
        }
        if (tailSections.length > 0 && charts.length > 0) { pdf.addPage(); fillBg(); y = margin }
        for (const line of tailSections) {
            if (line.startsWith('====')) continue
            const isTitle = ['PROFILER', 'PUBSUB'].includes(line.trim())
            if (isTitle) { checkPage(14); y += 4; pdf.setFontSize(14); pdf.setTextColor(headerColor); pdf.text(line.trim(), margin, y); y += 8; continue }
            if (line === '') { y += 2; continue }
            checkPage(4); pdf.setTextColor(textColor); pdf.setFontSize(8)
            for (const wl of pdf.splitTextToSize(line, contentW)) { checkPage(4); pdf.text(wl, margin, y); y += 3.5 }
        }
        return pdf.output('blob') as unknown as Blob
    }

    // --- Dashboard computed values ---
    const serverInfoData = info ? (() => {
        const s = info.server || {}, c = info.cpu || {}
        return { os: s.os || '', port: s.tcp_port || '', pid: s.process_id || '', configFile: s.config_file || '', cpuSys: c.used_cpu_sys || '0', cpuUser: c.used_cpu_user || '0' }
    })() : null

    const persistenceData = info?.persistence ? (() => {
        const p = info.persistence
        const lastSaveTs = parseInt(p.rdb_last_save_time, 10)
        const lastSave = lastSaveTs ? new Date(lastSaveTs * 1000).toLocaleString() : 'N/A'
        return {
            rdbLastSave: lastSave, rdbStatus: p.rdb_last_bgsave_status || 'N/A',
            rdbChanges: p.rdb_changes_since_last_save ?? 'N/A',
            aofEnabled: p.aof_enabled === '1' ? 'Yes' : 'No',
            aofSize: p.aof_enabled === '1' ? formatBytes(parseInt(p.aof_current_size, 10) || 0) : '',
        }
    })() : null

    const replicationData = info?.replication ? (() => {
        const r = info.replication
        const result: any = { role: r.role || 'unknown' }
        if (r.role === 'master') result.replicas = r.connected_slaves ?? '0'
        else if (r.role === 'slave') { result.masterHost = r.master_host; result.masterPort = r.master_port; result.linkStatus = r.master_link_status }
        return result
    })() : null

    const keyspaceEntries = info?.keyspace ? Object.keys(info.keyspace)
        .filter((k: string) => k.startsWith('db'))
        .sort((a: string, b: string) => parseInt(a.slice(2), 10) - parseInt(b.slice(2), 10))
        .map((db: string) => {
            const entry = info.keyspace[db]
            return { db, keys: typeof entry === 'object' ? (entry.keys || '0') : '0', expires: typeof entry === 'object' ? (entry.expires || '0') : '0' }
        }) : []

    const modulesList = (modules || []).map((m: any) => ({ name: m.name || 'unknown', ver: String(m.ver ?? m.version ?? '') }))

    // --- Dashboard export functions ---
    const exportServerInfo = useCallback(() => {
        if (!serverInfoData) return
        const s = serverInfoData, mon = stringsRef.current?.page?.monitor || {} as any
        const lines = [`${mon.os || 'OS'}: ${s.os}`, `${mon.port || 'Port'}: ${s.port}`, `${mon.pid || 'Process ID'}: ${s.pid}`]
        if (s.configFile) lines.push(`${mon.configFile || 'Config File'}: ${s.configFile}`)
        lines.push(`${mon.cpuSys || 'System'} CPU: ${s.cpuSys}`, `${mon.cpuUser || 'User'} CPU: ${s.cpuUser}`)
        downloadText(lines.join('\n'), `${connName}-server-info.txt`)
    }, [connName, serverInfoData])

    const exportPersistence = useCallback(() => {
        if (!persistenceData) return
        const p = persistenceData, mon = stringsRef.current?.page?.monitor || {} as any
        const lines = [`${mon.rdbLastSave || 'RDB Last Save'}: ${p.rdbLastSave}`, `${mon.rdbStatus || 'RDB Status'}: ${p.rdbStatus}`,
            `${mon.rdbChanges || 'Changes Since Last Save'}: ${p.rdbChanges}`, `${mon.aofEnabled || 'AOF Enabled'}: ${p.aofEnabled}`]
        if (p.aofSize) lines.push(`${mon.aofSize || 'AOF Size'}: ${p.aofSize}`)
        downloadText(lines.join('\n'), `${connName}-persistence.txt`)
    }, [connName, persistenceData])

    const exportReplication = useCallback(() => {
        if (!replicationData) return
        const r = replicationData, mon = stringsRef.current?.page?.monitor || {} as any
        const lines = [`${mon.role || 'Role'}: ${r.role}`]
        if (r.replicas !== undefined) lines.push(`${mon.replicas || 'Connected Replicas'}: ${r.replicas}`)
        if (r.masterHost) lines.push(`${mon.masterHost || 'Master Host'}: ${r.masterHost}:${r.masterPort}`)
        if (r.linkStatus) lines.push(`${mon.linkStatus || 'Link Status'}: ${r.linkStatus}`)
        downloadText(lines.join('\n'), `${connName}-replication.txt`)
    }, [connName, replicationData])

    const exportKeyspace = useCallback(() => {
        if (keyspaceEntries.length === 0) return
        const mon = stringsRef.current?.page?.monitor || {} as any
        const lines = keyspaceEntries.map((e: any) => `${e.db}: ${mon.keys || 'Keys'}: ${e.keys}, ${mon.expires || 'Expires'}: ${e.expires}`)
        downloadText(lines.join('\n'), `${connName}-keyspace.txt`)
    }, [connName, keyspaceEntries])

    const exportModules = useCallback(() => {
        const mon = stringsRef.current?.page?.monitor || {} as any
        if (modulesList.length === 0) { downloadText(mon.noModules || 'No modules loaded', `${connName}-modules.txt`); return }
        downloadText(modulesList.map((m: any) => `${m.name} v${m.ver}`).join('\n'), `${connName}-modules.txt`)
    }, [connName, modulesList])

    // --- Render helpers ---
    const InfoRow = ({ label, value }: { label: string; value: string | number }) => (
        <>
            <ListItem sx={{ px: 2, py: 1 }}>
                <Box sx={{ display: 'flex', width: '100%' }}>
                    <Box sx={{ flex: 1 }}>{label}</Box>
                    <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>{value}</Box>
                </Box>
            </ListItem>
            <Divider />
        </>
    )

    if (!current) {
        return (
            <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, p: 4, opacity: 0.5 }}>
                <HourglassEmpty /> {strings?.label?.loading || 'Loading...'}
            </Box>
        )
    }

    const mon = strings?.page?.monitor || {} as any

    return (
        <Box>
            {/* Overview */}
            <P3xrAccordion title={mon.title || 'Monitoring'} accordionKey="monitor-overview"
                actions={<>
                    <P3xrButton icon={paused ? <PlayArrow sx={{ fontSize: 18 }} /> : <Pause sx={{ fontSize: 18 }} />}
                        label={paused ? (strings?.intention?.resume || 'Resume') : (strings?.intention?.pause || 'Pause')}
                        color="inherit" onClick={(e) => { e.stopPropagation(); setPaused(p => !p) }} />
                    <P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportOverview() }} />
                    <P3xrButton icon={<Archive sx={{ fontSize: 18 }} />} label={strings?.page?.analysis?.exportAll || 'Export All'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportAll() }} />
                </>}
            >
                <List disablePadding>
                    <InfoRow label={`Redis ${current.server.version} \u00B7 ${current.server.mode}`} value={uptimeFormatted(current.server.uptime)} />
                    <InfoRow label={mon.memory || 'Memory'} value={current.memory.usedHuman} />
                    <InfoRow label={mon.rss || 'RSS'} value={current.memory.rssHuman} />
                    <InfoRow label={mon.peak || 'Peak'} value={current.memory.peakHuman} />
                    <InfoRow label={mon.fragmentation || 'Fragmentation'} value={`${current.memory.fragRatio}x`} />
                    <InfoRow label={mon.opsPerSec || 'Ops/sec'} value={current.stats.opsPerSec} />
                    <InfoRow label={mon.totalCommands || 'Total Commands'} value={current.stats.totalCommands} />
                    <InfoRow label={mon.clients || 'Clients'} value={current.clients.connected} />
                    <InfoRow label={mon.blocked || 'Blocked'} value={current.clients.blocked} />
                    <InfoRow label={mon.hitsMisses || 'Hit Rate'} value={`${current.stats.hitRate}%`} />
                    <InfoRow label={mon.hitsAndMisses || 'Hits / Misses'} value={`${current.stats.hits} / ${current.stats.misses}`} />
                    <InfoRow label={mon.networkIo || 'Network I/O'} value={`${current.stats.inputKbps.toFixed(1)} / ${current.stats.outputKbps.toFixed(1)} KB/s`} />
                    <InfoRow label={mon.expired || 'Expired'} value={current.stats.expiredKeys} />
                    <InfoRow label={mon.evicted || 'Evicted'} value={current.stats.evictedKeys} />
                </List>
            </P3xrAccordion>

            {/* Server Info */}
            {serverInfoData && (<>
                <br />
                <P3xrAccordion title={mon.serverInfo || 'Server Info'} accordionKey="monitor-server-info"
                    actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportServerInfo() }} />}>
                    <List disablePadding>
                        {serverInfoData.os && <InfoRow label={mon.os || 'OS'} value={serverInfoData.os} />}
                        {serverInfoData.port && <InfoRow label={mon.port || 'Port'} value={serverInfoData.port} />}
                        {serverInfoData.pid && <InfoRow label={mon.pid || 'Process ID'} value={serverInfoData.pid} />}
                        {serverInfoData.configFile && <InfoRow label={mon.configFile || 'Config File'} value={serverInfoData.configFile} />}
                        <InfoRow label={`${mon.cpuSys || 'System'} CPU`} value={serverInfoData.cpuSys} />
                        <InfoRow label={`${mon.cpuUser || 'User'} CPU`} value={serverInfoData.cpuUser} />
                    </List>
                </P3xrAccordion>
            </>)}

            {/* Persistence */}
            {persistenceData && (<>
                <br />
                <P3xrAccordion title={mon.persistence || 'Persistence'} accordionKey="monitor-persistence"
                    actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportPersistence() }} />}>
                    <List disablePadding>
                        <InfoRow label={mon.rdbLastSave || 'RDB Last Save'} value={persistenceData.rdbLastSave} />
                        <InfoRow label={mon.rdbStatus || 'RDB Status'} value={persistenceData.rdbStatus} />
                        <InfoRow label={mon.rdbChanges || 'Changes Since Last Save'} value={persistenceData.rdbChanges} />
                        <InfoRow label={mon.aofEnabled || 'AOF Enabled'} value={persistenceData.aofEnabled} />
                        {persistenceData.aofSize && <InfoRow label={mon.aofSize || 'AOF Size'} value={persistenceData.aofSize} />}
                    </List>
                </P3xrAccordion>
            </>)}

            {/* Replication */}
            {replicationData && (<>
                <br />
                <P3xrAccordion title={mon.replication || 'Replication'} accordionKey="monitor-replication"
                    actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportReplication() }} />}>
                    <List disablePadding>
                        <InfoRow label={mon.role || 'Role'} value={replicationData.role} />
                        {replicationData.replicas !== undefined && <InfoRow label={mon.replicas || 'Connected Replicas'} value={replicationData.replicas} />}
                        {replicationData.masterHost && <InfoRow label={mon.masterHost || 'Master Host'} value={`${replicationData.masterHost}:${replicationData.masterPort}`} />}
                        {replicationData.linkStatus && <InfoRow label={mon.linkStatus || 'Link Status'} value={replicationData.linkStatus} />}
                    </List>
                </P3xrAccordion>
            </>)}

            {/* Keyspace */}
            {keyspaceEntries.length > 0 && (<>
                <br />
                <P3xrAccordion title={mon.keyspace || 'Keyspace'} accordionKey="monitor-keyspace"
                    actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportKeyspace() }} />}>
                    <List disablePadding>
                        {keyspaceEntries.map((entry: any, i: number) => (
                            <Box key={entry.db}>
                                <ListItem sx={{ px: 2, py: 1 }}>
                                    <Box sx={{ display: 'flex', width: '100%' }}>
                                        <Box sx={{ flex: 1 }}>{entry.db}</Box>
                                        <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
                                            {mon.keys || 'Keys'}: {entry.keys} {'\u00B7'} {mon.expires || 'Expires'}: {entry.expires}
                                        </Box>
                                    </Box>
                                </ListItem>
                                {i < keyspaceEntries.length - 1 && <Divider />}
                            </Box>
                        ))}
                    </List>
                </P3xrAccordion>
            </>)}

            {/* Modules */}
            <br />
            <P3xrAccordion title={mon.modules || 'Loaded Modules'} accordionKey="monitor-modules"
                actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                    color="inherit" onClick={(e) => { e.stopPropagation(); exportModules() }} />}>
                {modulesList.length === 0 && (
                    <Box sx={{ p: 2, opacity: 0.5 }}>{mon.noModules || 'No modules loaded'}</Box>
                )}
                {modulesList.length > 0 && (
                    <List disablePadding>
                        {modulesList.map((mod: any, i: number) => (
                            <Box key={mod.name}>
                                <ListItem sx={{ px: 2, py: 1 }}>
                                    <Box sx={{ display: 'flex', width: '100%' }}>
                                        <Box sx={{ flex: 1 }}>{mod.name}</Box>
                                        <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>v{mod.ver}</Box>
                                    </Box>
                                </ListItem>
                                {i < modulesList.length - 1 && <Divider />}
                            </Box>
                        ))}
                    </List>
                )}
            </P3xrAccordion>

            <br />

            {/* Memory Chart */}
            <P3xrAccordion title={`${mon.memory || 'Memory'} (MB)`} accordionKey="monitor-chart-memory"
                actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                    color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(memChartRef, 'memory') }} />}>
                <Box ref={memChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
            </P3xrAccordion>

            <br />

            {/* Ops/sec Chart */}
            <P3xrAccordion title={mon.opsPerSec || 'Ops/sec'} accordionKey="monitor-chart-ops"
                actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                    color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(opsChartRef, 'ops') }} />}>
                <Box ref={opsChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
            </P3xrAccordion>

            <br />

            {/* Clients Chart */}
            <P3xrAccordion title={mon.clients || 'Clients'} accordionKey="monitor-chart-clients"
                actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                    color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(cliChartRef, 'clients') }} />}>
                <Box ref={cliChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
            </P3xrAccordion>

            <br />

            {/* Network I/O Chart */}
            <P3xrAccordion title={`${mon.networkIo || 'Network I/O'} (KB/s)`} accordionKey="monitor-chart-network"
                actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                    color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(netChartRef, 'network') }} />}>
                <Box ref={netChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
            </P3xrAccordion>

            {/* Slow Log */}
            <br />
            <P3xrAccordion title={mon.slowLog || 'Slow Log'} accordionKey="monitor-slowlog"
                actions={<>
                    {!isReadonly && <P3xrButton icon={<DeleteSweep sx={{ fontSize: 18 }} />} label="Reset"
                        color="inherit" onClick={(e) => { e.stopPropagation(); resetSlowLog() }} />}
                    <P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportSlowLog() }} />
                </>}>
                {current.slowlog.length === 0
                    ? <Box sx={{ p: 2, opacity: 0.6 }}>{mon.noSlowQueries || 'No slow queries recorded'}</Box>
                    : <List disablePadding>
                        {current.slowlog.map(entry => (
                            <Box key={entry.id}>
                                <ListItem sx={{ px: 2, py: 1 }}>
                                    <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
                                        <Box component="kbd" sx={{
                                            px: '6px', py: '2px', borderRadius: '4px', fontSize: 11,
                                            bgcolor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)',
                                            fontFamily: "'Roboto Mono', monospace", whiteSpace: 'nowrap',
                                        }}>{entry.duration}{'\u00B5'}s</Box>
                                        <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13, wordBreak: 'break-all' }}>{entry.command}</Box>
                                    </Box>
                                </ListItem>
                                <Divider />
                            </Box>
                        ))}
                    </List>
                }
            </P3xrAccordion>

            {/* Client List */}
            <br />
            <P3xrAccordion title={mon.clientList || 'Client List'} accordionKey="monitor-clients-list"
                actions={<>
                    <P3xrButton icon={autoRefreshClients ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
                        label={strings?.label?.autoRefresh || 'Auto'} color="inherit"
                        onClick={(e) => { e.stopPropagation(); toggleAutoClients() }} />
                    {!autoRefreshClients && (
                        <P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />} label={strings?.intention?.refresh || 'Refresh'}
                            color="inherit" onClick={(e) => { e.stopPropagation(); loadClientList() }} />
                    )}
                    <P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportClientList() }} />
                </>}>
                {clientList.length === 0 && (
                    <Box sx={{ p: 2, opacity: 0.5 }}>{clientListLoaded ? (mon.noClients || 'No clients') : (strings?.label?.loading || 'Loading...')}</Box>
                )}
                {clientList.length > 0 && (
                    <List disablePadding>
                        {clientList.map(client => (
                            <Box key={client.id}>
                                <ListItem sx={{ px: 2, py: 1 }}>
                                    <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
                                        <Box component="span" sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13, fontWeight: 700, minWidth: 150 }}>{client.addr}</Box>
                                        {client.name && <Box component="span" sx={{ opacity: 0.5, fontSize: 12 }}>({client.name})</Box>}
                                        <Box component="span" sx={{ flex: 1, textAlign: 'right', fontFamily: "'Roboto Mono', monospace", fontSize: 12, opacity: 0.6 }}>
                                            db{client.db} {'\u00B7'} {client.cmd} {'\u00B7'} {client.idle}s
                                        </Box>
                                        {!isReadonly && (
                                            <Tooltip title={mon.killClient || 'Kill client'}>
                                                <Close sx={{ fontSize: 18, width: 18, height: 18, cursor: 'pointer', color: 'error.main', '&:hover': { opacity: 1 } }}
                                                    onClick={() => killClient(client.id)} />
                                            </Tooltip>
                                        )}
                                    </Box>
                                </ListItem>
                                <Divider />
                            </Box>
                        ))}
                    </List>
                )}
            </P3xrAccordion>

            {/* Top Keys by Memory */}
            <br />
            <P3xrAccordion title={mon.topKeys || 'Top Keys by Memory'} accordionKey="monitor-top-keys"
                actions={<>
                    <P3xrButton icon={autoRefreshTopKeys ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
                        label={strings?.label?.autoRefresh || 'Auto'} color="inherit"
                        onClick={(e) => { e.stopPropagation(); toggleAutoTopKeys() }} />
                    {!autoRefreshTopKeys && (
                        <P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />} label={strings?.intention?.refresh || 'Refresh'}
                            color="inherit" onClick={(e) => { e.stopPropagation(); loadTopKeys() }} />
                    )}
                    <P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                        color="inherit" onClick={(e) => { e.stopPropagation(); exportTopKeysFile() }} />
                </>}>
                {topKeys.length === 0 && (
                    <Box sx={{ p: 2, opacity: 0.5 }}>{topKeysLoaded ? (mon.noKeys || 'No keys') : (strings?.label?.loading || 'Loading...')}</Box>
                )}
                {topKeys.length > 0 && (
                    <List disablePadding>
                        {topKeys.map((entry, i) => (
                            <Box key={entry.key}>
                                <ListItem sx={{ px: 2, py: 1 }}>
                                    <Box sx={{ display: 'flex', width: '100%' }}>
                                        <Box sx={{ flex: 1 }}>
                                            <Box component="span" sx={{ opacity: 0.4, mr: 1 }}>#{i + 1}</Box>
                                            <Box component="span" sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>{entry.key}</Box>
                                        </Box>
                                        <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>{formatBytes(entry.bytes)}</Box>
                                    </Box>
                                </ListItem>
                                <Divider />
                            </Box>
                        ))}
                    </List>
                )}
            </P3xrAccordion>

            {/* Cluster Slot Stats (cluster + 8.2+ only) */}
            {isCluster && rv.isAtLeast(8, 2) && (<>
                <br />
                <P3xrAccordion title={mon.slotStats || 'Cluster Slot Stats'} accordionKey="monitor-slot-stats"
                    actions={<>
                        <P3xrButton icon={<Refresh fontSize="small" />} label={strings?.intention?.refresh} onClick={e => { e.stopPropagation(); loadSlotStats() }} />
                    </>}>
                    <Box sx={{ px: 2, py: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
                        <select value={slotStatsMetric} onChange={e => { setSlotStatsMetric(e.target.value); loadSlotStats(e.target.value) }}
                            style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
                            <option value="KEY-COUNT">Key Count</option>
                            <option value="CPU-USEC">CPU (μs)</option>
                            <option value="MEMORY-BYTES">Memory (bytes)</option>
                        </select>
                    </Box>
                    {slotStats.length === 0 && (
                        <Box sx={{ p: 2, opacity: 0.5 }}>{slotStatsLoaded ? 'No slot data' : (strings?.label?.loading || 'Loading...')}</Box>
                    )}
                    {slotStats.length > 0 && (
                        <List disablePadding>
                            {slotStats.map((entry: any, i: number) => (
                                <Box key={entry.slot}>
                                    <ListItem sx={{ px: 2, py: 1 }}>
                                        <Box sx={{ display: 'flex', width: '100%' }}>
                                            <Box sx={{ flex: 1 }}>
                                                <Box component="span" sx={{ opacity: 0.4, mr: 1 }}>#{i + 1}</Box>
                                                <Box component="span" sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>Slot {entry.slot}</Box>
                                            </Box>
                                            <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
                                                {slotStatsMetric === 'KEY-COUNT' && `${entry['key-count']} keys`}
                                                {slotStatsMetric === 'CPU-USEC' && `${entry['cpu-usec']} μs`}
                                                {slotStatsMetric === 'MEMORY-BYTES' && formatBytes(entry['memory-bytes'])}
                                            </Box>
                                        </Box>
                                    </ListItem>
                                    <Divider />
                                </Box>
                            ))}
                        </List>
                    )}
                </P3xrAccordion>
            </>)}

            {/* Cluster Slot Map */}
            {isCluster && (<>
                <br />
                <P3xrAccordion title={mon.clusterSlotMap || 'Cluster Slot Map'} accordionKey="monitor-cluster-slots"
                    actions={<>
                        <P3xrButton icon={autoRefreshShards ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
                            label={strings?.label?.autoRefresh || 'Auto'} color="inherit"
                            onClick={(e) => { e.stopPropagation(); toggleAutoRefreshShards() }} />
                        {!autoRefreshShards && <P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />}
                            label={strings?.intention?.refresh || 'Refresh'} color="inherit"
                            onClick={(e) => { e.stopPropagation(); loadClusterShards() }} />}
                        <P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                            color="inherit" onClick={(e) => { e.stopPropagation(); exportClusterSlots() }} />
                    </>}>
                    {!clusterShards
                        ? <Box sx={{ p: 2, opacity: 0.6 }}>{mon.noClusterData || 'No cluster data available'}</Box>
                        : <>
                            <List disablePadding>
                                {clusterShards.map(shard => (
                                    <Box key={shard.master.id}>
                                        <ListItem sx={{ px: 2, py: 1 }}>
                                            <Box sx={{ display: 'flex', width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
                                                <Box>
                                                    <Box component="span" sx={{ fontWeight: 500 }}>{shard.master.host}:{shard.master.port}</Box>
                                                    <Box component="span" sx={{ ml: 1, opacity: 0.5, fontSize: 12 }}>
                                                        {shard.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', ')}
                                                    </Box>
                                                </Box>
                                                <Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
                                                    {getSlotCount(shard)} slots
                                                    {shard.replicas.length > 0 && <Box component="span" sx={{ opacity: 0.5, ml: 1 }}>
                                                        ({shard.replicas.map((r: any) => `${r.host}:${r.port}`).join(', ')})
                                                    </Box>}
                                                </Box>
                                            </Box>
                                        </ListItem>
                                        <Divider />
                                    </Box>
                                ))}
                            </List>
                            <Box sx={{ p: 1, px: 2, opacity: 0.6, fontSize: 12 }}>16384 slots across {clusterShards.length} masters</Box>
                        </>
                    }
                </P3xrAccordion>
            </>)}

        </Box>
    )
}