diff --git a/frontend/index.html b/frontend/index.html index afe50cf..6c2e7b6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -456,13 +456,13 @@ - - - - - - - + + + + + + + diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts index 0963689..122add3 100644 --- a/frontend/src/process-manager.ts +++ b/frontend/src/process-manager.ts @@ -33,6 +33,9 @@ interface ProcessEntry { command: string; } +type ProcessSortKey = 'pid' | 'user' | 'memoryBytes' | 'memoryPercent' | 'cpuPercent' | 'state' | 'time'; +type SortDirection = 'ascending' | 'descending'; + interface ProcessSnapshot { type: 'process_snapshot'; metrics: { @@ -135,12 +138,19 @@ export class ProcessManager { private socket: WebSocket | null = null; private generation = 0; private snapshot: ProcessSnapshot | null = null; + private sortKey: ProcessSortKey = 'cpuPercent'; + private sortDirection: SortDirection = 'descending'; private readonly progressAnimations = new Map(); constructor(options: ProcessManagerOptions) { this.elements = options.elements; this.getLanguage = options.getLanguage; this.onError = options.onError; + this.elements.panel.addEventListener('click', (event) => { + const button = (event.target as HTMLElement).closest('[data-process-sort]'); + if (!button) return; + this.changeSort(button.dataset.processSort as ProcessSortKey); + }); this.render(); } @@ -183,9 +193,66 @@ export class ProcessManager { } setLanguage(): void { + this.updateSortHeaders(); this.render(); } + private changeSort(key: ProcessSortKey): void { + if (this.sortKey === key) { + this.sortDirection = this.sortDirection === 'ascending' ? 'descending' : 'ascending'; + } else { + this.sortKey = key; + this.sortDirection = key === 'user' || key === 'state' ? 'ascending' : 'descending'; + } + this.updateSortHeaders(); + this.render(); + } + + private updateSortHeaders(): void { + for (const button of this.elements.panel.querySelectorAll('[data-process-sort]')) { + const key = button.dataset.processSort as ProcessSortKey; + const header = button.closest('th'); + const active = key === this.sortKey; + header?.setAttribute('aria-sort', active ? this.sortDirection : 'none'); + const label = button.dataset[this.getLanguage() === 'zh-CN' ? 'i18nZh' : 'i18nEn'] ?? button.textContent ?? ''; + button.setAttribute('aria-label', active + ? this.getLanguage() === 'zh-CN' + ? `${label},当前${this.sortDirection === 'ascending' ? '升序' : '降序'},点击切换排序` + : `${label}, currently ${this.sortDirection}; activate to reverse` + : this.getLanguage() === 'zh-CN' + ? `按 ${label} 排序` + : `Sort by ${label}`); + } + } + + private sortedProcesses(processes: ProcessEntry[]): ProcessEntry[] { + const key = this.sortKey; + const direction = this.sortDirection === 'ascending' ? 1 : -1; + const value = (process: ProcessEntry): string | number | null => { + if (key === 'time') return this.processTimeSeconds(process.time); + return process[key]; + }; + return processes.map((process, index) => ({ process, index })).sort((left, right) => { + const a = value(left.process); + const b = value(right.process); + if (a === null && b === null) return left.index - right.index; + if (a === null) return 1; + if (b === null) return -1; + const compared = typeof a === 'number' && typeof b === 'number' + ? a - b + : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); + return compared === 0 ? left.index - right.index : compared * direction; + }).map(({ process }) => process); + } + + private processTimeSeconds(value: string): number | null { + const parts = value.replace(',', '.').split(':').map(Number); + if (parts.some((part) => !Number.isFinite(part))) return null; + if (parts.length === 3) return (parts[0] * 3600) + (parts[1] * 60) + parts[2]; + if (parts.length === 2) return (parts[0] * 60) + parts[1]; + return parts.length === 1 ? parts[0] : null; + } + private handleMessage(serialized: string): void { let message: unknown; try { message = JSON.parse(serialized); } catch { return; } @@ -219,7 +286,7 @@ export class ProcessManager { } const fragment = document.createDocumentFragment(); - for (const process of snapshot.processes) { + for (const process of this.sortedProcesses(snapshot.processes)) { const row = document.createElement('tr'); const values = [ String(process.pid), diff --git a/frontend/src/style.css b/frontend/src/style.css index 03ac2b2..3ea4003 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1375,18 +1375,46 @@ button:disabled { color: var(--muted); font-family: var(--sans); font-size: 9px; - text-align: left; + text-align: right; white-space: nowrap; } .process-table th:first-child { width: 8%; } -.process-table th:nth-child(2) { width: 11%; } -.process-table th:nth-child(3) { width: 10%; } +.process-table th:nth-child(2) { width: 8%; } +.process-table th:nth-child(3) { width: 8%; } .process-table th:nth-child(4), -.process-table th:nth-child(5) { width: 8%; } -.process-table th:nth-child(6) { width: 7%; } -.process-table th:nth-child(7) { width: 10%; } -.process-table th:nth-child(8) { width: 38%; } +.process-table th:nth-child(5) { width: 7%; } +.process-table th:nth-child(6) { width: 6%; } +.process-table th:nth-child(7) { width: 9%; } +.process-table th:nth-child(8) { width: 47%; text-align: left; } + +.process-sort { + position: relative; + width: 100%; + height: 25px; + padding: 0 11px 0 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + text-align: right; +} + +.process-sort::after { + position: absolute; + right: 0; + color: var(--muted-2); + content: '↕'; +} + +.process-table th[aria-sort='ascending'] .process-sort, +.process-table th[aria-sort='descending'] .process-sort { + color: var(--accent); +} + +.process-table th[aria-sort='ascending'] .process-sort::after { content: '↑'; } +.process-table th[aria-sort='descending'] .process-sort::after { content: '↓'; } .process-table td { height: 24px; @@ -1397,13 +1425,14 @@ button:disabled { white-space: nowrap; } -.process-table td:first-child, -.process-table td:nth-child(3), -.process-table td:nth-child(4), -.process-table td:nth-child(5) { +.process-table td:not(:last-child) { text-align: right; } +.process-table td:last-child { + text-align: left; +} + .process-table tbody tr:hover { background: var(--surface-2); }
PID用户内存MEM%CPU%状态时间 名称/命令行