<script setup lang="ts">
import 'uplot/dist/uPlot.min.css'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import P3xrAccordion from '../../components/P3xrAccordion.vue'
import P3xrButton from '../../components/P3xrButton.vue'
import { useI18nStore } from '../../stores/i18n'
import { useRedisStateStore } from '../../stores/redis-state'
import { useCommonStore } from '../../stores/common'
import { request, onSocketEvent } from '../../stores/socket.service'
import { useMonitoringDataStore } from '../../stores/monitoring-data'
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 i18n = useI18nStore()
const state = useRedisStateStore()
const common = useCommonStore()
const monitorData = useMonitoringDataStore()
const strings = computed(() => i18n.strings)
const current = ref<MonitorSnapshot | null>(null)
const history = ref<MonitorSnapshot[]>([])
const paused = ref(false)
const clientList = ref<any[]>([])
const topKeys = ref<any[]>([])
const isReadonly = ref(false)
const autoRefreshClients = ref(localStorage.getItem('p3xr-monitor-auto-clients') === 'true')
const autoRefreshTopKeys = ref(localStorage.getItem('p3xr-monitor-auto-topkeys') === 'true')
const clientListLoaded = ref(false)
const topKeysLoaded = ref(false)
const slotStats = ref<any[]>([])
const slotStatsMetric = ref('KEY-COUNT')
const slotStatsLoaded = ref(false)
const isCluster = ref(false)
const clusterShards = ref<any[] | null>(null)
const autoRefreshShards = ref(localStorage.getItem('p3xr-monitor-auto-shards') === 'true')
let shardsInterval: any = null
// Chart refs
const memoryChartEl = ref<HTMLDivElement>()
const opsChartEl = ref<HTMLDivElement>()
const clientsChartEl = ref<HTMLDivElement>()
const networkChartEl = ref<HTMLDivElement>()
let intervalId: any
let uPlotLib: any
let memoryPlot: any
let opsPlot: any
let clientsPlot: any
let networkPlot: any
let chartsInitialized = false
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
const unsubFns: Array<() => void> = []
const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
const formatTime = (ms: number) => timeFormatter.format(new Date(ms))
const connName = computed(() => state.connection?.name)
const uptimeFormatted = computed(() => {
if (!current.value) return '-'
const s = current.value.server.uptime
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 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 downloadText(content: string, filename: string) {
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)
}
// --- Computed properties for sections ---
const serverInfo = computed(() => {
const info = state.info
if (!info) return null
const s = info.server || {}
const c = info.cpu || {}
return {
os: s.os || '',
port: s.tcp_port || '',
pid: s.process_id || '',
configFile: s.config_file || '',
cpuSys: c.used_cpu_sys,
cpuUser: c.used_cpu_user,
}
})
const persistenceInfo = computed(() => {
const info = state.info
if (!info?.persistence) return null
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,
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) : '',
}
})
const replicationInfo = computed(() => {
const info = state.info
if (!info?.replication) return null
const r = info.replication
const result: any = { role: r.role }
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
})
const keyspaceEntries = computed(() => {
const info = state.info
if (!info?.keyspace) return []
return Object.keys(info.keyspace)
.filter(k => k.startsWith('db'))
.sort((a, b) => parseInt(a.slice(2), 10) - parseInt(b.slice(2), 10))
.map(db => {
const entry = info.keyspace[db]
return {
db,
keys: typeof entry === 'object' ? (entry.keys) : '0',
expires: typeof entry === 'object' ? (entry.expires) : '0',
}
})
})
const modulesList = computed(() => {
return (state.modules || []).map((m: any) => ({
name: m.name,
ver: String(m.ver ?? m.version ?? ''),
}))
})
// --- Data fetching ---
async function fetchData() {
try {
const response = await request({ action: 'monitor/info', payload: {} })
const data: MonitorSnapshot = response.data
current.value = data
isCluster.value = state.connection?.cluster === true
history.value.push(data)
if (history.value.length > MAX_HISTORY) history.value.shift()
if (chartsInitialized) {
updateCharts()
} else if (uPlotLib && history.value.length >= 2) {
initCharts()
}
} catch { /* next tick will retry */ }
}
async function loadClientList() {
try {
const response = await request({ action: 'client/list', payload: {} })
clientList.value = response.data
clientListLoaded.value = true
} catch { clientListLoaded.value = true }
}
async function loadTopKeys() {
try {
const response = await request({ action: 'memory/top-keys', payload: { topN: 20 } })
topKeys.value = response.data
topKeysLoaded.value = true
} catch { topKeysLoaded.value = true }
}
async function loadSlotStats() {
try {
const response = await request({
action: 'cluster/slot-stats',
payload: { metric: slotStatsMetric.value, limit: 20 },
})
slotStats.value = response.slots || []
slotStatsLoaded.value = true
} catch { slotStatsLoaded.value = true; slotStats.value = [] }
}
async function killClient(id: string, event: Event) {
event.stopPropagation()
try {
await common.confirm({
message: strings.value?.page?.monitor?.confirmKillClient,
})
await request({ action: 'client/kill', payload: { id } })
common.toast(strings.value?.page?.monitor?.clientKilled)
await loadClientList()
} catch (e) {
if (e !== undefined) common.generalHandleError(e)
}
}
function togglePause() { paused.value = !paused.value }
function toggleAutoRefreshClients() {
autoRefreshClients.value = !autoRefreshClients.value
try { localStorage.setItem('p3xr-monitor-auto-clients', String(autoRefreshClients.value)) } catch {}
}
function toggleAutoRefreshTopKeys() {
autoRefreshTopKeys.value = !autoRefreshTopKeys.value
try { localStorage.setItem('p3xr-monitor-auto-topkeys', String(autoRefreshTopKeys.value)) } catch {}
}
// --- Export methods ---
function exportOverview() {
if (!current.value) return
const c = current.value
const mon = strings.value?.page?.monitor || {}
const lines = [
`${mon.memory}: ${c.memory.usedHuman}`,
`${mon.rss}: ${c.memory.rssHuman}`,
`${mon.peak}: ${c.memory.peakHuman}`,
`${mon.fragmentation}: ${c.memory.fragRatio}x`,
`${mon.opsPerSec}: ${c.stats.opsPerSec}`,
`${mon.totalCommands}: ${c.stats.totalCommands}`,
`${mon.clients}: ${c.clients.connected}`,
`${mon.blocked}: ${c.clients.blocked}`,
`${mon.hitsMisses}: ${c.stats.hitRate}%`,
`${mon.hitsAndMisses}: ${c.stats.hits} / ${c.stats.misses}`,
`${mon.networkIo}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`,
`${mon.expired}: ${c.stats.expiredKeys}`,
`${mon.evicted}: ${c.stats.evictedKeys}`,
]
downloadText(lines.join('\n'), `${connName.value}-overview.txt`)
}
function exportServerInfo() {
const s = serverInfo.value
if (!s) return
const mon = strings.value?.page?.monitor || {}
const lines = [
`${mon.os}: ${s.os}`,
`${mon.port}: ${s.port}`,
`${mon.pid}: ${s.pid}`,
`${mon.configFile}: ${s.configFile}`,
`${mon.cpuSys} CPU: ${s.cpuSys}`,
`${mon.cpuUser} CPU: ${s.cpuUser}`,
]
downloadText(lines.join('\n'), `${connName.value}-server-info.txt`)
}
function exportPersistence() {
const p = persistenceInfo.value
if (!p) return
const mon = strings.value?.page?.monitor || {}
const lines = [
`${mon.rdbLastSave}: ${p.rdbLastSave}`,
`${mon.rdbStatus}: ${p.rdbStatus}`,
`${mon.rdbChanges}: ${p.rdbChanges}`,
`${mon.aofEnabled}: ${p.aofEnabled}`,
]
if (p.aofSize) lines.push(`${mon.aofSize}: ${p.aofSize}`)
downloadText(lines.join('\n'), `${connName.value}-persistence.txt`)
}
function exportReplication() {
const r = replicationInfo.value
if (!r) return
const mon = strings.value?.page?.monitor || {}
const lines = [`${mon.role}: ${r.role}`]
if (r.replicas !== undefined) lines.push(`${mon.replicas}: ${r.replicas}`)
if (r.masterHost) lines.push(`${mon.masterHost}: ${r.masterHost}:${r.masterPort}`)
if (r.linkStatus) lines.push(`${mon.linkStatus}: ${r.linkStatus}`)
downloadText(lines.join('\n'), `${connName.value}-replication.txt`)
}
function exportKeyspace() {
const entries = keyspaceEntries.value
if (entries.length === 0) return
const mon = strings.value?.page?.monitor || {}
const lines = entries.map(e => `${e.db}: ${mon.keys}: ${e.keys}, ${mon.expires}: ${e.expires}`)
downloadText(lines.join('\n'), `${connName.value}-keyspace.txt`)
}
function exportModules() {
const mods = modulesList.value
const mon = strings.value?.page?.monitor || {}
if (mods.length === 0) {
downloadText(mon.noModules, `${connName.value}-modules.txt`)
return
}
const lines = mods.map(m => `${m.name} v${m.ver}`)
downloadText(lines.join('\n'), `${connName.value}-modules.txt`)
}
function exportChart(chartEl: HTMLDivElement | undefined, name: string) {
const canvas = chartEl?.querySelector('canvas') as HTMLCanvasElement
if (!canvas) return
const exportCanvas = document.createElement('canvas')
exportCanvas.width = canvas.width
exportCanvas.height = canvas.height
const ctx = exportCanvas.getContext('2d')!
const isDark = document.body.classList.contains('p3xr-theme-dark')
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (isDark ? '#1e1e1e' : '#ffffff')
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
ctx.drawImage(canvas, 0, 0)
const url = exportCanvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = `${connName.value}-${name}.png`
a.click()
}
async function resetSlowLog() {
try {
const mon = strings.value?.page?.monitor || {}
await common.confirm({ message: mon.confirmSlowLogReset })
await request({ action: 'monitor/slowlog-reset' })
common.toast(mon.slowLogResetDone)
} catch {}
}
function exportSlowLog() {
if (!current.value) return
const lines = current.value.slowlog.map(e => `${e.duration}\u00B5s ${e.command}`)
downloadText(lines.join('\n'), `${connName.value}-slowlog.txt`)
}
function exportClientList() {
const lines = clientList.value.map(c => `${c.addr} ${c.name || ''} db${c.db} ${c.cmd} idle:${c.idle}s`)
downloadText(lines.join('\n'), `${connName.value}-clients.txt`)
}
async function loadClusterShards() {
try {
const resp = await request({ action: 'cluster/shards' })
clusterShards.value = resp.data.shards
} catch (e) { common.generalHandleError(e) }
}
function toggleAutoRefreshShards() {
autoRefreshShards.value = !autoRefreshShards.value
localStorage.setItem('p3xr-monitor-auto-shards', String(autoRefreshShards.value))
if (autoRefreshShards.value) {
loadClusterShards()
shardsInterval = setInterval(() => loadClusterShards(), 2000)
} else {
clearInterval(shardsInterval)
shardsInterval = null
}
}
function getSlotCount(shard: any): number {
return shard.slotRanges.reduce((sum: number, [a, b]: [number, number]) => sum + (b - a + 1), 0)
}
function exportClusterSlots() {
if (!clusterShards.value) return
const lines = clusterShards.value.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}`
})
downloadText(lines.join('\n'), `${connName.value}-cluster-slots.txt`)
}
function exportTopKeys() {
const lines = topKeys.value.map((e: any, i: number) => `#${i + 1} ${e.key} ${formatBytes(e.bytes)}`)
downloadText(lines.join('\n'), `${connName.value}-topkeys.txt`)
}
// --- Charts ---
function getChartColors() {
const isDark = document.body.classList.contains('p3xr-theme-dark')
const style = getComputedStyle(document.body)
const primary = style.getPropertyValue('--p3xr-btn-primary-bg').trim()
const accent = style.getPropertyValue('--p3xr-btn-accent-bg').trim()
const warn = style.getPropertyValue('--p3xr-btn-warn-bg').trim()
return {
primary: primary || (isDark ? '#90caf9' : '#1976d2'),
accent: accent || (isDark ? '#ce93d8' : '#9c27b0'),
warn: warn || (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)',
}
}
function buildChartData() {
return {
timestamps: history.value.map(h => h.timestamp / 1000),
memUsed: history.value.map(h => h.memory.used / (1024 * 1024)),
memRss: history.value.map(h => h.memory.rss / (1024 * 1024)),
ops: history.value.map(h => h.stats.opsPerSec),
connected: history.value.map(h => h.clients.connected),
blocked: history.value.map(h => h.clients.blocked),
netIn: history.value.map(h => h.stats.inputKbps),
netOut: history.value.map(h => h.stats.outputKbps),
}
}
function getChartWidth(el: HTMLDivElement | undefined): number {
return el?.offsetWidth || 500
}
function createOpts(width: number, seriesConfig: any[]): any {
const colors = getChartColors()
return {
width,
height: 180,
cursor: { show: true, drag: { x: false, y: false } },
legend: { show: true, live: false },
scales: { x: { time: true } },
axes: [
{
stroke: colors.text,
grid: { stroke: colors.grid, width: 1 },
ticks: { stroke: colors.grid },
font: '11px Roboto',
values: (_: any, ticks: number[]) => ticks.map(t => formatTime(t * 1000)),
},
{
stroke: colors.text,
grid: { stroke: colors.grid, width: 1 },
ticks: { stroke: colors.grid },
font: '11px Roboto Mono',
size: 55,
},
],
series: [
{ label: strings.value?.label?.time, value: (_: any, rawValue: number) => rawValue ? formatTime(rawValue * 1000) : '' },
...seriesConfig,
],
}
}
function initCharts() {
if (!uPlotLib || chartsInitialized) return
const colors = getChartColors()
const data = buildChartData()
const memEl = memoryChartEl.value
const opsEl = opsChartEl.value
const cliEl = clientsChartEl.value
const netEl = networkChartEl.value
if (!memEl || !opsEl || !cliEl || !netEl) return
const s = strings.value?.page?.monitor || {}
memoryPlot = new uPlotLib(
createOpts(getChartWidth(memEl), [
{ label: s.memory, stroke: colors.primary, width: 2, fill: colors.primary + '15' },
{ label: 'RSS', stroke: colors.accent, width: 2 },
]),
[data.timestamps, data.memUsed, data.memRss],
memEl,
)
opsPlot = new uPlotLib(
createOpts(getChartWidth(opsEl), [
{ label: s.opsPerSec, stroke: colors.primary, width: 2, fill: colors.primary + '20' },
]),
[data.timestamps, data.ops],
opsEl,
)
clientsPlot = new uPlotLib(
createOpts(getChartWidth(cliEl), [
{ label: s.clients, stroke: colors.primary, width: 2 },
{ label: s.blocked, stroke: colors.warn, width: 2 },
]),
[data.timestamps, data.connected, data.blocked],
cliEl,
)
networkPlot = new uPlotLib(
createOpts(getChartWidth(netEl), [
{ label: '\u2193 In', stroke: colors.primary, width: 2, fill: colors.primary + '15' },
{ label: '\u2191 Out', stroke: colors.accent, width: 2 },
]),
[data.timestamps, data.netIn, data.netOut],
netEl,
)
chartsInitialized = true
let resizeTimer: any
resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
const mw = getChartWidth(memEl)
const ow = getChartWidth(opsEl)
const cw = getChartWidth(cliEl)
const nw = getChartWidth(netEl)
if (mw > 0) memoryPlot?.setSize({ width: mw, height: 180 })
if (ow > 0) opsPlot?.setSize({ width: ow, height: 180 })
if (cw > 0) clientsPlot?.setSize({ width: cw, height: 180 })
if (nw > 0) networkPlot?.setSize({ width: nw, height: 180 })
}, 50)
})
resizeObserver.observe(memEl)
resizeObserver.observe(opsEl)
resizeObserver.observe(cliEl)
resizeObserver.observe(netEl)
}
function reinitCharts() {
memoryPlot?.destroy()
opsPlot?.destroy()
clientsPlot?.destroy()
networkPlot?.destroy()
chartsInitialized = false
if (history.value.length >= 2) initCharts()
}
function updateCharts() {
if (!chartsInitialized) return
const data = buildChartData()
memoryPlot?.setData([data.timestamps, data.memUsed, data.memRss])
opsPlot?.setData([data.timestamps, data.ops])
clientsPlot?.setData([data.timestamps, data.connected, data.blocked])
networkPlot?.setData([data.timestamps, data.netIn, data.netOut])
}
async function loadUPlot() {
const uPlotModule = await import('uplot')
uPlotLib = uPlotModule.default
if (history.value.length >= 2) initCharts()
}
// --- Export All helpers (chart rendering for ZIP/PDF) ---
function renderLineChart(
timestamps: number[],
series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>,
colors: ReturnType<typeof getChartColors>,
): HTMLCanvasElement {
const dpr = 2
const width = 900, height = 260
const padTop = 32, padBottom = 40, padLeft = 60, padRight = 16, legendH = 20
const chartW = width - padLeft - padRight, 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)
const isDark = document.body.classList.contains('p3xr-theme-dark')
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (isDark ? '#1e1e1e' : '#ffffff')
ctx.fillRect(0, 0, width, height)
const n = timestamps.length
if (n < 2) return canvas
let yMin = Infinity, 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, tMin = timestamps[0], tMax = timestamps[n - 1], 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
for (let i = 0; i <= 5; i++) {
const gy = padTop + (chartH / 5) * i
ctx.beginPath(); ctx.moveTo(padLeft, gy); ctx.lineTo(padLeft + chartW, gy); ctx.stroke()
const val = yMax - (yRange / 5) * 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)), t = timestamps[idx], d = new Date(t * 1000)
ctx.fillText(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`, 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]), y = toY(s.values[i]); i === 0 ? ctx.moveTo(x, y) : 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
}
function renderBarChartCanvas(items: Array<{ label: string; value: number }>): HTMLCanvasElement | null {
if (items.length === 0) return null
const colors = getChartColors()
const isDark = colors.text.includes('255')
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, topPad = 8
const 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)
const bgIsDark = document.body.classList.contains('p3xr-theme-dark')
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (bgIsDark ? '#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
}
function renderPulseChartsForExport(): Array<{ label: string; canvas: HTMLCanvasElement }> {
const colors = getChartColors()
const s = strings.value?.page?.monitor || {}
let data: ReturnType<typeof buildChartData>
if (history.value.length >= 2) { data = buildChartData() }
else if (current.value) {
const c = current.value, 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 [] }
return [
{ label: (s.memory) + ' (MB)', series: [{ label: s.memory, color: colors.primary, values: data.memUsed, fill: true }, { label: 'RSS', color: colors.accent, values: data.memRss }] },
{ label: s.opsPerSec, series: [{ label: s.opsPerSec, color: colors.primary, values: data.ops, fill: true }] },
{ label: s.clients, series: [{ label: s.clients, color: colors.primary, values: data.connected }, { label: s.blocked, color: colors.warn, values: data.blocked }] },
{ label: (s.networkIo) + ' (KB/s)', series: [{ label: '\u2193 In', color: colors.primary, values: data.netIn, fill: true }, { label: '\u2191 Out', color: colors.accent, values: data.netOut }] },
].map(config => ({ label: config.label, canvas: renderLineChart(data.timestamps, config.series, colors) }))
}
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 isDark = document.body.classList.contains('p3xr-theme-dark')
const bgColor = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (isDark ? '#1e1e1e' : '#ffffff')
const colors = getChartColors()
ctx.fillStyle = bgColor; 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 isDark = document.body.classList.contains('p3xr-theme-dark')
const bgColor = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (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(), 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 isSectionTitle = ['PULSE', 'PROFILER', 'PUBSUB', 'ANALYSIS'].includes(line.trim())
if (isSectionTitle) { 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); const title = line.replace(/^-+\s*/, '').replace(/\s*-+$/, ''); y += 2; pdf.setFontSize(10); pdf.setTextColor(headerColor); pdf.text(title, 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'), ratio = chart.canvas.height / chart.canvas.width
const imgW = contentW, imgH = Math.min(imgW * ratio, pageH - y - margin)
pdf.addImage(imgData, 'PNG', margin, y, imgH / ratio > contentW ? contentW : imgH / ratio, imgH); y += imgH
}
if (tailSections.length > 0 && charts.length > 0) { pdf.addPage(); fillBg(); y = margin }
for (const line of tailSections) {
if (line.startsWith('====')) continue
if (['PROFILER', 'PUBSUB'].includes(line.trim())) { 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
}
// --- Export All ---
async function exportAll() {
if (!current.value) return
try {
const JSZip = (await import('jszip')).default
const zip = new JSZip()
const c = current.value
const sections: string[] = []
const mon = strings.value?.page?.monitor || {}
const a = strings.value?.page?.analysis || {}
sections.push(
`============================`, ` PULSE`, `============================`, ``,
`--- ${mon.title} ---`,
`Redis ${c.server.version} \u00B7 ${c.server.mode} \u00B7 Uptime: ${uptimeFormatted.value}`,
`${mon.memory}: ${c.memory.usedHuman}`,
`${mon.rss}: ${c.memory.rssHuman}`,
`${mon.peak}: ${c.memory.peakHuman}`,
`${mon.fragmentation}: ${c.memory.fragRatio}x`,
`${mon.opsPerSec}: ${c.stats.opsPerSec}`,
`${mon.totalCommands}: ${c.stats.totalCommands}`,
`${mon.clients}: ${c.clients.connected}`,
`${mon.blocked}: ${c.clients.blocked}`,
`${mon.hitsMisses}: ${c.stats.hitRate}%`,
`${mon.hitsAndMisses}: ${c.stats.hits} / ${c.stats.misses}`,
`${mon.networkIo}: ${c.stats.inputKbps.toFixed(1)} / ${c.stats.outputKbps.toFixed(1)} KB/s`,
`${mon.expired}: ${c.stats.expiredKeys}`,
`${mon.evicted}: ${c.stats.evictedKeys}`,
)
const si = serverInfo.value
if (si) {
sections.push(``, `--- ${mon.serverInfo} ---`)
sections.push(`${mon.os}: ${si.os}`, `${mon.port}: ${si.port}`, `${mon.pid}: ${si.pid}`)
if (si.configFile) sections.push(`${mon.configFile}: ${si.configFile}`)
sections.push(`${mon.cpuSys} CPU: ${si.cpuSys}`, `${mon.cpuUser} CPU: ${si.cpuUser}`)
}
const pi = persistenceInfo.value
if (pi) {
sections.push(``, `--- ${mon.persistence} ---`)
sections.push(`${mon.rdbLastSave}: ${pi.rdbLastSave}`, `${mon.rdbStatus}: ${pi.rdbStatus}`)
sections.push(`${mon.rdbChanges}: ${pi.rdbChanges}`, `${mon.aofEnabled}: ${pi.aofEnabled}`)
if (pi.aofSize) sections.push(`${mon.aofSize}: ${pi.aofSize}`)
}
const ri = replicationInfo.value
if (ri) {
sections.push(``, `--- ${mon.replication} ---`)
sections.push(`${mon.role}: ${ri.role}`)
if (ri.replicas !== undefined) sections.push(`${mon.replicas}: ${ri.replicas}`)
if (ri.masterHost) sections.push(`${mon.masterHost}: ${ri.masterHost}:${ri.masterPort}`)
if (ri.linkStatus) sections.push(`${mon.linkStatus}: ${ri.linkStatus}`)
}
const ks = keyspaceEntries.value
if (ks.length > 0) {
sections.push(``, `--- ${mon.keyspace} ---`)
sections.push(...ks.map(e => `${e.db}: ${mon.keys}: ${e.keys}, ${mon.expires}: ${e.expires}`))
}
const mods = modulesList.value
if (mods.length > 0) {
sections.push(``, `--- ${mon.modules} ---`)
sections.push(...mods.map(m => `${m.name} v${m.ver}`))
} else {
sections.push(``, `--- ${mon.modules} ---`, mon.noModules)
}
if (c.slowlog.length > 0) {
sections.push(``, `--- ${mon.slowLog} ---`)
sections.push(...c.slowlog.map(e => `${e.duration}\u00B5s ${e.command}`))
}
if (clientList.value.length > 0) {
sections.push(``, `--- ${mon.clientList} ---`)
sections.push(...clientList.value.map(cl => `${cl.addr} ${cl.name || ''} db${cl.db} ${cl.cmd} idle:${cl.idle}s`))
}
if (topKeys.value.length > 0) {
sections.push(``, `--- ${mon.topKeys} ---`)
sections.push(...topKeys.value.map((e: any, i: number) => `#${i + 1} ${e.key} ${formatBytes(e.bytes)}`))
}
// Analysis
let 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
const exp = d.expirationOverview
const typeEntries = Object.keys(d.typeDistribution || {}).map((t: string) => ({
type: t, count: d.typeDistribution[t], bytes: d.typeMemory?.[t] || 0,
})).sort((a: any, b: any) => b.bytes - a.bytes)
sections.push(``, ``, `============================`, ` ANALYSIS`, `============================`)
sections.push(``, `--- ${a.keysScanned} ---`, `${a.keysScanned}: ${d.totalScanned} / ${d.dbSize}`)
sections.push(``, `--- ${a.memoryBreakdown} ---`)
sections.push(`${a.totalMemory}: ${m.usedHuman}`, `${a.rssMemory}: ${m.rssHuman}`, `${a.peakMemory}: ${m.peakHuman}`)
sections.push(`${a.overheadMemory}: ${formatBytes(m.overhead)}`, `${a.datasetMemory}: ${formatBytes(m.dataset)}`)
sections.push(`${a.luaMemory}: ${formatBytes(m.lua)}`, `${a.fragmentation}: ${m.fragRatio}x`, `${a.allocator}: ${m.allocator}`)
sections.push(``, `--- ${a.typeDistribution} ---`)
sections.push(...typeEntries.map((t: any) => `${t.type}: ${t.count} ${a.keyCount}, ${formatBytes(t.bytes)}`))
if (d.prefixMemory?.length > 0) {
sections.push(``, `--- ${a.prefixMemory} ---`)
sections.push(...d.prefixMemory.map((p: any, i: number) => `#${i + 1} ${p.prefix} \u2014 ${p.keyCount} ${a.keyCount}, ${formatBytes(p.totalBytes)}`))
}
sections.push(``, `--- ${a.expirationOverview} ---`)
sections.push(`${a.withTTL}: ${exp.withTTL}`, `${a.persistent}: ${exp.persistent}`, `${a.avgTTL}: ${exp.avgTTL}s`)
analysisChartItems = [
{ name: a.typeDistribution, items: typeEntries.map((t: any) => ({ label: t.type, value: t.bytes })) },
{ name: a.prefixMemory, items: (d.prefixMemory || []).slice(0, 20).map((p: any) => ({ label: p.prefix, value: p.totalBytes })) },
]
}
} catch { /* analysis optional */ }
// Profiler + PubSub tail
const sanitize = (s: string) => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
const tailSections: string[] = []
if (monitorData.profilerEntries.length > 0) {
tailSections.push(``, ``, `============================`, ` PROFILER`, `============================`, ``)
tailSections.push(...monitorData.profilerEntries.map(
e => sanitize(`${e.fullTimestamp} [${e.database} ${e.source}] ${e.command}`)
))
}
if (monitorData.pubsubEntries.length > 0) {
tailSections.push(``, ``, `============================`, ` PUBSUB`, `============================`, ``)
tailSections.push(...monitorData.pubsubEntries.map(
e => sanitize(`${e.fullTimestamp} ${e.channel} ${e.message}`)
))
}
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)
// Collect all chart canvases and stitch into 1 tall PNG
const allCanvases: Array<{ label: string; canvas: HTMLCanvasElement }> = []
allCanvases.push(...renderPulseChartsForExport())
for (const ci of analysisChartItems) {
if (ci.items.length === 0) continue
const canvas = renderBarChartCanvas(ci.items)
if (canvas) allCanvases.push({ label: ci.name, canvas })
}
if (allCanvases.length > 0) {
const blob = await stitchCharts(allCanvases)
if (blob) zip.file('charts.png', blob)
}
// Generate PDF with text + charts
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.value}-monitoring.zip`
link.click()
URL.revokeObjectURL(url)
} catch (e) {
common.generalHandleError(e)
}
}
// --- Lifecycle ---
onMounted(() => {
isReadonly.value = state.connection?.readonly === true
isCluster.value = state.connection?.cluster === true
fetchData()
loadClientList()
loadTopKeys()
// Reload all data on connection change
unsubFns.push(onSocketEvent('connections', () => {
isReadonly.value = state.connection?.readonly === true
isCluster.value = state.connection?.cluster === true
history.value = []
chartsInitialized = false
memoryPlot?.destroy()
opsPlot?.destroy()
clientsPlot?.destroy()
networkPlot?.destroy()
fetchData()
loadClientList()
loadTopKeys()
}))
intervalId = setInterval(() => {
if (!paused.value) {
fetchData()
if (autoRefreshClients.value) loadClientList()
if (autoRefreshTopKeys.value) loadTopKeys()
}
}, 2000)
// Theme change re-init
themeObserver = new MutationObserver(() => {
if (chartsInitialized) setTimeout(() => reinitCharts(), 100)
})
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] })
// Language change re-init
let prevLang = i18n.currentLang
const langCheckInterval = setInterval(() => {
if (i18n.currentLang !== prevLang) {
prevLang = i18n.currentLang
if (chartsInitialized) setTimeout(() => reinitCharts(), 100)
}
}, 500)
unsubFns.push(() => clearInterval(langCheckInterval))
if (autoRefreshShards.value && isCluster.value) {
shardsInterval = setInterval(() => loadClusterShards(), 2000)
}
nextTick(() => setTimeout(() => loadUPlot(), 500))
})
onUnmounted(() => {
if (intervalId) clearInterval(intervalId)
if (shardsInterval) clearInterval(shardsInterval)
unsubFns.forEach(fn => fn())
themeObserver?.disconnect()
resizeObserver?.disconnect()
memoryPlot?.destroy()
opsPlot?.destroy()
clientsPlot?.destroy()
networkPlot?.destroy()
})
</script>
<template>
<div v-if="!current" class="p3xr-monitoring-loading">
<v-icon>mdi-timer-sand</v-icon>
<span>{{ strings?.label?.loading }}</span>
</div>
<div v-if="current">
<!-- Overview -->
<P3xrAccordion :title="strings?.page?.monitor?.title" accordion-key="monitor-overview">
<template #actions>
<P3xrButton
@click="togglePause(); $event.stopPropagation()"
:label="paused ? (strings?.intention?.resume) : (strings?.intention?.pause)"
:icon="paused ? 'mdi-play' : 'mdi-pause'"
/>
<P3xrButton
@click="exportOverview(); $event.stopPropagation()"
:label="strings?.intention?.export"
icon="mdi-download"
/>
<P3xrButton
@click="exportAll(); $event.stopPropagation()"
:label="strings?.page?.analysis?.exportAll"
icon="mdi-archive"
/>
</template>
<v-list density="compact" class="pa-0">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">Redis {{ current.server.version }} · {{ current.server.mode }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ uptimeFormatted }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.memory }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.memory.usedHuman }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.rss }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.memory.rssHuman }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.peak }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.memory.peakHuman }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.fragmentation }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.memory.fragRatio }}x</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.opsPerSec }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.opsPerSec }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.totalCommands }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.totalCommands }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.clients }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.clients.connected }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.blocked }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.clients.blocked }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.hitsMisses }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.hitRate }}%</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.hitsAndMisses }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.hits }} / {{ current.stats.misses }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.networkIo }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.inputKbps.toFixed(1) }} / {{ current.stats.outputKbps.toFixed(1) }} KB/s</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.expired }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.expiredKeys }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.evicted }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ current.stats.evictedKeys }}</div>
</div>
</v-list-item>
</v-list>
</P3xrAccordion>
<!-- Server Info -->
<template v-if="serverInfo">
<br />
<P3xrAccordion :title="strings?.page?.monitor?.serverInfo" accordion-key="monitor-server-info">
<template #actions>
<P3xrButton @click="exportServerInfo(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<v-list density="compact" class="pa-0">
<template v-if="serverInfo.os">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.os }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ serverInfo.os }}</div>
</div>
</v-list-item>
<v-divider />
</template>
<template v-if="serverInfo.port">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.port }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ serverInfo.port }}</div>
</div>
</v-list-item>
<v-divider />
</template>
<template v-if="serverInfo.pid">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.pid }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ serverInfo.pid }}</div>
</div>
</v-list-item>
<v-divider />
</template>
<template v-if="serverInfo.configFile">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.configFile }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ serverInfo.configFile }}</div>
</div>
</v-list-item>
<v-divider />
</template>
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.cpuSys }} CPU</div>
<div class="p3xr-pair-value p3xr-mono">{{ serverInfo.cpuSys }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.cpuUser }} CPU</div>
<div class="p3xr-pair-value p3xr-mono">{{ serverInfo.cpuUser }}</div>
</div>
</v-list-item>
</v-list>
</P3xrAccordion>
</template>
<!-- Persistence -->
<template v-if="persistenceInfo">
<br />
<P3xrAccordion :title="strings?.page?.monitor?.persistence" accordion-key="monitor-persistence">
<template #actions>
<P3xrButton @click="exportPersistence(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<v-list density="compact" class="pa-0">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.rdbLastSave }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ persistenceInfo.rdbLastSave }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.rdbStatus }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ persistenceInfo.rdbStatus }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.rdbChanges }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ persistenceInfo.rdbChanges }}</div>
</div>
</v-list-item>
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.aofEnabled }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ persistenceInfo.aofEnabled }}</div>
</div>
</v-list-item>
<template v-if="persistenceInfo.aofSize">
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.aofSize }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ persistenceInfo.aofSize }}</div>
</div>
</v-list-item>
</template>
</v-list>
</P3xrAccordion>
</template>
<!-- Replication -->
<template v-if="replicationInfo">
<br />
<P3xrAccordion :title="strings?.page?.monitor?.replication" accordion-key="monitor-replication">
<template #actions>
<P3xrButton @click="exportReplication(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<v-list density="compact" class="pa-0">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.role }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ replicationInfo.role }}</div>
</div>
</v-list-item>
<template v-if="replicationInfo.replicas !== undefined">
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.replicas }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ replicationInfo.replicas }}</div>
</div>
</v-list-item>
</template>
<template v-if="replicationInfo.masterHost">
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.masterHost }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ replicationInfo.masterHost }}:{{ replicationInfo.masterPort }}</div>
</div>
</v-list-item>
</template>
<template v-if="replicationInfo.linkStatus">
<v-divider />
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ strings?.page?.monitor?.linkStatus }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ replicationInfo.linkStatus }}</div>
</div>
</v-list-item>
</template>
</v-list>
</P3xrAccordion>
</template>
<!-- Keyspace -->
<template v-if="keyspaceEntries.length > 0">
<br />
<P3xrAccordion :title="strings?.page?.monitor?.keyspace" accordion-key="monitor-keyspace">
<template #actions>
<P3xrButton @click="exportKeyspace(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<v-list density="compact" class="pa-0">
<template v-for="(entry, idx) in keyspaceEntries" :key="entry.db">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ entry.db }}</div>
<div class="p3xr-pair-value p3xr-mono">{{ strings?.page?.monitor?.keys }}: {{ entry.keys }} · {{ strings?.page?.monitor?.expires }}: {{ entry.expires }}</div>
</div>
</v-list-item>
<v-divider v-if="idx < keyspaceEntries.length - 1" />
</template>
</v-list>
</P3xrAccordion>
</template>
<!-- Modules -->
<br />
<P3xrAccordion :title="strings?.page?.monitor?.modules" accordion-key="monitor-modules">
<template #actions>
<P3xrButton @click="exportModules(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<div v-if="modulesList.length === 0" style="padding: 16px; opacity: 0.5;">{{ strings?.page?.monitor?.noModules }}</div>
<v-list v-if="modulesList.length > 0" density="compact" class="pa-0">
<template v-for="(mod, idx) in modulesList" :key="mod.name">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">{{ mod.name }}</div>
<div class="p3xr-pair-value p3xr-mono">v{{ mod.ver }}</div>
</div>
</v-list-item>
<v-divider v-if="idx < modulesList.length - 1" />
</template>
</v-list>
</P3xrAccordion>
<br />
<!-- Charts -->
<P3xrAccordion :title="(strings?.page?.monitor?.memory) + ' (MB)'" accordion-key="monitor-chart-memory">
<template #actions>
<P3xrButton @click="exportChart(memoryChartEl, 'memory'); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<div ref="memoryChartEl" class="p3xr-monitoring-chart"></div>
</P3xrAccordion>
<br />
<P3xrAccordion :title="strings?.page?.monitor?.opsPerSec" accordion-key="monitor-chart-ops">
<template #actions>
<P3xrButton @click="exportChart(opsChartEl, 'ops'); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<div ref="opsChartEl" class="p3xr-monitoring-chart"></div>
</P3xrAccordion>
<br />
<P3xrAccordion :title="strings?.page?.monitor?.clients" accordion-key="monitor-chart-clients">
<template #actions>
<P3xrButton @click="exportChart(clientsChartEl, 'clients'); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<div ref="clientsChartEl" class="p3xr-monitoring-chart"></div>
</P3xrAccordion>
<br />
<P3xrAccordion :title="(strings?.page?.monitor?.networkIo) + ' (KB/s)'" accordion-key="monitor-chart-network">
<template #actions>
<P3xrButton @click="exportChart(networkChartEl, 'network'); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<div ref="networkChartEl" class="p3xr-monitoring-chart"></div>
</P3xrAccordion>
<!-- Slow Log -->
<br />
<P3xrAccordion :title="strings?.page?.monitor?.slowLog" accordion-key="monitor-slowlog">
<template #actions>
<P3xrButton v-if="!isReadonly" @click="resetSlowLog(); $event.stopPropagation()" label="Reset" icon="mdi-delete-sweep" />
<P3xrButton @click="exportSlowLog(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
</template>
<div v-if="current.slowlog.length === 0" style="padding: 12px 16px; opacity: 0.6;">
{{ strings?.page?.monitor?.noSlowQueries }}
</div>
<v-list v-else density="compact" class="pa-0">
<template v-for="entry in current.slowlog" :key="entry.id">
<v-list-item>
<div class="p3xr-slowlog-row">
<kbd class="p3xr-kbd p3xr-kbd-small">{{ entry.duration }}µs</kbd>
<span class="p3xr-slowlog-cmd">{{ entry.command }}</span>
</div>
</v-list-item>
<v-divider />
</template>
</v-list>
</P3xrAccordion>
<!-- Client List -->
<br />
<P3xrAccordion :title="strings?.page?.monitor?.clientList" accordion-key="monitor-clients-list">
<template #actions>
<P3xrButton
@click="toggleAutoRefreshClients(); $event.stopPropagation()"
:label="strings?.label?.autoRefresh"
:icon="autoRefreshClients ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'"
/>
<P3xrButton v-if="!autoRefreshClients"
@click="loadClientList(); $event.stopPropagation()"
:label="strings?.intention?.refresh"
icon="mdi-refresh"
/>
<P3xrButton
@click="exportClientList(); $event.stopPropagation()"
:label="strings?.intention?.export"
icon="mdi-download"
/>
</template>
<div v-if="clientList.length === 0 && clientListLoaded" style="padding: 16px; opacity: 0.5;">{{ strings?.page?.monitor?.noClients }}</div>
<div v-if="clientList.length === 0 && !clientListLoaded" style="padding: 16px; opacity: 0.5;">{{ strings?.label?.loading }}</div>
<v-list v-if="clientList.length > 0" density="compact" class="pa-0">
<template v-for="client in clientList" :key="client.id">
<v-list-item>
<div class="p3xr-client-row">
<span class="p3xr-mono p3xr-client-addr">{{ client.addr }}</span>
<span v-if="client.name" class="p3xr-client-name">({{ client.name }})</span>
<span class="p3xr-client-info">
db{{ client.db }} · {{ client.cmd }} · {{ client.idle }}s
</span>
<v-icon v-if="!isReadonly" class="p3xr-client-kill" @click="killClient(client.id, $event)">mdi-close</v-icon>
</div>
</v-list-item>
<v-divider />
</template>
</v-list>
</P3xrAccordion>
<!-- Memory Top Keys -->
<br />
<P3xrAccordion :title="strings?.page?.monitor?.topKeys" accordion-key="monitor-top-keys">
<template #actions>
<P3xrButton
@click="toggleAutoRefreshTopKeys(); $event.stopPropagation()"
:label="strings?.label?.autoRefresh"
:icon="autoRefreshTopKeys ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'"
/>
<P3xrButton v-if="!autoRefreshTopKeys"
@click="loadTopKeys(); $event.stopPropagation()"
:label="strings?.intention?.refresh"
icon="mdi-refresh"
/>
<P3xrButton
@click="exportTopKeys(); $event.stopPropagation()"
:label="strings?.intention?.export"
icon="mdi-download"
/>
</template>
<div v-if="topKeys.length === 0 && topKeysLoaded" style="padding: 16px; opacity: 0.5;">{{ strings?.page?.monitor?.noKeys }}</div>
<div v-if="topKeys.length === 0 && !topKeysLoaded" style="padding: 16px; opacity: 0.5;">{{ strings?.label?.loading }}</div>
<v-list v-if="topKeys.length > 0" density="compact" class="pa-0">
<template v-for="(entry, i) in topKeys" :key="entry.key">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">
<span style="opacity: 0.4; margin-right: 8px;">#{{ i + 1 }}</span>
<span class="p3xr-mono" style="font-size: 13px;">{{ entry.key }}</span>
</div>
<div class="p3xr-pair-value p3xr-mono">{{ formatBytes(entry.bytes) }}</div>
</div>
</v-list-item>
<v-divider />
</template>
</v-list>
</P3xrAccordion>
<!-- Cluster Slot Stats (conditional) -->
<template v-if="isCluster">
<br />
<P3xrAccordion :title="strings?.page?.monitor?.slotStats" accordion-key="monitor-slot-stats">
<template #actions>
<P3xrButton
@click="loadSlotStats(); $event.stopPropagation()"
:label="strings?.intention?.refresh"
icon="mdi-refresh"
/>
</template>
<div style="padding: 8px 16px; display: flex; gap: 8px; align-items: center;">
<v-select
v-model="slotStatsMetric"
:items="[
{ title: 'Key Count', value: 'KEY-COUNT' },
{ title: 'CPU (\u00B5s)', value: 'CPU-USEC' },
{ title: 'Memory (bytes)', value: 'MEMORY-BYTES' },
]"
label="Metric"
style="max-width: 180px;"
@update:model-value="loadSlotStats()"
/>
</div>
<div v-if="slotStats.length === 0 && slotStatsLoaded" style="padding: 16px; opacity: 0.5;">No slot data</div>
<v-list v-if="slotStats.length > 0" density="compact" class="pa-0">
<template v-for="(entry, i) in slotStats" :key="entry.slot">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">
<span style="opacity: 0.4; margin-right: 8px;">#{{ i + 1 }}</span>
<span class="p3xr-mono">Slot {{ entry.slot }}</span>
</div>
<div class="p3xr-pair-value p3xr-mono">
<template v-if="slotStatsMetric === 'KEY-COUNT'">{{ entry['key-count'] }} keys</template>
<template v-if="slotStatsMetric === 'CPU-USEC'">{{ entry['cpu-usec'] }} µs</template>
<template v-if="slotStatsMetric === 'MEMORY-BYTES'">{{ formatBytes(entry['memory-bytes']) }}</template>
</div>
</div>
</v-list-item>
<v-divider />
</template>
</v-list>
</P3xrAccordion>
</template>
<!-- Cluster Slot Map -->
<template v-if="isCluster">
<br />
<P3xrAccordion :title="strings?.page?.monitor?.clusterSlotMap" accordion-key="monitor-cluster-slots">
<template #actions>
<P3xrButton
@click="toggleAutoRefreshShards(); $event.stopPropagation()"
:label="strings?.label?.autoRefresh"
:icon="autoRefreshShards ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'"
/>
<P3xrButton
v-if="!autoRefreshShards"
@click="loadClusterShards(); $event.stopPropagation()"
:label="strings?.intention?.refresh"
icon="mdi-refresh"
/>
<P3xrButton
@click="exportClusterSlots(); $event.stopPropagation()"
:label="strings?.intention?.export"
icon="mdi-download"
/>
</template>
<div v-if="!clusterShards" style="padding: 12px 16px; opacity: 0.6;">
{{ strings?.page?.monitor?.noClusterData }}
</div>
<template v-else>
<v-list density="compact" class="pa-0">
<template v-for="shard in clusterShards" :key="shard.master.id">
<v-list-item>
<div class="p3xr-pair-row">
<div class="p3xr-pair-label">
<span style="font-weight: 500;">{{ shard.master.host }}:{{ shard.master.port }}</span>
<span style="opacity: 0.5; margin-left: 8px; font-size: 12px;">
{{ shard.slotRanges.map(r => r[0] + '-' + r[1]).join(', ') }}
</span>
</div>
<div class="p3xr-pair-value p3xr-mono">
{{ getSlotCount(shard) }} slots
<span v-if="shard.replicas.length > 0" style="opacity: 0.5; margin-left: 8px;">
({{ shard.replicas.map(r => r.host + ':' + r.port).join(', ') }})
</span>
</div>
</div>
</v-list-item>
<v-divider />
</template>
</v-list>
<div style="padding: 8px 16px; opacity: 0.6; font-size: 12px;">
16384 slots across {{ clusterShards.length }} masters
</div>
</template>
</P3xrAccordion>
</template>
</div>
</template>
<style scoped>
.p3xr-monitoring-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 64px;
opacity: 0.5;
font-size: 18px;
}
.p3xr-pair-row {
display: flex;
width: 100%;
gap: 16px;
align-items: center;
}
.p3xr-pair-label {
flex: 1 1 auto;
min-width: 0;
font-weight: 500;
}
.p3xr-pair-value {
flex: 0 1 auto;
min-width: 0;
text-align: right;
white-space: nowrap;
}
.p3xr-mono {
font-family: 'Roboto Mono', monospace;
font-size: 13px;
}
.p3xr-monitoring-chart {
width: 100%;
min-height: 180px;
overflow: hidden;
}
.p3xr-slowlog-row {
display: flex;
width: 100%;
gap: 12px;
align-items: center;
}
.p3xr-kbd {
font-family: 'Roboto Mono', monospace;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 4px;
padding: 2px 8px;
background-color: rgba(128, 128, 128, 0.1);
white-space: nowrap;
}
.p3xr-kbd-small {
font-size: 11px;
min-width: 60px;
text-align: center;
}
.p3xr-slowlog-cmd {
font-family: 'Roboto Mono', monospace;
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.p3xr-client-row {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
}
.p3xr-client-addr {
font-size: 13px;
font-weight: 700;
min-width: 150px;
}
.p3xr-client-name {
opacity: 0.5;
font-size: 12px;
}
.p3xr-client-info {
flex: 1;
text-align: right;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
opacity: 0.6;
}
.p3xr-client-kill {
cursor: pointer;
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: var(--p3xr-btn-warn-bg, #f44336);
opacity: 0.7;
flex-shrink: 0;
}
.p3xr-client-kill:hover {
opacity: 1;
}
</style>
<style>
/* uPlot chart styles (must be unscoped to reach uPlot DOM) */
.p3xr-monitoring-chart .uplot {
font-family: 'Roboto', sans-serif;
}
.p3xr-monitoring-chart .u-legend {
font-size: 12px;
opacity: 0.8;
}
.p3xr-monitoring-chart .u-legend .u-series td {
padding: 1px 4px;
}
</style>