import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy, ElementRef, ViewChild, AfterViewInit, NgZone, ViewEncapsulation } 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
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));
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';
import { RedisStateService } from '../../services/redis-state.service';
import { MonitoringDataService } from './monitoring-data.service';
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, FormsModule,
MatIconModule, MatButtonModule,
MatTooltipModule, MatDividerModule, MatListModule,
MatFormFieldModule, MatInputModule,
P3xrAccordionComponent,
P3xrButtonComponent,
],
templateUrl: './monitoring.component.html',
styleUrls: ['./monitoring.component.scss'],
encapsulation: ViewEncapsulation.None,
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;
slotStats: Array<{ slot: number; 'key-count'?: number; 'cpu-usec'?: number; 'memory-bytes'?: number }> = [];
slotStatsMetric = 'KEY-COUNT';
slotStatsLoaded = false;
isCluster = false;
clusterShards: any[] | null = null;
autoRefreshShards = localStorage.getItem('p3xr-monitor-auto-shards') === 'true';
private shardsInterval: any = null;
@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> = [];
private boundRecalcHost: (() => void) | null = null;
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(MonitoringDataService) private monitorData: MonitoringDataService,
) {
this.strings = this.i18n.strings;
}
ngOnInit(): void {
this.isReadonly = this.state.connection()?.readonly === true;
this.isCluster = this.state.connection()?.cluster === true;
this.fetchData();
this.loadClientList();
this.loadTopKeys();
if (this.isCluster && this.state.redisVersion().isAtLeast(8, 2)) {
this.loadSlotStats();
}
// Reload all data when connection changes
const sub = this.socket.stateChanged$.subscribe(() => {
this.isReadonly = this.state.connection()?.readonly === true;
this.isCluster = this.state.connection()?.cluster === 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);
if (this.shardsInterval) clearInterval(this.shardsInterval);
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) : (this.strings().intention?.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,
});
await this.socket.request({ action: 'client/kill', payload: { id } });
this.common.toast({ message: this.strings().page?.monitor?.clientKilled });
await this.loadClientList();
} catch (e) {
if (e !== undefined) this.common.generalHandleError(e);
}
}
async loadSlotStats(): Promise<void> {
try {
const response = await this.socket.request({
action: 'cluster/slot-stats',
payload: { metric: this.slotStatsMetric, limit: 20 },
});
this.slotStats = response.slots || [];
this.slotStatsLoaded = true;
this.safeDetectChanges();
} catch { this.slotStatsLoaded = true; this.slotStats = []; }
}
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;
}
// --- Dashboard computed properties ---
get serverInfo(): { os: string; port: string; pid: string; configFile: string; cpuSys: string; cpuUser: string } | null {
const info = this.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 || '0',
cpuUser: c.used_cpu_user || '0',
};
}
get persistenceInfo(): { rdbLastSave: string; rdbStatus: string; rdbChanges: string; aofEnabled: string; aofSize: string } | null {
const info = this.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 || 'N/A',
rdbChanges: p.rdb_changes_since_last_save ?? 'N/A',
aofEnabled: p.aof_enabled === '1' ? 'Yes' : 'No',
aofSize: p.aof_enabled === '1' ? this.formatBytes(parseInt(p.aof_current_size, 10) || 0) : '',
};
}
get replicationInfo(): { role: string; replicas?: string; masterHost?: string; masterPort?: string; linkStatus?: string } | null {
const info = this.state.info();
if (!info?.replication) return null;
const r = info.replication;
const result: any = { role: r.role || 'unknown' };
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;
}
get keyspaceEntries(): Array<{ db: string; keys: string; expires: string }> {
const info = this.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') : '0',
expires: typeof entry === 'object' ? (entry.expires || '0') : '0',
};
});
}
get modulesList(): Array<{ name: string; ver: string }> {
return (this.state.modules() || []).map((m: any) => ({
name: m.name || 'unknown',
ver: String(m.ver ?? m.version ?? ''),
}));
}
// --- Dashboard export methods ---
exportServerInfo(): void {
const s = this.serverInfo;
if (!s) return;
const mon = this.strings().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}`,
];
this.downloadText(lines.join('\n'), `${this.connName}-server-info.txt`);
}
exportPersistence(): void {
const p = this.persistenceInfo;
if (!p) return;
const mon = this.strings().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}`);
this.downloadText(lines.join('\n'), `${this.connName}-persistence.txt`);
}
exportReplication(): void {
const r = this.replicationInfo;
if (!r) return;
const mon = this.strings().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}`);
this.downloadText(lines.join('\n'), `${this.connName}-replication.txt`);
}
exportKeyspace(): void {
const entries = this.keyspaceEntries;
if (entries.length === 0) return;
const mon = this.strings().page?.monitor || {};
const lines = entries.map(e => `${e.db}: ${mon.keys}: ${e.keys}, ${mon.expires}: ${e.expires}`);
this.downloadText(lines.join('\n'), `${this.connName}-keyspace.txt`);
}
exportModules(): void {
const mods = this.modulesList;
const mon = this.strings().page?.monitor || {};
if (mods.length === 0) {
this.downloadText(mon.noModules, `${this.connName}-modules.txt`);
return;
}
const lines = mods.map(m => `${m.name} v${m.ver}`);
this.downloadText(lines.join('\n'), `${this.connName}-modules.txt`);
}
private get connName(): string {
return this.state.connection()?.name || 'redis';
}
exportOverview(): void {
if (!this.current) return;
const c = this.current;
const mon = this.strings().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}`,
];
this.downloadText(lines.join('\n'), `${this.connName}-overview.txt`);
}
exportChart(chartRef: ElementRef<HTMLDivElement> | undefined, name: string): void {
const canvas = chartRef?.nativeElement?.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')!;
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();
}
async resetSlowLog(): Promise<void> {
try {
await this.common.confirm({ message: this.strings().page?.monitor?.confirmSlowLogReset });
await this.socket.request({ action: 'monitor/slowlog-reset' });
this.common.toast({ message: this.strings().page?.monitor?.slowLogResetDone });
} catch {}
}
exportSlowLog(): void {
if (!this.current) return;
const lines = this.current.slowlog.map(e => `${e.duration}µs ${e.command}`);
this.downloadText(lines.join('\n'), `${this.connName}-slowlog.txt`);
}
async loadClusterShards(): Promise<void> {
try {
const resp = await this.socket.request({ action: 'cluster/shards' });
this.clusterShards = resp.data.shards;
this.safeDetectChanges();
} catch (e) { this.common.generalHandleError(e); }
}
toggleAutoRefreshShards(): void {
this.autoRefreshShards = !this.autoRefreshShards;
localStorage.setItem('p3xr-monitor-auto-shards', String(this.autoRefreshShards));
if (this.autoRefreshShards) {
this.loadClusterShards();
this.shardsInterval = setInterval(() => this.loadClusterShards(), 2000);
} else {
clearInterval(this.shardsInterval);
this.shardsInterval = null;
}
}
getSlotCount(shard: any): number {
return shard.slotRanges.reduce((sum: number, [a, b]: [number, number]) => sum + (b - a + 1), 0);
}
formatSlotRanges(shard: any): string {
return shard.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', ');
}
formatReplicas(shard: any): string {
return shard.replicas.map((r: any) => `${r.host}:${r.port}`).join(', ');
}
exportClusterSlots(): void {
if (!this.clusterShards) return;
const lines = this.clusterShards.map(s => {
const slots = s.slotRanges.map(([a, b]: [number, number]) => `${a}-${b}`).join(', ');
const count = this.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 || 'none'}`;
});
this.downloadText(lines.join('\n'), `${this.connName}-cluster-slots.txt`);
}
exportClientList(): void {
const lines = this.clientList.map(c => `${c.addr} ${c.name || ''} db${c.db} ${c.cmd} idle:${c.idle}s`);
this.downloadText(lines.join('\n'), `${this.connName}-clients.txt`);
}
exportTopKeys(): void {
const lines = this.topKeys.map((e, i) => `#${i + 1} ${e.key} ${this.formatBytes(e.bytes)}`);
this.downloadText(lines.join('\n'), `${this.connName}-topkeys.txt`);
}
async exportAll(): Promise<void> {
if (!this.current) return;
try {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
const c = this.current;
const sections: string[] = [];
// === PULSE ===
const mon = this.strings().page?.monitor || {};
const a = this.strings().page?.analysis || {};
sections.push(
`============================`,
` PULSE`,
`============================`,
``,
`--- ${mon.title} ---`,
`Redis ${c.server.version} · ${c.server.mode} · Uptime: ${this.uptimeFormatted}`,
`${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}`,
);
// Dashboard sections
const si = this.serverInfo;
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 = this.persistenceInfo;
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 = this.replicationInfo;
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 = this.keyspaceEntries;
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 = this.modulesList;
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}µs ${e.command}`));
}
if (this.clientList.length > 0) {
sections.push(``, `--- ${mon.clientList} ---`);
sections.push(...this.clientList.map(cl => `${cl.addr} ${cl.name || ''} db${cl.db} ${cl.cmd} idle:${cl.idle}s`));
}
if (this.topKeys.length > 0) {
sections.push(``, `--- ${mon.topKeys} ---`);
sections.push(...this.topKeys.map((e, i) => `#${i + 1} ${e.key} ${this.formatBytes(e.bytes)}`));
}
// === ANALYSIS ===
let analysisChartItems: Array<{ name: string; items: Array<{ label: string; value: number }> }> = [];
try {
const resp = await this.socket.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}: ${this.formatBytes(m.overhead)}`, `${a.datasetMemory}: ${this.formatBytes(m.dataset)}`);
sections.push(`${a.luaMemory}: ${this.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}, ${this.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}, ${this.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 sections (long, go last in txt and PDF)
// Sanitize: strip null bytes and non-printable control chars from raw Redis data
const sanitize = (s: string) => s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
const tailSections: string[] = [];
if (this.monitorData.profilerEntries.length > 0) {
tailSections.push(``, ``, `============================`, ` PROFILER`, `============================`, ``);
tailSections.push(...this.monitorData.profilerEntries.map(
e => sanitize(`${e.fullTimestamp} [${e.database} ${e.source}] ${e.command}`)
));
}
if (this.monitorData.pubsubEntries.length > 0) {
tailSections.push(``, ``, `============================`, ` PUBSUB`, `============================`, ``);
tailSections.push(...this.monitorData.pubsubEntries.map(
e => sanitize(`${e.fullTimestamp} ${e.channel} ${e.message}`)
));
}
// Write the single text file: sections + tail (UTF-8 with BOM)
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 }> = [];
// Pulse charts (render offscreen so they work even with collapsed accordions)
allCanvases.push(...this.renderPulseChartsForExport());
// Analysis charts (render offscreen)
for (const ci of analysisChartItems) {
if (ci.items.length === 0) continue;
const canvas = this.renderBarChart(ci.items);
if (canvas) allCanvases.push({ label: ci.name, canvas });
}
// Stitch all canvases into 1 tall image
if (allCanvases.length > 0) {
const blob = await this.stitchCharts(allCanvases);
if (blob) zip.file('charts.png', blob);
}
// Generate PDF with text + charts
try {
const pdfBlob = await this.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 = `${this.connName}-monitoring.zip`;
link.click();
URL.revokeObjectURL(url);
} catch (e) {
this.common.generalHandleError(e);
}
}
private async stitchCharts(items: Array<{ label: string; canvas: HTMLCanvasElement }>): Promise<Blob | null> {
const padding = 32;
const labelHeight = 60;
const chartSpacing = 40;
// Use full native pixel width of the widest chart, minimum 2400px
const width = Math.max(2400, ...items.map(i => i.canvas.width));
// Calculate total height at native pixel resolution
let totalHeight = padding;
for (const item of items) {
const scaledH = item.canvas.height * (width / item.canvas.width);
totalHeight += labelHeight + scaledH + chartSpacing;
}
totalHeight += padding;
const stitched = document.createElement('canvas');
stitched.width = width;
stitched.height = totalHeight;
const ctx = stitched.getContext('2d')!;
const bgColor = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff';
const colors = this.getChartColors();
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, totalHeight);
let y = padding;
for (const item of items) {
// Label
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;
// Draw chart at full width
const drawW = width - padding * 2;
const 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'));
}
private renderPulseChartsForExport(): Array<{ label: string; canvas: HTMLCanvasElement }> {
// Use history if available, otherwise build a minimal dataset from the current snapshot
let data: ReturnType<typeof this.buildChartData>;
if (this.history.length >= 2) {
data = this.buildChartData();
} else if (this.current) {
const c = this.current;
const 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 [];
}
const colors = this.getChartColors();
const s = this.strings().page?.monitor || {};
const chartConfigs: Array<{
label: string;
series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>;
}> = [
{
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 },
],
},
];
return chartConfigs.map(config =>
({ label: config.label, canvas: this.renderLineChart(data.timestamps, config.series, colors) })
);
}
private renderLineChart(
timestamps: number[],
series: Array<{ label: string; color: string; values: number[]; fill?: boolean }>,
colors: ReturnType<typeof this.getChartColors>,
): HTMLCanvasElement {
const dpr = 2;
const width = 900;
const height = 260;
const padTop = 32;
const padBottom = 40;
const padLeft = 60;
const padRight = 16;
const legendH = 20;
const chartW = width - padLeft - padRight;
const 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);
// Background
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#ffffff';
ctx.fillRect(0, 0, width, height);
const n = timestamps.length;
if (n < 2) return canvas;
// Compute Y scale across all series
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;
const tMin = timestamps[0], tMax = timestamps[n - 1];
const tRange = tMax - tMin || 1;
const toX = (t: number) => padLeft + ((t - tMin) / tRange) * chartW;
const toY = (v: number) => padTop + chartH - ((v - yMin) / yRange) * chartH;
// Grid lines
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
const ySteps = 5;
for (let i = 0; i <= ySteps; i++) {
const gy = padTop + (chartH / ySteps) * i;
ctx.beginPath(); ctx.moveTo(padLeft, gy); ctx.lineTo(padLeft + chartW, gy); ctx.stroke();
const val = yMax - (yRange / ySteps) * 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);
}
// Time labels
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));
const t = timestamps[idx];
const d = new Date(t * 1000);
const label = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
ctx.fillText(label, toX(t), padTop + chartH + 6);
}
// Draw series
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]);
const y = toY(s.values[i]);
if (i === 0) ctx.moveTo(x, y); else 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();
}
}
// Legend
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;
}
private renderBarChart(items: Array<{ label: string; value: number }>): HTMLCanvasElement | null {
if (items.length === 0) return null;
const colors = this.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;
const width = 800;
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;
const canvas = document.createElement('canvas');
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || '#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) + '…' : 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(this.formatBytes(item.value), chartRight + 8, y + barHeight / 2);
});
return canvas;
}
private async 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() || '#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();
const pageH = pdf.internal.pageSize.getHeight();
const margin = 12;
const 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;
}
};
// Text sections
for (const line of sections) {
if (line.startsWith('====')) {
continue;
}
const isSectionTitle = line.trim() === 'PULSE' || line.trim() === 'PROFILER' || line.trim() === 'PUBSUB' || line.trim() === 'ANALYSIS';
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);
const wrapped = pdf.splitTextToSize(line, contentW);
for (const wl of wrapped) {
checkPage(4);
pdf.text(wl, margin, y);
y += 3.5;
}
}
// Charts — each on its own page
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');
const ratio = chart.canvas.height / chart.canvas.width;
const availH = pageH - y - margin;
const imgW = contentW;
const imgH = imgW * ratio;
if (imgH > availH) {
// Scale to fit available height, keep full width
const drawH = availH;
const drawW = drawH / ratio;
pdf.addImage(imgData, 'PNG', margin, y, drawW, drawH);
y += drawH;
} else {
pdf.addImage(imgData, 'PNG', margin, y, imgW, imgH);
y += imgH;
}
}
// Tail sections (Profiler / PubSub — after charts, start new page)
if (tailSections.length > 0 && charts.length > 0) {
pdf.addPage();
fillBg();
y = margin;
}
for (const line of tailSections) {
if (line.startsWith('====')) {
continue;
}
const isSectionTitle = line.trim() === 'PROFILER' || line.trim() === 'PUBSUB';
if (isSectionTitle) {
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);
const wrapped = pdf.splitTextToSize(line, contentW);
for (const wl of wrapped) {
checkPage(4);
pdf.text(wl, margin, y);
y += 3.5;
}
}
return pdf.output('blob') as unknown as Blob;
}
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);
}
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.isCluster = this.state.connection()?.cluster === true;
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;
// uPlot CSS is loaded globally via angular.json styles[]
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 => formatTime(t * 1000)),
},
{
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, value: (_: any, rawValue: number) => rawValue ? formatTime(rawValue * 1000) : '' },
...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, 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, 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, stroke: colors.primary, width: 2 },
{ label: s.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]);
}
}