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';
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());

View File

@@ -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'),

View File

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

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

View File

@@ -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">
@@ -231,216 +233,249 @@
}
@for (att of attachmentsList; track att.id) {
@if (shouldShowAttachmentInList(att)) {
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
>
<ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-0.5 text-xs"
[class.italic]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source...' }}
</div>
</div>
</div>
<button
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
Retry
</button>
</div>
}
} @else if (att.isVideo || att.isAudio) {
@if (att.available && att.objectUrl) {
@if (att.isVideo) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
} @else {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-1 text-xs leading-relaxed"
[class.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
>
{{ att.mediaStatusText }}
<ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-0.5 text-xs"
[class.italic]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source...' }}
</div>
</div>
</div>
<button
(click)="requestAttachment(att)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ att.mediaActionLabel }}
Retry
</button>
</div>
</div>
}
} @else {
<div
appThemeNode="chatAttachmentCard"
class="rounded-md border border-border bg-secondary/40 p-2"
>
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
}
} @else if (att.isVideo || att.isAudio) {
@if (att.available && att.objectUrl) {
@if (att.isVideo) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
<div class="flex items-center gap-2">
@if (!att.isUploader) {
@if (!att.available) {
<div class="h-1.5 w-24 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="att.progressPercent"
></div>
} @else {
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-1 text-xs leading-relaxed"
[class.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.mediaStatusText }}
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
</div>
<button
(click)="requestAttachment(att)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ att.mediaActionLabel }}
</button>
</div>
</div>
}
} @else {
<div
appThemeNode="chatAttachmentCard"
class="rounded-md border border-border bg-secondary/40 p-2"
>
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!att.isUploader) {
@if (!att.available) {
<div class="h-1.5 w-24 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openAttachmentExternally(att)"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
</button>
}
@if (att.canUseExperimentalPlayer) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openExperimentalPlayer(att)"
>
<ng-icon
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
</button>
}
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Cancel
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
@@ -465,68 +500,35 @@
Play
</button>
}
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openAttachmentExternally(att)"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
</button>
}
@if (att.canUseExperimentalPlayer) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openExperimentalPlayer(att)"
>
<ng-icon
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
</button>
}
}
</div>
</div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
@if (att.experimentalPlayerActive && att.objectUrl) {
@defer {
<app-experimental-vlc-player
[src]="att.objectUrl"
[filename]="att.filename"
[mime]="att.mime"
[sizeLabel]="formatBytes(att.size)"
(closed)="closeExperimentalPlayer()"
(downloadRequested)="downloadAttachment(att)"
/>
} @loading {
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
Loading experimental player...
</div>
@if (att.experimentalPlayerActive && att.objectUrl) {
@defer {
<app-experimental-vlc-player
[src]="att.objectUrl"
[filename]="att.filename"
[mime]="att.mime"
[sizeLabel]="formatBytes(att.size)"
(closed)="closeExperimentalPlayer()"
(downloadRequested)="downloadAttachment(att)"
/>
} @loading {
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
Loading experimental player...
</div>
}
}
}
}
}
}
</div>
}

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 -->
<button
appThemeNode="serversRailCreateButton"

View File

@@ -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">

View File

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