import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDividerModule } from '@angular/material/divider'; import { MatListModule } from '@angular/material/list'; import dayjs from 'dayjs'; import { I18nService } from '../../services/i18n.service'; import { SocketService } from '../../services/socket.service'; import { CommonService } from '../../services/common.service'; import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component'; import { P3xrButtonComponent } from '../../components/p3xr-button.component'; declare const p3xr: any; require('./monitoring.component.scss'); interface MonitorSnapshot { timestamp: number; memory: { used: number; rss: number; peak: number; usedHuman: string; rssHuman: string; peakHuman: string; fragRatio: number }; stats: { opsPerSec: number; hits: number; misses: number; hitRate: number; inputKbps: number; outputKbps: number; totalCommands: number; expiredKeys: number; evictedKeys: number }; clients: { connected: number; blocked: number }; server: { version: string; uptime: number; mode: string }; keyspace: Record; slowlog: Array<{ id: number; timestamp: number; duration: number; command: string }>; } const MAX_HISTORY = 120; @Component({ selector: 'p3xr-monitoring', standalone: true, imports: [ CommonModule, MatIconModule, MatButtonModule, MatTooltipModule, MatDividerModule, MatListModule, P3xrAccordionComponent, P3xrButtonComponent, ], templateUrl: './monitoring.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class MonitoringComponent implements OnInit, OnDestroy, AfterViewInit { strings; current: MonitorSnapshot | null = null; history: MonitorSnapshot[] = []; paused = false; clientList: any[] = []; topKeys: any[] = []; isReadonly = false; autoRefreshClients = localStorage.getItem('p3xr-monitor-auto-clients') === 'true'; autoRefreshTopKeys = localStorage.getItem('p3xr-monitor-auto-topkeys') === 'true'; clientListLoaded = false; topKeysLoaded = false; @ViewChild('memoryChart') memoryChartRef!: ElementRef; @ViewChild('opsChart') opsChartRef!: ElementRef; @ViewChild('clientsChart') clientsChartRef!: ElementRef; @ViewChild('networkChart') networkChartRef!: ElementRef; private intervalId: any; private uPlot: any; private memoryPlot: any; private opsPlot: any; private clientsPlot: any; private networkPlot: any; private chartsInitialized = false; private resizeObserver: ResizeObserver | null = null; private themeObserver: MutationObserver | null = null; private unsubFns: Array<() => void> = []; constructor( @Inject(I18nService) private i18n: I18nService, @Inject(SocketService) private socket: SocketService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, @Inject(NgZone) private ngZone: NgZone, ) { this.strings = this.i18n.strings; } ngOnInit(): void { this.isReadonly = p3xr?.state?.connection?.readonly === true; this.fetchData(); this.loadClientList(); this.loadTopKeys(); // Reload all data when connection changes const sub = this.socket.stateChanged$.subscribe(() => { this.isReadonly = p3xr?.state?.connection?.readonly === true; this.history = []; this.chartsInitialized = false; this.memoryPlot?.destroy(); this.opsPlot?.destroy(); this.clientsPlot?.destroy(); this.networkPlot?.destroy(); this.fetchData(); this.loadClientList(); this.loadTopKeys(); }); this.unsubFns.push(() => sub.unsubscribe()); this.ngZone.runOutsideAngular(() => { this.intervalId = setInterval(() => { if (!this.paused) { this.fetchData(); if (this.autoRefreshClients) this.loadClientList(); if (this.autoRefreshTopKeys) this.loadTopKeys(); } }, 2000); // Reinit charts on theme or language change this.themeObserver = new MutationObserver(() => { if (this.chartsInitialized) { setTimeout(() => this.reinitCharts(), 100); } }); this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); // Watch for language changes via i18n signal let prevLang = this.i18n.currentLang(); const langCheckInterval = setInterval(() => { const currentLang = this.i18n.currentLang(); if (currentLang !== prevLang) { prevLang = currentLang; if (this.chartsInitialized) { setTimeout(() => this.reinitCharts(), 100); } } }, 500); this.unsubFns.push(() => clearInterval(langCheckInterval)); }); } ngAfterViewInit(): void { // Delay chart init to ensure DOM has layout setTimeout(() => this.loadUPlot(), 500); } ngOnDestroy(): void { if (this.intervalId) clearInterval(this.intervalId); this.unsubFns.forEach(fn => fn()); this.themeObserver?.disconnect(); this.resizeObserver?.disconnect(); this.memoryPlot?.destroy(); this.opsPlot?.destroy(); this.clientsPlot?.destroy(); this.networkPlot?.destroy(); } serverInfoLabel(): string { if (!this.current) return ''; const s = this.current.server; const pause = this.paused ? (this.strings().intention?.resume || 'Resume') : (this.strings().intention?.pause || 'Pause'); return `Redis ${s.version} · ${s.mode} · ${this.uptimeFormatted} · ${pause}`; } toggleAutoRefreshClients(): void { this.autoRefreshClients = !this.autoRefreshClients; try { localStorage.setItem('p3xr-monitor-auto-clients', String(this.autoRefreshClients)); } catch {} } toggleAutoRefreshTopKeys(): void { this.autoRefreshTopKeys = !this.autoRefreshTopKeys; try { localStorage.setItem('p3xr-monitor-auto-topkeys', String(this.autoRefreshTopKeys)); } catch {} } async loadClientList(): Promise { try { const response = await this.socket.request({ action: 'client-list', payload: {} }); this.clientList = response.data; this.clientListLoaded = true; this.safeDetectChanges(); } catch { this.clientListLoaded = true; } } async killClient(id: string, event: Event): Promise { event.stopPropagation(); try { await this.common.confirm({ message: this.strings().page?.monitor?.confirmKillClient || 'Are you sure to kill this client?', }); await this.socket.request({ action: 'client-kill', payload: { id } }); this.common.toast({ message: this.strings().page?.monitor?.clientKilled || 'Client killed' }); await this.loadClientList(); } catch (e) { if (e !== undefined) this.common.generalHandleError(e); } } async loadTopKeys(): Promise { try { const response = await this.socket.request({ action: 'memory-top-keys', payload: { topN: 20 } }); this.topKeys = response.data; this.topKeysLoaded = true; this.safeDetectChanges(); } catch { this.topKeysLoaded = true; } } private safeDetectChanges(): void { this.ngZone.run(() => { const scrollContainer = document.getElementById('p3xr-database-content-container') || document.querySelector('.p3xr-layout-content'); const scrollTop = scrollContainer?.scrollTop ?? window.scrollY; try { this.cdr.detectChanges(); } catch { /* ignore late teardown */ } requestAnimationFrame(() => { if (scrollContainer) { scrollContainer.scrollTop = scrollTop; } else { window.scrollTo(0, scrollTop); } }); }); } 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'; } togglePause(): void { this.paused = !this.paused; } get uptimeFormatted(): string { if (!this.current) return '-'; const s = this.current.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`; } private async fetchData(): Promise { try { const response = await this.socket.request({ action: 'monitor-info', payload: {}, }); const data: MonitorSnapshot = response.data; this.current = data; this.history.push(data); if (this.history.length > MAX_HISTORY) { this.history.shift(); } if (this.chartsInitialized) { this.updateCharts(); } else if (this.uPlot && this.history.length >= 2) { this.initCharts(); } this.safeDetectChanges(); } catch { /* next tick will retry */ } } private async loadUPlot(): Promise { const uPlotModule = await import('uplot'); this.uPlot = uPlotModule.default; // Import uPlot CSS inline if (!document.getElementById('uplot-css')) { const style = document.createElement('style'); style.id = 'uplot-css'; try { const cssModule = require('uplot/dist/uPlot.min.css'); style.textContent = typeof cssModule === 'string' ? cssModule : ''; } catch { // Fallback: minimal uPlot styles style.textContent = '.uplot { font-family: inherit; } .u-legend { display: flex; gap: 12px; padding: 4px 0; font-size: 12px; }'; } document.head.appendChild(style); } if (this.history.length >= 2) { this.initCharts(); } } private 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)', }; } private reinitCharts(): void { this.memoryPlot?.destroy(); this.opsPlot?.destroy(); this.clientsPlot?.destroy(); this.networkPlot?.destroy(); this.chartsInitialized = false; if (this.history.length >= 2) { this.initCharts(); } } private getChartWidth(el: HTMLDivElement | undefined): number { return el?.offsetWidth || 500; } private createOpts(width: number, seriesConfig: any[]): any { const colors = this.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 => dayjs(t * 1000).format('HH:mm:ss')), }, { stroke: colors.text, grid: { stroke: colors.grid, width: 1 }, ticks: { stroke: colors.grid }, font: '11px Roboto Mono', size: 55, }, ], series: [ { label: this.strings().label?.time || 'Time', value: (_: any, rawValue: number) => rawValue ? dayjs(rawValue * 1000).format('HH:mm:ss') : '' }, ...seriesConfig, ], }; } private initCharts(): void { if (!this.uPlot || this.chartsInitialized) return; const colors = this.getChartColors(); const data = this.buildChartData(); const memEl = this.memoryChartRef?.nativeElement; const opsEl = this.opsChartRef?.nativeElement; const cliEl = this.clientsChartRef?.nativeElement; const netEl = this.networkChartRef?.nativeElement; if (!memEl || !opsEl || !cliEl || !netEl) return; const s = this.strings().page?.monitor || {}; this.memoryPlot = new this.uPlot( this.createOpts(this.getChartWidth(memEl), [ { label: s.memory || 'Memory', stroke: colors.primary, width: 2, fill: colors.primary + '15' }, { label: 'RSS', stroke: colors.accent, width: 2 }, ]), [data.timestamps, data.memUsed, data.memRss], memEl, ); this.opsPlot = new this.uPlot( this.createOpts(this.getChartWidth(opsEl), [ { label: s.opsPerSec || 'Ops/s', stroke: colors.primary, width: 2, fill: colors.primary + '20' }, ]), [data.timestamps, data.ops], opsEl, ); this.clientsPlot = new this.uPlot( this.createOpts(this.getChartWidth(cliEl), [ { label: s.clients || 'Connected', stroke: colors.primary, width: 2 }, { label: s.blocked || 'Blocked', stroke: colors.warn, width: 2 }, ]), [data.timestamps, data.connected, data.blocked], cliEl, ); this.networkPlot = new this.uPlot( this.createOpts(this.getChartWidth(netEl), [ { label: '↓ In', stroke: colors.primary, width: 2, fill: colors.primary + '15' }, { label: '↑ Out', stroke: colors.accent, width: 2 }, ]), [data.timestamps, data.netIn, data.netOut], netEl, ); this.chartsInitialized = true; // Auto-resize charts on container resize (window resize, accordion toggle) let resizeTimer: any; this.resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const mw = this.getChartWidth(memEl); const ow = this.getChartWidth(opsEl); const cw = this.getChartWidth(cliEl); const nw = this.getChartWidth(netEl); if (mw > 0) this.memoryPlot?.setSize({ width: mw, height: 180 }); if (ow > 0) this.opsPlot?.setSize({ width: ow, height: 180 }); if (cw > 0) this.clientsPlot?.setSize({ width: cw, height: 180 }); if (nw > 0) this.networkPlot?.setSize({ width: nw, height: 180 }); }, 50); }); this.resizeObserver.observe(memEl); this.resizeObserver.observe(opsEl); this.resizeObserver.observe(cliEl); this.resizeObserver.observe(netEl); } private buildChartData() { return { timestamps: this.history.map(h => h.timestamp / 1000), memUsed: this.history.map(h => h.memory.used / (1024 * 1024)), memRss: this.history.map(h => h.memory.rss / (1024 * 1024)), ops: this.history.map(h => h.stats.opsPerSec), connected: this.history.map(h => h.clients.connected), blocked: this.history.map(h => h.clients.blocked), netIn: this.history.map(h => h.stats.inputKbps), netOut: this.history.map(h => h.stats.outputKbps), }; } private updateCharts(): void { if (!this.chartsInitialized) return; const data = this.buildChartData(); this.memoryPlot?.setData([data.timestamps, data.memUsed, data.memRss]); this.opsPlot?.setData([data.timestamps, data.ops]); this.clientsPlot?.setData([data.timestamps, data.connected, data.blocked]); this.networkPlot?.setData([data.timestamps, data.netIn, data.netOut]); } }