RSS Git Download  Clone
Raw Blame History 50kB 979 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,
} from '@mui/icons-material'
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 { 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 { 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 [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)
    }, [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) }
    }, [])

    // --- Init ---
    useEffect(() => {
        fetchData()
        loadClientList()
        loadTopKeys()

        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 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}`,
            )

            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
    }

    // --- 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>

            <Box sx={{ mt: 1 }} />

            {/* 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>

            <Box sx={{ mt: 1 }} />

            {/* 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>

            <Box sx={{ mt: 1 }} />

            {/* 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>

            <Box sx={{ mt: 1 }} />

            {/* 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 */}
            {current.slowlog.length > 0 && (
                <>
                    <Box sx={{ mt: 1 }} />
                    <P3xrAccordion title={mon.slowLog || 'Slow Log'} accordionKey="monitor-slowlog"
                        actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
                            color="inherit" onClick={(e) => { e.stopPropagation(); exportSlowLog() }} />}>
                        <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 */}
            <Box sx={{ mt: 1 }} />
            <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 */}
            <Box sx={{ mt: 1 }} />
            <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>

        </Box>
    )
}