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';
|
} 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());
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
></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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user