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