diff --git a/frontend/index.html b/frontend/index.html index f33e521..f5e0e7a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -472,6 +472,7 @@ + 操作 @@ -516,6 +517,26 @@ + + +
+ +
+

危险操作

+

确认结束进程?

+

将通过 SSH 向远端主机发送 SIGTERM 信号。该操作不可撤销。

+
+
+
PID
--
+
命令
--
+
+ +
+ + +
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b0030a3..80b511f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -445,6 +445,7 @@ processManager = new ProcessManager({ getLanguage: () => currentLanguage, onError: (message) => event(message, 'process', true), onReconnect: (zh, en) => event(bilingual(zh, en), 'process'), + onToast: (zh, en, kind) => toast(bilingual(zh, en), kind), }); function terminalTheme(): Record { diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts index 13f189e..a26bdc0 100644 --- a/frontend/src/process-manager.ts +++ b/frontend/src/process-manager.ts @@ -14,6 +14,13 @@ export interface ProcessManagerElements { swapValue: HTMLElement; swapAmount: HTMLElement; swapProgress: HTMLProgressElement; + killDialog: HTMLDialogElement; + killDialogPid: HTMLElement; + killDialogCommand: HTMLElement; + killDialogResult: HTMLElement; + killConfirmButton: HTMLButtonElement; + killRejectButton: HTMLButtonElement; + toastRegion: HTMLElement; } interface ResourceUsage { @@ -48,11 +55,27 @@ interface ProcessSnapshot { timestamp: number; } +interface PendingKillContext { + pid: number; + command: string; +} + +interface ProcessKillResult { + type: 'process_kill_result'; + pid: number; + requestId: string; + ok: boolean; + exitStatus: number | null; + stdout: string; + stderr: string; +} + interface ProcessManagerOptions { elements: ProcessManagerElements; getLanguage: () => 'zh-CN' | 'en'; onError: (message: string) => void; onReconnect?: (zh: string, en: string) => void; + onToast?: (zh: string, en: string, kind: 'info' | 'error') => void; } const MAX_PROCESSES = 512; @@ -60,6 +83,10 @@ const MAX_PROCESSES = 512; const RECONNECT_BASE_MS = 1_000; const RECONNECT_MAX_MS = 15_000; const RECONNECT_MAX_ATTEMPTS = 10; +// Hard cap on the stdout/stderr text we render from a kill result. Backend already truncates +// the wire payload, but the frontend keeps its own ceiling so a malformed payload cannot fill +// the dialog / toast with megabytes of text. +const KILL_RESULT_TEXT_LIMIT = 2048; function finitePercent(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 10_000; @@ -141,6 +168,7 @@ export class ProcessManager { private readonly getLanguage: () => 'zh-CN' | 'en'; private readonly onError: (message: string) => void; private readonly onReconnect: ((zh: string, en: string) => void) | undefined; + private readonly onToast: ((zh: string, en: string, kind: 'info' | 'error') => void) | undefined; private socket: WebSocket | null = null; private generation = 0; private snapshot: ProcessSnapshot | null = null; @@ -153,17 +181,45 @@ export class ProcessManager { private url: string | null = null; private reconnectTimer: number | null = null; private reconnectAttempts = 0; + // requestId -> pending kill request context. The backend matches by requestId, so the map is + // keyed that way; we keep the PID alongside so the dialog can render against the right row. + private readonly pendingKills = new Map(); + // pid -> requestId for the kill that is currently awaiting an SSH response. Used to dedupe + // accidental repeat clicks on the same row while a previous request is still in flight. + private readonly pendingKillsByPid = new Map(); constructor(options: ProcessManagerOptions) { this.elements = options.elements; this.getLanguage = options.getLanguage; this.onError = options.onError; this.onReconnect = options.onReconnect; + this.onToast = options.onToast; 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); + const target = event.target as HTMLElement; + const sortButton = target.closest('[data-process-sort]'); + if (sortButton) { + this.changeSort(sortButton.dataset.processSort as ProcessSortKey); + return; + } + const killButton = target.closest('[data-process-kill-pid]'); + if (killButton) { + const pid = Number.parseInt(killButton.dataset.processKillPid ?? '', 10); + if (!Number.isSafeInteger(pid) || pid <= 0) return; + const row = killButton.closest('tr'); + const commandCell = row?.children.item(7); + const command = commandCell?.textContent ?? '--'; + this.openKillDialog(pid, command); + return; + } + const copyCell = target.closest('[data-process-copy-value]'); + if (copyCell && this.elements.tableBody.contains(copyCell)) { + const value = copyCell.dataset.processCopyValue ?? ''; + if (!value) return; + void this.copyProcessValue(value, copyCell.dataset.processCopyKind as 'pid' | 'command' | undefined); + return; + } }); + this.bindKillDialog(); this.render(); } @@ -212,6 +268,18 @@ export class ProcessManager { this.clearReconnectTimer(); this.resetSocket(); this.snapshot = null; + for (const requestId of this.pendingKills.keys()) { + this.pendingKillsByPid.delete(this.pendingKills.get(requestId)!.pid); + } + this.pendingKills.clear(); + if (this.elements.killDialog.open) this.elements.killDialog.close('reject'); + this.elements.killDialogResult.hidden = true; + this.elements.killDialogResult.removeAttribute('data-state'); + this.elements.killDialogResult.textContent = ''; + this.elements.killDialog.removeAttribute('data-state'); + this.elements.killDialog.removeAttribute('data-request-id'); + this.elements.killDialog.removeAttribute('data-pid'); + this.elements.killDialog.removeAttribute('data-command'); this.render(); } @@ -290,9 +358,247 @@ export class ProcessManager { this.setStatus('正在等待首个 top 快照…', 'Waiting for the first top snapshot…'); } else if (value.type === 'process_error' && typeof value.message === 'string') { this.showError(value.message, value.message); + } else if (value.type === 'process_kill_result') { + this.handleKillResult(value); } } + private handleKillResult(value: Record): void { + const requestId = typeof value.requestId === 'string' ? value.requestId : ''; + const context = requestId ? this.pendingKills.get(requestId) : undefined; + if (!context) return; + this.pendingKills.delete(requestId); + this.pendingKillsByPid.delete(context.pid); + const result: ProcessKillResult = { + type: 'process_kill_result', + pid: typeof value.pid === 'number' ? value.pid : context.pid, + requestId, + ok: value.ok === true, + exitStatus: typeof value.exitStatus === 'number' && Number.isFinite(value.exitStatus) ? value.exitStatus : null, + stdout: typeof value.stdout === 'string' ? value.stdout : '', + stderr: typeof value.stderr === 'string' ? value.stderr : '', + }; + if (this.elements.killDialog.open && this.elements.killDialog.dataset.requestId === requestId) { + this.renderKillDialogResult(result); + } else { + this.toastKillResult(result); + } + } + + private bindKillDialog(): void { + const dialog = this.elements.killDialog; + // Native form submissions will close the dialog. We intercept the + // confirm button so we can flip it into a pending state, fire the WebSocket message, and + // keep the dialog open until the server responds. + this.elements.killConfirmButton.addEventListener('click', (event) => { + const dataset = dialog.dataset; + if (dataset.state === 'pending') { + // The user clicked again while waiting for the server; ignore the repeat. + event.preventDefault(); + return; + } + if (dataset.state === 'done') { + // After a result, the same button acts as "Close" — let the form submit normally. + return; + } + const pid = Number.parseInt(dataset.pid ?? '', 10); + if (!Number.isSafeInteger(pid) || pid <= 0) { + event.preventDefault(); + dialog.close('reject'); + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + event.preventDefault(); + this.bilingualToast('进程监控尚未就绪,无法发送请求。', 'The process monitor is not ready yet; cannot send the request.', 'error'); + dialog.close('reject'); + return; + } + if (this.pendingKillsByPid.has(pid)) { + event.preventDefault(); + this.bilingualToast(`PID ${pid} 的结束请求仍在处理中。`, `A kill request for PID ${pid} is already in progress.`, 'error'); + dialog.close('reject'); + return; + } + event.preventDefault(); + const requestId = this.generateRequestId(); + dataset.state = 'pending'; + dataset.requestId = requestId; + this.pendingKills.set(requestId, { pid, command: dataset.command ?? '' }); + this.pendingKillsByPid.set(pid, requestId); + this.setKillConfirmPending(true); + try { + this.socket.send(JSON.stringify({ type: 'process_kill', pid, requestId })); + } catch (error) { + // Synchronous failure (closed socket, etc.) — roll back and surface to the user. + const detail = error instanceof Error ? error.message : String(error); + this.pendingKills.delete(requestId); + this.pendingKillsByPid.delete(pid); + dataset.state = ''; + delete dataset.requestId; + this.setKillConfirmPending(false); + this.bilingualToast(`无法发送结束请求:${detail}`, `Could not send the kill request: ${detail}`, 'error'); + dialog.close('reject'); + this.render(); + } + }); + dialog.addEventListener('close', () => { + const dataset = dialog.dataset; + const pendingRequestId = dataset.state === 'pending' ? dataset.requestId ?? '' : ''; + // If the user dismissed the dialog while a kill was still pending, keep the request + // alive on the wire so the result still flows back as a toast. Only the local UI state + // resets here. + dataset.state = ''; + delete dataset.requestId; + this.elements.killDialogResult.hidden = true; + this.elements.killDialogResult.removeAttribute('data-state'); + this.elements.killDialogResult.textContent = ''; + this.setKillConfirmPending(false); + // If the dialog closed with the cancel button (or Esc) before confirmation, no + // requestId was ever registered, so this is a no-op. + if (pendingRequestId) { + // The result handler will fall back to a toast because the dialog is no longer open. + } + this.render(); + }); + } + + private openKillDialog(pid: number, command: string): void { + const existing = this.pendingKillsByPid.get(pid); + if (existing) { + this.bilingualToast(`PID ${pid} 的结束请求仍在处理中。`, `A kill request for PID ${pid} is already in progress.`, 'error'); + return; + } + const dialog = this.elements.killDialog; + const dataset = dialog.dataset; + dataset.pid = String(pid); + dataset.command = command; + dataset.state = ''; + delete dataset.requestId; + this.elements.killDialogPid.textContent = String(pid); + this.elements.killDialogCommand.textContent = command || '--'; + this.elements.killDialogResult.hidden = true; + this.elements.killDialogResult.removeAttribute('data-state'); + this.elements.killDialogResult.textContent = ''; + this.setKillConfirmPending(false); + if (typeof dialog.showModal === 'function') { + if (dialog.open) dialog.close('reject'); + dialog.showModal(); + } + } + + private setKillConfirmPending(pending: boolean): void { + const button = this.elements.killConfirmButton; + const labelPending = this.getLanguage() === 'zh-CN' ? '发送中…' : 'Sending…'; + const labelDone = this.getLanguage() === 'zh-CN' ? '完成' : 'Done'; + const labelIdle = this.getLanguage() === 'zh-CN' ? '确定结束' : 'Terminate'; + if (pending) { + button.disabled = true; + button.dataset.pendingLabel = labelPending; + button.textContent = labelPending; + button.dataset.state = 'pending'; + } else { + button.disabled = false; + delete button.dataset.pendingLabel; + delete button.dataset.state; + if (this.elements.killDialog.dataset.requestId && this.elements.killDialog.dataset.state === 'done') { + button.textContent = labelDone; + } else { + button.textContent = labelIdle; + } + } + } + + private renderKillDialogResult(result: ProcessKillResult): void { + const dialog = this.elements.killDialog; + const target = this.elements.killDialogResult; + const language = this.getLanguage(); + dialog.dataset.state = 'done'; + const lines: string[] = []; + if (result.ok) { + target.dataset.state = 'success'; + lines.push(language === 'zh-CN' + ? `已向远端发送 SIGTERM 信号 (PID ${result.pid})。` + : `Sent SIGTERM to PID ${result.pid} on the remote host.`); + } else { + target.dataset.state = 'error'; + const reason = result.stderr.trim() || result.stdout.trim() || (language === 'zh-CN' ? '远端未返回原因' : 'No reason returned by the remote host'); + lines.push(language === 'zh-CN' ? `结束失败:${reason}` : `Terminate failed: ${reason}`); + } + if (result.stdout.trim()) { + lines.push('--- stdout ---'); + lines.push(this.truncateForDisplay(result.stdout)); + } + if (result.stderr.trim()) { + lines.push('--- stderr ---'); + lines.push(this.truncateForDisplay(result.stderr)); + } + target.textContent = lines.join('\n'); + target.hidden = false; + this.setKillConfirmPending(false); + // After the result arrives, let the user close the dialog with a click on the confirm + // button (which now reads "Done"). + } + + private toastKillResult(result: ProcessKillResult): void { + const reason = result.stderr.trim() || result.stdout.trim(); + let zh: string; + let en: string; + if (result.ok) { + zh = `已结束 PID ${result.pid}`; + en = `Terminated PID ${result.pid}`; + } else if (reason) { + zh = `结束 PID ${result.pid} 失败:${reason.slice(0, 240)}`; + en = `Failed to terminate PID ${result.pid}: ${reason.slice(0, 240)}`; + } else { + zh = `结束 PID ${result.pid} 失败`; + en = `Failed to terminate PID ${result.pid}`; + } + this.bilingualToast(zh, en, 'error'); + } + + private bilingualToast(zh: string, en: string, kind: 'info' | 'error' = 'info'): void { + if (this.onToast) { + this.onToast(zh, en, kind); + return; + } + // Fallback for environments where the parent didn't inject a toast handler. + const region = this.elements.toastRegion; + if (!region) return; + const item = document.createElement('div'); + item.className = `toast${kind === 'error' ? ' error' : ''}`; + item.textContent = this.getLanguage() === 'zh-CN' ? zh : en; + region.append(item); + window.setTimeout(() => item.remove(), 4_500); + } + + private copyProcessValue(value: string, kind: 'pid' | 'command' | undefined): void { + const isPid = kind === 'pid' || /^\d+$/.test(value); + void navigator.clipboard.writeText(value).then( + () => { + const truncated = value.length > 64 ? `${value.slice(0, 64)}…` : value; + const display = isPid ? `已复制 PID ${value}` : `已复制命令:${truncated}`; + const displayEn = isPid ? `Copied PID ${value}` : `Copied command: ${truncated}`; + this.bilingualToast(display, displayEn, 'info'); + }, + () => this.bilingualToast('无法访问剪贴板。', 'Could not access the clipboard.', 'error'), + ); + } + + private truncateForDisplay(text: string): string { + if (text.length <= KILL_RESULT_TEXT_LIMIT) return text; + return `${text.slice(0, KILL_RESULT_TEXT_LIMIT)}\n…`; + } + + private generateRequestId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Fallback for environments without crypto.randomUUID — still unique enough for a short + // session, and bounded to 64 characters to satisfy the backend validator. + const random = Math.random().toString(36).slice(2, 10); + return `req-${Date.now().toString(36)}-${random}`; + } + private render(): void { const snapshot = this.snapshot; if (!snapshot) { @@ -309,6 +615,9 @@ export class ProcessManager { } const fragment = document.createDocumentFragment(); + const language = this.getLanguage(); + const killLabelZh = '强制结束'; + const killLabelEn = 'Force terminate'; for (const process of this.sortedProcesses(snapshot.processes)) { const row = document.createElement('tr'); const values = [ @@ -324,9 +633,38 @@ export class ProcessManager { values.forEach((value, index) => { const cell = document.createElement('td'); cell.textContent = value; - if (index === values.length - 1) cell.title = value; + const isCopyable = index === 0 || index === 7; + if (isCopyable) { + cell.className = 'process-copyable'; + cell.tabIndex = 0; + cell.dataset.processCopyValue = value; + cell.dataset.processCopyKind = index === 0 ? 'pid' : 'command'; + cell.title = `${value} — 点击复制 / Click to copy`; + cell.setAttribute('aria-label', `${value} — 点击复制 / Click to copy`); + } else if (index === values.length - 1) { + cell.title = value; + } row.append(cell); }); + const killCell = document.createElement('td'); + killCell.className = 'process-kill-cell'; + const killButton = document.createElement('button'); + killButton.className = 'process-kill-button'; + killButton.type = 'button'; + killButton.dataset.processKillPid = String(process.pid); + const inFlight = this.pendingKillsByPid.has(process.pid); + killButton.disabled = inFlight; + killButton.dataset.i18nZh = killLabelZh; + killButton.dataset.i18nEn = killLabelEn; + killButton.textContent = language === 'zh-CN' ? killLabelZh : killLabelEn; + killButton.title = process.command || '--'; + killButton.dataset.i18nAriaLabelZh = `结束进程 ${process.pid}`; + killButton.dataset.i18nAriaLabelEn = `Terminate process ${process.pid}`; + killButton.setAttribute('aria-label', language === 'zh-CN' + ? `结束进程 ${process.pid}` + : `Terminate process ${process.pid}`); + killCell.append(killButton); + row.append(killCell); fragment.append(row); } this.elements.tableBody.replaceChildren(fragment); @@ -477,19 +815,26 @@ function getElement(id: string): T { export function collectProcessManagerElements(): ProcessManagerElements { return { panel: getElement('process-manager-panel'), - tableBody: getElement('process-table-body'), + tableBody: getElement('process-table-body'), status: getElement('process-manager-status'), empty: getElement('process-manager-empty'), error: getElement('process-manager-error'), updated: getElement('process-updated'), cpuValue: getElement('resource-cpu-value'), - cpuProgress: getElement('resource-cpu-progress'), + cpuProgress: getElement('resource-cpu-progress'), cpuLoad: getElement('resource-cpu-load'), memoryValue: getElement('resource-memory-value'), memoryAmount: getElement('resource-memory-amount'), - memoryProgress: getElement('resource-memory-progress'), + memoryProgress: getElement('resource-memory-progress'), swapValue: getElement('resource-swap-value'), swapAmount: getElement('resource-swap-amount'), - swapProgress: getElement('resource-swap-progress'), + swapProgress: getElement('resource-swap-progress'), + killDialog: getElement('process-kill-dialog'), + killDialogPid: getElement('process-kill-target-pid'), + killDialogCommand: getElement('process-kill-target-command'), + killDialogResult: getElement('process-kill-result'), + killConfirmButton: getElement('confirm-process-kill'), + killRejectButton: getElement('reject-process-kill'), + toastRegion: getElement('toast-region'), }; } diff --git a/frontend/src/style.css b/frontend/src/style.css index ae7261f..ea9504f 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1646,15 +1646,16 @@ button:disabled { white-space: nowrap; } -.process-table th:first-child { width: 8%; } +.process-table th:first-child { width: 7%; } .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: 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: center; } +.process-table th:nth-child(8) { width: 41%; text-align: center; } .process-table th:nth-child(8) .process-sort { text-align: center; } +.process-table th:nth-child(9) { width: 8%; text-align: center; } .process-sort { position: relative; @@ -1697,6 +1698,8 @@ button:disabled { text-align: right; } +.process-table td:nth-child(8) { text-align: left; } + .process-table td:last-child { text-align: left; } @@ -2209,6 +2212,199 @@ button:disabled { flex: 0 0 auto; } +/* Process table "Actions" column */ +.process-table th.process-kill-column { + padding: 0 4px; +} + +.process-kill-cell { + padding: 0 4px; + display: flex; + justify-content: center; + align-items: center; +} + +.process-copyable { + cursor: pointer; + user-select: none; + transition: background-color 0.12s ease, color 0.12s ease; +} + +.process-copyable:hover, +.process-copyable:focus-visible { + background: var(--surface-2); + color: var(--accent); + outline: none; +} + +.process-copyable:active { + background: color-mix(in srgb, var(--accent) 15%, var(--surface-2)); +} + +.process-kill-button { + display: inline-grid; + min-width: 50px; + height: 20px; + padding: 0 8px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--danger) 28%, var(--border)); + border-radius: 5px; + background: transparent; + color: var(--danger); + font-family: var(--sans); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.process-kill-button:hover:not(:disabled), +.process-kill-button:focus-visible:not(:disabled) { + border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); + background: var(--danger-dim); + color: var(--danger); + outline: none; +} + +.process-kill-button:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 35%, transparent); +} + +.process-kill-button[disabled] { + cursor: not-allowed; + opacity: 0.55; + background: transparent; +} + +/* Process-kill confirmation dialog — mirrors the host-key-dialog layout */ +.process-kill-dialog { + width: min(460px, calc(100vw - 28px)); + max-height: calc(100dvh - 28px); + overflow-y: auto; + padding: 0; + border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border)); + border-radius: 14px; + background: var(--surface); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.58); + color: var(--text); +} + +.process-kill-dialog::backdrop { + background: rgba(2, 6, 9, 0.76); + -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); +} + +.process-kill-dialog form { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px 16px; + padding: 22px; +} + +.process-kill-dialog .dialog-icon { + border-color: color-mix(in srgb, var(--danger) 52%, var(--border)); + background: var(--danger-dim); + color: var(--danger); +} + +.process-kill-dialog .eyebrow, +.process-kill-dialog h2 { + color: var(--danger); +} + +.process-kill-dialog h2 { + margin: 0 0 7px; + font-size: 18px; +} + +.process-kill-dialog p:not(.eyebrow) { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} + +.process-kill-dialog dl, +.process-kill-dialog .process-kill-result, +.process-kill-dialog .dialog-actions { + grid-column: 1 / -1; +} + +.process-kill-dialog dl { + display: grid; + gap: 1px; + overflow: hidden; + margin: 2px 0 0; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--border-soft); +} + +.process-kill-dialog dl div { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 12px; + padding: 9px 11px; + background: var(--panel); +} + +.process-kill-dialog dt { + color: var(--muted); + font-size: 10px; +} + +.process-kill-dialog dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--text); + font-family: var(--mono); + font-size: 10px; +} + +.process-kill-dialog dd.process-kill-command { + white-space: pre-wrap; + word-break: break-all; + max-height: 80px; + overflow-y: auto; +} + +.process-kill-result { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-2); + font-family: var(--mono); + font-size: 10px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 140px; + overflow-y: auto; +} + +.process-kill-result[data-state='success'] { + border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); + background: color-mix(in srgb, var(--accent) 10%, var(--surface-2)); + color: var(--accent); +} + +.process-kill-result[data-state='error'] { + border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); + background: var(--danger-dim); + color: var(--danger); +} + +@media (max-width: 760px) { + .process-kill-dialog dl div { + grid-template-columns: 72px minmax(0, 1fr); + } +} + .panel-scrim { position: fixed; inset: var(--topbar-height) 0 0; diff --git a/src/backend/session.ts b/src/backend/session.ts index cf0b733..32d9d5e 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -63,6 +63,21 @@ interface PendingProcessChannelOpen { timeout: ReturnType | null; cancelled: boolean; } +interface PendingProcessKillChannel { + readonly channelID: number; + readonly channel: SSHChannel; + readonly requestId: string; + readonly pid: number; + timeout: ReturnType | null; + cancelled: boolean; + openConfirmed: boolean; + decoder: TextDecoder; + stdout: string; + stderr: string; + stdoutBytes: number; + stderrBytes: number; + finalized: boolean; +} const LOCAL_WINDOW_THRESHOLD = 512 * 1024; const MAX_VERSION_BYTES = 8192; const MAX_QUEUED_INPUT = 1024 * 1024; @@ -70,6 +85,11 @@ const MAX_QUEUED_SFTP_UPLOAD = 1024 * 1024; const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 15_000; const PROCESS_CHANNEL_OPEN_TIMEOUT_MS = 15_000; const PROCESS_MAX_BUFFER_BYTES = 2 * 1024 * 1024; +const PROCESS_KILL_CHANNEL_OPEN_TIMEOUT_MS = 15_000; +// Bound the per-kill stdout/stderr payload returned to the browser. `kill` usually produces no +// output on success, but a hostile or buggy server could dump arbitrary data through the exec +// channel; cap the bytes we keep per stream so one bad request cannot exhaust worker memory. +const PROCESS_KILL_MAX_BUFFER_BYTES = 4 * 1024; const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__'; // Octal escapes keep the delimiter itself out of the command line shown by top. // `top` flags differ across platforms: Linux procps uses `-n 1` (1 iteration) + `-c` (full command line), @@ -139,6 +159,9 @@ export class SSHSession { private processBuffer = ''; private processDecoder = new TextDecoder(); private pendingProcessChannelOpen: PendingProcessChannelOpen | null = null; + // Concurrent `kill -TERM ` requests, keyed by local channel ID. Each request gets its + // own exec channel so the top-monitor channel stays undisturbed. + private readonly pendingProcessKillChannels = new Map(); private ignoreNextKexPacket = false; private inputQueue: Uint8Array[] = []; private queueHeadOffset = 0; @@ -216,6 +239,10 @@ export class SSHSession { this.clearPendingProcessChannelOpen(); this.processChannel = null; this.processBuffer = ''; + for (const kill of this.pendingProcessKillChannels.values()) { + if (kill.timeout) clearTimeout(kill.timeout); + } + this.pendingProcessKillChannels.clear(); this.channels.clear(); try { this.sftpWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ } this.sftpWebSocket = null; @@ -615,6 +642,22 @@ export class SSHSession { if (this.phase !== 'pty') throw new Error('Unexpected shell channel open confirmation'); this.pendingChannelRequest = 'pty'; await this.sendEncrypted(channel.buildPTYRequest(this.config.cols, this.config.rows, this.config.term)); + } else if (this.pendingProcessKillChannels.has(channelID)) { + const kill = this.pendingProcessKillChannels.get(channelID)!; + kill.openConfirmed = true; + if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; } + if (kill.cancelled || this.processWebSocket === null) { + await this.sendAuxiliaryChannelClose(channel); + } else { + try { + // The PID is a validated safe integer (1..2^31-1); interpolation is safe because + // its decimal representation cannot contain shell metacharacters. + await this.sendEncrypted(channel.buildExecRequest(`kill -TERM ${kill.pid}`)); + } catch (error) { + this.finalizeProcessKill(kill, error instanceof Error ? error.message : String(error)); + await this.sendAuxiliaryChannelClose(channel); + } + } } else if (channel === this.sftpChannel && this.sftpHandler && !this.pendingSFTPChannelOpen?.cancelled) { try { await this.sendEncrypted(channel.buildSubsystemRequest('sftp')); @@ -657,6 +700,9 @@ export class SSHSession { } else if (channel === this.processChannel) { this.processChannel = null; this.sendProcessError('SSH server rejected the process-monitor channel'); + } else { + const kill = this.pendingProcessKillChannels.get(channelID); + if (kill) this.finalizeProcessKill(kill, 'SSH server rejected the kill channel'); } return; } @@ -713,6 +759,8 @@ export class SSHSession { } } else if (channel === this.processChannel) { this.consumeProcessOutput(output); + } else if (this.pendingProcessKillChannels.has(channelID)) { + this.appendKillOutput(this.pendingProcessKillChannels.get(channelID)!, output, false); } await this.adjustLocalWindow(channel); return; @@ -727,6 +775,8 @@ export class SSHSession { } else if (channel === this.processChannel) { const message = new TextDecoder().decode(output).trim(); if (message) this.sendProcessError(message.slice(0, 512)); + } else if (this.pendingProcessKillChannels.has(channelID)) { + this.appendKillOutput(this.pendingProcessKillChannels.get(channelID)!, output, true); } await this.adjustLocalWindow(channel); return; @@ -762,6 +812,9 @@ export class SSHSession { this.flushProcessBuffer(); this.processChannel = null; this.sendProcessError('The process monitor stopped'); + } else { + const kill = this.pendingProcessKillChannels.get(channelID); + if (kill) this.finalizeProcessKill(kill); } } return; @@ -806,6 +859,20 @@ export class SSHSession { if (this.processWebSocket !== ws) return; this.processWebSocket = null; void this.closeProcessChannel(); + // Outstanding kill requests cannot deliver their result back without the monitor socket; + // close them so the server releases the SSH channels. + for (const kill of [...this.pendingProcessKillChannels.values()]) { + kill.cancelled = true; + if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; } + if (kill.openConfirmed) { + if (kill.channel.isOpen() && !kill.channel.hasSentClose()) void this.sendAuxiliaryChannelClose(kill.channel); + this.finalizeProcessKill(kill, 'Process monitor disconnected'); + } else { + this.pendingProcessKillChannels.delete(kill.channelID); + this.channels.delete(kill.channelID); + if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; } + } + } } async handleProcessClientMessage(message: string | ArrayBuffer): Promise { @@ -818,9 +885,53 @@ export class SSHSession { if (value.type === 'ping') { this.sendProcessJson({ type: 'pong' }); return; } if (value.type === 'process_start') { await this.openProcessChannel(); return; } if (value.type === 'process_stop') { await this.closeProcessChannel(); return; } + if (value.type === 'process_kill') { + const pid = value.pid; + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0 || pid > 2_147_483_647) { + throw new Error('Invalid process-kill request: PID out of range'); + } + const requestId = value.requestId; + if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 64) { + throw new Error('Invalid process-kill request: requestId is missing or too long'); + } + await this.openProcessKillChannel(pid, requestId); + return; + } throw new Error('Unsupported process-monitor message'); } + private async openProcessKillChannel(pid: number, requestId: string): Promise { + if (!this.processWebSocket) throw new Error('Process-monitor WebSocket is not attached'); + if (this.phase !== 'ready') throw new Error('SSH connection is not ready'); + const channelID = this.nextChannelID++; + const channel = new SSHChannel(); + const pending: PendingProcessKillChannel = { + channelID, + channel, + requestId, + pid, + timeout: null, + cancelled: false, + openConfirmed: false, + decoder: new TextDecoder(), + stdout: '', + stderr: '', + stdoutBytes: 0, + stderrBytes: 0, + finalized: false, + }; + this.channels.set(channelID, channel); + this.pendingProcessKillChannels.set(channelID, pending); + try { + await this.sendEncrypted(channel.buildOpenSession(channelID)); + pending.timeout = setTimeout(() => this.expirePendingProcessKill(pending), PROCESS_KILL_CHANNEL_OPEN_TIMEOUT_MS); + } catch (error) { + this.clearPendingProcessKill(channel); + this.channels.delete(channelID); + throw error; + } + } + private async openProcessChannel(): Promise { if (!this.processWebSocket) throw new Error('Process-monitor WebSocket is not attached'); if (this.phase !== 'ready') throw new Error('SSH connection is not ready'); @@ -916,6 +1027,78 @@ export class SSHSession { this.pendingProcessChannelOpen = null; } + private appendKillOutput(kill: PendingProcessKillChannel, output: Uint8Array, isStderr: boolean): void { + if (kill.finalized) return; + const text = kill.decoder.decode(output, { stream: true }); + if (!text) return; + if (isStderr) { + const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stderrBytes; + if (remaining <= 0) return; + const slice = text.length > remaining ? text.slice(0, remaining) : text; + kill.stderr += slice; + kill.stderrBytes += slice.length; + } else { + const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stdoutBytes; + if (remaining <= 0) return; + const slice = text.length > remaining ? text.slice(0, remaining) : text; + kill.stdout += slice; + kill.stdoutBytes += slice.length; + } + } + + private finalizeProcessKill(kill: PendingProcessKillChannel, errorMessage?: string): void { + if (kill.finalized) return; + kill.finalized = true; + if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; } + // Flush any decoder tail so callers see a complete (possibly empty) string. + if (!errorMessage) { + const tail = kill.decoder.decode(); + if (tail) { + const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stdoutBytes; + if (remaining > 0) { + const slice = tail.length > remaining ? tail.slice(0, remaining) : tail; + kill.stdout += slice; + kill.stdoutBytes += slice.length; + } + } + } + const stdout = kill.stdout; + const stderr = errorMessage ?? kill.stderr; + const ok = errorMessage === undefined && stderr.trim() === ''; + // The SSH RFC exposes the exit code via a server-sent channel request before close; this + // session does not currently decode that request, so we cannot report the real status here. + // Surface "null" so the client knows we fell back to the stderr heuristic. + const exitStatus: number | null = null; + this.pendingProcessKillChannels.delete(kill.channelID); + this.channels.delete(kill.channelID); + this.sendProcessJson({ + type: 'process_kill_result', + pid: kill.pid, + requestId: kill.requestId, + ok, + exitStatus, + stdout, + stderr, + }); + } + + private clearPendingProcessKill(channel?: SSHChannel): void { + if (!channel) return; + const kill = this.pendingProcessKillChannels.get(channel.getLocalChannelID()); + if (!kill || kill.channel !== channel) return; + if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; } + kill.cancelled = true; + } + + private expirePendingProcessKill(expected: PendingProcessKillChannel): void { + const current = this.pendingProcessKillChannels.get(expected.channelID); + if (current !== expected) return; + expected.timeout = null; + expected.cancelled = true; + this.finalizeProcessKill(expected, 'Timed out opening the kill channel'); + if (expected.channel.isOpen()) void this.sendAuxiliaryChannelClose(expected.channel); + } + private sendProcessJson(value: unknown): void { if (this.processWebSocket?.readyState === WebSocket.OPEN) this.processWebSocket.send(JSON.stringify(value)); }