Files
EdgeSSH/frontend/src/process-manager.ts
T

343 lines
13 KiB
TypeScript

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;
}
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<ResourceUsage>;
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<ProcessEntry>;
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<ProcessSnapshot>;
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;
constructor(options: ProcessManagerOptions) {
this.elements = options.elements;
this.getLanguage = options.getLanguage;
this.onError = options.onError;
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.render();
}
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<string, unknown>;
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 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);
progress.value = value === null ? 0 : Math.min(100, value);
if (value === null) {
valueElement.style.removeProperty('--usage-color');
progress.style.removeProperty('--usage-color');
return;
}
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 = '--';
progress.value = 0;
valueElement.style.removeProperty('--usage-color');
progress.style.removeProperty('--usage-color');
}
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;
}
valueElement.textContent = formatPercent(usage.percent);
amountElement.textContent = `${formatBytes(usage.usedBytes)}/${formatBytes(usage.totalBytes)}`;
progress.value = usage.percent;
}
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<T extends HTMLElement>(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'),
};
}