/** * 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 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(null) const [paused, setPaused] = useState(false) const [clientList, setClientList] = useState([]) const [topKeys, setTopKeys] = useState([]) 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([]) const uPlotRef = useRef(null) const chartsInitRef = useRef(false) const memChartRef = useRef(null) const opsChartRef = useRef(null) const cliChartRef = useRef(null) const netChartRef = useRef(null) const memPlotRef = useRef(null) const opsPlotRef = useRef(null) const cliPlotRef = useRef(null) const netPlotRef = useRef(null) const resizeObRef = useRef(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, 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]) // --- 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 }> = [] const chartRefs = [ { ref: memChartRef, label: `${mon.memory || 'Memory'} (MB)` }, { ref: opsChartRef, label: mon.opsPerSec || 'Ops/sec' }, { ref: cliChartRef, label: mon.clients || 'Clients' }, { ref: netChartRef, label: `${mon.networkIo || 'Network I/O'} (KB/s)` }, ] for (const cr of chartRefs) { const canvas = cr.ref.current?.querySelector('canvas') as HTMLCanvasElement if (canvas) allCanvases.push({ label: cr.label, canvas }) } // 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 = isDark ? '#1e1e1e' : '#ffffff' 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 { 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 = isDark ? '#1e1e1e' : '#ffffff' 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 { const { jsPDF } = await import('jspdf') const bgColor = isDark ? '#1e1e1e' : '#ffffff' 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 imgW = contentW, imgH = Math.min(imgW * ratio, pageH - y - margin) 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 }) => ( <> {label} {value} ) if (!current) { return ( {strings?.label?.loading || 'Loading...'} ) } const mon = strings?.page?.monitor || {} as any return ( {/* Overview */} : } label={paused ? (strings?.intention?.resume || 'Resume') : (strings?.intention?.pause || 'Pause')} color="inherit" onClick={(e) => { e.stopPropagation(); setPaused(p => !p) }} /> } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportOverview() }} /> } label={strings?.page?.analysis?.exportAll || 'Export All'} color="inherit" onClick={(e) => { e.stopPropagation(); exportAll() }} /> } > {/* Memory Chart */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(memChartRef, 'memory') }} />}> {/* Ops/sec Chart */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(opsChartRef, 'ops') }} />}> {/* Clients Chart */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(cliChartRef, 'clients') }} />}> {/* Network I/O Chart */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(netChartRef, 'network') }} />}> {/* Slow Log */} {current.slowlog.length > 0 && ( <> } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportSlowLog() }} />}> {current.slowlog.map(entry => ( {entry.duration}{'\u00B5'}s {entry.command} ))} )} {/* Client List */} : } label={strings?.label?.autoRefresh || 'Auto'} color="inherit" onClick={(e) => { e.stopPropagation(); toggleAutoClients() }} /> {!autoRefreshClients && ( } label={strings?.intention?.refresh || 'Refresh'} color="inherit" onClick={(e) => { e.stopPropagation(); loadClientList() }} /> )} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportClientList() }} /> }> {clientList.length === 0 && ( {clientListLoaded ? (mon.noClients || 'No clients') : (strings?.label?.loading || 'Loading...')} )} {clientList.length > 0 && ( {clientList.map(client => ( {client.addr} {client.name && ({client.name})} db{client.db} {'\u00B7'} {client.cmd} {'\u00B7'} {client.idle}s {!isReadonly && ( killClient(client.id)} /> )} ))} )} {/* Top Keys by Memory */} : } label={strings?.label?.autoRefresh || 'Auto'} color="inherit" onClick={(e) => { e.stopPropagation(); toggleAutoTopKeys() }} /> {!autoRefreshTopKeys && ( } label={strings?.intention?.refresh || 'Refresh'} color="inherit" onClick={(e) => { e.stopPropagation(); loadTopKeys() }} /> )} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportTopKeysFile() }} /> }> {topKeys.length === 0 && ( {topKeysLoaded ? (mon.noKeys || 'No keys') : (strings?.label?.loading || 'Loading...')} )} {topKeys.length > 0 && ( {topKeys.map((entry, i) => ( #{i + 1} {entry.key} {formatBytes(entry.bytes)} ))} )} ) }