perf: diagnoistics improvements
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'environment'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'high-memory'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user