/** * Memory Analysis page — exact port of Angular memory-analysis.component. * Bar charts via canvas (theme-aware), memory breakdown, type distribution, prefix memory, expiration. */ import { useState, useEffect, useCallback, useRef } from 'react' import { Box, List, ListItem, Divider, TextField, useTheme, } from '@mui/material' import { PlayArrow, HourglassEmpty, Download, Analytics, CheckBox, CheckBoxOutlineBlank, Refresh } from '@mui/icons-material' 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' function formatBytes(bytes: number): string { if (bytes == null || isNaN(bytes)) return '-' if (bytes < 1024) return bytes + ' B' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB' return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB' } function formatTTL(seconds: number): string { if (!seconds || seconds <= 0) return '-' if (seconds < 60) return seconds + 's' if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's' if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h' } 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 MemoryAnalysisPage() { const strings = useI18nStore(s => s.strings) const currentLang = useI18nStore(s => s.currentLang) const connection = useRedisStateStore(s => s.connection) const { generalHandleError } = useCommonStore() const muiTheme = useTheme() const isDark = muiTheme.palette.mode === 'dark' const connName = connection?.name || 'redis' const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [topN, setTopN] = useState(20) const [maxScanKeys, setMaxScanKeys] = useState(5000) const [typeEntries, setTypeEntries] = useState>([]) const [doctorText, setDoctorText] = useState(null) const [doctorLoading, setDoctorLoading] = useState(false) const [autoRefreshDoctor, setAutoRefreshDoctor] = useState(() => localStorage.getItem('p3xr-monitor-auto-doctor') === 'true') const doctorIntervalRef = useRef(null) const typeChartRef = useRef(null) const prefixChartRef = useRef(null) const resizeObRef = useRef(null) const redrawRef = useRef<() => void>(() => {}) const getChartColors = useCallback(() => ({ primary: muiTheme.palette.primary.main || (isDark ? '#90caf9' : '#1976d2'), accent: muiTheme.palette.secondary.main || (isDark ? '#ce93d8' : '#9c27b0'), warn: muiTheme.palette.error.main || (isDark ? '#ef9a9a' : '#f44336'), 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)', }), [muiTheme, isDark]) const getBarColors = useCallback(() => { const c = getChartColors() return [c.primary, c.accent, c.warn, isDark ? '#ffb74d' : '#ff9800', isDark ? '#81c784' : '#4caf50', isDark ? '#4dd0e1' : '#00bcd4', isDark ? '#a1887f' : '#795548', isDark ? '#90a4ae' : '#607d8b'] }, [getChartColors, isDark]) const drawBarChart = useCallback((container: HTMLDivElement | null, items: Array<{ label: string; value: number }>) => { if (!container || items.length === 0 || container.offsetWidth <= 0) return container.innerHTML = '' const colors = getChartColors() const barColors = getBarColors() const dpr = window.devicePixelRatio || 1 const width = container.offsetWidth || 500 const barHeight = 24, labelWidth = 120, valueWidth = 80 const chartLeft = labelWidth + 8, chartRight = width - valueWidth - 8 const chartWidth = chartRight - chartLeft, topPad = 8 const height = topPad + items.length * (barHeight + 4) + 8 const canvas = document.createElement('canvas') canvas.width = width * dpr; canvas.height = height * dpr canvas.style.width = width + 'px'; canvas.style.height = height + 'px' const ctx = canvas.getContext('2d')! ctx.scale(dpr, dpr) 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) }) container.appendChild(canvas) }, [getChartColors, getBarColors]) const drawCharts = useCallback((analysisData: any, entries: typeof typeEntries) => { drawBarChart(typeChartRef.current, entries.map(t => ({ label: t.type, value: t.bytes }))) drawBarChart(prefixChartRef.current, (analysisData?.prefixMemory || []).slice(0, 20).map((p: any) => ({ label: p.prefix, value: p.totalBytes }))) }, [drawBarChart]) // Keep redraw ref in sync for ResizeObserver (always reads latest closure values) redrawRef.current = () => { if (data && typeEntries.length > 0) drawCharts(data, typeEntries) } const runAnalysis = useCallback(async () => { if (loading) return setLoading(true) try { const resp = await request({ action: 'memory/analysis', payload: { topN, maxScanKeys } }) const d = resp.data setData(d) const entries = Object.keys(d.typeDistribution).map(type => ({ type, count: d.typeDistribution[type], bytes: d.typeMemory[type] || 0, })).sort((a, b) => b.bytes - a.bytes) setTypeEntries(entries) setLoading(false) setTimeout(() => drawCharts(d, entries), 100) } catch (e) { setLoading(false) generalHandleError(e) } }, [topN, maxScanKeys, loading, drawCharts, generalHandleError]) const runDoctor = useCallback(async () => { setDoctorLoading(true) try { const resp = await request({ action: 'memory/doctor' }) setDoctorText(resp.data.text) } catch (e) { generalHandleError(e) } finally { setDoctorLoading(false) } }, [generalHandleError]) const toggleAutoDoctor = useCallback(() => { setAutoRefreshDoctor(prev => { const next = !prev localStorage.setItem('p3xr-monitor-auto-doctor', String(next)) if (next) runDoctor() return next }) }, [runDoctor]) useEffect(() => { if (autoRefreshDoctor) { doctorIntervalRef.current = setInterval(() => runDoctor(), 2000) } else { clearInterval(doctorIntervalRef.current) } return () => clearInterval(doctorIntervalRef.current) }, [autoRefreshDoctor, runDoctor]) const connectionId = connection?.id useEffect(() => { setData(null) setTypeEntries([]) if (connectionId) runAnalysis() }, [connectionId]) // eslint-disable-line react-hooks/exhaustive-deps // Redraw charts on theme change const primaryColor = muiTheme.palette.primary.main useEffect(() => { if (data) setTimeout(() => drawCharts(data, typeEntries), 100) }, [isDark, currentLang, primaryColor]) // eslint-disable-line react-hooks/exhaustive-deps // ResizeObserver for responsive bar charts in accordions const hasData = !!data useEffect(() => { if (!hasData) return let rt: any const ob = new ResizeObserver(() => { clearTimeout(rt) rt = setTimeout(() => redrawRef.current(), 50) }) resizeObRef.current = ob if (typeChartRef.current) ob.observe(typeChartRef.current) if (prefixChartRef.current) ob.observe(prefixChartRef.current) return () => { clearTimeout(rt) ob.disconnect() resizeObRef.current = null } }, [hasData]) // eslint-disable-line react-hooks/exhaustive-deps 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 s = strings?.page?.analysis || {} as any const InfoRow = ({ label, value }: { label: string; value: string | number }) => ( <> {label} {value} ) const doctorAccordion = ( : } label={strings?.label?.autoRefresh || 'Auto'} color="inherit" onClick={(e) => { e.stopPropagation(); toggleAutoDoctor() }} /> {!autoRefreshDoctor && : } label={doctorLoading ? (strings?.label?.loading || 'Loading...') : (strings?.intention?.refresh || 'Refresh')} color="inherit" disabled={doctorLoading} onClick={(e) => { e.stopPropagation(); runDoctor() }} />} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); if (doctorText) downloadText(doctorText, `${connName}-memory-doctor.txt`) }} /> }> {!doctorText ? {s.doctorNoData || 'Click Refresh to run Memory Doctor diagnostics.'} : {doctorText} } ) if (loading && !data) { return {doctorAccordion} {s.running || 'Analyzing...'} } if (!loading && !data) { return {doctorAccordion} {s.noData || 'No data. Click Run Analysis to start.'} } if (!data) return null const m = data.memoryInfo const exp = data.expirationOverview return ( {doctorAccordion} {/* Controls */} : } label={loading ? (s.running || 'Analyzing...') : (s.runAnalysis || 'Run Analysis')} color="inherit" disabled={loading} onClick={(e) => { e.stopPropagation(); runAnalysis() }} /> } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); downloadText([`${s.keysScanned || 'Keys Scanned'}: ${data.totalScanned} / ${data.dbSize}`, `${s.topN || 'Top N'}: ${topN}`, `${s.maxScanKeys || 'Max Scan Keys'}: ${maxScanKeys}`].join('\n'), `${connName}-analysis-overview.txt`) }} /> }> {s.topN || 'Top N'} setTopN(Number(e.target.value))} slotProps={{ htmlInput: { min: 5, max: 100 } }} sx={{ width: 80 }} /> {s.maxScanKeys || 'Max Scan Keys'} setMaxScanKeys(Number(e.target.value))} slotProps={{ htmlInput: { min: 100, max: 100000, step: 1000 } }} sx={{ width: 100 }} />
{/* Memory Breakdown */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); downloadText([`${s.totalMemory || 'Total'}: ${m.usedHuman}`, `${s.rssMemory || 'RSS'}: ${m.rssHuman}`, `${s.peakMemory || 'Peak'}: ${m.peakHuman}`, `${s.overheadMemory || 'Overhead'}: ${formatBytes(m.overhead)}`, `${s.datasetMemory || 'Dataset'}: ${formatBytes(m.dataset)}`, `${s.luaMemory || 'Lua'}: ${formatBytes(m.lua)}`, `${s.fragmentation || 'Fragmentation'}: ${m.fragRatio}x`, `${s.allocator || 'Allocator'}: ${m.allocator}`].join('\n'), `${connName}-memory-breakdown.txt`) }} />}>
{/* Type Distribution */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(typeChartRef, 'type-distribution') }} />}> {typeEntries.map(item => ( {item.type} {item.count} keys {formatBytes(item.bytes)} ))}
{/* Memory by Prefix */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(prefixChartRef, 'memory-by-prefix') }} />}> {(data.prefixMemory || []).map((item: any, i: number) => ( #{i + 1} {item.prefix} {item.keyCount} keys {formatBytes(item.totalBytes)} ))}
{/* Key Expiration Overview */} } label={strings?.intention?.export || 'Export'} color="inherit" onClick={(e) => { e.stopPropagation(); downloadText([`${s.withTTL || 'With TTL'}: ${exp.withTTL}`, `${s.persistent || 'Persistent'}: ${exp.persistent}`, `${s.avgTTL || 'Average TTL'}: ${formatTTL(exp.avgTTL)}`].join('\n'), `${connName}-expiration.txt`) }} />}>
) }