RSS Git Download  Clone
Raw Blame History 13kB 334 lines
import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
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 { I18nService } from '../../services/i18n.service';
import { SocketService } from '../../services/socket.service';
import { CommonService } from '../../services/common.service';
import { SettingsService } from '../../services/settings.service';
import { P3xrAccordionComponent } from '../../components/p3xr-accordion.component';
import { P3xrButtonComponent } from '../../components/p3xr-button.component';
import { P3xrInputComponent } from '../../components/p3xr-input.component';
import { RedisStateService } from '../../services/redis-state.service';

require('./memory-analysis.component.scss');

@Component({
    selector: 'p3xr-memory-analysis',
    standalone: true,
    imports: [
        CommonModule, FormsModule,
        MatIconModule, MatButtonModule, MatTooltipModule,
        MatDividerModule, MatListModule,
        P3xrAccordionComponent, P3xrButtonComponent, P3xrInputComponent,
    ],
    templateUrl: './memory-analysis.component.html',
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MemoryAnalysisComponent implements OnInit, OnDestroy, AfterViewInit {
    strings;
    data: any = null;
    loading = false;
    topN = 20;
    maxScanKeys = 5000;
    typeEntries: Array<{ type: string; count: number; bytes: number }> = [];

    @ViewChild('typeChart') typeChartRef!: ElementRef<HTMLDivElement>;
    @ViewChild('prefixChart') prefixChartRef!: ElementRef<HTMLDivElement>;

    private unsubFns: Array<() => void> = [];
    private boundRecalcHost: (() => void) | null = null;
    private themeObserver: MutationObserver | null = null;
    private resizeTimer: any;

    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,
        @Inject(ElementRef) private elementRef: ElementRef,
        @Inject(RedisStateService) private state: RedisStateService,
        @Inject(SettingsService) private settings: SettingsService,
    ) {
        this.strings = this.i18n.strings;
    }

    s() {
        return this.strings().page?.analysis || {};
    }

    get connName(): string {
        return this.state.connection()?.name || 'redis';
    }

    ngOnInit(): void {
        this.runAnalysis();
        const sub = this.socket.stateChanged$.subscribe(() => {
            this.data = null;
            this.runAnalysis();
        });
        this.unsubFns.push(() => sub.unsubscribe());
    }

    ngAfterViewInit(): void {
        this.ngZone.runOutsideAngular(() => {
            this.boundRecalcHost = () => {
                clearTimeout(this.resizeTimer);
                this.resizeTimer = setTimeout(() => { if (this.data) this.drawCharts(); }, 150);
            };
            window.addEventListener('resize', this.boundRecalcHost);
        });

        this.ngZone.runOutsideAngular(() => {
            this.themeObserver = new MutationObserver(() => {
                if (this.data) setTimeout(() => this.drawCharts(), 100);
            });
            this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
        });
    }

    ngOnDestroy(): void {
        this.themeObserver?.disconnect();
        if (this.boundRecalcHost) {
            window.removeEventListener('resize', this.boundRecalcHost);
        }
        this.unsubFns.forEach(fn => fn());
    }

    private recalcHostHeight(): void {
        const el = this.elementRef.nativeElement as HTMLElement;
        if (!el) return;
        const rect = el.getBoundingClientRect();
        const footerHeight = document.getElementById('p3xr-layout-footer-container')?.offsetHeight || 48;
        const available = window.innerHeight - rect.top - footerHeight;
        el.style.height = Math.max(available, 100) + 'px';
        el.style.overflowY = 'auto';
    }

    async runAnalysis(): Promise<void> {
        if (this.loading) return;
        this.loading = true;
        this.safeDetectChanges();
        try {
            const response = await this.socket.request({
                action: 'memory-analysis',
                payload: { topN: this.topN, maxScanKeys: this.maxScanKeys },
            });
            this.data = response.data;
            this.typeEntries = Object.keys(this.data.typeDistribution).map(type => ({
                type,
                count: this.data.typeDistribution[type],
                bytes: this.data.typeMemory[type] || 0,
            })).sort((a, b) => b.bytes - a.bytes);
            this.loading = false;
            this.safeDetectChanges();
            setTimeout(() => this.drawCharts(), 100);
        } catch (e) {
            this.loading = false;
            this.safeDetectChanges();
            this.common.generalHandleError(e);
        }
    }

    formatBytes(bytes: number): string {
        if (bytes == null || isNaN(bytes)) return '-';
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
        return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
    }

    formatTTL(seconds: number): string {
        if (!seconds || seconds <= 0) return '-';
        try {
            const humanizeDuration = require('humanize-duration');
            const hdOpts = this.settings.getHumanizeDurationOptions();
            return humanizeDuration(seconds * 1000, { ...hdOpts, delimiter: ' ' });
        } catch {
            if (seconds < 60) return seconds + 's';
            if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
            if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
            return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h';
        }
    }

    private formatUptime(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`;
    }

    private 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);
    }

    exportOverview(): void {
        if (!this.data) return;
        const t = this.s();
        this.downloadText([
            `${t.keysScanned || 'Keys Scanned'}: ${this.data.totalScanned} / ${this.data.dbSize}`,
            `${t.topN || 'Top N'}: ${this.topN}`,
            `${t.maxScanKeys || 'Max Scan Keys'}: ${this.maxScanKeys}`,
        ].join('\n'), `${this.connName}-analysis-overview.txt`);
    }

    exportMemoryBreakdown(): void {
        if (!this.data) return;
        const t = this.s();
        const m = this.data.memoryInfo;
        this.downloadText([
            `${t.totalMemory || 'Total'}: ${m.usedHuman}`,
            `${t.rssMemory || 'RSS'}: ${m.rssHuman}`,
            `${t.peakMemory || 'Peak'}: ${m.peakHuman}`,
            `${t.overheadMemory || 'Overhead'}: ${this.formatBytes(m.overhead)}`,
            `${t.datasetMemory || 'Dataset'}: ${this.formatBytes(m.dataset)}`,
            `${t.luaMemory || 'Lua'}: ${this.formatBytes(m.lua)}`,
            `${t.fragmentation || 'Fragmentation'}: ${m.fragRatio}x`,
            `${t.allocator || 'Allocator'}: ${m.allocator}`,
        ].join('\n'), `${this.connName}-memory-breakdown.txt`);
    }

    exportExpiration(): void {
        if (!this.data) return;
        const t = this.s();
        const e = this.data.expirationOverview;
        this.downloadText([
            `${t.withTTL || 'With TTL'}: ${e.withTTL}`,
            `${t.persistent || 'Persistent'}: ${e.persistent}`,
            `${t.avgTTL || 'Average TTL'}: ${this.formatTTL(e.avgTTL)}`,
        ].join('\n'), `${this.connName}-expiration.txt`);
    }

    exportChart(chartRef: ElementRef<HTMLDivElement> | undefined, name: string): void {
        const canvas = chartRef?.nativeElement?.querySelector('canvas') as HTMLCanvasElement;
        if (!canvas) return;
        // Create a copy with solid background
        const exportCanvas = document.createElement('canvas');
        exportCanvas.width = canvas.width;
        exportCanvas.height = canvas.height;
        const ctx = exportCanvas.getContext('2d')!;
        ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#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 = `${this.connName}-${name}.png`;
        a.click();
    }

    private safeDetectChanges(): void {
        this.ngZone.run(() => {
            try { this.cdr.detectChanges(); } catch { /* teardown */ }
        });
    }

    drawCharts(): void {
        this.drawBarChart(this.typeChartRef?.nativeElement, this.typeEntries.map(t => ({
            label: t.type,
            value: t.bytes,
        })));
        this.drawBarChart(this.prefixChartRef?.nativeElement, (this.data?.prefixMemory || []).slice(0, 20).map((p: any) => ({
            label: p.prefix,
            value: p.totalBytes,
        })));
    }

    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)',
            isDark,
        };
    }

    private getBarColors(colors: ReturnType<typeof this.getChartColors>): string[] {
        const isDark = colors.isDark;
        return [
            colors.primary,
            colors.accent,
            colors.warn,
            isDark ? '#ffb74d' : '#ff9800',
            isDark ? '#81c784' : '#4caf50',
            isDark ? '#4dd0e1' : '#00bcd4',
            isDark ? '#a1887f' : '#795548',
            isDark ? '#90a4ae' : '#607d8b',
        ];
    }

    private drawBarChart(container: HTMLDivElement | undefined, items: Array<{ label: string; value: number }>): void {
        if (!container || items.length === 0) return;
        container.innerHTML = '';

        const colors = this.getChartColors();
        const barColors = this.getBarColors(colors);

        const canvas = document.createElement('canvas');
        const dpr = window.devicePixelRatio || 1;
        const width = container.offsetWidth || 500;
        const barHeight = 24;
        const labelWidth = 120;
        const valueWidth = 80;
        const chartLeft = labelWidth + 8;
        const chartRight = width - valueWidth - 8;
        const chartWidth = chartRight - chartLeft;
        const topPad = 8;
        const height = topPad + items.length * (barHeight + 4) + 8;

        canvas.width = width * dpr;
        canvas.height = height * dpr;
        canvas.style.width = width + 'px';
        canvas.style.height = height + 'px';

        const ctx = canvas.getContext('2d')!;
        ctx.scale(dpr, dpr);

        const maxVal = Math.max(...items.map(i => i.value), 1);

        items.forEach((item, i) => {
            const y = topPad + i * (barHeight + 4);

            ctx.fillStyle = colors.text;
            ctx.font = '12px Roboto, sans-serif';
            ctx.textAlign = 'right';
            ctx.textBaseline = 'middle';
            ctx.fillText(item.label.length > 15 ? item.label.substring(0, 14) + '…' : item.label, labelWidth, y + barHeight / 2);

            ctx.fillStyle = colors.grid;
            ctx.fillRect(chartLeft, y, chartWidth, barHeight);

            const barWidth = (item.value / maxVal) * chartWidth;
            ctx.fillStyle = barColors[i % barColors.length];
            ctx.fillRect(chartLeft, y, barWidth, barHeight);

            ctx.fillStyle = colors.text;
            ctx.font = '11px Roboto Mono, monospace';
            ctx.textAlign = 'left';
            ctx.fillText(this.formatBytes(item.value), chartRight + 8, y + barHeight / 2);
        });

        container.appendChild(canvas);
    }
}