/** * 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 } 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 typeChartRef = useRef(null) const prefixChartRef = useRef(null) 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) 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]) 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]) useEffect(() => { runAnalysis() }, []) // 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 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} ) if (loading && !data) { return {s.running || 'Analyzing...'} } if (!loading && !data) { return {s.noData || 'No data. Click Run Analysis to start.'} } if (!data) return null const m = data.memoryInfo const exp = data.expirationOverview return ( {/* 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`) }} />}> ) }