perf: diagnoistics improvements

This commit is contained in:
2026-06-12 01:22:01 +02:00
parent 29032b5a36
commit dac5cb42a5
29 changed files with 1168 additions and 28 deletions

View File

@@ -15,6 +15,16 @@
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
"updateSettings": "Update settings",
"restartNow": "Restart now"
},
"highMemoryAlert": {
"badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
"openLog": "Open log file",
"showInFolder": "Show in folder",
"copyPath": "Copy path",
"dismiss": "Dismiss",
"dismissAriaLabel": "Dismiss high memory alert"
}
}
}

View File

@@ -15,6 +15,16 @@
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
"updateSettings": "Update settings",
"restartNow": "Restart now"
},
"highMemoryAlert": {
"badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
"openLog": "Open log file",
"showInFolder": "Show in folder",
"copyPath": "Copy path",
"dismiss": "Dismiss",
"dismissAriaLabel": "Dismiss high memory alert"
}
},
"attachment": {

View File

@@ -167,6 +167,7 @@
<app-incoming-call-modal />
<app-screen-share-source-picker />
<app-native-context-menu />
<app-high-memory-alert-modal />
<app-debug-console [showLauncher]="false" />
<app-theme-picker-overlay />
</div>

View File

@@ -26,6 +26,7 @@ import {
loadLastViewedChatFromStorage
} from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service';
import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service';
@@ -53,6 +54,7 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component';
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
DebugConsoleComponent,
ScreenShareSourcePickerComponent,
NativeContextMenuComponent,
HighMemoryAlertModalComponent,
PrivateCallComponent,
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
desktopUpdateState = this.desktopUpdates.state;
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
readonly databaseService = inject(DatabaseService);
readonly router = inject(Router);
readonly servers = inject(ServerDirectoryFacade);
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
// - desktop deep-link bridge (only relevant after first paint)
// - background presence + game activity loops
void this.desktopUpdates.initialize();
void this.desktopHighMemoryAlert.initialize();
void this.kickOffBackgroundBootstrap();
// The only thing we genuinely must await before deciding which route

View File

@@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry {
payload: Record<string, unknown>;
}
export interface ElectronHighMemoryAlertRecord {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
@@ -272,6 +279,9 @@ export interface ElectronApi {
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
isPerfDiagEnabled?: () => Promise<boolean>;
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;

View File

@@ -6,6 +6,7 @@ import {
import {
formatAppRamLabel,
formatKilobytesAsGigabytes,
formatKilobytesAsMegabytes,
sumWorkingSetKb
} from './electron-app-metrics.rules';
@@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => {
});
});
describe('formatKilobytesAsGigabytes', () => {
it('formats totals in gigabytes with two decimals', () => {
expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50');
expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00');
});
});
describe('formatKilobytesAsMegabytes', () => {
it('rounds large values to whole megabytes', () => {
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');

View File

@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
return `${megabytes.toFixed(2)} MB`;
}
export function formatKilobytesAsGigabytes(kilobytes: number): string {
return (kilobytes / (1024 * 1024)).toFixed(2);
}
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
const totalKb = sumWorkingSetKb(snapshot.processes);

View File

@@ -0,0 +1,82 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { PlatformService } from '../platform';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
@Injectable({ providedIn: 'root' })
export class DesktopHighMemoryAlertService {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
readonly peakUsageGb = computed(() => {
const alert = this.pendingAlert();
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
});
async initialize(): Promise<void> {
if (!this.platform.isElectron) {
return;
}
const api = this.electronBridge.getApi();
if (!api?.getPendingHighMemoryAlert) {
return;
}
const alert = await api.getPendingHighMemoryAlert();
if (alert) {
this.pendingAlert.set(alert);
}
}
async dismiss(): Promise<void> {
const api = this.electronBridge.getApi();
await api?.acknowledgeHighMemoryAlert?.();
this.pendingAlert.set(null);
}
async openLogFile(): Promise<void> {
const alert = this.pendingAlert();
const api = this.electronBridge.getApi();
if (!alert?.logFilePath || !api?.openFilePath) {
return;
}
await api.openFilePath(alert.logFilePath);
}
async showLogFileInFolder(): Promise<void> {
const alert = this.pendingAlert();
const api = this.electronBridge.getApi();
if (!alert?.logFilePath || !api?.showLogFileInFolder) {
return;
}
await api.showLogFileInFolder(alert.logFilePath);
}
async copyLogPath(): Promise<void> {
const alert = this.pendingAlert();
if (!alert?.logFilePath) {
return;
}
await navigator.clipboard.writeText(alert.logFilePath);
}
}

View File

@@ -0,0 +1,85 @@
@if (alertService.pendingAlert(); as alert) {
<app-modal-backdrop
[zIndex]="120"
[ariaLabel]="'app.highMemoryAlert.dismissAriaLabel' | translate"
(dismissed)="dismiss()"
/>
<div
appThemeNode="highMemoryAlertDialog"
class="fixed inset-0 z-[121] flex items-center justify-center px-4"
role="alertdialog"
[attr.aria-labelledby]="'high-memory-alert-title'"
[attr.aria-describedby]="'high-memory-alert-description'"
>
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
<button
type="button"
(click)="dismiss()"
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
[attr.aria-label]="'app.highMemoryAlert.dismissAriaLabel' | translate"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-destructive">
{{ 'app.highMemoryAlert.badge' | translate }}
</p>
<h2
id="high-memory-alert-title"
class="mt-1 pr-10 text-base font-semibold text-foreground"
>
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }}
</h2>
<p
id="high-memory-alert-description"
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
>
{{ 'app.highMemoryAlert.message' | translate }}
</p>
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
{{ alert.logFilePath }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
(click)="openLogFile()"
class="inline-flex items-center rounded-lg bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
{{ 'app.highMemoryAlert.openLog' | translate }}
</button>
<button
type="button"
(click)="showLogFileInFolder()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
{{ 'app.highMemoryAlert.showInFolder' | translate }}
</button>
<button
type="button"
(click)="copyLogPath()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
{{ 'app.highMemoryAlert.copyPath' | translate }}
</button>
<button
type="button"
(click)="dismiss()"
class="inline-flex items-center rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
{{ 'app.highMemoryAlert.dismiss' | translate }}
</button>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,47 @@
import { Component, inject } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { DesktopHighMemoryAlertService } from '../../../core/services/desktop-high-memory-alert.service';
import { ThemeNodeDirective } from '../../../domains/theme';
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
import { ModalBackdropComponent } from '../../../shared/components/modal-backdrop/modal-backdrop.component';
@Component({
selector: 'app-high-memory-alert-modal',
standalone: true,
imports: [
NgIcon,
ThemeNodeDirective,
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
lucideX
})
],
templateUrl: './high-memory-alert-modal.component.html',
host: {
style: 'display: contents;'
}
})
export class HighMemoryAlertModalComponent {
readonly alertService = inject(DesktopHighMemoryAlertService);
async dismiss(): Promise<void> {
await this.alertService.dismiss();
}
openLogFile(): void {
void this.alertService.openLogFile();
}
showLogFileInFolder(): void {
void this.alertService.showLogFileInFolder();
}
copyLogPath(): void {
void this.alertService.copyLogPath();
}
}

View File

@@ -7,6 +7,11 @@ import type { ElectronApi } from '../../core/platform/electron/electron-api.mode
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
declare global {
// Registered for synchronous main-process sampling at high-memory threshold.
var __collectPerfDiagSample: (() => PerfDiagEntry[]) | undefined;
}
const SAMPLE_INTERVAL_MS = 10_000;
let started = false;
@@ -36,6 +41,22 @@ export async function bootstrapPerfDiagnostics(
started = true;
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry)
};
@@ -92,5 +113,6 @@ function stopPerfDiagnosticsSampling(): void {
sampleTimer = null;
}
delete globalThis.__collectPerfDiagSample;
started = false;
}

View File

@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'environment'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'high-memory'
| 'crash'
| 'unresponsive';