feat: 添加进程结束功能,支持通过 SSH 向远端主机发送 SIGTERM 信号并处理结果
This commit is contained in:
1 parent
12f59293a7
commit
50830414e9
5 files changed
+756
-10
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user