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