feat: 添加进程结束功能,支持通过 SSH 向远端主机发送 SIGTERM 信号并处理结果

This commit is contained in:
cmliu committed 2026-07-31 17:18:46 +08:00
1 parent 12f59293a7
commit 50830414e9
5 files changed
+756 -10

No files matched your search

+21
View File
@@ -472,6 +472,7 @@
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="state" data-i18n-zh="状态" data-i18n-en="State">状态</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="time" data-i18n-zh="时间" data-i18n-en="Time">时间</button></th>
<th scope="col" aria-sort="none"><button class="process-sort" type="button" data-process-sort="command" data-i18n-zh="名称/命令行" data-i18n-en="Name / command">名称/命令行</button></th>
<th scope="col" class="process-kill-column" data-i18n-zh="操作" data-i18n-en="Actions">操作</th>
</tr>
</thead>
<tbody id="process-table-body"></tbody>
@@ -516,6 +517,26 @@
</div>
</form>
</dialog>
<dialog id="process-kill-dialog" class="process-kill-dialog" aria-labelledby="process-kill-title">
<form method="dialog">
<span class="dialog-icon" aria-hidden="true">!</span>
<div>
<p class="eyebrow" data-i18n-zh="危险操作" data-i18n-en="Destructive action">危险操作</p>
<h2 id="process-kill-title" data-i18n-zh="确认结束进程?" data-i18n-en="Terminate process?">确认结束进程?</h2>
<p id="process-kill-description" data-i18n-zh="将通过 SSH 向远端主机发送 SIGTERM 信号。该操作不可撤销。" data-i18n-en="This will send a SIGTERM signal to the remote process via SSH. This action cannot be undone.">将通过 SSH 向远端主机发送 SIGTERM 信号。该操作不可撤销。</p>
</div>
<dl>
<div><dt data-i18n-zh="PID" data-i18n-en="PID">PID</dt><dd id="process-kill-target-pid">--</dd></div>
<div><dt data-i18n-zh="命令" data-i18n-en="Command">命令</dt><dd id="process-kill-target-command" class="process-kill-command">--</dd></div>
</dl>
<div id="process-kill-result" class="process-kill-result" role="status" aria-live="polite" hidden></div>
<div class="dialog-actions">
<button id="reject-process-kill" class="secondary-button" value="reject" type="submit" data-i18n-zh="取消" data-i18n-en="Cancel">取消</button>
<button id="confirm-process-kill" class="primary-button is-danger" value="confirm" type="submit" data-i18n-zh="确定结束" data-i18n-en="Terminate">确定结束</button>
</div>
</form>
</dialog>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+1
View File
@@ -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<string, string> {
+353 -8
View File
@@ -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<string, PendingKillContext>();
// 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<number, string>();
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<HTMLButtonElement>('[data-process-sort]');
if (!button) return;
this.changeSort(button.dataset.processSort as ProcessSortKey);
const target = event.target as HTMLElement;
const sortButton = target.closest<HTMLButtonElement>('[data-process-sort]');
if (sortButton) {
this.changeSort(sortButton.dataset.processSort as ProcessSortKey);
return;
}
const killButton = target.closest<HTMLButtonElement>('[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<HTMLTableRowElement>('tr');
const commandCell = row?.children.item(7);
const command = commandCell?.textContent ?? '--';
this.openKillDialog(pid, command);
return;
}
const copyCell = target.closest<HTMLTableCellElement>('[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<string, unknown>): 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 <dialog method="dialog"> 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<T extends HTMLElement>(id: string): T {
export function collectProcessManagerElements(): ProcessManagerElements {
return {
panel: getElement('process-manager-panel'),
tableBody: getElement('process-table-body'),
tableBody: getElement<HTMLTableSectionElement>('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<HTMLProgressElement>('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<HTMLProgressElement>('resource-memory-progress'),
swapValue: getElement('resource-swap-value'),
swapAmount: getElement('resource-swap-amount'),
swapProgress: getElement('resource-swap-progress'),
swapProgress: getElement<HTMLProgressElement>('resource-swap-progress'),
killDialog: getElement<HTMLDialogElement>('process-kill-dialog'),
killDialogPid: getElement('process-kill-target-pid'),
killDialogCommand: getElement('process-kill-target-command'),
killDialogResult: getElement('process-kill-result'),
killConfirmButton: getElement<HTMLButtonElement>('confirm-process-kill'),
killRejectButton: getElement<HTMLButtonElement>('reject-process-kill'),
toastRegion: getElement('toast-region'),
};
}
+198 -2
View File
@@ -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;
+183
View File
@@ -63,6 +63,21 @@ interface PendingProcessChannelOpen {
timeout: ReturnType<typeof setTimeout> | null;
cancelled: boolean;
}
interface PendingProcessKillChannel {
readonly channelID: number;
readonly channel: SSHChannel;
readonly requestId: string;
readonly pid: number;
timeout: ReturnType<typeof setTimeout> | 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 <pid>` 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<number, PendingProcessKillChannel>();
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<void> {
@@ -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<void> {
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<void> {
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));
}