perf: Add ram metric
This commit is contained in:
23
electron/app-metrics.ts
Normal file
23
electron/app-metrics.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { app } from 'electron';
|
||||
|
||||
export interface AppMetricsProcessSnapshot {
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
}
|
||||
|
||||
export interface AppMetricsSnapshot {
|
||||
collectedAt: number;
|
||||
processes: AppMetricsProcessSnapshot[];
|
||||
}
|
||||
|
||||
export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
||||
return {
|
||||
collectedAt: Date.now(),
|
||||
processes: app.getAppMetrics().map((metric) => ({
|
||||
pid: metric.pid,
|
||||
type: metric.type,
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from '../data-management';
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
import { detectActiveGame } from '../game-detection';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||
@@ -362,6 +363,8 @@ export function setupSystemHandlers(): void {
|
||||
return await stopLinuxScreenShareMonitorCapture(captureId);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-metrics', () => collectAppMetricsSnapshot());
|
||||
|
||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
||||
ipcMain.handle('export-user-data', async () => await exportUserData());
|
||||
|
||||
@@ -240,6 +240,14 @@ export interface ElectronAPI {
|
||||
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppMetrics: () => Promise<{
|
||||
collectedAt: number;
|
||||
processes: {
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
}[];
|
||||
}>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -374,6 +382,7 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.removeListener(LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||
|
||||
@@ -233,6 +233,17 @@ export interface ActiveGameCandidateResult {
|
||||
fallbackProcessNames: string[];
|
||||
}
|
||||
|
||||
export interface ElectronAppMetricsProcess {
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
}
|
||||
|
||||
export interface ElectronAppMetricsSnapshot {
|
||||
collectedAt: number;
|
||||
processes: ElectronAppMetricsProcess[];
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -251,6 +262,7 @@ export interface ElectronApi {
|
||||
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
formatAppRamLabel,
|
||||
formatKilobytesAsMegabytes,
|
||||
sumWorkingSetKb
|
||||
} from './electron-app-metrics.rules';
|
||||
import type { ElectronAppMetricsSnapshot } from './electron-app-metrics.rules';
|
||||
|
||||
function createSnapshot(
|
||||
processes: ElectronAppMetricsSnapshot['processes']
|
||||
): ElectronAppMetricsSnapshot {
|
||||
return {
|
||||
collectedAt: 1,
|
||||
processes
|
||||
};
|
||||
}
|
||||
|
||||
describe('sumWorkingSetKb', () => {
|
||||
it('sums working set across processes that report memory', () => {
|
||||
const total = sumWorkingSetKb([{ pid: 1, type: 'Browser', workingSetKb: 1024 }, { pid: 2, type: 'GPU', workingSetKb: 512 }]);
|
||||
|
||||
expect(total).toBe(1536);
|
||||
});
|
||||
|
||||
it('ignores processes without memory readings', () => {
|
||||
const total = sumWorkingSetKb([{ pid: 1, type: 'Browser', workingSetKb: 2048 }, { pid: 2, type: 'Unknown', workingSetKb: null }]);
|
||||
|
||||
expect(total).toBe(2048);
|
||||
});
|
||||
|
||||
it('returns null when no process reports memory', () => {
|
||||
expect(sumWorkingSetKb([{ pid: 1, type: 'Browser', workingSetKb: null }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatKilobytesAsMegabytes', () => {
|
||||
it('rounds large values to whole megabytes', () => {
|
||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||
});
|
||||
|
||||
it('keeps one decimal for medium values', () => {
|
||||
expect(formatKilobytesAsMegabytes(15.4 * 1024)).toBe('15.4 MB');
|
||||
});
|
||||
|
||||
it('keeps two decimals for small values', () => {
|
||||
expect(formatKilobytesAsMegabytes(1.25 * 1024)).toBe('1.25 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAppRamLabel', () => {
|
||||
it('formats total working set as a compact RAM label', () => {
|
||||
const browser = { pid: 1, type: 'Browser', workingSetKb: 300 * 1024 };
|
||||
const gpu = { pid: 2, type: 'GPU', workingSetKb: 112 * 1024 };
|
||||
const label = formatAppRamLabel(createSnapshot([browser, gpu]));
|
||||
|
||||
expect(label).toBe('412 MB');
|
||||
});
|
||||
|
||||
it('returns null when metrics contain no memory readings', () => {
|
||||
expect(formatAppRamLabel(createSnapshot([{ pid: 1, type: 'Browser', workingSetKb: null }]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface ElectronAppMetricsProcess {
|
||||
pid: number;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
}
|
||||
|
||||
export interface ElectronAppMetricsSnapshot {
|
||||
collectedAt: number;
|
||||
processes: ElectronAppMetricsProcess[];
|
||||
}
|
||||
|
||||
export function sumWorkingSetKb(processes: ElectronAppMetricsProcess[]): number | null {
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const process of processes) {
|
||||
if (process.workingSetKb == null || process.workingSetKb < 0)
|
||||
continue;
|
||||
|
||||
total += process.workingSetKb;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? total : null;
|
||||
}
|
||||
|
||||
export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
||||
const megabytes = kilobytes / 1024;
|
||||
|
||||
if (megabytes >= 100)
|
||||
return `${Math.round(megabytes)} MB`;
|
||||
|
||||
if (megabytes >= 10)
|
||||
return `${megabytes.toFixed(1)} MB`;
|
||||
|
||||
return `${megabytes.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||
|
||||
if (totalKb == null)
|
||||
return null;
|
||||
|
||||
return formatKilobytesAsMegabytes(totalKb);
|
||||
}
|
||||
@@ -335,9 +335,7 @@
|
||||
></textarea>
|
||||
|
||||
@if (dragActive()) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-primary bg-primary/5"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-primary bg-primary/5">
|
||||
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -198,7 +198,9 @@
|
||||
name="lucideImage"
|
||||
class="h-5 w-5 text-primary"
|
||||
/>
|
||||
<span class="chat-image-grid-loading-label">{{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}%</span>
|
||||
<span class="chat-image-grid-loading-label"
|
||||
>{{ ((gridImage.receivedBytes || 0) * 100) / gridImage.size | number: '1.0-0' }}%</span
|
||||
>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="chat-image-grid-cell chat-image-grid-loading">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<nav class="relative flex h-full w-16 min-w-16 max-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:max-w-none md:w-full">
|
||||
<nav
|
||||
class="relative flex h-full w-16 min-w-16 max-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:max-w-none md:w-full"
|
||||
>
|
||||
<!-- Home / dashboard button -->
|
||||
<button
|
||||
appThemeNode="serversRailCreateButton"
|
||||
|
||||
@@ -31,6 +31,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (isElectron) {
|
||||
<section class="rounded-lg border border-border bg-secondary/20 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideMemoryStick"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="text-sm">Process RAM</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '—' }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Live total working set from Electron app metrics. Updates every 2 seconds.</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideBug,
|
||||
lucideCircleAlert,
|
||||
lucideClock3,
|
||||
lucideMemoryStick,
|
||||
lucideTrash2,
|
||||
lucideTriangleAlert
|
||||
} from '@ng-icons/lucide';
|
||||
import { interval } from 'rxjs';
|
||||
import { startWith, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
|
||||
const APP_METRICS_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-debugging-settings',
|
||||
@@ -24,6 +36,7 @@ import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
lucideBug,
|
||||
lucideCircleAlert,
|
||||
lucideClock3,
|
||||
lucideMemoryStick,
|
||||
lucideTrash2,
|
||||
lucideTriangleAlert
|
||||
})
|
||||
@@ -31,8 +44,13 @@ import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||
templateUrl: './debugging-settings.component.html'
|
||||
})
|
||||
export class DebuggingSettingsComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly debugging = inject(DebuggingService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
readonly ramLabel = signal<string | null>(null);
|
||||
readonly enabled = this.debugging.enabled;
|
||||
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
||||
readonly entryCount = computed(() => {
|
||||
@@ -54,6 +72,11 @@ export class DebuggingSettingsComponent {
|
||||
return lastEntry ? lastEntry.timeLabel : 'No logs yet';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
if (this.isElectron)
|
||||
this.startRamPolling();
|
||||
}
|
||||
|
||||
onEnabledChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
@@ -67,4 +90,26 @@ export class DebuggingSettingsComponent {
|
||||
clearLogs(): void {
|
||||
this.debugging.clear();
|
||||
}
|
||||
|
||||
private startRamPolling(): void {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.getAppMetrics)
|
||||
return;
|
||||
|
||||
interval(APP_METRICS_POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
startWith(0),
|
||||
switchMap(() => api.getAppMetrics()),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe({
|
||||
next: (snapshot) => {
|
||||
this.ramLabel.set(formatAppRamLabel(snapshot));
|
||||
},
|
||||
error: () => {
|
||||
this.ramLabel.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user