mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat: add GNU screen wrapping with process monitor
Add screen session management infrastructure: - ScreenManager class to create, track, and kill GNU screen sessions - Persistence of screen sessions to ~/.claudeman/screens.json - Process stats collection (memory, CPU, child count) - Reconciliation to detect dead/orphan screens New API endpoints: - GET /api/screens - list screens with stats - DELETE /api/screens/:sessionId - kill screen - POST /api/screens/reconcile - find dead screens - POST /api/screens/stats/start|stop - control stats polling New Process Monitor panel: - Slide-up panel with real-time stats (2s updates) - Shows memory, CPU, child processes per screen - Kill button for each screen session - Reconcile button to clean up dead screens Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ScreenSession, ProcessStats, ScreenSessionWithStats } from './types.js';
|
||||
|
||||
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
|
||||
export class ScreenManager extends EventEmitter {
|
||||
private screens: Map<string, ScreenSession> = new Map();
|
||||
private statsInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadScreens();
|
||||
}
|
||||
|
||||
// Load saved screens from disk
|
||||
private loadScreens(): void {
|
||||
try {
|
||||
if (existsSync(SCREENS_FILE)) {
|
||||
const content = readFileSync(SCREENS_FILE, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data)) {
|
||||
for (const screen of data) {
|
||||
this.screens.set(screen.sessionId, screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ScreenManager] Failed to load screens:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Save screens to disk
|
||||
private saveScreens(): void {
|
||||
try {
|
||||
const dir = dirname(SCREENS_FILE);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const data = Array.from(this.screens.values());
|
||||
writeFileSync(SCREENS_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[ScreenManager] Failed to save screens:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new GNU screen session
|
||||
async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell'): Promise<ScreenSession> {
|
||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
// Create screen in detached mode with the appropriate command
|
||||
const cmd = mode === 'claude' ? 'claude --dangerously-skip-permissions' : '$SHELL';
|
||||
|
||||
try {
|
||||
// Start screen in detached mode
|
||||
const screenProcess = spawn('screen', [
|
||||
'-dmS', screenName,
|
||||
'-c', '/dev/null', // Use empty config
|
||||
'bash', '-c', `cd "${workingDir}" && ${cmd}`
|
||||
], {
|
||||
cwd: workingDir,
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
|
||||
screenProcess.unref();
|
||||
|
||||
// Wait a moment for screen to start
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get the PID of the screen session
|
||||
const pid = this.getScreenPid(screenName);
|
||||
if (!pid) {
|
||||
throw new Error('Failed to get screen PID');
|
||||
}
|
||||
|
||||
const screen: ScreenSession = {
|
||||
sessionId,
|
||||
screenName,
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode,
|
||||
attached: false
|
||||
};
|
||||
|
||||
this.screens.set(sessionId, screen);
|
||||
this.saveScreens();
|
||||
this.emit('screenCreated', screen);
|
||||
|
||||
return screen;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to create screen: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get screen session PID
|
||||
private getScreenPid(screenName: string): number | null {
|
||||
try {
|
||||
const output = execSync(`screen -ls | grep "${screenName}"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000
|
||||
});
|
||||
// Output format: "12345.claudeman-abc12345 (Detached)"
|
||||
const match = output.match(/(\d+)\./);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Kill a screen session
|
||||
async killScreen(sessionId: string): Promise<boolean> {
|
||||
const screen = this.screens.get(sessionId);
|
||||
if (!screen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Kill screen session by name
|
||||
execSync(`screen -S ${screen.screenName} -X quit`, {
|
||||
timeout: 5000
|
||||
});
|
||||
} catch {
|
||||
// Try killing by PID if name-based kill failed
|
||||
try {
|
||||
process.kill(screen.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
|
||||
this.screens.delete(sessionId);
|
||||
this.saveScreens();
|
||||
this.emit('screenKilled', { sessionId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get all tracked screens
|
||||
getScreens(): ScreenSession[] {
|
||||
return Array.from(this.screens.values());
|
||||
}
|
||||
|
||||
// Get screen by session ID
|
||||
getScreen(sessionId: string): ScreenSession | undefined {
|
||||
return this.screens.get(sessionId);
|
||||
}
|
||||
|
||||
// Reconcile screens - find orphaned/dead screens
|
||||
async reconcileScreens(): Promise<{ alive: string[]; dead: string[] }> {
|
||||
const alive: string[] = [];
|
||||
const dead: string[] = [];
|
||||
|
||||
for (const [sessionId, screen] of this.screens) {
|
||||
const pid = this.getScreenPid(screen.screenName);
|
||||
if (pid) {
|
||||
alive.push(sessionId);
|
||||
// Update PID if it changed
|
||||
if (pid !== screen.pid) {
|
||||
screen.pid = pid;
|
||||
}
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.screens.delete(sessionId);
|
||||
this.emit('screenDied', { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (dead.length > 0) {
|
||||
this.saveScreens();
|
||||
}
|
||||
|
||||
return { alive, dead };
|
||||
}
|
||||
|
||||
// Get process stats for a screen
|
||||
async getProcessStats(sessionId: string): Promise<ProcessStats | null> {
|
||||
const screen = this.screens.get(sessionId);
|
||||
if (!screen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get memory and CPU usage using ps
|
||||
const psOutput = execSync(
|
||||
`ps -o rss=,pcpu= -p ${screen.pid} 2>/dev/null || echo "0 0"`,
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
).trim();
|
||||
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0);
|
||||
|
||||
// Count child processes
|
||||
let childCount = 0;
|
||||
try {
|
||||
const childOutput = execSync(
|
||||
`pgrep -P ${screen.pid} | wc -l`,
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
).trim();
|
||||
childCount = parseInt(childOutput, 10) || 0;
|
||||
} catch {
|
||||
// No children or command failed
|
||||
}
|
||||
|
||||
return {
|
||||
memoryMB: Math.round(rss / 1024 * 10) / 10, // KB to MB
|
||||
cpuPercent: Math.round(cpu * 10) / 10,
|
||||
childCount,
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all screens with stats
|
||||
async getScreensWithStats(): Promise<ScreenSessionWithStats[]> {
|
||||
const result: ScreenSessionWithStats[] = [];
|
||||
|
||||
for (const screen of this.screens.values()) {
|
||||
const stats = await this.getProcessStats(screen.sessionId);
|
||||
result.push({ ...screen, stats: stats || undefined });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Start periodic stats collection
|
||||
startStatsCollection(intervalMs: number = 2000): void {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
|
||||
this.statsInterval = setInterval(async () => {
|
||||
const screensWithStats = await this.getScreensWithStats();
|
||||
this.emit('statsUpdated', screensWithStats);
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
// Stop stats collection
|
||||
stopStatsCollection(): void {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
this.statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Register a session as using screen (for when session creates its own screen)
|
||||
registerScreen(screen: ScreenSession): void {
|
||||
this.screens.set(screen.sessionId, screen);
|
||||
this.saveScreens();
|
||||
}
|
||||
|
||||
// Mark screen as attached/detached
|
||||
setAttached(sessionId: string, attached: boolean): void {
|
||||
const screen = this.screens.get(sessionId);
|
||||
if (screen) {
|
||||
screen.attached = attached;
|
||||
this.saveScreens();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if screen is available on the system
|
||||
static isScreenAvailable(): boolean {
|
||||
try {
|
||||
execSync('which screen', { encoding: 'utf-8', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,28 @@ export interface CaseInfo {
|
||||
hasClaudeMd?: boolean;
|
||||
}
|
||||
|
||||
// Screen session types for GNU screen wrapping
|
||||
export interface ScreenSession {
|
||||
sessionId: string; // Claudeman session ID
|
||||
screenName: string; // GNU screen session name
|
||||
pid: number; // Screen process PID
|
||||
createdAt: number;
|
||||
workingDir: string;
|
||||
mode: 'claude' | 'shell';
|
||||
attached: boolean; // Whether webserver is attached
|
||||
}
|
||||
|
||||
export interface ProcessStats {
|
||||
memoryMB: number; // Memory usage in MB
|
||||
cpuPercent: number; // CPU usage percentage
|
||||
childCount: number; // Number of child processes
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ScreenSessionWithStats extends ScreenSession {
|
||||
stats?: ProcessStats;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: AppConfig = {
|
||||
pollIntervalMs: 1000,
|
||||
defaultTimeoutMs: 300000, // 5 minutes
|
||||
|
||||
@@ -14,6 +14,7 @@ class ClaudemanApp {
|
||||
this.terminalBuffers = new Map(); // Store terminal content per session
|
||||
this.editingSessionId = null; // Session being edited in options modal
|
||||
this.pendingCloseSessionId = null; // Session pending close confirmation
|
||||
this.screenSessions = []; // Screen sessions for process monitor
|
||||
|
||||
// Terminal write batching
|
||||
this.pendingWrites = '';
|
||||
@@ -405,6 +406,33 @@ class ClaudemanApp {
|
||||
this.renderTaskPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Screen events
|
||||
this.eventSource.addEventListener('screen:created', (e) => {
|
||||
const screen = JSON.parse(e.data);
|
||||
this.screenSessions.push(screen);
|
||||
this.renderProcessPanel();
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('screen:killed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
this.renderProcessPanel();
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('screen:died', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
this.renderProcessPanel();
|
||||
this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning');
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('screen:statsUpdated', (e) => {
|
||||
this.screenSessions = JSON.parse(e.data);
|
||||
if (document.getElementById('processPanel').classList.contains('open')) {
|
||||
this.renderProcessPanel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConnectionStatus(status) {
|
||||
@@ -1413,6 +1441,7 @@ class ClaudemanApp {
|
||||
document.getElementById('respawnPanel').classList.remove('open');
|
||||
document.getElementById('newSessionPanel').classList.remove('open');
|
||||
document.getElementById('taskPanel').classList.remove('open');
|
||||
document.getElementById('processPanel').classList.remove('open');
|
||||
}
|
||||
|
||||
// ========== Task Panel ==========
|
||||
@@ -1497,6 +1526,97 @@ class ClaudemanApp {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========== Process Panel ==========
|
||||
|
||||
async toggleProcessPanel() {
|
||||
const panel = document.getElementById('processPanel');
|
||||
panel.classList.toggle('open');
|
||||
|
||||
if (panel.classList.contains('open')) {
|
||||
// Load screens and start stats collection
|
||||
await this.loadScreens();
|
||||
await fetch('/api/screens/stats/start', { method: 'POST' });
|
||||
} else {
|
||||
// Stop stats collection when panel is closed
|
||||
await fetch('/api/screens/stats/stop', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
|
||||
async loadScreens() {
|
||||
try {
|
||||
const res = await fetch('/api/screens');
|
||||
const data = await res.json();
|
||||
this.screenSessions = data.screens || [];
|
||||
this.renderProcessPanel();
|
||||
} catch (err) {
|
||||
console.error('Failed to load screens:', err);
|
||||
}
|
||||
}
|
||||
|
||||
renderProcessPanel() {
|
||||
const body = document.getElementById('processPanelBody');
|
||||
|
||||
if (!this.screenSessions || this.screenSessions.length === 0) {
|
||||
body.innerHTML = '<div class="process-empty">No screen sessions tracked</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const screen of this.screenSessions) {
|
||||
const stats = screen.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 };
|
||||
const modeClass = screen.mode === 'shell' ? 'shell' : '';
|
||||
|
||||
html += `
|
||||
<div class="process-item">
|
||||
<span class="process-mode ${modeClass}">${screen.mode}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${this.escapeHtml(screen.screenName)}</div>
|
||||
<div class="process-meta">
|
||||
<span class="process-stat memory">${stats.memoryMB}MB</span>
|
||||
<span class="process-stat cpu">${stats.cpuPercent}%</span>
|
||||
<span class="process-stat children">${stats.childCount} children</span>
|
||||
<span>PID: ${screen.pid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${screen.sessionId}')" title="Kill screen">Kill</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
async killScreen(sessionId) {
|
||||
if (!confirm('Kill this screen session?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' });
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId);
|
||||
this.renderProcessPanel();
|
||||
this.showToast('Screen killed', 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to kill screen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileScreens() {
|
||||
try {
|
||||
const res = await fetch('/api/screens/reconcile', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.dead && data.dead.length > 0) {
|
||||
this.showToast(`Found ${data.dead.length} dead screen(s)`, 'warning');
|
||||
await this.loadScreens();
|
||||
} else {
|
||||
this.showToast('All screens are alive', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to reconcile screens', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Toast ==========
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
|
||||
<button class="btn-toolbar" onclick="app.copyTerminal()" title="Copy terminal">Copy</button>
|
||||
<button class="btn-toolbar" onclick="app.clearTerminal()" title="Clear terminal (Ctrl+L)">Clear</button>
|
||||
<button class="btn-toolbar" onclick="app.toggleProcessPanel()" title="Process Monitor">Processes</button>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
@@ -226,6 +227,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process Monitor Panel (slide-up) -->
|
||||
<div class="process-panel" id="processPanel">
|
||||
<div class="process-panel-header">
|
||||
<span>Process Monitor</span>
|
||||
<div class="process-panel-actions">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.reconcileScreens()" title="Check for dead screens">Reconcile</button>
|
||||
<button class="btn-close" onclick="app.toggleProcessPanel()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-panel-body" id="processPanelBody">
|
||||
<div class="process-empty">No screen sessions tracked</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Tree Panel (slide-up) -->
|
||||
<div class="task-panel" id="taskPanel">
|
||||
<div class="task-panel-header">
|
||||
|
||||
@@ -1118,6 +1118,124 @@ kbd {
|
||||
background: var(--border-light);
|
||||
}
|
||||
|
||||
/* Process Monitor Panel */
|
||||
.process-panel {
|
||||
position: fixed;
|
||||
bottom: var(--toolbar-height);
|
||||
left: 0.5rem;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 1rem);
|
||||
max-height: 50vh;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.process-panel.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.process-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.process-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.process-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.process-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.process-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.process-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.process-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.process-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.process-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.process-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.process-stat.memory { color: var(--accent-hover); }
|
||||
.process-stat.cpu { color: var(--green); }
|
||||
.process-stat.children { color: var(--yellow); }
|
||||
|
||||
.process-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.process-mode {
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.process-mode.shell {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.header-brand {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import { ScreenManager } from '../screen-manager.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -53,6 +54,7 @@ export class WebServer extends EventEmitter {
|
||||
private sseClients: Set<FastifyReply> = new Set();
|
||||
private store = getStore();
|
||||
private port: number;
|
||||
private screenManager: ScreenManager;
|
||||
// Terminal batching for performance
|
||||
private terminalBatches: Map<string, string> = new Map();
|
||||
private terminalBatchTimer: NodeJS.Timeout | null = null;
|
||||
@@ -61,6 +63,21 @@ export class WebServer extends EventEmitter {
|
||||
super();
|
||||
this.port = port;
|
||||
this.app = Fastify({ logger: false });
|
||||
this.screenManager = new ScreenManager();
|
||||
|
||||
// Set up screen manager event listeners
|
||||
this.screenManager.on('screenCreated', (screen) => {
|
||||
this.broadcast('screen:created', screen);
|
||||
});
|
||||
this.screenManager.on('screenKilled', (data) => {
|
||||
this.broadcast('screen:killed', data);
|
||||
});
|
||||
this.screenManager.on('screenDied', (data) => {
|
||||
this.broadcast('screen:died', data);
|
||||
});
|
||||
this.screenManager.on('statsUpdated', (screens) => {
|
||||
this.broadcast('screen:statsUpdated', screens);
|
||||
});
|
||||
}
|
||||
|
||||
private async setupRoutes(): Promise<void> {
|
||||
@@ -684,6 +701,42 @@ export class WebServer extends EventEmitter {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Screen Management Endpoints ============
|
||||
|
||||
// Get all tracked screens with stats
|
||||
this.app.get('/api/screens', async () => {
|
||||
const screens = await this.screenManager.getScreensWithStats();
|
||||
return {
|
||||
screens,
|
||||
screenAvailable: ScreenManager.isScreenAvailable()
|
||||
};
|
||||
});
|
||||
|
||||
// Kill a screen session
|
||||
this.app.delete('/api/screens/:sessionId', async (req) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
const success = await this.screenManager.killScreen(sessionId);
|
||||
return { success };
|
||||
});
|
||||
|
||||
// Reconcile screens (find dead ones)
|
||||
this.app.post('/api/screens/reconcile', async () => {
|
||||
const result = await this.screenManager.reconcileScreens();
|
||||
return result;
|
||||
});
|
||||
|
||||
// Start stats collection
|
||||
this.app.post('/api/screens/stats/start', async () => {
|
||||
this.screenManager.startStatsCollection(2000);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Stop stats collection
|
||||
this.app.post('/api/screens/stats/stop', async () => {
|
||||
this.screenManager.stopStatsCollection();
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
private setupSessionListeners(session: Session): void {
|
||||
@@ -988,6 +1041,9 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.terminalBatches.clear();
|
||||
|
||||
// Stop screen stats collection
|
||||
this.screenManager.stopStatsCollection();
|
||||
|
||||
// Stop all respawn controllers
|
||||
for (const controller of this.respawnControllers.values()) {
|
||||
controller.stop();
|
||||
|
||||
Reference in New Issue
Block a user