diff --git a/README.md b/README.md
index e8483fa..71a6b56 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
- Cloudflare Workers 原生部署,使用 Durable Objects 隔离每个 SSH 会话。
- 基于 xterm.js 的响应式终端,支持桌面端和移动端、自动缩放、全屏和会话日志。
- 内置 SFTP 文件管理,可浏览目录、上传/下载文件、新建与删除空目录、删除文件和重命名。
+- 内置实时进程管理,通过独立 WebSocket 和 SSH exec channel 展示 `top` 进程、CPU、负载、内存与 Swap 使用率。
- 支持 SSH password 与单密码提示的 keyboard-interactive 认证,以及 Ed25519、RSA、ECDSA 的未加密 OpenSSH 私钥认证。
- 首次连接时暂停认证并显示主机 SHA-256 指纹,确认后才会发送 SSH 凭据。
- SSH 连接成功后自动写入浏览器本地"历史记录",密码使用 AES-256-GCM 加密,私钥不保存。
@@ -154,6 +155,7 @@ npm run dev:web # 访问 http://localhost:5173,Vite 代理 /api 到 8787
| `/api/session` | `POST` | 匿名创建一次性会话票据 |
| `/api/ssh?ticket=...&session=...` | `GET` + WebSocket Upgrade | 进入对应 Durable Object 并建立 SSH 会话 |
| `/api/sftp?session=...&token=...` | `GET` + WebSocket Upgrade | 使用一次性附着令牌进入当前会话的文件通道 |
+| `/api/processes?session=...&token=...` | `GET` + WebSocket Upgrade | 使用一次性附着令牌进入当前会话的进程监控通道 |
所有 `/api/*` 响应均允许跨站访问,并支持浏览器 `OPTIONS` 预检请求。公网访问控制应由 Cloudflare Access、WAF 和限流策略提供。
diff --git a/frontend/index.html b/frontend/index.html
index cfc5ecf..6c2e7b6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -263,6 +263,32 @@
选择目标并连接
+
+
+
+ CPU
+ --
+ -- / -- / --
+
+
+
+
+
+ 内存
+ --
+ --/--
+
+
+
+
+
+ Swap
+ --
+ --/--
+
+
+
+
延迟--
时长00:00
@@ -329,6 +355,22 @@
文件管理
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ 名称/命令行 |
+
+
+
+
+
没有可显示的进程
+
+
+
+ 连接 SSH 后即可查看实时进程
+ 最后更新 --
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index ec759e5..61208eb 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -7,6 +7,7 @@ import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './p
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
import { FileManager, collectFileManagerElements } from './file-manager';
+import { ProcessManager, collectProcessManagerElements } from './process-manager';
import { resetTerminalForConnection } from './terminal-session';
import './style.css';
@@ -292,6 +293,8 @@ const ui = {
eventMessage: element('event-message'),
fileManagerTab: element('file-manager-tab'),
fileManagerPanel: element('file-manager-panel'),
+ processManagerTab: element('process-manager-tab'),
+ processManagerPanel: element('process-manager-panel'),
eventToggle: element('event-toggle'),
eventLog: element('event-log'),
toastRegion: element('toast-region'),
@@ -359,6 +362,7 @@ function applyLanguage(language: Language, persist = false): void {
}
}
if (fileManager) fileManager.setLanguage();
+ if (processManager) processManager.setLanguage();
if (persist) {
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language); } catch { /* Language still applies for this page. */ }
@@ -398,6 +402,7 @@ const latestHistoryMutation = new Map();
const panelMedia = window.matchMedia('(max-width: 760px)');
let panelOpen = !panelMedia.matches;
let fileManager: FileManager;
+let processManager: ProcessManager;
const terminal = new Terminal({
allowProposedApi: false,
@@ -421,6 +426,11 @@ fileManager = new FileManager({
getLanguage: () => currentLanguage,
onError: (message) => event(message, 'sftp', true),
});
+processManager = new ProcessManager({
+ elements: collectProcessManagerElements(),
+ getLanguage: () => currentLanguage,
+ onError: (message) => event(message, 'process', true),
+});
function terminalTheme(): Record {
return {
@@ -1211,24 +1221,29 @@ function sendHostKeyDecision(accept: boolean): void {
: bilingual('主机密钥已拒绝。', 'Host key rejected.'), 'host-key', !accept);
}
-type WorkspaceTab = 'files' | 'log';
+type WorkspaceTab = 'files' | 'processes' | 'log';
let activeWorkspaceTab: WorkspaceTab | null = null;
function setWorkspaceTab(tab: WorkspaceTab | null, focus = false, rovingTab = tab ?? activeWorkspaceTab ?? 'files'): void {
const filesActive = tab === 'files';
+ const processesActive = tab === 'processes';
const logActive = tab === 'log';
activeWorkspaceTab = tab;
ui.terminalCard.classList.toggle('workspace-panel-open', tab !== null);
ui.fileManagerPanel.hidden = !filesActive;
+ ui.processManagerPanel.hidden = !processesActive;
ui.eventLog.hidden = !logActive;
ui.fileManagerTab.setAttribute('aria-selected', String(filesActive));
+ ui.processManagerTab.setAttribute('aria-selected', String(processesActive));
ui.eventToggle.setAttribute('aria-selected', String(logActive));
ui.fileManagerTab.setAttribute('aria-expanded', String(filesActive));
+ ui.processManagerTab.setAttribute('aria-expanded', String(processesActive));
ui.eventToggle.setAttribute('aria-expanded', String(logActive));
ui.fileManagerTab.tabIndex = rovingTab === 'files' ? 0 : -1;
+ ui.processManagerTab.tabIndex = rovingTab === 'processes' ? 0 : -1;
ui.eventToggle.tabIndex = rovingTab === 'log' ? 0 : -1;
if (logActive) requestAnimationFrame(() => { ui.eventLog.scrollTop = ui.eventLog.scrollHeight; });
- if (focus) (rovingTab === 'files' ? ui.fileManagerTab : ui.eventToggle).focus();
+ if (focus) ({ files: ui.fileManagerTab, processes: ui.processManagerTab, log: ui.eventToggle })[rovingTab].focus();
requestAnimationFrame(() => fitTerminal(true));
}
@@ -1239,8 +1254,12 @@ function toggleWorkspaceTab(tab: WorkspaceTab): void {
function handleWorkspaceTabKey(event: KeyboardEvent): void {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
- const files = event.key === 'Home' || (event.key !== 'End' && event.currentTarget === ui.eventToggle);
- setWorkspaceTab(files ? 'files' : 'log', true);
+ const tabs: WorkspaceTab[] = ['files', 'processes', 'log'];
+ const current = tabs.findIndex((tab) => ({ files: ui.fileManagerTab, processes: ui.processManagerTab, log: ui.eventToggle })[tab] === event.currentTarget);
+ const next = event.key === 'Home' ? 0
+ : event.key === 'End' ? tabs.length - 1
+ : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
+ setWorkspaceTab(tabs[next], true);
}
function configureHostKeyDialog(hostKey: HostKeyPrompt): void {
@@ -1295,6 +1314,20 @@ function handleServerMessage(message: ServerMessage): void {
event(bilingual('文件管理通道已可用。', 'File management channel is available.'), 'sftp');
return;
}
+ if (type === 'process_attach') {
+ if (typeof message.url !== 'string' || !message.url.startsWith('/api/processes?')) {
+ event(bilingual('收到无效的进程监控连接信息。', 'Received invalid process-monitor connection details.'), 'protocol', true);
+ return;
+ }
+ try {
+ processManager.attach(message.url);
+ } catch {
+ event(bilingual('无法打开进程监控连接。', 'Could not open the process-monitor connection.'), 'process', true);
+ return;
+ }
+ event(bilingual('进程监控通道已可用。', 'Process monitor channel is available.'), 'process');
+ return;
+ }
if (type === 'pong') {
if (lastPingAt > 0) ui.metricRtt.textContent = `${Math.max(1, Math.round(performance.now() - lastPingAt))} ms`;
return;
@@ -1415,6 +1448,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin
currentRememberedFingerprint = '';
stopTimers();
fileManager.reset();
+ processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
currentSessionSubtitle = displayReason;
@@ -1557,6 +1591,7 @@ async function connect(): Promise {
currentRememberedFingerprint = '';
stopTimers();
fileManager.reset();
+ processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
const wasActive = connectionState === 'connected';
@@ -1600,6 +1635,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
socket = null;
stopTimers();
fileManager.reset();
+ processManager.reset();
clearHostKeyPrompt();
invalidateHistoryPasswordLoad();
currentExpectedFingerprint = '';
@@ -1858,8 +1894,10 @@ ui.fullscreenTerminal.addEventListener('click', async () => {
});
document.addEventListener('fullscreenchange', () => fitTerminal(true));
ui.fileManagerTab.addEventListener('click', () => toggleWorkspaceTab('files'));
+ui.processManagerTab.addEventListener('click', () => toggleWorkspaceTab('processes'));
ui.eventToggle.addEventListener('click', () => toggleWorkspaceTab('log'));
ui.fileManagerTab.addEventListener('keydown', handleWorkspaceTabKey);
+ui.processManagerTab.addEventListener('keydown', handleWorkspaceTabKey);
ui.eventToggle.addEventListener('keydown', handleWorkspaceTabKey);
ui.languageToggle.addEventListener('click', () => {
applyLanguage(currentLanguage === 'zh-CN' ? 'en' : 'zh-CN', true);
@@ -1884,6 +1922,7 @@ terminal.onData(sendTerminalData);
new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage);
window.addEventListener('beforeunload', () => {
fileManager.reset();
+ processManager.reset();
socket?.close(1000, 'Page closed');
});
document.addEventListener('keydown', (keyEvent) => {
diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts
new file mode 100644
index 0000000..122add3
--- /dev/null
+++ b/frontend/src/process-manager.ts
@@ -0,0 +1,441 @@
+export interface ProcessManagerElements {
+ panel: HTMLElement;
+ tableBody: HTMLTableSectionElement;
+ status: HTMLElement;
+ empty: HTMLElement;
+ error: HTMLElement;
+ updated: HTMLElement;
+ cpuValue: HTMLElement;
+ cpuProgress: HTMLProgressElement;
+ cpuLoad: HTMLElement;
+ memoryValue: HTMLElement;
+ memoryAmount: HTMLElement;
+ memoryProgress: HTMLProgressElement;
+ swapValue: HTMLElement;
+ swapAmount: HTMLElement;
+ swapProgress: HTMLProgressElement;
+}
+
+interface ResourceUsage {
+ usedBytes: number;
+ totalBytes: number;
+ percent: number;
+}
+
+interface ProcessEntry {
+ pid: number;
+ user: string;
+ memoryBytes: number | null;
+ memoryPercent: number | null;
+ cpuPercent: number | null;
+ state: string;
+ time: string;
+ command: string;
+}
+
+type ProcessSortKey = 'pid' | 'user' | 'memoryBytes' | 'memoryPercent' | 'cpuPercent' | 'state' | 'time';
+type SortDirection = 'ascending' | 'descending';
+
+interface ProcessSnapshot {
+ type: 'process_snapshot';
+ metrics: {
+ cpuPercent: number | null;
+ loadAverage: [number, number, number] | null;
+ memory: ResourceUsage | null;
+ swap: ResourceUsage | null;
+ };
+ processes: ProcessEntry[];
+ timestamp: number;
+}
+
+interface ProcessManagerOptions {
+ elements: ProcessManagerElements;
+ getLanguage: () => 'zh-CN' | 'en';
+ onError: (message: string) => void;
+}
+
+const MAX_PROCESSES = 512;
+
+function finitePercent(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 10_000;
+}
+
+function isUsage(value: unknown): value is ResourceUsage {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const usage = value as Partial;
+ return typeof usage.usedBytes === 'number' && Number.isFinite(usage.usedBytes) && usage.usedBytes >= 0
+ && typeof usage.totalBytes === 'number' && Number.isFinite(usage.totalBytes) && usage.totalBytes >= usage.usedBytes
+ && finitePercent(usage.percent) && usage.percent <= 100;
+}
+
+function isProcess(value: unknown): value is ProcessEntry {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const process = value as Partial;
+ return typeof process.pid === 'number' && Number.isSafeInteger(process.pid) && process.pid > 0
+ && typeof process.user === 'string' && process.user.length <= 256
+ && (process.memoryBytes === null || (typeof process.memoryBytes === 'number' && Number.isFinite(process.memoryBytes) && process.memoryBytes >= 0))
+ && (process.memoryPercent === null || finitePercent(process.memoryPercent))
+ && (process.cpuPercent === null || finitePercent(process.cpuPercent))
+ && typeof process.state === 'string' && process.state.length <= 32
+ && typeof process.time === 'string' && process.time.length <= 64
+ && typeof process.command === 'string' && process.command.length <= 4096;
+}
+
+function isSnapshot(value: unknown): value is ProcessSnapshot {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const snapshot = value as Partial;
+ const metrics = snapshot.metrics;
+ return snapshot.type === 'process_snapshot'
+ && typeof snapshot.timestamp === 'number' && Number.isFinite(snapshot.timestamp)
+ && Array.isArray(snapshot.processes) && snapshot.processes.length <= MAX_PROCESSES && snapshot.processes.every(isProcess)
+ && Boolean(metrics) && typeof metrics === 'object'
+ && (metrics!.cpuPercent === null || finitePercent(metrics!.cpuPercent))
+ && (metrics!.loadAverage === null || (Array.isArray(metrics!.loadAverage) && metrics!.loadAverage.length === 3 && metrics!.loadAverage.every(finitePercent)))
+ && (metrics!.memory === null || isUsage(metrics!.memory))
+ && (metrics!.swap === null || isUsage(metrics!.swap));
+}
+
+function formatPercent(value: number | null): string {
+ if (value === null) return '--';
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)}%`;
+}
+
+function formatBytes(bytes: number | null): string {
+ if (bytes === null) return '--';
+ const units = ['B', 'K', 'M', 'G', 'T', 'P'];
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ const precision = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2;
+ return `${value.toFixed(precision)}${units[unit]}`;
+}
+
+function usageColor(percent: number): string {
+ const value = Math.min(100, Math.max(0, percent));
+ const stops = [
+ { value: 0, hue: 150 },
+ { value: 50, hue: 125 },
+ { value: 65, hue: 55 },
+ { value: 80, hue: 30 },
+ { value: 100, hue: 0 },
+ ];
+ const upperIndex = stops.findIndex((stop) => value <= stop.value);
+ if (upperIndex <= 0) return `hsl(${stops[0].hue} 82% 58%)`;
+ const lower = stops[upperIndex - 1];
+ const upper = stops[upperIndex];
+ const position = (value - lower.value) / (upper.value - lower.value);
+ const hue = lower.hue + ((upper.hue - lower.hue) * position);
+ return `hsl(${hue.toFixed(1)} 82% 58%)`;
+}
+
+export class ProcessManager {
+ private readonly elements: ProcessManagerElements;
+ private readonly getLanguage: () => 'zh-CN' | 'en';
+ private readonly onError: (message: string) => void;
+ private socket: WebSocket | null = null;
+ private generation = 0;
+ private snapshot: ProcessSnapshot | null = null;
+ private sortKey: ProcessSortKey = 'cpuPercent';
+ private sortDirection: SortDirection = 'descending';
+ private readonly progressAnimations = new Map();
+
+ constructor(options: ProcessManagerOptions) {
+ this.elements = options.elements;
+ this.getLanguage = options.getLanguage;
+ this.onError = options.onError;
+ this.elements.panel.addEventListener('click', (event) => {
+ const button = (event.target as HTMLElement).closest('[data-process-sort]');
+ if (!button) return;
+ this.changeSort(button.dataset.processSort as ProcessSortKey);
+ });
+ this.render();
+ }
+
+ attach(url: string): void {
+ this.resetSocket();
+ const target = new URL(url, window.location.href);
+ if (target.origin !== window.location.origin || target.pathname !== '/api/processes') {
+ throw new Error('Process WebSocket must use the current origin');
+ }
+ target.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const generation = ++this.generation;
+ const socket = new WebSocket(target);
+ this.socket = socket;
+ this.setStatus('正在启动进程监控…', 'Starting process monitor…');
+ socket.addEventListener('open', () => {
+ if (!this.isCurrent(socket, generation)) return;
+ socket.send(JSON.stringify({ type: 'process_start' }));
+ });
+ socket.addEventListener('message', (event) => {
+ if (!this.isCurrent(socket, generation) || typeof event.data !== 'string') return;
+ this.handleMessage(event.data);
+ });
+ socket.addEventListener('error', () => {
+ if (!this.isCurrent(socket, generation)) return;
+ this.showError('进程监控连接错误。', 'Process monitor connection error.');
+ });
+ socket.addEventListener('close', (event) => {
+ if (!this.isCurrent(socket, generation)) return;
+ this.socket = null;
+ if (event.code !== 1000 && event.code !== 1005) {
+ this.showError('进程监控已意外停止。', 'Process monitor stopped unexpectedly.');
+ }
+ });
+ }
+
+ reset(): void {
+ this.resetSocket();
+ this.snapshot = null;
+ this.render();
+ }
+
+ setLanguage(): void {
+ this.updateSortHeaders();
+ this.render();
+ }
+
+ private changeSort(key: ProcessSortKey): void {
+ if (this.sortKey === key) {
+ this.sortDirection = this.sortDirection === 'ascending' ? 'descending' : 'ascending';
+ } else {
+ this.sortKey = key;
+ this.sortDirection = key === 'user' || key === 'state' ? 'ascending' : 'descending';
+ }
+ this.updateSortHeaders();
+ this.render();
+ }
+
+ private updateSortHeaders(): void {
+ for (const button of this.elements.panel.querySelectorAll('[data-process-sort]')) {
+ const key = button.dataset.processSort as ProcessSortKey;
+ const header = button.closest('th');
+ const active = key === this.sortKey;
+ header?.setAttribute('aria-sort', active ? this.sortDirection : 'none');
+ const label = button.dataset[this.getLanguage() === 'zh-CN' ? 'i18nZh' : 'i18nEn'] ?? button.textContent ?? '';
+ button.setAttribute('aria-label', active
+ ? this.getLanguage() === 'zh-CN'
+ ? `${label},当前${this.sortDirection === 'ascending' ? '升序' : '降序'},点击切换排序`
+ : `${label}, currently ${this.sortDirection}; activate to reverse`
+ : this.getLanguage() === 'zh-CN'
+ ? `按 ${label} 排序`
+ : `Sort by ${label}`);
+ }
+ }
+
+ private sortedProcesses(processes: ProcessEntry[]): ProcessEntry[] {
+ const key = this.sortKey;
+ const direction = this.sortDirection === 'ascending' ? 1 : -1;
+ const value = (process: ProcessEntry): string | number | null => {
+ if (key === 'time') return this.processTimeSeconds(process.time);
+ return process[key];
+ };
+ return processes.map((process, index) => ({ process, index })).sort((left, right) => {
+ const a = value(left.process);
+ const b = value(right.process);
+ if (a === null && b === null) return left.index - right.index;
+ if (a === null) return 1;
+ if (b === null) return -1;
+ const compared = typeof a === 'number' && typeof b === 'number'
+ ? a - b
+ : String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
+ return compared === 0 ? left.index - right.index : compared * direction;
+ }).map(({ process }) => process);
+ }
+
+ private processTimeSeconds(value: string): number | null {
+ const parts = value.replace(',', '.').split(':').map(Number);
+ if (parts.some((part) => !Number.isFinite(part))) return null;
+ if (parts.length === 3) return (parts[0] * 3600) + (parts[1] * 60) + parts[2];
+ if (parts.length === 2) return (parts[0] * 60) + parts[1];
+ return parts.length === 1 ? parts[0] : null;
+ }
+
+ private handleMessage(serialized: string): void {
+ let message: unknown;
+ try { message = JSON.parse(serialized); } catch { return; }
+ if (isSnapshot(message)) {
+ this.snapshot = message;
+ this.render();
+ return;
+ }
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return;
+ const value = message as Record;
+ if (value.type === 'process_ready') {
+ 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);
+ }
+ }
+
+ private render(): void {
+ const snapshot = this.snapshot;
+ if (!snapshot) {
+ this.elements.tableBody.replaceChildren();
+ this.elements.empty.hidden = true;
+ this.elements.error.hidden = true;
+ this.elements.updated.textContent = '--';
+ this.resetMetric(this.elements.cpuValue, this.elements.cpuProgress);
+ this.elements.cpuLoad.textContent = '-- / -- / --';
+ this.resetUsage(this.elements.memoryValue, this.elements.memoryAmount, this.elements.memoryProgress);
+ this.resetUsage(this.elements.swapValue, this.elements.swapAmount, this.elements.swapProgress);
+ this.setStatus('连接 SSH 后即可查看实时进程', 'Connect to SSH to view live processes');
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ for (const process of this.sortedProcesses(snapshot.processes)) {
+ const row = document.createElement('tr');
+ const values = [
+ String(process.pid),
+ process.user || '--',
+ formatBytes(process.memoryBytes),
+ formatPercent(process.memoryPercent),
+ formatPercent(process.cpuPercent),
+ process.state || '--',
+ process.time || '--',
+ process.command || '--',
+ ];
+ values.forEach((value, index) => {
+ const cell = document.createElement('td');
+ cell.textContent = value;
+ if (index === values.length - 1) cell.title = value;
+ row.append(cell);
+ });
+ fragment.append(row);
+ }
+ this.elements.tableBody.replaceChildren(fragment);
+ this.elements.empty.hidden = snapshot.processes.length !== 0;
+ this.elements.error.hidden = true;
+ const updated = new Date(snapshot.timestamp).toLocaleTimeString([], { hour12: false });
+ this.elements.updated.textContent = updated;
+ this.setStatus(
+ `共 ${snapshot.processes.length} 个进程 · 更新于 ${updated}`,
+ `${snapshot.processes.length} processes · Updated ${updated}`,
+ );
+ this.setMetric(this.elements.cpuValue, this.elements.cpuProgress, snapshot.metrics.cpuPercent);
+ this.elements.cpuLoad.textContent = snapshot.metrics.loadAverage?.map((value) => value.toFixed(2)).join(' / ') ?? '-- / -- / --';
+ this.setUsage(this.elements.memoryValue, this.elements.memoryAmount, this.elements.memoryProgress, snapshot.metrics.memory);
+ this.setUsage(this.elements.swapValue, this.elements.swapAmount, this.elements.swapProgress, snapshot.metrics.swap);
+ }
+
+ private setMetric(valueElement: HTMLElement, progress: HTMLProgressElement, value: number | null): void {
+ valueElement.textContent = formatPercent(value);
+ if (value === null) {
+ this.resetProgress(progress);
+ valueElement.style.removeProperty('--usage-color');
+ progress.style.removeProperty('--usage-color');
+ return;
+ }
+ this.animateProgress(progress, Math.min(100, value));
+ const color = usageColor(value);
+ valueElement.style.setProperty('--usage-color', color);
+ progress.style.setProperty('--usage-color', color);
+ }
+
+ private resetMetric(valueElement: HTMLElement, progress: HTMLProgressElement): void {
+ valueElement.textContent = '--';
+ this.resetProgress(progress);
+ valueElement.style.removeProperty('--usage-color');
+ progress.style.removeProperty('--usage-color');
+ }
+
+ private resetProgress(progress: HTMLProgressElement): void {
+ const animation = this.progressAnimations.get(progress);
+ if (animation !== undefined) cancelAnimationFrame(animation);
+ this.progressAnimations.delete(progress);
+ progress.value = 0;
+ }
+
+ private animateProgress(progress: HTMLProgressElement, target: number): void {
+ const previousAnimation = this.progressAnimations.get(progress);
+ if (previousAnimation !== undefined) cancelAnimationFrame(previousAnimation);
+ const startValue = progress.value;
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || Math.abs(target - startValue) < 0.1) {
+ progress.value = target;
+ this.progressAnimations.delete(progress);
+ return;
+ }
+ const startedAt = performance.now();
+ const duration = 520;
+ const step = (now: number): void => {
+ const elapsed = Math.min(1, (now - startedAt) / duration);
+ const eased = 1 - ((1 - elapsed) ** 3);
+ progress.value = startValue + ((target - startValue) * eased);
+ if (elapsed < 1) {
+ this.progressAnimations.set(progress, requestAnimationFrame(step));
+ } else {
+ this.progressAnimations.delete(progress);
+ }
+ };
+ this.progressAnimations.set(progress, requestAnimationFrame(step));
+ }
+
+ private resetUsage(valueElement: HTMLElement, amountElement: HTMLElement, progress: HTMLProgressElement): void {
+ this.resetMetric(valueElement, progress);
+ amountElement.textContent = '--/--';
+ }
+
+ private setUsage(valueElement: HTMLElement, amountElement: HTMLElement, progress: HTMLProgressElement, usage: ResourceUsage | null): void {
+ if (!usage) {
+ this.resetUsage(valueElement, amountElement, progress);
+ return;
+ }
+ this.setMetric(valueElement, progress, usage.percent);
+ amountElement.textContent = `${formatBytes(usage.usedBytes)}/${formatBytes(usage.totalBytes)}`;
+ }
+
+ private setStatus(zh: string, en: string): void {
+ this.elements.status.textContent = this.getLanguage() === 'zh-CN' ? zh : en;
+ }
+
+ private showError(zh: string, en: string): void {
+ const message = this.getLanguage() === 'zh-CN' ? zh : en;
+ this.elements.error.textContent = message;
+ this.elements.error.hidden = false;
+ this.elements.empty.hidden = true;
+ this.setStatus(zh, en);
+ this.onError(message);
+ }
+
+ private resetSocket(): void {
+ const socket = this.socket;
+ this.socket = null;
+ this.generation++;
+ if (!socket) return;
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'process_stop' }));
+ if (socket.readyState < WebSocket.CLOSING) socket.close(1000, 'Process monitor reset');
+ }
+
+ private isCurrent(socket: WebSocket, generation: number): boolean {
+ return this.socket === socket && this.generation === generation;
+ }
+}
+
+function getElement(id: string): T {
+ const element = document.getElementById(id);
+ if (!element) throw new Error(`Missing process manager element #${id}`);
+ return element as T;
+}
+
+export function collectProcessManagerElements(): ProcessManagerElements {
+ return {
+ panel: getElement('process-manager-panel'),
+ 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'),
+ cpuLoad: getElement('resource-cpu-load'),
+ memoryValue: getElement('resource-memory-value'),
+ memoryAmount: getElement('resource-memory-amount'),
+ memoryProgress: getElement('resource-memory-progress'),
+ swapValue: getElement('resource-swap-value'),
+ swapAmount: getElement('resource-swap-amount'),
+ swapProgress: getElement('resource-swap-progress'),
+ };
+}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 6e2721d..9528640 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -964,12 +964,95 @@ button:disabled {
text-align: right;
}
+.resource-metrics {
+ display: flex;
+ min-width: 0;
+ flex: 0 1 490px;
+ align-items: center;
+ gap: 9px;
+}
+
+.resource-metric {
+ min-width: 0;
+ flex: 1 1 0;
+ color: #a8b8c1;
+ font-family: var(--mono);
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.resource-metric-line {
+ display: flex;
+ min-width: 0;
+ align-items: baseline;
+ gap: 6px;
+}
+
+.resource-metric-line > span {
+ flex: 0 0 auto;
+ color: #8fa2ad;
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+
+.resource-metric-line > strong {
+ flex: 0 0 auto;
+ overflow: hidden;
+ color: #d4e0e5;
+ font-size: 10px;
+ font-weight: 750;
+ text-overflow: ellipsis;
+}
+
+.resource-metric-line > .resource-percentage {
+ color: color-mix(in srgb, var(--usage-color, #d4e0e5) 88%, var(--text));
+ font-weight: 900;
+ transition: color 220ms ease;
+}
+
+.resource-metric-line > .resource-detail {
+ min-width: 0;
+ flex: 1 1 auto;
+ color: #9aadb7;
+ font-weight: 400;
+ text-align: right;
+}
+
+.resource-metric progress {
+ width: 100%;
+ height: 4px;
+ display: block;
+ margin-top: 2px;
+ overflow: hidden;
+ border: 0;
+ border-radius: 99px;
+ accent-color: var(--usage-color, var(--accent));
+}
+
+.resource-metric progress::-webkit-progress-bar {
+ border-radius: 99px;
+ background: #263642;
+}
+
+.resource-metric progress::-webkit-progress-value {
+ border-radius: 99px;
+ background: var(--usage-color, var(--accent));
+ transition: background-color 320ms ease;
+}
+
+.resource-metric progress::-moz-progress-bar {
+ border-radius: 99px;
+ background: var(--usage-color, var(--accent));
+ transition: background-color 320ms ease;
+}
+
.session-metrics span {
margin-bottom: 2px;
- color: #5e707d;
- font-size: 7px;
+ color: #81939e;
+ font-size: 8px;
font-weight: 700;
- letter-spacing: 0.08em;
+ letter-spacing: 0.04em;
}
.session-metrics strong {
@@ -1150,6 +1233,8 @@ button:disabled {
}
#event-message {
+ min-width: 0;
+ flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1187,7 +1272,8 @@ button:disabled {
background: transparent;
color: var(--muted);
font-family: inherit;
- font-size: inherit;
+ font-size: 10px;
+ font-weight: 650;
}
.dock-tab-icon {
@@ -1253,6 +1339,143 @@ button:disabled {
background: var(--surface);
}
+.process-manager {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ grid-template-rows: minmax(0, 1fr) 25px;
+ overflow: hidden;
+ background: var(--surface);
+}
+
+.process-table-wrap {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ scrollbar-color: var(--border) transparent;
+ scrollbar-width: thin;
+}
+
+.process-table {
+ width: 100%;
+ min-width: 820px;
+ border-spacing: 0;
+ table-layout: fixed;
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 10px;
+}
+
+.process-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ height: 25px;
+ padding: 0 8px;
+ border-right: 1px solid var(--border-soft);
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-2);
+ color: var(--muted);
+ font-family: var(--sans);
+ font-size: 9px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.process-table th:first-child { width: 8%; }
+.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: left; }
+
+.process-sort {
+ position: relative;
+ width: 100%;
+ height: 25px;
+ padding: 0 11px 0 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: 700;
+ text-align: right;
+}
+
+.process-sort::after {
+ position: absolute;
+ right: 0;
+ color: var(--muted-2);
+ content: '↕';
+}
+
+.process-table th[aria-sort='ascending'] .process-sort,
+.process-table th[aria-sort='descending'] .process-sort {
+ color: var(--accent);
+}
+
+.process-table th[aria-sort='ascending'] .process-sort::after { content: '↑'; }
+.process-table th[aria-sort='descending'] .process-sort::after { content: '↓'; }
+
+.process-table td {
+ height: 24px;
+ padding: 0 8px;
+ overflow: hidden;
+ border-bottom: 1px solid var(--border-soft);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.process-table td:not(:last-child) {
+ text-align: right;
+}
+
+.process-table td:last-child {
+ text-align: left;
+}
+
+.process-table tbody tr:hover {
+ background: var(--surface-2);
+}
+
+.process-manager-state {
+ position: absolute;
+ inset: 25px 0 0;
+ display: grid;
+ padding: 16px;
+ place-items: center;
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.process-manager-state.error {
+ color: var(--danger);
+}
+
+.process-statusbar {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 8px;
+ border-top: 1px solid var(--border-soft);
+ background: var(--panel);
+ color: var(--muted-2);
+ font-family: var(--mono);
+ font-size: 8px;
+ white-space: nowrap;
+}
+
+#process-manager-status {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
.file-toolbar {
display: flex;
min-width: 0;
@@ -1747,6 +1970,35 @@ button:disabled {
.session-metrics > div:first-child {
display: none;
}
+
+}
+
+@media (max-width: 1500px) {
+ .resource-metric progress {
+ display: none;
+ }
+}
+
+@media (max-width: 1080px) {
+ .session-metrics {
+ display: none;
+ }
+
+ .resource-metrics {
+ gap: 5px;
+ }
+
+ .resource-metric {
+ flex-basis: 0;
+ }
+
+ .resource-detail {
+ display: none;
+ }
+
+ .terminal-actions .button-label {
+ display: none;
+ }
}
@media (max-width: 760px) {
@@ -1802,13 +2054,38 @@ button:disabled {
padding: 8px;
}
- .session-metrics > div {
- display: none;
+ .terminal-toolbar {
+ gap: 6px;
+ padding-inline: 7px;
}
- .session-metrics > div:nth-child(2) {
- display: block;
- border-left: 0;
+ .session-target {
+ flex: 0 1 88px;
+ gap: 6px;
+ }
+
+ .resource-metrics {
+ flex: 0 1 180px;
+ gap: 4px;
+ }
+
+ .resource-metric {
+ flex: 1 1 0;
+ }
+
+ .resource-metric-line {
+ justify-content: center;
+ gap: 3px;
+ }
+
+ .terminal-actions {
+ flex: 0 0 auto;
+ gap: 3px;
+ margin-left: auto;
+ }
+
+ .terminal-actions .toolbar-button {
+ padding-inline: 6px;
}
.terminal-card {
@@ -1847,6 +2124,19 @@ button:disabled {
min-width: 560px;
}
+ .process-table {
+ min-width: 650px;
+ }
+
+ .process-table th:nth-child(4),
+ .process-table td:nth-child(4),
+ .process-table th:nth-child(6),
+ .process-table td:nth-child(6),
+ .process-table th:nth-child(7),
+ .process-table td:nth-child(7) {
+ display: none;
+ }
+
.file-table [data-column='permissions'],
.file-table [data-column='owner'],
.file-table [data-column='group'],
@@ -1867,7 +2157,51 @@ button:disabled {
.dock-tab {
min-width: 0;
- padding: 0 13px;
+ gap: 4px;
+ padding: 0 7px;
+ font-size: 10px;
+ }
+
+ .dock-tab .button-label {
+ display: inline;
+ white-space: nowrap;
+ }
+
+ .dock-tab-icon {
+ width: 13px;
+ height: 13px;
+ }
+}
+
+@media (max-width: 200px) {
+ .terminal-pane {
+ grid-template-rows: 70px minmax(0, 1fr);
+ }
+
+ .terminal-toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: 34px 28px;
+ gap: 0 8px;
+ padding-block: 3px;
+ }
+
+ .resource-metrics {
+ width: 100%;
+ grid-column: 1 / -1;
+ justify-content: space-between;
+ border-top: 1px solid #1c2932;
+ }
+
+ .resource-metric,
+ .resource-memory,
+ .resource-swap {
+ min-width: 0;
+ }
+
+ .resource-metric-line > span,
+ .resource-metric-line > strong {
+ font-size: 8px;
}
}
diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts
index 6271264..e61c73d 100644
--- a/src/backend/durable-object.ts
+++ b/src/backend/durable-object.ts
@@ -6,7 +6,8 @@ import { SSHSession } from './session';
interface MainAttachment { role: 'main'; phase: 'waiting' | 'connecting' | 'connected' }
interface SFTPAttachment { role: 'sftp'; phase: 'connected' }
-type Attachment = MainAttachment | SFTPAttachment;
+interface ProcessAttachment { role: 'process'; phase: 'connected' }
+type Attachment = MainAttachment | SFTPAttachment | ProcessAttachment;
interface StoredTicket { secret: number[]; expiresAt: number; ip: string }
interface PendingConnection {
cancelled: boolean;
@@ -20,6 +21,7 @@ interface SFTPAttachToken {
timeout: ReturnType;
session?: SSHSession;
}
+type AuxiliaryAttachToken = SFTPAttachToken;
const TICKET_STORAGE_KEY = 'session-ticket';
const SFTP_ATTACH_TOKEN_TTL_MS = 10 * 60 * 1000;
const SFTP_ATTACH_TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -35,13 +37,18 @@ export class SSHSessionDO implements DurableObject {
private readonly sftpSessions = new Map();
private readonly sftpOwners = new Map();
private readonly sftpWebSocketsByMain = new Map>();
+ private readonly processAttachTokens = new Map();
+ private readonly processTokenByMainWebSocket = new Map();
+ private readonly processSessions = new Map();
+ private readonly processOwners = new Map();
+ private readonly processWebSocketsByMain = new Map>();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
for (const ws of state.getWebSockets()) {
const attachment = ws.deserializeAttachment() as Attachment | null;
- if (attachment?.role === 'sftp') {
+ if (attachment?.role === 'sftp' || attachment?.role === 'process') {
try { ws.close(1012, 'Worker session restarted; reconnect required'); } catch { /* already closed */ }
} else if (attachment?.phase === 'connected') {
try { ws.close(1012, 'Worker session restarted; reconnect required'); } catch { /* already closed */ }
@@ -68,13 +75,18 @@ export class SSHSessionDO implements DurableObject {
return stored ? Response.json(created) : Response.json({ error: 'Ticket already created' }, { status: 409 });
}
if (url.pathname === '/sftp') return this.attachSFTP(request);
+ if (url.pathname === '/processes') return this.attachProcesses(request);
if (url.pathname !== '/connect') return Response.json({ error: 'Not found' }, { status: 404 });
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return Response.json({ error: 'WebSocket upgrade required' }, { status: 426 });
const sftpAttachToken = request.headers.get('x-sftp-attach-token');
const sftpAttachUrl = request.headers.get('x-sftp-attach-url');
+ const processAttachToken = request.headers.get('x-process-attach-token');
+ const processAttachUrl = request.headers.get('x-process-attach-url');
if (!sftpAttachToken || !SFTP_ATTACH_TOKEN_PATTERN.test(sftpAttachToken)
- || !sftpAttachUrl || !this.isValidSFTPAttachUrl(sftpAttachUrl, sftpAttachToken)) {
- return Response.json({ error: 'Invalid SFTP attachment authorization' }, { status: 400 });
+ || !sftpAttachUrl || !this.isValidAttachUrl(sftpAttachUrl, sftpAttachToken, '/api/sftp')
+ || !processAttachToken || !SFTP_ATTACH_TOKEN_PATTERN.test(processAttachToken)
+ || !processAttachUrl || !this.isValidAttachUrl(processAttachUrl, processAttachToken, '/api/processes')) {
+ return Response.json({ error: 'Invalid attachment authorization' }, { status: 400 });
}
const ticket = request.headers.get('x-session-ticket');
const ip = request.headers.get('x-client-ip') ?? 'unknown';
@@ -87,12 +99,27 @@ export class SSHSessionDO implements DurableObject {
this.state.acceptWebSocket(server);
server.serializeAttachment({ role: 'main', phase: 'waiting' } satisfies MainAttachment);
this.registerSFTPAttachToken(sftpAttachToken, sftpAttachUrl, server);
+ this.registerProcessAttachToken(processAttachToken, processAttachUrl, server);
const deadline = setTimeout(() => this.reject(server, 'Connect message timeout'), 10_000);
this.deadlines.set(server, deadline);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise {
+ const processSession = this.processSessions.get(ws);
+ if (processSession || this.isProcessWebSocket(ws)) {
+ try {
+ if (!processSession || ws.readyState !== WebSocket.OPEN) {
+ this.cleanupProcessWebSocket(ws);
+ if (ws.readyState === WebSocket.OPEN) ws.close(1008, 'Process monitor is unavailable');
+ return;
+ }
+ await processSession.handleProcessClientMessage(message);
+ } catch (error) {
+ try { ws.send(JSON.stringify({ type: 'process_error', message: error instanceof Error ? error.message : String(error) })); } catch { /* already closed */ }
+ }
+ return;
+ }
const sftpSession = this.sftpSessions.get(ws);
if (sftpSession || this.isSFTPWebSocket(ws)) {
try {
@@ -148,6 +175,12 @@ export class SSHSessionDO implements DurableObject {
sftpAttach.session = ssh;
ssh.setSFTPAttachUrl(sftpAttach.attachUrl);
}
+ const processToken = this.processTokenByMainWebSocket.get(ws);
+ const processAttach = processToken ? this.processAttachTokens.get(processToken) : undefined;
+ if (processAttach) {
+ processAttach.session = ssh;
+ ssh.setProcessAttachUrl(processAttach.attachUrl);
+ }
this.sessions.set(ws, ssh);
this.pendingConnections.delete(ws);
pending.socket = undefined;
@@ -159,12 +192,14 @@ export class SSHSessionDO implements DurableObject {
}
async webSocketClose(ws: WebSocket): Promise {
- if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws);
+ if (this.isProcessWebSocket(ws)) this.cleanupProcessWebSocket(ws);
+ else if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws);
else this.cleanup(ws);
}
async webSocketError(ws: WebSocket): Promise {
- if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws);
+ if (this.isProcessWebSocket(ws)) this.cleanupProcessWebSocket(ws);
+ else if (this.isSFTPWebSocket(ws)) this.cleanupSFTPWebSocket(ws);
else this.cleanup(ws);
}
@@ -238,6 +273,36 @@ export class SSHSessionDO implements DurableObject {
return new Response(null, { status: 101, webSocket: client });
}
+ private attachProcesses(request: Request): Response {
+ if (request.method !== 'GET') return Response.json({ error: 'Method not allowed' }, { status: 405 });
+ if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
+ return Response.json({ error: 'WebSocket upgrade required' }, { status: 426 });
+ }
+ const token = request.headers.get('x-process-attach-token');
+ if (!token || !SFTP_ATTACH_TOKEN_PATTERN.test(token)) {
+ return Response.json({ error: 'Invalid process attachment token' }, { status: 401 });
+ }
+ const attach = this.processAttachTokens.get(token);
+ if (!attach || attach.expiresAt < Date.now() || attach.mainWebSocket.readyState !== WebSocket.OPEN) {
+ if (attach) this.deleteProcessAttachToken(token, attach);
+ return Response.json({ error: 'Invalid or expired process attachment token' }, { status: 401 });
+ }
+ if (!attach.session) return Response.json({ error: 'SSH session is still initializing' }, { status: 409 });
+ this.deleteProcessAttachToken(token, attach);
+ const pair = new WebSocketPair();
+ const client = pair[0];
+ const server = pair[1];
+ this.state.acceptWebSocket(server);
+ server.serializeAttachment({ role: 'process', phase: 'connected' } satisfies ProcessAttachment);
+ this.processSessions.set(server, attach.session);
+ this.processOwners.set(server, attach.mainWebSocket);
+ const sockets = this.processWebSocketsByMain.get(attach.mainWebSocket) ?? new Set();
+ sockets.add(server);
+ this.processWebSocketsByMain.set(attach.mainWebSocket, sockets);
+ attach.session.attachProcessWebSocket(server);
+ return new Response(null, { status: 101, webSocket: client });
+ }
+
private registerSFTPAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void {
const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS;
const attach: SFTPAttachToken = {
@@ -250,6 +315,28 @@ export class SSHSessionDO implements DurableObject {
this.sftpTokenByMainWebSocket.set(mainWebSocket, token);
}
+ private registerProcessAttachToken(token: string, attachUrl: string, mainWebSocket: WebSocket): void {
+ const expiresAt = Date.now() + SFTP_ATTACH_TOKEN_TTL_MS;
+ const attach: AuxiliaryAttachToken = {
+ mainWebSocket,
+ attachUrl,
+ expiresAt,
+ timeout: setTimeout(() => this.deleteProcessAttachToken(token, attach), SFTP_ATTACH_TOKEN_TTL_MS),
+ };
+ this.processAttachTokens.set(token, attach);
+ this.processTokenByMainWebSocket.set(mainWebSocket, token);
+ }
+
+ private deleteProcessAttachToken(token: string, expected?: AuxiliaryAttachToken): void {
+ const attach = this.processAttachTokens.get(token);
+ if (!attach || (expected && attach !== expected)) return;
+ clearTimeout(attach.timeout);
+ this.processAttachTokens.delete(token);
+ if (this.processTokenByMainWebSocket.get(attach.mainWebSocket) === token) {
+ this.processTokenByMainWebSocket.delete(attach.mainWebSocket);
+ }
+ }
+
private deleteSFTPAttachToken(token: string, expected?: SFTPAttachToken): void {
const attach = this.sftpAttachTokens.get(token);
if (!attach || (expected && attach !== expected)) return;
@@ -260,13 +347,13 @@ export class SSHSessionDO implements DurableObject {
}
}
- private isValidSFTPAttachUrl(value: string, token: string): boolean {
+ private isValidAttachUrl(value: string, token: string, pathname: '/api/sftp' | '/api/processes'): boolean {
if (value.length > 2048) return false;
try {
const url = new URL(value, 'https://session.invalid');
- return value.startsWith('/api/sftp?')
+ return value.startsWith(`${pathname}?`)
&& url.origin === 'https://session.invalid'
- && url.pathname === '/api/sftp'
+ && url.pathname === pathname
&& !url.hash
&& url.searchParams.get('token') === token
&& url.searchParams.get('session') === this.state.id.toString();
@@ -316,6 +403,8 @@ export class SSHSessionDO implements DurableObject {
this.clearDeadline(ws);
const token = this.sftpTokenByMainWebSocket.get(ws);
if (token) this.deleteSFTPAttachToken(token);
+ const processToken = this.processTokenByMainWebSocket.get(ws);
+ if (processToken) this.deleteProcessAttachToken(processToken);
const sftpWebSockets = this.sftpWebSocketsByMain.get(ws);
if (sftpWebSockets) {
for (const sftpWebSocket of [...sftpWebSockets]) {
@@ -324,6 +413,14 @@ export class SSHSessionDO implements DurableObject {
}
this.sftpWebSocketsByMain.delete(ws);
}
+ const processWebSockets = this.processWebSocketsByMain.get(ws);
+ if (processWebSockets) {
+ for (const processWebSocket of [...processWebSockets]) {
+ this.cleanupProcessWebSocket(processWebSocket);
+ try { processWebSocket.close(1000, 'SSH session closed'); } catch { /* already closed */ }
+ }
+ this.processWebSocketsByMain.delete(ws);
+ }
const pending = this.pendingConnections.get(ws);
if (pending) {
pending.cancelled = true;
@@ -353,6 +450,25 @@ export class SSHSessionDO implements DurableObject {
return attachment?.role === 'sftp';
}
+ private cleanupProcessWebSocket(ws: WebSocket): void {
+ const session = this.processSessions.get(ws);
+ if (!session) return;
+ this.processSessions.delete(ws);
+ try { session.detachProcessWebSocket(ws); } catch { /* session is already closed */ }
+ const mainWebSocket = this.processOwners.get(ws);
+ this.processOwners.delete(ws);
+ if (!mainWebSocket) return;
+ const sockets = this.processWebSocketsByMain.get(mainWebSocket);
+ sockets?.delete(ws);
+ if (sockets?.size === 0) this.processWebSocketsByMain.delete(mainWebSocket);
+ }
+
+ private isProcessWebSocket(ws: WebSocket): boolean {
+ if (this.processSessions.has(ws)) return true;
+ const attachment = ws.deserializeAttachment() as Attachment | null;
+ return attachment?.role === 'process';
+ }
+
private assertConnectionActive(ws: WebSocket, pending: PendingConnection, socket?: Socket): void {
if (!pending.cancelled && this.pendingConnections.get(ws) === pending && ws.readyState === WebSocket.OPEN) return;
if (socket) this.closePendingSocket(pending, socket);
diff --git a/src/backend/process-framing.ts b/src/backend/process-framing.ts
new file mode 100644
index 0000000..e5e7119
--- /dev/null
+++ b/src/backend/process-framing.ts
@@ -0,0 +1,7 @@
+export function retainTrailingMarkerPrefix(buffer: string, marker: string): string {
+ const maxLength = Math.min(buffer.length, Math.max(0, marker.length - 1));
+ for (let length = maxLength; length > 0; length--) {
+ if (buffer.endsWith(marker.slice(0, length))) return buffer.slice(-length);
+ }
+ return '';
+}
diff --git a/src/backend/session.ts b/src/backend/session.ts
index 76bdd01..1367684 100644
--- a/src/backend/session.ts
+++ b/src/backend/session.ts
@@ -46,6 +46,8 @@ import { SSHChannel, type ChannelDataChunk } from '../ssh/channel';
import { SFTPHandler } from './sftp-handler';
import { classifyHostKey } from '../ssh/host-key';
import { encodeString, readUint32, toBufferSource } from '../ssh/utils';
+import { parseTopSnapshot } from './top-parser';
+import { retainTrailingMarkerPrefix } from './process-framing';
type Cipher = SSHAESGCMCipher | SSHAESCTRCipher;
type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed';
@@ -55,11 +57,22 @@ interface PendingSFTPChannelOpen {
timeout: ReturnType | null;
cancelled: boolean;
}
+interface PendingProcessChannelOpen {
+ readonly channelID: number;
+ readonly channel: SSHChannel;
+ timeout: ReturnType | null;
+ cancelled: boolean;
+}
const LOCAL_WINDOW_THRESHOLD = 512 * 1024;
const MAX_VERSION_BYTES = 8192;
const MAX_QUEUED_INPUT = 1024 * 1024;
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_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__';
+// Octal escapes keep the delimiter itself out of the command line shown by top.
+const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; top -b -n 1 -w 512 2>/dev/null || top -b -n 1 2>/dev/null || exit 127; sleep 2; done'";
const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com');
export class SSHSession {
@@ -108,6 +121,12 @@ export class SSHSession {
private sftpTaskChain: Promise = Promise.resolve();
private sftpQueuedUploadBytes = 0;
private pendingSFTPChannelOpen: PendingSFTPChannelOpen | null = null;
+ private processChannel: SSHChannel | null = null;
+ private processWebSocket: WebSocket | null = null;
+ private processAttachUrl = '';
+ private processBuffer = '';
+ private processDecoder = new TextDecoder();
+ private pendingProcessChannelOpen: PendingProcessChannelOpen | null = null;
private ignoreNextKexPacket = false;
private inputQueue: Uint8Array[] = [];
private queueHeadOffset = 0;
@@ -182,9 +201,14 @@ export class SSHSession {
this.sftpHandler = null;
this.sftpChannel = null;
this.clearPendingSFTPChannelOpen();
+ this.clearPendingProcessChannelOpen();
+ this.processChannel = null;
+ this.processBuffer = '';
this.channels.clear();
try { this.sftpWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ }
this.sftpWebSocket = null;
+ try { this.processWebSocket?.close(normal ? 1000 : 1011, normal ? 'SSH session closed' : 'SSH session failed'); } catch { /* already closed */ }
+ this.processWebSocket = null;
this.pendingHostConfirmation?.resolve(false);
this.pendingHostConfirmation = null;
this.config.password = undefined;
@@ -590,16 +614,27 @@ export class SSHSession {
await this.sendSFTPChannelClose(channel);
throw error;
}
+ } else if (channel === this.processChannel && this.processWebSocket && !this.pendingProcessChannelOpen?.cancelled) {
+ try {
+ await this.sendEncrypted(channel.buildExecRequest(PROCESS_MONITOR_COMMAND));
+ } catch (error) {
+ this.clearPendingProcessChannelOpen(channel);
+ this.processChannel = null;
+ await this.sendAuxiliaryChannelClose(channel);
+ throw error;
+ }
} else {
- // The file WebSocket may close while the channel open is in flight.
+ // An attachment WebSocket may close while its channel is opening.
this.clearPendingSFTPChannelOpen(channel);
- await this.sendSFTPChannelClose(channel);
+ this.clearPendingProcessChannelOpen(channel);
+ await this.sendAuxiliaryChannelClose(channel);
}
return;
}
if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) {
channel.handleOpenFailure(payload);
this.clearPendingSFTPChannelOpen(channel);
+ this.clearPendingProcessChannelOpen(channel);
this.channels.delete(channelID);
if (isShell) throw new Error('SSH server rejected the session channel');
if (channel === this.sftpChannel) {
@@ -607,6 +642,9 @@ export class SSHSession {
this.sftpHandler = null;
this.sftpChannel = null;
this.sendSFTPError('init', 'SSH server rejected the SFTP channel');
+ } else if (channel === this.processChannel) {
+ this.processChannel = null;
+ this.sendProcessError('SSH server rejected the process-monitor channel');
}
return;
}
@@ -637,6 +675,15 @@ export class SSHSession {
this.sftpChannel = null;
this.sendSFTPError('init', 'SSH server rejected the SFTP subsystem');
await this.sendSFTPChannelClose(channel);
+ } else if (channel === this.processChannel) {
+ this.clearPendingProcessChannelOpen(channel);
+ if (type === SSH_MSG_CHANNEL_FAILURE) {
+ this.processChannel = null;
+ this.sendProcessError('SSH server rejected the top command');
+ await this.sendAuxiliaryChannelClose(channel);
+ } else {
+ this.sendProcessJson({ type: 'process_ready' });
+ }
}
return;
}
@@ -652,6 +699,8 @@ export class SSHSession {
this.sendSFTPError('protocol', error instanceof Error ? error.message : String(error));
await this.closeSFTPChannel();
}
+ } else if (channel === this.processChannel) {
+ this.consumeProcessOutput(output);
}
await this.adjustLocalWindow(channel);
return;
@@ -663,6 +712,9 @@ export class SSHSession {
this.ws.send(output);
} else if (channel === this.sftpChannel) {
this.sendSFTPError('protocol', new TextDecoder().decode(output) || 'SFTP channel reported an error');
+ } else if (channel === this.processChannel) {
+ const message = new TextDecoder().decode(output).trim();
+ if (message) this.sendProcessError(message.slice(0, 512));
}
await this.adjustLocalWindow(channel);
return;
@@ -677,6 +729,7 @@ export class SSHSession {
channel.handleEof(payload);
if (isShell) this.status('remote_eof', 'SSH server finished sending output');
else if (channel === this.sftpChannel) this.sftpHandler?.onClosed();
+ else if (channel === this.processChannel) this.flushProcessBuffer();
return;
}
if (type === SSH_MSG_CHANNEL_CLOSE) {
@@ -692,6 +745,11 @@ export class SSHSession {
this.sftpHandler?.onClosed();
this.sftpHandler = null;
this.sftpChannel = null;
+ } else if (channel === this.processChannel) {
+ this.clearPendingProcessChannelOpen(channel);
+ this.flushProcessBuffer();
+ this.processChannel = null;
+ this.sendProcessError('The process monitor stopped');
}
}
return;
@@ -710,6 +768,7 @@ export class SSHSession {
});
this.status('shell_ready', 'Shell is ready');
if (this.sftpAttachUrl) this.sendJson({ type: 'sftp_attach', url: this.sftpAttachUrl });
+ if (this.processAttachUrl) this.sendJson({ type: 'process_attach', url: this.processAttachUrl });
void this.flushInput();
}
@@ -718,6 +777,141 @@ export class SSHSession {
this.sftpAttachUrl = url;
}
+ setProcessAttachUrl(url: string): void {
+ if (!/^\/api\/processes\?/.test(url)) throw new Error('Invalid process attach URL');
+ this.processAttachUrl = url;
+ }
+
+ attachProcessWebSocket(ws: WebSocket): void {
+ if (this.phase === 'closed' || this.processWebSocket) {
+ try { ws.close(1008, 'Process monitor is unavailable'); } catch { /* already closed */ }
+ return;
+ }
+ this.processWebSocket = ws;
+ }
+
+ detachProcessWebSocket(ws: WebSocket): void {
+ if (this.processWebSocket !== ws) return;
+ this.processWebSocket = null;
+ void this.closeProcessChannel();
+ }
+
+ async handleProcessClientMessage(message: string | ArrayBuffer): Promise {
+ if (this.phase === 'closed' || !this.processWebSocket) return;
+ if (message instanceof ArrayBuffer || message.length > 4096) throw new Error('Invalid process-monitor message');
+ let decoded: unknown;
+ try { decoded = JSON.parse(message); } catch { throw new Error('Invalid process-monitor JSON'); }
+ if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error('Invalid process-monitor message');
+ const value = decoded as Record;
+ 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; }
+ throw new Error('Unsupported process-monitor message');
+ }
+
+ 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');
+ if (this.pendingProcessChannelOpen?.cancelled) throw new Error('The previous process channel is still closing');
+ if (this.processChannel) {
+ this.sendProcessJson({ type: 'process_ready' });
+ return;
+ }
+ const channelID = this.nextChannelID++;
+ const channel = new SSHChannel();
+ const pending: PendingProcessChannelOpen = { channelID, channel, timeout: null, cancelled: false };
+ this.channels.set(channelID, channel);
+ this.processChannel = channel;
+ this.processBuffer = '';
+ this.processDecoder = new TextDecoder();
+ this.pendingProcessChannelOpen = pending;
+ try {
+ await this.sendEncrypted(channel.buildOpenSession(channelID));
+ if (this.pendingProcessChannelOpen === pending) {
+ pending.timeout = setTimeout(() => this.expirePendingProcessChannelOpen(pending), PROCESS_CHANNEL_OPEN_TIMEOUT_MS);
+ }
+ } catch (error) {
+ this.clearPendingProcessChannelOpen(channel);
+ this.channels.delete(channelID);
+ this.processChannel = null;
+ throw error;
+ }
+ }
+
+ private async closeProcessChannel(): Promise {
+ const channel = this.processChannel;
+ const pending = this.pendingProcessChannelOpen;
+ if (pending?.channel === channel) pending.cancelled = true;
+ this.processChannel = null;
+ this.processBuffer = '';
+ if (!channel?.isOpen()) return;
+ await this.sendAuxiliaryChannelClose(channel);
+ }
+
+ private consumeProcessOutput(data: Uint8Array): void {
+ this.processBuffer += this.processDecoder.decode(data, { stream: true });
+ if (this.processBuffer.length > PROCESS_MAX_BUFFER_BYTES) {
+ const marker = this.processBuffer.lastIndexOf(PROCESS_SNAPSHOT_MARKER);
+ this.processBuffer = marker >= 0
+ ? this.processBuffer.slice(marker)
+ : retainTrailingMarkerPrefix(this.processBuffer, PROCESS_SNAPSHOT_MARKER);
+ this.sendProcessError('Process snapshot exceeded the buffer limit');
+ return;
+ }
+ while (true) {
+ const first = this.processBuffer.indexOf(PROCESS_SNAPSHOT_MARKER);
+ if (first < 0) {
+ this.processBuffer = retainTrailingMarkerPrefix(this.processBuffer, PROCESS_SNAPSHOT_MARKER);
+ return;
+ }
+ if (first > 0) this.processBuffer = this.processBuffer.slice(first);
+ const next = this.processBuffer.indexOf(PROCESS_SNAPSHOT_MARKER, PROCESS_SNAPSHOT_MARKER.length);
+ if (next < 0) return;
+ this.emitProcessSnapshot(this.processBuffer.slice(PROCESS_SNAPSHOT_MARKER.length, next));
+ this.processBuffer = this.processBuffer.slice(next);
+ }
+ }
+
+ private flushProcessBuffer(): void {
+ this.processBuffer += this.processDecoder.decode();
+ const marker = this.processBuffer.lastIndexOf(PROCESS_SNAPSHOT_MARKER);
+ if (marker >= 0) this.emitProcessSnapshot(this.processBuffer.slice(marker + PROCESS_SNAPSHOT_MARKER.length));
+ this.processBuffer = '';
+ this.processDecoder = new TextDecoder();
+ }
+
+ private emitProcessSnapshot(raw: string): void {
+ const snapshot = parseTopSnapshot(raw);
+ if (snapshot) this.sendProcessJson({ type: 'process_snapshot', ...snapshot });
+ }
+
+ private expirePendingProcessChannelOpen(expected: PendingProcessChannelOpen): void {
+ if (this.pendingProcessChannelOpen !== expected) return;
+ this.pendingProcessChannelOpen = null;
+ expected.timeout = null;
+ expected.cancelled = true;
+ if (this.processChannel === expected.channel) this.processChannel = null;
+ this.sendProcessError('Timed out opening the process-monitor channel');
+ if (expected.channel.isOpen()) void this.sendAuxiliaryChannelClose(expected.channel);
+ try { this.processWebSocket?.close(1011, 'Process channel setup timed out'); } catch { /* already closed */ }
+ }
+
+ private clearPendingProcessChannelOpen(channel?: SSHChannel): void {
+ const pending = this.pendingProcessChannelOpen;
+ if (!pending || (channel && pending.channel !== channel)) return;
+ if (pending.timeout) clearTimeout(pending.timeout);
+ pending.timeout = null;
+ this.pendingProcessChannelOpen = null;
+ }
+
+ private sendProcessJson(value: unknown): void {
+ if (this.processWebSocket?.readyState === WebSocket.OPEN) this.processWebSocket.send(JSON.stringify(value));
+ }
+
+ private sendProcessError(message: string): void {
+ this.sendProcessJson({ type: 'process_error', message });
+ }
+
attachSFTPWebSocket(ws: WebSocket): void {
if (this.phase === 'closed' || this.sftpWebSocket) {
try { ws.close(1008, 'SFTP connection is unavailable'); } catch { /* already closed */ }
@@ -888,6 +1082,10 @@ export class SSHSession {
}
private async sendSFTPChannelClose(channel: SSHChannel): Promise {
+ await this.sendAuxiliaryChannelClose(channel);
+ }
+
+ private async sendAuxiliaryChannelClose(channel: SSHChannel): Promise {
if (!channel.isOpen() || channel.hasSentClose()) return;
try {
await this.sendEncrypted(channel.buildEof());
diff --git a/src/backend/top-parser.ts b/src/backend/top-parser.ts
new file mode 100644
index 0000000..a5ed431
--- /dev/null
+++ b/src/backend/top-parser.ts
@@ -0,0 +1,159 @@
+export interface ProcessMetrics {
+ cpuPercent: number | null;
+ loadAverage: [number, number, number] | null;
+ memory: ResourceUsage | null;
+ swap: ResourceUsage | null;
+}
+
+export interface ResourceUsage {
+ usedBytes: number;
+ totalBytes: number;
+ percent: number;
+}
+
+export interface ProcessEntry {
+ pid: number;
+ user: string;
+ memoryBytes: number | null;
+ memoryPercent: number | null;
+ cpuPercent: number | null;
+ state: string;
+ time: string;
+ command: string;
+}
+
+export interface ProcessSnapshot {
+ metrics: ProcessMetrics;
+ processes: ProcessEntry[];
+ timestamp: number;
+}
+
+const ANSI_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
+const MAX_PROCESSES = 512;
+
+function finiteNumber(value: string | undefined): number | null {
+ if (!value) return null;
+ const parsed = Number(value.replace(',', '.').replace(/%$/, ''));
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
+}
+
+function clampPercent(value: number): number {
+ return Math.min(100, Math.max(0, value));
+}
+
+function parseLoad(lines: string[]): [number, number, number] | null {
+ for (const line of lines) {
+ const match = line.match(/load average:\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
+ if (!match) continue;
+ const values = match.slice(1, 4).map((value) => finiteNumber(value));
+ if (values.every((value): value is number => value !== null)) return values as [number, number, number];
+ }
+ return null;
+}
+
+function parseCPU(lines: string[]): number | null {
+ for (const line of lines) {
+ if (!/(?:^|\s)(?:%?Cpu\(s\)|CPU):/i.test(line)) continue;
+ const idle = line.match(/([\d.]+)\s*%?\s*(?:id|idle)\b/i);
+ const idlePercent = finiteNumber(idle?.[1]);
+ if (idlePercent !== null) return clampPercent(100 - idlePercent);
+ }
+ return null;
+}
+
+function unitMultiplier(label: string | undefined): number {
+ const unit = label?.toLowerCase() ?? '';
+ if (unit.startsWith('g')) return 1024 ** 3;
+ if (unit.startsWith('m')) return 1024 ** 2;
+ if (unit.startsWith('k')) return 1024;
+ return 1;
+}
+
+function parseUsage(lines: string[], kind: 'Mem' | 'Swap'): ResourceUsage | null {
+ for (const line of lines) {
+ if (!new RegExp(`(?:^|\\s)(?:KiB|MiB|GiB)?\\s*${kind}\\s*:`, 'i').test(line)) continue;
+ const unit = line.match(/(?:^|\s)(KiB|MiB|GiB)\s*(?:Mem|Swap)\s*:/i)?.[1];
+ const multiplier = unitMultiplier(unit ?? 'KiB');
+ const amount = (label: 'total' | 'used' | 'free'): number | null => {
+ const match = line.match(new RegExp(`([\\d.]+)\\s*([kmgt]?)\\s+${label}\\b`, 'i'));
+ const value = finiteNumber(match?.[1]);
+ return value === null ? null : value * (match?.[2] ? unitMultiplier(match[2]) : multiplier);
+ };
+ const total = amount('total');
+ const used = amount('used');
+ const free = amount('free');
+ const resolvedTotal = total ?? (used !== null && free !== null ? used + free : null);
+ if (resolvedTotal === null || used === null || resolvedTotal < 0) continue;
+ const totalBytes = Math.round(resolvedTotal);
+ const usedBytes = Math.min(totalBytes, Math.round(used));
+ return {
+ usedBytes,
+ totalBytes,
+ percent: totalBytes > 0 ? clampPercent((usedBytes / totalBytes) * 100) : 0,
+ };
+ }
+ return null;
+}
+
+function headerIndex(headers: string[], ...names: string[]): number {
+ return headers.findIndex((header) => names.includes(header.toUpperCase()));
+}
+
+function parseMemoryValue(value: string | undefined): number | null {
+ if (!value || !/^[\d.]+[kmgtpe]?b?$/i.test(value)) return null;
+ const match = value.match(/^([\d.]+)([kmgtpe]?)/i);
+ const amount = finiteNumber(match?.[1]);
+ if (amount === null) return null;
+ const suffix = match?.[2].toLowerCase() ?? '';
+ const power = suffix ? 'kmgtpe'.indexOf(suffix) + 1 : 1;
+ return Math.round(amount * (1024 ** power));
+}
+
+function parseProcesses(lines: string[]): ProcessEntry[] {
+ const headerLineIndex = lines.findIndex((line) => /^\s*PID\s+/i.test(line) && /(?:^|\s)%?CPU(?:\s|$)/i.test(line));
+ if (headerLineIndex < 0) return [];
+ const headers = lines[headerLineIndex].trim().split(/\s+/);
+ const pidIndex = headerIndex(headers, 'PID');
+ const userIndex = headerIndex(headers, 'USER', 'USERNAME');
+ const cpuIndex = headerIndex(headers, '%CPU', 'CPU%');
+ const residentMemoryIndex = headerIndex(headers, 'RES', 'RSS');
+ const memoryIndex = residentMemoryIndex >= 0 ? residentMemoryIndex : headerIndex(headers, 'VSZ', 'VIRT');
+ const memoryPercentIndex = headerIndex(headers, '%MEM', 'MEM%', '%VSZ');
+ const stateIndex = headerIndex(headers, 'S', 'STAT', 'STATE');
+ const timeIndex = headerIndex(headers, 'TIME+', 'TIME');
+ const commandIndex = headerIndex(headers, 'COMMAND', 'CMD', 'COMMAND+');
+ if (pidIndex < 0 || commandIndex < 0) return [];
+
+ const processes: ProcessEntry[] = [];
+ for (const line of lines.slice(headerLineIndex + 1)) {
+ if (!line.trim()) continue;
+ const fields = line.trim().split(/\s+/);
+ const pid = Number(fields[pidIndex]);
+ if (!Number.isSafeInteger(pid) || pid <= 0 || fields.length <= commandIndex) continue;
+ processes.push({
+ pid,
+ user: userIndex >= 0 ? fields[userIndex] ?? '' : '',
+ memoryBytes: memoryIndex >= 0 ? parseMemoryValue(fields[memoryIndex]) : null,
+ memoryPercent: memoryPercentIndex >= 0 ? finiteNumber(fields[memoryPercentIndex]) : null,
+ cpuPercent: cpuIndex >= 0 ? finiteNumber(fields[cpuIndex]) : null,
+ state: stateIndex >= 0 ? fields[stateIndex] ?? '' : '',
+ time: timeIndex >= 0 ? fields[timeIndex] ?? '' : '',
+ command: fields.slice(commandIndex).join(' '),
+ });
+ if (processes.length >= MAX_PROCESSES) break;
+ }
+ return processes;
+}
+
+export function parseTopSnapshot(raw: string, timestamp = Date.now()): ProcessSnapshot | null {
+ const lines = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, '').split('\n');
+ const processes = parseProcesses(lines);
+ const metrics: ProcessMetrics = {
+ cpuPercent: parseCPU(lines),
+ loadAverage: parseLoad(lines),
+ memory: parseUsage(lines, 'Mem'),
+ swap: parseUsage(lines, 'Swap'),
+ };
+ if (processes.length === 0 && Object.values(metrics).every((value) => value === null)) return null;
+ return { metrics, processes, timestamp };
+}
diff --git a/src/ssh/channel.ts b/src/ssh/channel.ts
index ebccb92..7fe4f3a 100644
--- a/src/ssh/channel.ts
+++ b/src/ssh/channel.ts
@@ -11,6 +11,7 @@ import { encodeString, readUint32, writeUint32 } from './utils';
const SESSION_FIELD = encodeString('session');
const PTY_REQ_FIELD = encodeString('pty-req');
const SHELL_FIELD = encodeString('shell');
+const EXEC_FIELD = encodeString('exec');
const SUBSYSTEM_FIELD = encodeString('subsystem');
const WINDOW_CHANGE_FIELD = encodeString('window-change');
const EMPTY_TERMINAL_MODES_FIELD = encodeString(new Uint8Array([0]));
@@ -145,6 +146,23 @@ export class SSHChannel {
return payload;
}
+ buildExecRequest(command: string): Uint8Array {
+ const commandBytes = new TextEncoder().encode(command);
+ if (commandBytes.length === 0 || commandBytes.length > 4096 || /[\0\r\n]/.test(command)) {
+ throw new Error('Invalid SSH exec command');
+ }
+ const commandField = encodeString(commandBytes);
+ const payload = new Uint8Array(1 + 4 + EXEC_FIELD.length + 1 + commandField.length);
+ let offset = 0;
+ payload[offset++] = SSH_MSG_CHANNEL_REQUEST;
+ writeUint32(payload, offset, this.getRemoteChannelID());
+ offset += 4;
+ offset = writeBytes(payload, offset, EXEC_FIELD);
+ payload[offset++] = 0x01;
+ writeBytes(payload, offset, commandField);
+ return payload;
+ }
+
buildSubsystemRequest(subsystem: string): Uint8Array {
if (!/^[A-Za-z0-9@._+-]{1,64}$/.test(subsystem)) throw new Error('Invalid SSH subsystem name');
const subsystemField = encodeString(subsystem);
diff --git a/src/worker.ts b/src/worker.ts
index c8ef1e4..83c2dac 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -51,13 +51,19 @@ async function sshUpgrade(request: Request, env: Env): Promise {
headers.delete('Cookie');
headers.delete('Authorization');
const sftpAttachToken = crypto.randomUUID();
+ const processAttachToken = crypto.randomUUID();
const sftpAttachUrl = new URL('/api/sftp', 'https://session.invalid');
sftpAttachUrl.searchParams.set('session', id.toString());
sftpAttachUrl.searchParams.set('token', sftpAttachToken);
+ const processAttachUrl = new URL('/api/processes', 'https://session.invalid');
+ processAttachUrl.searchParams.set('session', id.toString());
+ processAttachUrl.searchParams.set('token', processAttachToken);
headers.set('x-session-ticket', ticket);
headers.set('x-client-ip', clientAddress(request));
headers.set('x-sftp-attach-token', sftpAttachToken);
headers.set('x-sftp-attach-url', `${sftpAttachUrl.pathname}${sftpAttachUrl.search}`);
+ headers.set('x-process-attach-token', processAttachToken);
+ headers.set('x-process-attach-url', `${processAttachUrl.pathname}${processAttachUrl.search}`);
const response = await env.SSH_SESSIONS.get(id).fetch(new Request('https://session.internal/connect', { headers }));
if (response.status === 401) return jsonError('Invalid, expired, or already used session ticket', 401);
return response;
@@ -87,6 +93,30 @@ async function sftpUpgrade(request: Request, env: Env): Promise {
return response;
}
+async function processUpgrade(request: Request, env: Env): Promise {
+ if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') return jsonError('WebSocket upgrade required', 426);
+ if (!hasValidWebSocketOrigin(request)) return jsonError('WebSocket origin is not allowed', 403);
+ const url = new URL(request.url);
+ const sessionId = url.searchParams.get('session');
+ const token = url.searchParams.get('token');
+ if (!sessionId || !token) return jsonError('Missing process attachment authorization', 401);
+ let id: DurableObjectId;
+ try { id = env.SSH_SESSIONS.idFromString(sessionId); } catch { return jsonError('Invalid session identifier', 401); }
+ const headers = new Headers(request.headers);
+ headers.delete('Cookie');
+ headers.delete('Authorization');
+ headers.delete('x-session-ticket');
+ headers.delete('x-client-ip');
+ headers.delete('x-process-attach-url');
+ headers.set('x-process-attach-token', token);
+ const response = await env.SSH_SESSIONS.get(id).fetch(new Request('https://session.internal/processes', {
+ method: 'GET',
+ headers,
+ }));
+ if (response.status === 401) return jsonError('Invalid, expired, or already used process attachment token', 401);
+ return response;
+}
+
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
@@ -117,6 +147,10 @@ export default {
if (request.method !== 'GET') return corsResponse(jsonError('Method not allowed', 405));
return corsResponse(await sftpUpgrade(request, env));
}
+ if (url.pathname === '/api/processes') {
+ if (request.method !== 'GET') return corsResponse(jsonError('Method not allowed', 405));
+ return corsResponse(await processUpgrade(request, env));
+ }
if (isApiRequest) return corsResponse(jsonError('Not found', 404));
if (!env.ASSETS) return jsonError('Static assets binding is not configured', 503);
return secureResponse(await env.ASSETS.fetch(request));