perf: Add ram metric

This commit is contained in:
2026-06-05 15:27:33 +02:00
parent a675f12e61
commit 4070ef6caf
11 changed files with 478 additions and 255 deletions

23
electron/app-metrics.ts Normal file
View 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
}))
};
}

View File

@@ -60,6 +60,7 @@ import {
} from '../data-management'; } from '../data-management';
import { listRunningProcessNames } from '../process-list'; import { listRunningProcessNames } from '../process-list';
import { detectActiveGame } from '../game-detection'; import { detectActiveGame } from '../game-detection';
import { collectAppMetricsSnapshot } from '../app-metrics';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20; const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
@@ -362,6 +363,8 @@ export function setupSystemHandlers(): void {
return await stopLinuxScreenShareMonitorCapture(captureId); return await stopLinuxScreenShareMonitorCapture(captureId);
}); });
ipcMain.handle('get-app-metrics', () => collectAppMetricsSnapshot());
ipcMain.handle('get-app-data-path', () => app.getPath('userData')); ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder()); ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
ipcMain.handle('export-user-data', async () => await exportUserData()); ipcMain.handle('export-user-data', async () => await exportUserData());

View File

@@ -240,6 +240,14 @@ export interface ElectronAPI {
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>; stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppMetrics: () => Promise<{
collectedAt: number;
processes: {
pid: number;
type: string;
workingSetKb: number | null;
}[];
}>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>; exportUserData: () => Promise<ExportUserDataResult>;
@@ -374,6 +382,7 @@ const electronAPI: ElectronAPI = {
ipcRenderer.removeListener(LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL, wrappedListener); ipcRenderer.removeListener(LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL, wrappedListener);
}; };
}, },
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'), openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'), exportUserData: () => ipcRenderer.invoke('export-user-data'),

View File

@@ -233,6 +233,17 @@ export interface ActiveGameCandidateResult {
fallbackProcessNames: string[]; fallbackProcessNames: string[];
} }
export interface ElectronAppMetricsProcess {
pid: number;
type: string;
workingSetKb: number | null;
}
export interface ElectronAppMetricsSnapshot {
collectedAt: number;
processes: ElectronAppMetricsProcess[];
}
export interface ElectronApi { export interface ElectronApi {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -251,6 +262,7 @@ export interface ElectronApi {
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>; stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>; exportUserData: () => Promise<ExportUserDataResult>;

View File

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

View File

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

View File

@@ -335,9 +335,7 @@
></textarea> ></textarea>
@if (dragActive()) { @if (dragActive()) {
<div <div class="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-primary bg-primary/5">
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 class="text-sm text-muted-foreground">Drop files to attach</div>
</div> </div>
} }

View File

@@ -198,7 +198,9 @@
name="lucideImage" name="lucideImage"
class="h-5 w-5 text-primary" 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> </div>
} @else { } @else {
<div class="chat-image-grid-cell chat-image-grid-loading"> <div class="chat-image-grid-cell chat-image-grid-loading">

View File

@@ -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 --> <!-- Home / dashboard button -->
<button <button
appThemeNode="serversRailCreateButton" appThemeNode="serversRailCreateButton"

View File

@@ -31,6 +31,22 @@
</div> </div>
</section> </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"> <section class="grid gap-3 sm:grid-cols-3">
<div class="rounded-lg border border-border bg-secondary/20 p-4"> <div class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-center gap-2 text-muted-foreground"> <div class="flex items-center gap-2 text-muted-foreground">

View File

@@ -1,19 +1,31 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
DestroyRef,
computed, computed,
inject inject,
signal
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideBug, lucideBug,
lucideCircleAlert, lucideCircleAlert,
lucideClock3, lucideClock3,
lucideMemoryStick,
lucideTrash2, lucideTrash2,
lucideTriangleAlert lucideTriangleAlert
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { interval } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { DebuggingService } from '../../../../core/services/debugging.service'; 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({ @Component({
selector: 'app-debugging-settings', selector: 'app-debugging-settings',
@@ -24,6 +36,7 @@ import { DebuggingService } from '../../../../core/services/debugging.service';
lucideBug, lucideBug,
lucideCircleAlert, lucideCircleAlert,
lucideClock3, lucideClock3,
lucideMemoryStick,
lucideTrash2, lucideTrash2,
lucideTriangleAlert lucideTriangleAlert
}) })
@@ -31,8 +44,13 @@ import { DebuggingService } from '../../../../core/services/debugging.service';
templateUrl: './debugging-settings.component.html' templateUrl: './debugging-settings.component.html'
}) })
export class DebuggingSettingsComponent { export class DebuggingSettingsComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService); readonly debugging = inject(DebuggingService);
readonly isElectron = this.platform.isElectron;
readonly ramLabel = signal<string | null>(null);
readonly enabled = this.debugging.enabled; readonly enabled = this.debugging.enabled;
readonly isConsoleOpen = this.debugging.isConsoleOpen; readonly isConsoleOpen = this.debugging.isConsoleOpen;
readonly entryCount = computed(() => { readonly entryCount = computed(() => {
@@ -54,6 +72,11 @@ export class DebuggingSettingsComponent {
return lastEntry ? lastEntry.timeLabel : 'No logs yet'; return lastEntry ? lastEntry.timeLabel : 'No logs yet';
}); });
constructor() {
if (this.isElectron)
this.startRamPolling();
}
onEnabledChange(event: Event): void { onEnabledChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@@ -67,4 +90,26 @@ export class DebuggingSettingsComponent {
clearLogs(): void { clearLogs(): void {
this.debugging.clear(); 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);
}
});
}
} }