/**
* Pulse (monitoring overview) — exact port of Angular monitoring.component.
* Real-time metrics with 4 uPlot charts, slow log, client list, top keys.
* Charts are theme-aware and language-aware.
* Export all (ZIP with TXT + charts PNG + PDF) deferred to end.
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box, List, ListItem, Divider, useTheme, CircularProgress, Tooltip,
} from '@mui/material'
import {
Pause, PlayArrow, Download, Archive, Refresh, Close,
CheckBox, CheckBoxOutlineBlank, HourglassEmpty, DeleteSweep,
} from '@mui/icons-material'
import 'uplot/dist/uPlot.min.css'
import { useI18nStore } from '../../stores/i18n.store'
import { useRedisStateStore } from '../../stores/redis-state.store'
import { parseRedisVersion } from '../../../core/redis-version'
import { useCommonStore } from '../../stores/common.store'
import { request } from '../../stores/socket.service'
import P3xrAccordion from '../../components/P3xrAccordion'
import P3xrButton from '../../components/P3xrButton'
interface MonitorSnapshot {
timestamp: number
memory: { used: number; rss: number; peak: number; usedHuman: string; rssHuman: string; peakHuman: string; fragRatio: number }
stats: { opsPerSec: number; hits: number; misses: number; hitRate: number; inputKbps: number; outputKbps: number; totalCommands: number; expiredKeys: number; evictedKeys: number }
clients: { connected: number; blocked: number }
server: { version: string; uptime: number; mode: string }
keyspace: Record<string, string>
slowlog: Array<{ id: number; timestamp: number; duration: number; command: string }>
}
const MAX_HISTORY = 120
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function uptimeFormatted(s: number): string {
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
function downloadText(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = filename; a.click()
URL.revokeObjectURL(url)
}
export default function PulsePage() {
const strings = useI18nStore(s => s.strings)
const currentLang = useI18nStore(s => s.currentLang)
const connection = useRedisStateStore(s => s.connection)
const info = useRedisStateStore(s => s.info)
const modules = useRedisStateStore(s => s.modules)
const { toast, confirm, generalHandleError } = useCommonStore()
const muiTheme = useTheme()
const isDark = muiTheme.palette.mode === 'dark'
const isReadonly = connection?.readonly === true
const connName = connection?.name || 'redis'
const [current, setCurrent] = useState<MonitorSnapshot | null>(null)
const [paused, setPaused] = useState(false)
const [clientList, setClientList] = useState<any[]>([])
const [topKeys, setTopKeys] = useState<any[]>([])
const [slotStats, setSlotStats] = useState<any[]>([])
const [slotStatsMetric, setSlotStatsMetric] = useState('KEY-COUNT')
const [slotStatsLoaded, setSlotStatsLoaded] = useState(false)
const [clientListLoaded, setClientListLoaded] = useState(false)
const [topKeysLoaded, setTopKeysLoaded] = useState(false)
const [autoRefreshClients, setAutoRefreshClients] = useState(() => localStorage.getItem('p3xr-monitor-auto-clients') === 'true')
const [autoRefreshTopKeys, setAutoRefreshTopKeys] = useState(() => localStorage.getItem('p3xr-monitor-auto-topkeys') === 'true')
const historyRef = useRef<MonitorSnapshot[]>([])
const uPlotRef = useRef<any>(null)
const chartsInitRef = useRef(false)
const memChartRef = useRef<HTMLDivElement>(null)
const opsChartRef = useRef<HTMLDivElement>(null)
const cliChartRef = useRef<HTMLDivElement>(null)
const netChartRef = useRef<HTMLDivElement>(null)
const memPlotRef = useRef<any>(null)
const opsPlotRef = useRef<any>(null)
const cliPlotRef = useRef<any>(null)
const netPlotRef = useRef<any>(null)
const resizeObRef = useRef<ResizeObserver | null>(null)
const pausedRef = useRef(false)
const autoCliRef = useRef(autoRefreshClients)
const autoTopRef = useRef(autoRefreshTopKeys)
pausedRef.current = paused
autoCliRef.current = autoRefreshClients
autoTopRef.current = autoRefreshTopKeys
// Theme colors via ref — always reads latest palette on every render
const themeRef = useRef({
primary: muiTheme.palette.primary.main,
accent: muiTheme.palette.secondary.main,
warn: muiTheme.palette.error.main,
text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)',
grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
})
themeRef.current = {
primary: muiTheme.palette.primary.main,
accent: muiTheme.palette.secondary.main,
warn: muiTheme.palette.error.main,
text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)',
grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
}
const buildChartData = useCallback(() => {
const h = historyRef.current
return {
timestamps: h.map(s => s.timestamp / 1000),
memUsed: h.map(s => s.memory.used / (1024 * 1024)),
memRss: h.map(s => s.memory.rss / (1024 * 1024)),
ops: h.map(s => s.stats.opsPerSec),
connected: h.map(s => s.clients.connected),
blocked: h.map(s => s.clients.blocked),
netIn: h.map(s => s.stats.inputKbps),
netOut: h.map(s => s.stats.outputKbps),
}
}, [])
const stringsRef = useRef(strings)
stringsRef.current = strings
const createOpts = useCallback((width: number, seriesConfig: any[]) => {
const c = themeRef.current
return {
width, height: 180,
cursor: { show: true, drag: { x: false, y: false } },
legend: { show: true, live: false },
scales: { x: { time: true } },
axes: [
{ stroke: c.text, grid: { stroke: c.grid, width: 1 }, ticks: { stroke: c.grid }, font: '11px Roboto',
values: (_: any, ticks: number[]) => ticks.map(t => formatTime(t * 1000)) },
{ stroke: c.text, grid: { stroke: c.grid, width: 1 }, ticks: { stroke: c.grid }, font: '11px Roboto Mono', size: 55 },
],
series: [
{ label: stringsRef.current?.label?.time || 'Time', value: (_: any, v: number) => v ? formatTime(v * 1000) : '' },
...seriesConfig,
],
}
}, []) // reads theme+strings from refs
const destroyCharts = useCallback(() => {
resizeObRef.current?.disconnect(); resizeObRef.current = null
memPlotRef.current?.destroy(); memPlotRef.current = null
opsPlotRef.current?.destroy(); opsPlotRef.current = null
cliPlotRef.current?.destroy(); cliPlotRef.current = null
netPlotRef.current?.destroy(); netPlotRef.current = null
chartsInitRef.current = false
}, [])
const initCharts = useCallback(() => {
if (!uPlotRef.current || chartsInitRef.current) return
const memEl = memChartRef.current, opsEl = opsChartRef.current
const cliEl = cliChartRef.current, netEl = netChartRef.current
if (!memEl || !opsEl || !cliEl || !netEl) return
const data = buildChartData()
if (data.timestamps.length < 2) return
const c = themeRef.current
const s = stringsRef.current?.page?.monitor || {} as any
memPlotRef.current = new uPlotRef.current(createOpts(memEl.offsetWidth || 500, [
{ label: s.memory || 'Memory', stroke: c.primary, width: 2, fill: c.primary + '15' },
{ label: 'RSS', stroke: c.accent, width: 2 },
]), [data.timestamps, data.memUsed, data.memRss], memEl)
opsPlotRef.current = new uPlotRef.current(createOpts(opsEl.offsetWidth || 500, [
{ label: s.opsPerSec || 'Ops/s', stroke: c.primary, width: 2, fill: c.primary + '20' },
]), [data.timestamps, data.ops], opsEl)
cliPlotRef.current = new uPlotRef.current(createOpts(cliEl.offsetWidth || 500, [
{ label: s.clients || 'Connected', stroke: c.primary, width: 2 },
{ label: s.blocked || 'Blocked', stroke: c.warn, width: 2 },
]), [data.timestamps, data.connected, data.blocked], cliEl)
netPlotRef.current = new uPlotRef.current(createOpts(netEl.offsetWidth || 500, [
{ label: '\u2193 In', stroke: c.primary, width: 2, fill: c.primary + '15' },
{ label: '\u2191 Out', stroke: c.accent, width: 2 },
]), [data.timestamps, data.netIn, data.netOut], netEl)
chartsInitRef.current = true
let rt: any
resizeObRef.current = new ResizeObserver(() => {
clearTimeout(rt)
rt = setTimeout(() => {
const h = 180
if (memEl.offsetWidth > 0) memPlotRef.current?.setSize({ width: memEl.offsetWidth, height: h })
if (opsEl.offsetWidth > 0) opsPlotRef.current?.setSize({ width: opsEl.offsetWidth, height: h })
if (cliEl.offsetWidth > 0) cliPlotRef.current?.setSize({ width: cliEl.offsetWidth, height: h })
if (netEl.offsetWidth > 0) netPlotRef.current?.setSize({ width: netEl.offsetWidth, height: h })
}, 50)
})
resizeObRef.current.observe(memEl)
resizeObRef.current.observe(opsEl)
resizeObRef.current.observe(cliEl)
resizeObRef.current.observe(netEl)
}, [buildChartData, createOpts, destroyCharts]) // reads theme+strings from refs
const updateCharts = useCallback(() => {
if (!chartsInitRef.current) return
const d = buildChartData()
memPlotRef.current?.setData([d.timestamps, d.memUsed, d.memRss])
opsPlotRef.current?.setData([d.timestamps, d.ops])
cliPlotRef.current?.setData([d.timestamps, d.connected, d.blocked])
netPlotRef.current?.setData([d.timestamps, d.netIn, d.netOut])
}, [buildChartData])
// --- Data fetching ---
const fetchData = useCallback(async () => {
try {
const resp = await request({ action: 'monitor/info', payload: {} })
const data: MonitorSnapshot = resp.data
setCurrent(data)
historyRef.current.push(data)
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift()
if (chartsInitRef.current) {
updateCharts()
} else if (uPlotRef.current && historyRef.current.length >= 2) {
initCharts()
}
} catch { /* next tick */ }
}, [updateCharts, initCharts])
const loadClientList = useCallback(async () => {
try {
const resp = await request({ action: 'client/list', payload: {} })
setClientList(resp.data)
setClientListLoaded(true)
} catch { setClientListLoaded(true) }
}, [])
const loadTopKeys = useCallback(async () => {
try {
const resp = await request({ action: 'memory/top-keys', payload: { topN: 20 } })
setTopKeys(resp.data)
setTopKeysLoaded(true)
} catch { setTopKeysLoaded(true) }
}, [])
const loadSlotStats = useCallback(async (metric?: string) => {
try {
const resp = await request({ action: 'cluster/slot-stats', payload: { metric: metric || slotStatsMetric, limit: 20 } })
setSlotStats(resp.slots || [])
setSlotStatsLoaded(true)
} catch { setSlotStatsLoaded(true); setSlotStats([]) }
}, [slotStatsMetric])
const isCluster = useRedisStateStore.getState().connection?.cluster === true
const rv = parseRedisVersion(useRedisStateStore.getState().info?.server?.redis_version)
const [clusterShards, setClusterShards] = useState<any[] | null>(null)
const [autoRefreshShards, setAutoRefreshShards] = useState(() => localStorage.getItem('p3xr-monitor-auto-shards') === 'true')
const shardsIntervalRef = useRef<any>(null)
const loadClusterShards = useCallback(async () => {
try {
const resp = await request({ action: 'cluster/shards' })
setClusterShards(resp.data.shards)
} catch (e) { generalHandleError(e) }
}, [generalHandleError])
const toggleAutoRefreshShards = useCallback(() => {
setAutoRefreshShards(prev => {
const next = !prev
localStorage.setItem('p3xr-monitor-auto-shards', String(next))
if (next) loadClusterShards()
return next
})
}, [loadClusterShards])
useEffect(() => {
if (autoRefreshShards) {
shardsIntervalRef.current = setInterval(() => loadClusterShards(), 2000)
} else {
clearInterval(shardsIntervalRef.current)
}
return () => clearInterval(shardsIntervalRef.current)
}, [autoRefreshShards, loadClusterShards])
const getSlotCount = (shard: any) => shard.slotRanges.reduce((sum: number, [a, b]: [number, number]) => sum + (b - a + 1), 0)
const exportClusterSlots = useCallback(() => {
if (!clusterShards) return
const lines = clusterShards.map(s => {
const slots = s.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', ')
const count = getSlotCount(s)
const replicas = s.replicas.map((r: any) => `${r.host}:${r.port}`).join(', ')
return `${s.master.host}:${s.master.port} | ${slots} | ${count} slots | replicas: ${replicas || 'none'}`
})
downloadText(lines.join('\n'), `${connName}-cluster-slots.txt`)
}, [clusterShards, connName])
// --- Init ---
useEffect(() => {
fetchData()
loadClientList()
loadTopKeys()
if (isCluster && rv.isAtLeast(8, 2)) loadSlotStats()
import('uplot').then(mod => {
uPlotRef.current = mod.default
if (historyRef.current.length >= 2) setTimeout(() => initCharts(), 300)
})
const interval = setInterval(() => {
if (!pausedRef.current) {
fetchData()
if (autoCliRef.current) loadClientList()
if (autoTopRef.current) loadTopKeys()
}
}, 2000)
return () => {
clearInterval(interval)
destroyCharts()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Re-init on connection change
useEffect(() => {
if (!connection) return
historyRef.current = []
destroyCharts()
setCurrent(null)
setClientList([])
setTopKeys([])
setClientListLoaded(false)
setTopKeysLoaded(false)
fetchData()
loadClientList()
loadTopKeys()
}, [connection]) // eslint-disable-line react-hooks/exhaustive-deps
// Re-init charts on theme/language change — destroy + rebuild with fresh colors from refs
const primaryColor = muiTheme.palette.primary.main
useEffect(() => {
if (!uPlotRef.current || historyRef.current.length < 2) return
// Always destroy and re-create to pick up new theme colors
destroyCharts()
const t = setTimeout(() => initCharts(), 150)
return () => clearTimeout(t)
}, [isDark, currentLang, primaryColor]) // eslint-disable-line react-hooks/exhaustive-deps
// --- Actions ---
const killClient = useCallback(async (id: string) => {
try {
await confirm({ message: strings?.page?.monitor?.confirmKillClient || 'Are you sure to kill this client?' })
await request({ action: 'client/kill', payload: { id } })
toast(strings?.page?.monitor?.clientKilled || 'Client killed')
loadClientList()
} catch (e: any) { if (e !== undefined) generalHandleError(e) }
}, [strings, confirm, toast, loadClientList, generalHandleError])
const toggleAutoClients = () => {
const next = !autoRefreshClients
setAutoRefreshClients(next)
try { localStorage.setItem('p3xr-monitor-auto-clients', String(next)) } catch {}
}
const toggleAutoTopKeys = () => {
const next = !autoRefreshTopKeys
setAutoRefreshTopKeys(next)
try { localStorage.setItem('p3xr-monitor-auto-topkeys', String(next)) } catch {}
}
const exportOverview = useCallback(() => {
if (!current) return
const c = current, mon = strings?.page?.monitor || {} as any
const lines = [
`${mon.memory || 'Memory'}: ${c.memory.usedHuman}`, `${mon.rss || 'RSS'}: ${c.memory.rssHuman}`,
`${mon.peak || 'Peak'}: ${c.memory.peakHuman}`, `${mon.fragmentation || 'Fragmentation'}: ${c.memory.fragRatio}x`,
`${mon.opsPerSec || 'Ops/sec'}: ${c.stats.opsPerSec}`, `${mon.totalCommands || 'Total'}: ${c.stats.totalCommands}`,
`${mon.clients || 'Clients'}: ${c.clients.connected}`, `${mon.blocked || 'Blocked'}: ${c.clients.blocked}`,
`${mon.hitsMisses || 'Hit Rate'}: ${c.stats.hitRate}%`,
`${mon.hitsAndMisses || 'Hits / Misses'}: ${c.stats.hits} / ${c.stats.misses}`,
`${mon.networkIo || 'Network I/O'}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`,
`${mon.expired || 'Expired'}: ${c.stats.expiredKeys}`, `${mon.evicted || 'Evicted'}: ${c.stats.evictedKeys}`,
]
downloadText(lines.join('\n'), `${connName}-overview.txt`)
}, [current, strings, connName])
const exportChart = useCallback((ref: React.RefObject<HTMLDivElement | null>, name: string) => {
const canvas = ref.current?.querySelector('canvas') as HTMLCanvasElement
if (!canvas) return
const ec = document.createElement('canvas')
ec.width = canvas.width; ec.height = canvas.height
const ctx = ec.getContext('2d')!
ctx.fillStyle = isDark ? '#1e1e1e' : '#ffffff'
ctx.fillRect(0, 0, ec.width, ec.height)
ctx.drawImage(canvas, 0, 0)
const a = document.createElement('a'); a.href = ec.toDataURL('image/png')
a.download = `${connName}-${name}.png`; a.click()
}, [connName, isDark])
const resetSlowLog = useCallback(async () => {
try {
const m = stringsRef.current?.page?.monitor || {}
await confirm({ message: m.confirmSlowLogReset || 'Are you sure to reset the slow log?' })
await request({ action: 'monitor/slowlog-reset' })
toast({ message: m.slowLogResetDone || 'Slow log reset' })
} catch {}
}, [confirm, toast])
const exportSlowLog = useCallback(() => {
if (!current) return
const lines = current.slowlog.map(e => `${e.duration}\u00B5s ${e.command}`)
downloadText(lines.join('\n'), `${connName}-slowlog.txt`)
}, [current, connName])
const exportClientList = useCallback(() => {
const lines = clientList.map(c => `${c.addr} ${c.name || ''} db${c.db} ${c.cmd} idle:${c.idle}s`)
downloadText(lines.join('\n'), `${connName}-clients.txt`)
}, [clientList, connName])
const exportTopKeysFile = useCallback(() => {
const lines = topKeys.map((e, i) => `#${i + 1} ${e.key} ${formatBytes(e.bytes)}`)
downloadText(lines.join('\n'), `${connName}-topkeys.txt`)
}, [topKeys, connName])
function getExportBackgroundColor(): string {
return getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (isDark ? '#1e1e1e' : '#ffffff')
}
function renderPulseChartsForExport(): Array<{ label: string; canvas: HTMLCanvasElement }> {
let data: ReturnType<typeof buildChartData>
if (historyRef.current.length >= 2) {
data = buildChartData()
} else if (current) {
const c = current
const now = Date.now() / 1000
data = {
timestamps: [now - 1, now],
memUsed: [c.memory.used / (1024 * 1024), c.memory.used / (1024 * 1024)],
memRss: [c.memory.rss / (1024 * 1024), c.memory.rss / (1024 * 1024)],
ops: [c.stats.opsPerSec, c.stats.opsPerSec],
connected: [c.clients.connected, c.clients.connected],
blocked: [c.clients.blocked, c.clients.blocked],
netIn: [c.stats.inputKbps, c.stats.inputKbps],
netOut: [c.stats.outputKbps, c.stats.outputKbps],
}
} else {
return []
}
const colors = themeRef.current
const s = stringsRef.current?.page?.monitor || {} as any
const chartConfigs: Array<{
label: string
series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>
}> = [
{
label: `${s.memory || 'Memory'} (MB)`,
series: [
{ label: s.memory || 'Memory', color: colors.primary, values: data.memUsed, fill: true },
{ label: 'RSS', color: colors.accent, values: data.memRss },
],
},
{
label: s.opsPerSec || 'Ops/sec',
series: [
{ label: s.opsPerSec || 'Ops/s', color: colors.primary, values: data.ops, fill: true },
],
},
{
label: s.clients || 'Clients',
series: [
{ label: s.clients || 'Connected', color: colors.primary, values: data.connected },
{ label: s.blocked || 'Blocked', color: colors.warn, values: data.blocked },
],
},
{
label: `${s.networkIo || 'Network I/O'} (KB/s)`,
series: [
{ label: '\u2193 In', color: colors.primary, values: data.netIn, fill: true },
{ label: '\u2191 Out', color: colors.accent, values: data.netOut },
],
},
]
return chartConfigs.map(config => ({
label: config.label,
canvas: renderLineChartForExport(data.timestamps, config.series, colors),
}))
}
function renderLineChartForExport(
timestamps: number[],
series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>,
colors: typeof themeRef.current,
): HTMLCanvasElement {
const dpr = 2
const width = 900
const height = 260
const padTop = 32
const padBottom = 40
const padLeft = 60
const padRight = 16
const legendH = 20
const chartW = width - padLeft - padRight
const chartH = height - padTop - padBottom - legendH
const canvas = document.createElement('canvas')
canvas.width = width * dpr
canvas.height = height * dpr
const ctx = canvas.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.fillStyle = getExportBackgroundColor()
ctx.fillRect(0, 0, width, height)
const n = timestamps.length
if (n < 2) return canvas
let yMin = Infinity
let yMax = -Infinity
for (const s of series) {
for (const v of s.values) {
if (v < yMin) yMin = v
if (v > yMax) yMax = v
}
}
if (yMin === yMax) {
yMin -= 1
yMax += 1
}
const yRange = yMax - yMin
const tMin = timestamps[0]
const tMax = timestamps[n - 1]
const tRange = tMax - tMin || 1
const toX = (t: number) => padLeft + ((t - tMin) / tRange) * chartW
const toY = (v: number) => padTop + chartH - ((v - yMin) / yRange) * chartH
ctx.strokeStyle = colors.grid
ctx.lineWidth = 1
const ySteps = 5
for (let i = 0; i <= ySteps; i++) {
const gy = padTop + (chartH / ySteps) * i
ctx.beginPath()
ctx.moveTo(padLeft, gy)
ctx.lineTo(padLeft + chartW, gy)
ctx.stroke()
const val = yMax - (yRange / ySteps) * i
ctx.fillStyle = colors.text
ctx.font = '10px Roboto Mono, monospace'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
ctx.fillText(val >= 1000 ? `${(val / 1000).toFixed(1)}k` : val.toFixed(1), padLeft - 6, gy)
}
const labelCount = Math.min(6, n)
ctx.font = '10px Roboto, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillStyle = colors.text
for (let i = 0; i < labelCount; i++) {
const idx = Math.round((i / (labelCount - 1)) * (n - 1))
const t = timestamps[idx]
const d = new Date(t * 1000)
const label = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
ctx.fillText(label, toX(t), padTop + chartH + 6)
}
for (const s of series) {
ctx.strokeStyle = s.color
ctx.lineWidth = 2
ctx.lineJoin = 'round'
ctx.beginPath()
for (let i = 0; i < n; i++) {
const x = toX(timestamps[i])
const y = toY(s.values[i])
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
if (s.fill) {
ctx.fillStyle = `${s.color}20`
ctx.beginPath()
ctx.moveTo(toX(timestamps[0]), toY(s.values[0]))
for (let i = 1; i < n; i++) ctx.lineTo(toX(timestamps[i]), toY(s.values[i]))
ctx.lineTo(toX(timestamps[n - 1]), padTop + chartH)
ctx.lineTo(toX(timestamps[0]), padTop + chartH)
ctx.closePath()
ctx.fill()
}
}
let lx = padLeft
const ly = height - legendH + 4
ctx.font = '11px Roboto, sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
for (const s of series) {
ctx.fillStyle = s.color
ctx.fillRect(lx, ly - 4, 12, 8)
ctx.fillStyle = colors.text
ctx.fillText(s.label, lx + 16, ly)
lx += ctx.measureText(s.label).width + 32
}
return canvas
}
// --- Export All (ZIP with TXT + charts PNG + PDF) ---
const exportAll = useCallback(async () => {
if (!current) return
try {
const JSZip = (await import('jszip')).default
const zip = new JSZip()
const c = current
const sections: string[] = []
const mon = stringsRef.current?.page?.monitor || {} as any
const a = stringsRef.current?.page?.analysis || {} as any
const sanitize = (s: string) => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
// === PULSE ===
sections.push(`============================`, ` PULSE`, `============================`, ``,
`--- ${mon.title || 'Monitoring'} ---`,
`Redis ${c.server.version} \u00B7 ${c.server.mode} \u00B7 Uptime: ${uptimeFormatted(c.server.uptime)}`,
`${mon.memory || 'Memory'}: ${c.memory.usedHuman}`, `${mon.rss || 'RSS'}: ${c.memory.rssHuman}`,
`${mon.peak || 'Peak'}: ${c.memory.peakHuman}`, `${mon.fragmentation || 'Fragmentation'}: ${c.memory.fragRatio}x`,
`${mon.opsPerSec || 'Ops/sec'}: ${c.stats.opsPerSec}`, `${mon.totalCommands || 'Total'}: ${c.stats.totalCommands}`,
`${mon.clients || 'Clients'}: ${c.clients.connected}`, `${mon.blocked || 'Blocked'}: ${c.clients.blocked}`,
`${mon.hitsMisses || 'Hit Rate'}: ${c.stats.hitRate}%`,
`${mon.hitsAndMisses || 'Hits / Misses'}: ${c.stats.hits} / ${c.stats.misses}`,
`${mon.networkIo || 'Network I/O'}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`,
`${mon.expired || 'Expired'}: ${c.stats.expiredKeys}`, `${mon.evicted || 'Evicted'}: ${c.stats.evictedKeys}`,
)
// Dashboard sections
const storeInfo = useRedisStateStore.getState().info
const storeMods = useRedisStateStore.getState().modules || []
if (storeInfo?.server) {
const si = storeInfo.server, ci = storeInfo.cpu || {}
sections.push(``, `--- ${mon.serverInfo || 'Server Info'} ---`)
sections.push(`${mon.os || 'OS'}: ${si.os || ''}`, `${mon.port || 'Port'}: ${si.tcp_port || ''}`, `${mon.pid || 'Process ID'}: ${si.process_id || ''}`)
if (si.config_file) sections.push(`${mon.configFile || 'Config File'}: ${si.config_file}`)
sections.push(`${mon.cpuSys || 'System'} CPU: ${ci.used_cpu_sys || '0'}`, `${mon.cpuUser || 'User'} CPU: ${ci.used_cpu_user || '0'}`)
}
if (storeInfo?.persistence) {
const p = storeInfo.persistence
const lastSaveTs = parseInt(p.rdb_last_save_time, 10)
const lastSave = lastSaveTs ? new Date(lastSaveTs * 1000).toLocaleString() : 'N/A'
sections.push(``, `--- ${mon.persistence || 'Persistence'} ---`)
sections.push(`${mon.rdbLastSave || 'RDB Last Save'}: ${lastSave}`, `${mon.rdbStatus || 'RDB Status'}: ${p.rdb_last_bgsave_status || 'N/A'}`)
sections.push(`${mon.rdbChanges || 'Changes Since Last Save'}: ${p.rdb_changes_since_last_save ?? 'N/A'}`, `${mon.aofEnabled || 'AOF Enabled'}: ${p.aof_enabled === '1' ? 'Yes' : 'No'}`)
if (p.aof_enabled === '1') sections.push(`${mon.aofSize || 'AOF Size'}: ${formatBytes(parseInt(p.aof_current_size, 10) || 0)}`)
}
if (storeInfo?.replication) {
const r = storeInfo.replication
sections.push(``, `--- ${mon.replication || 'Replication'} ---`, `${mon.role || 'Role'}: ${r.role || 'unknown'}`)
if (r.role === 'master') sections.push(`${mon.replicas || 'Connected Replicas'}: ${r.connected_slaves ?? '0'}`)
if (r.role === 'slave') { if (r.master_host) sections.push(`${mon.masterHost || 'Master Host'}: ${r.master_host}:${r.master_port}`); if (r.master_link_status) sections.push(`${mon.linkStatus || 'Link Status'}: ${r.master_link_status}`) }
}
if (storeInfo?.keyspace) {
const ks = Object.keys(storeInfo.keyspace).filter((k: string) => k.startsWith('db')).sort((a: string, b: string) => parseInt(a.slice(2), 10) - parseInt(b.slice(2), 10))
if (ks.length > 0) {
sections.push(``, `--- ${mon.keyspace || 'Keyspace'} ---`)
sections.push(...ks.map((db: string) => { const e = storeInfo.keyspace[db]; return `${db}: ${mon.keys || 'Keys'}: ${typeof e === 'object' ? e.keys || '0' : '0'}, ${mon.expires || 'Expires'}: ${typeof e === 'object' ? e.expires || '0' : '0'}` }))
}
}
if (storeMods.length > 0) {
sections.push(``, `--- ${mon.modules || 'Loaded Modules'} ---`)
sections.push(...storeMods.map((m: any) => `${m.name || 'unknown'} v${m.ver ?? m.version ?? ''}`))
} else {
sections.push(``, `--- ${mon.modules || 'Loaded Modules'} ---`, mon.noModules || 'No modules loaded')
}
if (c.slowlog.length > 0) {
sections.push(``, `--- ${mon.slowLog || 'Slow Log'} ---`)
sections.push(...c.slowlog.map(e => `${e.duration}\u00B5s ${e.command}`))
}
if (clientList.length > 0) {
sections.push(``, `--- ${mon.clientList || 'Client List'} ---`)
sections.push(...clientList.map(cl => `${cl.addr} ${cl.name || ''} db${cl.db} ${cl.cmd} idle:${cl.idle}s`))
}
if (topKeys.length > 0) {
sections.push(``, `--- ${mon.topKeys || 'Top Keys by Memory'} ---`)
sections.push(...topKeys.map((e: any, i: number) => `#${i + 1} ${e.key} ${formatBytes(e.bytes)}`))
}
// === ANALYSIS ===
const analysisChartItems: Array<{ name: string; items: Array<{ label: string; value: number }> }> = []
try {
const resp = await request({ action: 'memory/analysis', payload: { topN: 20, maxScanKeys: 5000 } })
const d = resp.data
if (d) {
const m = d.memoryInfo, exp = d.expirationOverview
const typeEntries = Object.keys(d.typeDistribution || {}).map((t: string) => ({
type: t, count: d.typeDistribution[t], bytes: d.typeMemory?.[t] || 0,
})).sort((x: any, y: any) => y.bytes - x.bytes)
sections.push(``, ``, `============================`, ` ANALYSIS`, `============================`)
sections.push(``, `--- ${a.keysScanned || 'Keys Scanned'} ---`, `${a.keysScanned || 'Keys Scanned'}: ${d.totalScanned} / ${d.dbSize}`)
sections.push(``, `--- ${a.memoryBreakdown || 'Memory Breakdown'} ---`)
sections.push(`${a.totalMemory || 'Total'}: ${m.usedHuman}`, `${a.rssMemory || 'RSS'}: ${m.rssHuman}`, `${a.peakMemory || 'Peak'}: ${m.peakHuman}`)
sections.push(`${a.overheadMemory || 'Overhead'}: ${formatBytes(m.overhead)}`, `${a.datasetMemory || 'Dataset'}: ${formatBytes(m.dataset)}`)
sections.push(`${a.luaMemory || 'Lua'}: ${formatBytes(m.lua)}`, `${a.fragmentation || 'Fragmentation'}: ${m.fragRatio}x`, `${a.allocator || 'Allocator'}: ${m.allocator}`)
sections.push(``, `--- ${a.typeDistribution || 'Type Distribution'} ---`)
sections.push(...typeEntries.map((t: any) => `${t.type}: ${t.count} keys, ${formatBytes(t.bytes)}`))
if (d.prefixMemory?.length > 0) {
sections.push(``, `--- ${a.prefixMemory || 'Memory by Prefix'} ---`)
sections.push(...d.prefixMemory.map((p: any, i: number) => `#${i + 1} ${p.prefix} \u2014 ${p.keyCount} keys, ${formatBytes(p.totalBytes)}`))
}
sections.push(``, `--- ${a.expirationOverview || 'Key Expiration'} ---`)
sections.push(`${a.withTTL || 'With TTL'}: ${exp.withTTL}`, `${a.persistent || 'Persistent'}: ${exp.persistent}`, `${a.avgTTL || 'Average TTL'}: ${exp.avgTTL}s`)
analysisChartItems.push(
{ name: a.typeDistribution || 'Type Distribution', items: typeEntries.map((t: any) => ({ label: t.type, value: t.bytes })) },
{ name: a.prefixMemory || 'Memory by Prefix', items: (d.prefixMemory || []).slice(0, 20).map((p: any) => ({ label: p.prefix, value: p.totalBytes })) },
)
}
} catch { /* analysis optional */ }
// === PROFILER + PUBSUB tail ===
const tailSections: string[] = []
const { useMonitoringDataStore } = await import('../../stores/monitoring-data.store')
const monData = useMonitoringDataStore.getState()
if (monData.profilerEntries.length > 0) {
tailSections.push(``, ``, `============================`, ` PROFILER`, `============================`, ``)
tailSections.push(...monData.profilerEntries.map(e => sanitize(`${e.fullTimestamp} [${e.database} ${e.source}] ${e.command}`)))
}
if (monData.pubsubEntries.length > 0) {
tailSections.push(``, ``, `============================`, ` PUBSUB`, `============================`, ``)
tailSections.push(...monData.pubsubEntries.map(e => sanitize(`${e.fullTimestamp} ${e.channel} ${e.message}`)))
}
// TXT file (UTF-8 with BOM)
const textContent = [...sections, ...tailSections].join('\n')
const textBytes = new TextEncoder().encode(textContent)
const bom = new Uint8Array([0xEF, 0xBB, 0xBF])
const txtWithBom = new Uint8Array(bom.length + textBytes.length)
txtWithBom.set(bom)
txtWithBom.set(textBytes, bom.length)
zip.file('monitoring.txt', txtWithBom)
// Charts PNG — collect all pulse chart canvases + analysis bar charts
const allCanvases: Array<{ label: string; canvas: HTMLCanvasElement }> = []
allCanvases.push(...renderPulseChartsForExport())
// Render analysis bar charts
for (const ci of analysisChartItems) {
if (ci.items.length === 0) continue
const canvas = renderBarChartForExport(ci.items)
if (canvas) allCanvases.push({ label: ci.name, canvas })
}
// Stitch all canvases into 1 tall image
if (allCanvases.length > 0) {
const blob = await stitchCharts(allCanvases)
if (blob) zip.file('charts.png', blob)
}
// PDF
try {
const pdfBlob = await generatePdf(sections, allCanvases, tailSections)
if (pdfBlob) zip.file('monitoring.pdf', pdfBlob)
} catch { /* pdf optional */ }
const content = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(content)
const link = document.createElement('a')
link.href = url; link.download = `${connName}-monitoring.zip`; link.click()
URL.revokeObjectURL(url)
} catch (e) { generalHandleError(e) }
}, [current, clientList, topKeys, connName, isDark, generalHandleError])
// --- Export helpers ---
function renderBarChartForExport(items: Array<{ label: string; value: number }>): HTMLCanvasElement | null {
if (items.length === 0) return null
const colors = themeRef.current
const barColors = [colors.primary, colors.accent, colors.warn,
isDark ? '#ffb74d' : '#ff9800', isDark ? '#81c784' : '#4caf50',
isDark ? '#4dd0e1' : '#00bcd4', isDark ? '#a1887f' : '#795548', isDark ? '#90a4ae' : '#607d8b']
const dpr = 2, width = 800, barHeight = 24, labelWidth = 120, valueWidth = 80
const chartLeft = labelWidth + 8, chartRight = width - valueWidth - 8, chartWidth = chartRight - chartLeft
const topPad = 8, height = topPad + items.length * (barHeight + 4) + 8
const canvas = document.createElement('canvas')
canvas.width = width * dpr; canvas.height = height * dpr
const ctx = canvas.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.fillStyle = getExportBackgroundColor()
ctx.fillRect(0, 0, width, height)
const maxVal = Math.max(...items.map(i => i.value), 1)
items.forEach((item, i) => {
const y = topPad + i * (barHeight + 4)
ctx.fillStyle = colors.text; ctx.font = '12px Roboto, sans-serif'
ctx.textAlign = 'right'; ctx.textBaseline = 'middle'
ctx.fillText(item.label.length > 15 ? item.label.substring(0, 14) + '\u2026' : item.label, labelWidth, y + barHeight / 2)
ctx.fillStyle = colors.grid; ctx.fillRect(chartLeft, y, chartWidth, barHeight)
ctx.fillStyle = barColors[i % barColors.length]
ctx.fillRect(chartLeft, y, (item.value / maxVal) * chartWidth, barHeight)
ctx.fillStyle = colors.text; ctx.font = '11px Roboto Mono, monospace'
ctx.textAlign = 'left'; ctx.fillText(formatBytes(item.value), chartRight + 8, y + barHeight / 2)
})
return canvas
}
async function stitchCharts(items: Array<{ label: string; canvas: HTMLCanvasElement }>): Promise<Blob | null> {
const padding = 32, labelHeight = 60, chartSpacing = 40
const width = Math.max(2400, ...items.map(i => i.canvas.width))
let totalHeight = padding
for (const item of items) {
totalHeight += labelHeight + item.canvas.height * (width / item.canvas.width) + chartSpacing
}
totalHeight += padding
const stitched = document.createElement('canvas')
stitched.width = width; stitched.height = totalHeight
const ctx = stitched.getContext('2d')!
const colors = themeRef.current
ctx.fillStyle = getExportBackgroundColor()
ctx.fillRect(0, 0, width, totalHeight)
let y = padding
for (const item of items) {
ctx.fillStyle = colors.text; ctx.font = 'bold 28px Roboto, sans-serif'
ctx.textAlign = 'left'; ctx.textBaseline = 'top'
ctx.fillText(item.label, padding, y); y += labelHeight
const drawW = width - padding * 2, drawH = item.canvas.height * (drawW / item.canvas.width)
ctx.drawImage(item.canvas, padding, y, drawW, drawH); y += drawH + chartSpacing
}
return new Promise(resolve => stitched.toBlob(b => resolve(b), 'image/png'))
}
async function generatePdf(sections: string[], charts: Array<{ label: string; canvas: HTMLCanvasElement }>, tailSections: string[]): Promise<Blob | null> {
const { jsPDF } = await import('jspdf')
const bgColor = getExportBackgroundColor()
const textColor = isDark ? '#e0e0e0' : '#212121'
const headerColor = isDark ? '#90caf9' : '#1565c0'
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
const pageW = pdf.internal.pageSize.getWidth(), pageH = pdf.internal.pageSize.getHeight()
const margin = 12, contentW = pageW - margin * 2
let y = margin
const fillBg = () => { pdf.setFillColor(bgColor); pdf.rect(0, 0, pageW, pageH, 'F') }
fillBg()
const checkPage = (needed: number) => { if (y + needed > pageH - margin) { pdf.addPage(); fillBg(); y = margin } }
for (const line of sections) {
if (line.startsWith('====')) continue
const isTitle = ['PULSE', 'PROFILER', 'PUBSUB', 'ANALYSIS'].includes(line.trim())
if (isTitle) { checkPage(14); y += 4; pdf.setFontSize(14); pdf.setTextColor(headerColor); pdf.text(line.trim(), margin, y); y += 8; continue }
if (line.startsWith('---') && line.endsWith('---')) { checkPage(8); y += 2; pdf.setFontSize(10); pdf.setTextColor(headerColor); pdf.text(line.replace(/^-+\s*/, '').replace(/\s*-+$/, ''), margin, y); y += 5; continue }
if (line === '') { y += 2; continue }
checkPage(4); pdf.setTextColor(textColor); pdf.setFontSize(8)
for (const wl of pdf.splitTextToSize(line, contentW)) { checkPage(4); pdf.text(wl, margin, y); y += 3.5 }
}
for (const chart of charts) {
pdf.addPage(); fillBg(); y = margin
pdf.setFontSize(12); pdf.setTextColor(headerColor); pdf.text(chart.label, margin, y); y += 8
const imgData = chart.canvas.toDataURL('image/png')
const ratio = chart.canvas.height / chart.canvas.width
const availH = pageH - y - margin
const imgW = contentW
const imgH = imgW * ratio
if (imgH > availH) {
const drawH = availH
const drawW = drawH / ratio
pdf.addImage(imgData, 'PNG', margin, y, drawW, drawH)
y += drawH
} else {
pdf.addImage(imgData, 'PNG', margin, y, imgW, imgH)
y += imgH
}
}
if (tailSections.length > 0 && charts.length > 0) { pdf.addPage(); fillBg(); y = margin }
for (const line of tailSections) {
if (line.startsWith('====')) continue
const isTitle = ['PROFILER', 'PUBSUB'].includes(line.trim())
if (isTitle) { checkPage(14); y += 4; pdf.setFontSize(14); pdf.setTextColor(headerColor); pdf.text(line.trim(), margin, y); y += 8; continue }
if (line === '') { y += 2; continue }
checkPage(4); pdf.setTextColor(textColor); pdf.setFontSize(8)
for (const wl of pdf.splitTextToSize(line, contentW)) { checkPage(4); pdf.text(wl, margin, y); y += 3.5 }
}
return pdf.output('blob') as unknown as Blob
}
// --- Dashboard computed values ---
const serverInfoData = info ? (() => {
const s = info.server || {}, c = info.cpu || {}
return { os: s.os || '', port: s.tcp_port || '', pid: s.process_id || '', configFile: s.config_file || '', cpuSys: c.used_cpu_sys || '0', cpuUser: c.used_cpu_user || '0' }
})() : null
const persistenceData = info?.persistence ? (() => {
const p = info.persistence
const lastSaveTs = parseInt(p.rdb_last_save_time, 10)
const lastSave = lastSaveTs ? new Date(lastSaveTs * 1000).toLocaleString() : 'N/A'
return {
rdbLastSave: lastSave, rdbStatus: p.rdb_last_bgsave_status || 'N/A',
rdbChanges: p.rdb_changes_since_last_save ?? 'N/A',
aofEnabled: p.aof_enabled === '1' ? 'Yes' : 'No',
aofSize: p.aof_enabled === '1' ? formatBytes(parseInt(p.aof_current_size, 10) || 0) : '',
}
})() : null
const replicationData = info?.replication ? (() => {
const r = info.replication
const result: any = { role: r.role || 'unknown' }
if (r.role === 'master') result.replicas = r.connected_slaves ?? '0'
else if (r.role === 'slave') { result.masterHost = r.master_host; result.masterPort = r.master_port; result.linkStatus = r.master_link_status }
return result
})() : null
const keyspaceEntries = info?.keyspace ? Object.keys(info.keyspace)
.filter((k: string) => k.startsWith('db'))
.sort((a: string, b: string) => parseInt(a.slice(2), 10) - parseInt(b.slice(2), 10))
.map((db: string) => {
const entry = info.keyspace[db]
return { db, keys: typeof entry === 'object' ? (entry.keys || '0') : '0', expires: typeof entry === 'object' ? (entry.expires || '0') : '0' }
}) : []
const modulesList = (modules || []).map((m: any) => ({ name: m.name || 'unknown', ver: String(m.ver ?? m.version ?? '') }))
// --- Dashboard export functions ---
const exportServerInfo = useCallback(() => {
if (!serverInfoData) return
const s = serverInfoData, mon = stringsRef.current?.page?.monitor || {} as any
const lines = [`${mon.os || 'OS'}: ${s.os}`, `${mon.port || 'Port'}: ${s.port}`, `${mon.pid || 'Process ID'}: ${s.pid}`]
if (s.configFile) lines.push(`${mon.configFile || 'Config File'}: ${s.configFile}`)
lines.push(`${mon.cpuSys || 'System'} CPU: ${s.cpuSys}`, `${mon.cpuUser || 'User'} CPU: ${s.cpuUser}`)
downloadText(lines.join('\n'), `${connName}-server-info.txt`)
}, [connName, serverInfoData])
const exportPersistence = useCallback(() => {
if (!persistenceData) return
const p = persistenceData, mon = stringsRef.current?.page?.monitor || {} as any
const lines = [`${mon.rdbLastSave || 'RDB Last Save'}: ${p.rdbLastSave}`, `${mon.rdbStatus || 'RDB Status'}: ${p.rdbStatus}`,
`${mon.rdbChanges || 'Changes Since Last Save'}: ${p.rdbChanges}`, `${mon.aofEnabled || 'AOF Enabled'}: ${p.aofEnabled}`]
if (p.aofSize) lines.push(`${mon.aofSize || 'AOF Size'}: ${p.aofSize}`)
downloadText(lines.join('\n'), `${connName}-persistence.txt`)
}, [connName, persistenceData])
const exportReplication = useCallback(() => {
if (!replicationData) return
const r = replicationData, mon = stringsRef.current?.page?.monitor || {} as any
const lines = [`${mon.role || 'Role'}: ${r.role}`]
if (r.replicas !== undefined) lines.push(`${mon.replicas || 'Connected Replicas'}: ${r.replicas}`)
if (r.masterHost) lines.push(`${mon.masterHost || 'Master Host'}: ${r.masterHost}:${r.masterPort}`)
if (r.linkStatus) lines.push(`${mon.linkStatus || 'Link Status'}: ${r.linkStatus}`)
downloadText(lines.join('\n'), `${connName}-replication.txt`)
}, [connName, replicationData])
const exportKeyspace = useCallback(() => {
if (keyspaceEntries.length === 0) return
const mon = stringsRef.current?.page?.monitor || {} as any
const lines = keyspaceEntries.map((e: any) => `${e.db}: ${mon.keys || 'Keys'}: ${e.keys}, ${mon.expires || 'Expires'}: ${e.expires}`)
downloadText(lines.join('\n'), `${connName}-keyspace.txt`)
}, [connName, keyspaceEntries])
const exportModules = useCallback(() => {
const mon = stringsRef.current?.page?.monitor || {} as any
if (modulesList.length === 0) { downloadText(mon.noModules || 'No modules loaded', `${connName}-modules.txt`); return }
downloadText(modulesList.map((m: any) => `${m.name} v${m.ver}`).join('\n'), `${connName}-modules.txt`)
}, [connName, modulesList])
// --- Render helpers ---
const InfoRow = ({ label, value }: { label: string; value: string | number }) => (
<>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', width: '100%' }}>
<Box sx={{ flex: 1 }}>{label}</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>{value}</Box>
</Box>
</ListItem>
<Divider />
</>
)
if (!current) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, p: 4, opacity: 0.5 }}>
<HourglassEmpty /> {strings?.label?.loading || 'Loading...'}
</Box>
)
}
const mon = strings?.page?.monitor || {} as any
return (
<Box>
{/* Overview */}
<P3xrAccordion title={mon.title || 'Monitoring'} accordionKey="monitor-overview"
actions={<>
<P3xrButton icon={paused ? <PlayArrow sx={{ fontSize: 18 }} /> : <Pause sx={{ fontSize: 18 }} />}
label={paused ? (strings?.intention?.resume || 'Resume') : (strings?.intention?.pause || 'Pause')}
color="inherit" onClick={(e) => { e.stopPropagation(); setPaused(p => !p) }} />
<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportOverview() }} />
<P3xrButton icon={<Archive sx={{ fontSize: 18 }} />} label={strings?.page?.analysis?.exportAll || 'Export All'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportAll() }} />
</>}
>
<List disablePadding>
<InfoRow label={`Redis ${current.server.version} \u00B7 ${current.server.mode}`} value={uptimeFormatted(current.server.uptime)} />
<InfoRow label={mon.memory || 'Memory'} value={current.memory.usedHuman} />
<InfoRow label={mon.rss || 'RSS'} value={current.memory.rssHuman} />
<InfoRow label={mon.peak || 'Peak'} value={current.memory.peakHuman} />
<InfoRow label={mon.fragmentation || 'Fragmentation'} value={`${current.memory.fragRatio}x`} />
<InfoRow label={mon.opsPerSec || 'Ops/sec'} value={current.stats.opsPerSec} />
<InfoRow label={mon.totalCommands || 'Total Commands'} value={current.stats.totalCommands} />
<InfoRow label={mon.clients || 'Clients'} value={current.clients.connected} />
<InfoRow label={mon.blocked || 'Blocked'} value={current.clients.blocked} />
<InfoRow label={mon.hitsMisses || 'Hit Rate'} value={`${current.stats.hitRate}%`} />
<InfoRow label={mon.hitsAndMisses || 'Hits / Misses'} value={`${current.stats.hits} / ${current.stats.misses}`} />
<InfoRow label={mon.networkIo || 'Network I/O'} value={`${current.stats.inputKbps.toFixed(1)} / ${current.stats.outputKbps.toFixed(1)} KB/s`} />
<InfoRow label={mon.expired || 'Expired'} value={current.stats.expiredKeys} />
<InfoRow label={mon.evicted || 'Evicted'} value={current.stats.evictedKeys} />
</List>
</P3xrAccordion>
{/* Server Info */}
{serverInfoData && (<>
<br />
<P3xrAccordion title={mon.serverInfo || 'Server Info'} accordionKey="monitor-server-info"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportServerInfo() }} />}>
<List disablePadding>
{serverInfoData.os && <InfoRow label={mon.os || 'OS'} value={serverInfoData.os} />}
{serverInfoData.port && <InfoRow label={mon.port || 'Port'} value={serverInfoData.port} />}
{serverInfoData.pid && <InfoRow label={mon.pid || 'Process ID'} value={serverInfoData.pid} />}
{serverInfoData.configFile && <InfoRow label={mon.configFile || 'Config File'} value={serverInfoData.configFile} />}
<InfoRow label={`${mon.cpuSys || 'System'} CPU`} value={serverInfoData.cpuSys} />
<InfoRow label={`${mon.cpuUser || 'User'} CPU`} value={serverInfoData.cpuUser} />
</List>
</P3xrAccordion>
</>)}
{/* Persistence */}
{persistenceData && (<>
<br />
<P3xrAccordion title={mon.persistence || 'Persistence'} accordionKey="monitor-persistence"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportPersistence() }} />}>
<List disablePadding>
<InfoRow label={mon.rdbLastSave || 'RDB Last Save'} value={persistenceData.rdbLastSave} />
<InfoRow label={mon.rdbStatus || 'RDB Status'} value={persistenceData.rdbStatus} />
<InfoRow label={mon.rdbChanges || 'Changes Since Last Save'} value={persistenceData.rdbChanges} />
<InfoRow label={mon.aofEnabled || 'AOF Enabled'} value={persistenceData.aofEnabled} />
{persistenceData.aofSize && <InfoRow label={mon.aofSize || 'AOF Size'} value={persistenceData.aofSize} />}
</List>
</P3xrAccordion>
</>)}
{/* Replication */}
{replicationData && (<>
<br />
<P3xrAccordion title={mon.replication || 'Replication'} accordionKey="monitor-replication"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportReplication() }} />}>
<List disablePadding>
<InfoRow label={mon.role || 'Role'} value={replicationData.role} />
{replicationData.replicas !== undefined && <InfoRow label={mon.replicas || 'Connected Replicas'} value={replicationData.replicas} />}
{replicationData.masterHost && <InfoRow label={mon.masterHost || 'Master Host'} value={`${replicationData.masterHost}:${replicationData.masterPort}`} />}
{replicationData.linkStatus && <InfoRow label={mon.linkStatus || 'Link Status'} value={replicationData.linkStatus} />}
</List>
</P3xrAccordion>
</>)}
{/* Keyspace */}
{keyspaceEntries.length > 0 && (<>
<br />
<P3xrAccordion title={mon.keyspace || 'Keyspace'} accordionKey="monitor-keyspace"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportKeyspace() }} />}>
<List disablePadding>
{keyspaceEntries.map((entry: any, i: number) => (
<Box key={entry.db}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', width: '100%' }}>
<Box sx={{ flex: 1 }}>{entry.db}</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
{mon.keys || 'Keys'}: {entry.keys} {'\u00B7'} {mon.expires || 'Expires'}: {entry.expires}
</Box>
</Box>
</ListItem>
{i < keyspaceEntries.length - 1 && <Divider />}
</Box>
))}
</List>
</P3xrAccordion>
</>)}
{/* Modules */}
<br />
<P3xrAccordion title={mon.modules || 'Loaded Modules'} accordionKey="monitor-modules"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportModules() }} />}>
{modulesList.length === 0 && (
<Box sx={{ p: 2, opacity: 0.5 }}>{mon.noModules || 'No modules loaded'}</Box>
)}
{modulesList.length > 0 && (
<List disablePadding>
{modulesList.map((mod: any, i: number) => (
<Box key={mod.name}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', width: '100%' }}>
<Box sx={{ flex: 1 }}>{mod.name}</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>v{mod.ver}</Box>
</Box>
</ListItem>
{i < modulesList.length - 1 && <Divider />}
</Box>
))}
</List>
)}
</P3xrAccordion>
<br />
{/* Memory Chart */}
<P3xrAccordion title={`${mon.memory || 'Memory'} (MB)`} accordionKey="monitor-chart-memory"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(memChartRef, 'memory') }} />}>
<Box ref={memChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
</P3xrAccordion>
<br />
{/* Ops/sec Chart */}
<P3xrAccordion title={mon.opsPerSec || 'Ops/sec'} accordionKey="monitor-chart-ops"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(opsChartRef, 'ops') }} />}>
<Box ref={opsChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
</P3xrAccordion>
<br />
{/* Clients Chart */}
<P3xrAccordion title={mon.clients || 'Clients'} accordionKey="monitor-chart-clients"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(cliChartRef, 'clients') }} />}>
<Box ref={cliChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
</P3xrAccordion>
<br />
{/* Network I/O Chart */}
<P3xrAccordion title={`${mon.networkIo || 'Network I/O'} (KB/s)`} accordionKey="monitor-chart-network"
actions={<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportChart(netChartRef, 'network') }} />}>
<Box ref={netChartRef} sx={{ minHeight: 180, width: '100%', overflow: 'hidden' }} />
</P3xrAccordion>
{/* Slow Log */}
<br />
<P3xrAccordion title={mon.slowLog || 'Slow Log'} accordionKey="monitor-slowlog"
actions={<>
{!isReadonly && <P3xrButton icon={<DeleteSweep sx={{ fontSize: 18 }} />} label="Reset"
color="inherit" onClick={(e) => { e.stopPropagation(); resetSlowLog() }} />}
<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportSlowLog() }} />
</>}>
{current.slowlog.length === 0
? <Box sx={{ p: 2, opacity: 0.6 }}>{mon.noSlowQueries || 'No slow queries recorded'}</Box>
: <List disablePadding>
{current.slowlog.map(entry => (
<Box key={entry.id}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Box component="kbd" sx={{
px: '6px', py: '2px', borderRadius: '4px', fontSize: 11,
bgcolor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)',
fontFamily: "'Roboto Mono', monospace", whiteSpace: 'nowrap',
}}>{entry.duration}{'\u00B5'}s</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13, wordBreak: 'break-all' }}>{entry.command}</Box>
</Box>
</ListItem>
<Divider />
</Box>
))}
</List>
}
</P3xrAccordion>
{/* Client List */}
<br />
<P3xrAccordion title={mon.clientList || 'Client List'} accordionKey="monitor-clients-list"
actions={<>
<P3xrButton icon={autoRefreshClients ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
label={strings?.label?.autoRefresh || 'Auto'} color="inherit"
onClick={(e) => { e.stopPropagation(); toggleAutoClients() }} />
{!autoRefreshClients && (
<P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />} label={strings?.intention?.refresh || 'Refresh'}
color="inherit" onClick={(e) => { e.stopPropagation(); loadClientList() }} />
)}
<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportClientList() }} />
</>}>
{clientList.length === 0 && (
<Box sx={{ p: 2, opacity: 0.5 }}>{clientListLoaded ? (mon.noClients || 'No clients') : (strings?.label?.loading || 'Loading...')}</Box>
)}
{clientList.length > 0 && (
<List disablePadding>
{clientList.map(client => (
<Box key={client.id}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Box component="span" sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13, fontWeight: 700, minWidth: 150 }}>{client.addr}</Box>
{client.name && <Box component="span" sx={{ opacity: 0.5, fontSize: 12 }}>({client.name})</Box>}
<Box component="span" sx={{ flex: 1, textAlign: 'right', fontFamily: "'Roboto Mono', monospace", fontSize: 12, opacity: 0.6 }}>
db{client.db} {'\u00B7'} {client.cmd} {'\u00B7'} {client.idle}s
</Box>
{!isReadonly && (
<Tooltip title={mon.killClient || 'Kill client'}>
<Close sx={{ fontSize: 18, width: 18, height: 18, cursor: 'pointer', color: 'error.main', '&:hover': { opacity: 1 } }}
onClick={() => killClient(client.id)} />
</Tooltip>
)}
</Box>
</ListItem>
<Divider />
</Box>
))}
</List>
)}
</P3xrAccordion>
{/* Top Keys by Memory */}
<br />
<P3xrAccordion title={mon.topKeys || 'Top Keys by Memory'} accordionKey="monitor-top-keys"
actions={<>
<P3xrButton icon={autoRefreshTopKeys ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
label={strings?.label?.autoRefresh || 'Auto'} color="inherit"
onClick={(e) => { e.stopPropagation(); toggleAutoTopKeys() }} />
{!autoRefreshTopKeys && (
<P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />} label={strings?.intention?.refresh || 'Refresh'}
color="inherit" onClick={(e) => { e.stopPropagation(); loadTopKeys() }} />
)}
<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportTopKeysFile() }} />
</>}>
{topKeys.length === 0 && (
<Box sx={{ p: 2, opacity: 0.5 }}>{topKeysLoaded ? (mon.noKeys || 'No keys') : (strings?.label?.loading || 'Loading...')}</Box>
)}
{topKeys.length > 0 && (
<List disablePadding>
{topKeys.map((entry, i) => (
<Box key={entry.key}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', width: '100%' }}>
<Box sx={{ flex: 1 }}>
<Box component="span" sx={{ opacity: 0.4, mr: 1 }}>#{i + 1}</Box>
<Box component="span" sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>{entry.key}</Box>
</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>{formatBytes(entry.bytes)}</Box>
</Box>
</ListItem>
<Divider />
</Box>
))}
</List>
)}
</P3xrAccordion>
{/* Cluster Slot Stats (cluster + 8.2+ only) */}
{isCluster && rv.isAtLeast(8, 2) && (<>
<br />
<P3xrAccordion title={mon.slotStats || 'Cluster Slot Stats'} accordionKey="monitor-slot-stats"
actions={<>
<P3xrButton icon={<Refresh fontSize="small" />} label={strings?.intention?.refresh} onClick={e => { e.stopPropagation(); loadSlotStats() }} />
</>}>
<Box sx={{ px: 2, py: 1, display: 'flex', gap: 1, alignItems: 'center' }}>
<select value={slotStatsMetric} onChange={e => { setSlotStatsMetric(e.target.value); loadSlotStats(e.target.value) }}
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
<option value="KEY-COUNT">Key Count</option>
<option value="CPU-USEC">CPU (μs)</option>
<option value="MEMORY-BYTES">Memory (bytes)</option>
</select>
</Box>
{slotStats.length === 0 && (
<Box sx={{ p: 2, opacity: 0.5 }}>{slotStatsLoaded ? 'No slot data' : (strings?.label?.loading || 'Loading...')}</Box>
)}
{slotStats.length > 0 && (
<List disablePadding>
{slotStats.map((entry: any, i: number) => (
<Box key={entry.slot}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', width: '100%' }}>
<Box sx={{ flex: 1 }}>
<Box component="span" sx={{ opacity: 0.4, mr: 1 }}>#{i + 1}</Box>
<Box component="span" sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>Slot {entry.slot}</Box>
</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
{slotStatsMetric === 'KEY-COUNT' && `${entry['key-count']} keys`}
{slotStatsMetric === 'CPU-USEC' && `${entry['cpu-usec']} μs`}
{slotStatsMetric === 'MEMORY-BYTES' && formatBytes(entry['memory-bytes'])}
</Box>
</Box>
</ListItem>
<Divider />
</Box>
))}
</List>
)}
</P3xrAccordion>
</>)}
{/* Cluster Slot Map */}
{isCluster && (<>
<br />
<P3xrAccordion title={mon.clusterSlotMap || 'Cluster Slot Map'} accordionKey="monitor-cluster-slots"
actions={<>
<P3xrButton icon={autoRefreshShards ? <CheckBox sx={{ fontSize: 18 }} /> : <CheckBoxOutlineBlank sx={{ fontSize: 18 }} />}
label={strings?.label?.autoRefresh || 'Auto'} color="inherit"
onClick={(e) => { e.stopPropagation(); toggleAutoRefreshShards() }} />
{!autoRefreshShards && <P3xrButton icon={<Refresh sx={{ fontSize: 18 }} />}
label={strings?.intention?.refresh || 'Refresh'} color="inherit"
onClick={(e) => { e.stopPropagation(); loadClusterShards() }} />}
<P3xrButton icon={<Download sx={{ fontSize: 18 }} />} label={strings?.intention?.export || 'Export'}
color="inherit" onClick={(e) => { e.stopPropagation(); exportClusterSlots() }} />
</>}>
{!clusterShards
? <Box sx={{ p: 2, opacity: 0.6 }}>{mon.noClusterData || 'No cluster data available'}</Box>
: <>
<List disablePadding>
{clusterShards.map(shard => (
<Box key={shard.master.id}>
<ListItem sx={{ px: 2, py: 1 }}>
<Box sx={{ display: 'flex', width: '100%', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Box>
<Box component="span" sx={{ fontWeight: 500 }}>{shard.master.host}:{shard.master.port}</Box>
<Box component="span" sx={{ ml: 1, opacity: 0.5, fontSize: 12 }}>
{shard.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', ')}
</Box>
</Box>
<Box sx={{ fontFamily: "'Roboto Mono', monospace", fontSize: 13 }}>
{getSlotCount(shard)} slots
{shard.replicas.length > 0 && <Box component="span" sx={{ opacity: 0.5, ml: 1 }}>
({shard.replicas.map((r: any) => `${r.host}:${r.port}`).join(', ')})
</Box>}
</Box>
</Box>
</ListItem>
<Divider />
</Box>
))}
</List>
<Box sx={{ p: 1, px: 2, opacity: 0.6, fontSize: 12 }}>16384 slots across {clusterShards.length} masters</Box>
</>
}
</P3xrAccordion>
</>)}
</Box>
)
}