RSS Git Download  Clone
Raw Blame History 17kB 439 lines
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<string, string>;
    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<HTMLDivElement>;
    @ViewChild('opsChart') opsChartRef!: ElementRef<HTMLDivElement>;
    @ViewChild('clientsChart') clientsChartRef!: ElementRef<HTMLDivElement>;
    @ViewChild('networkChart') networkChartRef!: ElementRef<HTMLDivElement>;

    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<void> {
        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<void> {
        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<void> {
        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<void> {
        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<void> {
        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]);
    }
}