Adds real-time subprocess monitoring to the IDE via the callback-chain and subscribe/broadcast socket patterns: Shared types (packages/api): - New subprocess.ts message types: SubProcessStat, RequestProcessStatsMessage, SubscribeDroneMessage, UnsubscribeDroneMessage - New socket events in socket.ts: requestProcessStats, subscribeDrone, unsubscribeDrone (ClientToServer); drone:log, drone:status (ServerToClient) Drone (gadget-drone): - onRequestProcessStats handler calls SubProcessService.ps() + summarize(), returns typed SubProcessStat[] with status detection (exitCode/killed) Backend routing (gadget-code): - SocketService: getDroneSessionByRegistrationId(), droneMonitorIndex map, addDroneMonitor()/removeDroneMonitor()/broadcastToMonitors() - CodeSession: requestProcessStats proxy, subscribeDrone/unsubscribeDrone - DroneSession: broadcast drone:log + drone:status to monitor subscribers in addition to existing chat-session routing Frontend socket layer: - SocketClient: requestProcessStats(), subscribeDrone(), unsubscribeDrone() - drone:log + drone:status events forwarded to event bus Frontend components: - LogRenderer — reusable log rendering core extracted from LogPanel - SubProcessTable — btop-style process table with status dots - DroneMonitorGauge — canvas oscilloscope waveform (studio-equipment aesthetic) - DroneMonitor — 3-gauge container (CPU/red, NETWORK/cyan, FILE I/O/green) Frontend pages: - DroneManager: live log streaming, SubProcessTable, DroneMonitor gauges, 1-second polling, auto-scroll log with pause-on-scroll-up - Home/DroneInspector: process count summary, mini LogRenderer, navigate-to-manager link Documentation: - Updated gadget-drone/docs/subprocess.md Phase 2 section - Updated gadget-code/docs/ui-design-guide.md Phase 2 section - Updated docs/socket-protocol.md with new events, sequences, and patterns
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import type { User, DroneRegistration, SubProcessStat } from '../lib/api';
|
|
import { droneApi } from '../lib/api';
|
|
import { socketClient } from '../lib/socket';
|
|
import SubProcessTable from '../components/SubProcessTable';
|
|
import DroneMonitor from '../components/DroneMonitor';
|
|
import LogRenderer from '../components/LogRenderer';
|
|
import type { LogRendererEntry } from '../components/LogRenderer';
|
|
|
|
interface DroneManagerProps {
|
|
user: User | null;
|
|
}
|
|
|
|
interface ToastState {
|
|
message: string;
|
|
type: 'success' | 'error';
|
|
}
|
|
|
|
let logIdCounter = 0;
|
|
|
|
export default function DroneManager({ user }: DroneManagerProps) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [allDrones, setAllDrones] = useState<DroneRegistration[]>([]);
|
|
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [terminating, setTerminating] = useState<string | null>(null);
|
|
const [toast, setToast] = useState<ToastState | null>(null);
|
|
|
|
// SubProcess state
|
|
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
|
|
const [selectedPid, setSelectedPid] = useState<number | null>(null);
|
|
|
|
// Live log state
|
|
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Track cleanup
|
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const subscribedDroneRef = useRef<string | null>(null);
|
|
const droneChangeGeneration = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (!user) {
|
|
navigate('/sign-in');
|
|
return;
|
|
}
|
|
loadDrones();
|
|
}, [user]);
|
|
|
|
// Socket lifecycle — subscribe, poll, listen when selectedDrone changes
|
|
useEffect(() => {
|
|
const gen = ++droneChangeGeneration.current;
|
|
|
|
// Cleanup previous subscription
|
|
if (subscribedDroneRef.current) {
|
|
socketClient.unsubscribeDrone(subscribedDroneRef.current);
|
|
subscribedDroneRef.current = null;
|
|
}
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
|
|
if (!selectedDrone || selectedDrone.status === 'offline') {
|
|
setProcessStats([]);
|
|
setLogEntries([]);
|
|
return;
|
|
}
|
|
|
|
const regId = selectedDrone._id;
|
|
subscribedDroneRef.current = regId;
|
|
setProcessStats([]);
|
|
setLogEntries([]);
|
|
|
|
// Subscribe to live drone events
|
|
socketClient.subscribeDrone(regId);
|
|
|
|
// Listen for log events
|
|
const handleLog = (data: { timestamp: string; level: string; component: string; message: string; metadata?: unknown }) => {
|
|
if (droneChangeGeneration.current !== gen) return;
|
|
logIdCounter++;
|
|
setLogEntries((prev) => {
|
|
const entry: LogRendererEntry = {
|
|
id: `log-${logIdCounter}`,
|
|
timestamp: new Date(data.timestamp),
|
|
level: data.level,
|
|
component: data.component,
|
|
message: data.message,
|
|
metadata: data.metadata,
|
|
};
|
|
const next = [...prev, entry];
|
|
if (next.length > 200) {
|
|
return next.slice(next.length - 200);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
socketClient.on('drone:log', handleLog);
|
|
|
|
// Poll process stats every 1s
|
|
const poll = async () => {
|
|
if (droneChangeGeneration.current !== gen) return;
|
|
const result = await socketClient.requestProcessStats(regId);
|
|
if (result.success && result.processes && droneChangeGeneration.current === gen) {
|
|
setProcessStats(result.processes as SubProcessStat[]);
|
|
}
|
|
};
|
|
|
|
poll();
|
|
pollIntervalRef.current = setInterval(poll, 1000);
|
|
|
|
return () => {
|
|
// Mark this effect cycle as stale so async callbacks are no-ops
|
|
droneChangeGeneration.current++;
|
|
clearInterval(pollIntervalRef.current!);
|
|
pollIntervalRef.current = null;
|
|
socketClient.off('drone:log', handleLog);
|
|
socketClient.unsubscribeDrone(regId);
|
|
subscribedDroneRef.current = null;
|
|
};
|
|
}, [selectedDrone]);
|
|
|
|
// Auto-scroll log to bottom when new entries arrive
|
|
useEffect(() => {
|
|
if (autoScroll && logContainerRef.current) {
|
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
|
}
|
|
}, [logEntries, autoScroll]);
|
|
|
|
const handleScroll = () => {
|
|
if (logContainerRef.current) {
|
|
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
|
setAutoScroll(isNearBottom);
|
|
}
|
|
};
|
|
|
|
const loadDrones = async () => {
|
|
try {
|
|
const data = await droneApi.getForManager();
|
|
setAllDrones(data);
|
|
|
|
// Auto-select from route state if provided
|
|
const state = location.state as { droneId?: string } | null;
|
|
if (state?.droneId) {
|
|
const match = data.find((d) => d._id === state.droneId);
|
|
if (match) {
|
|
setSelectedDrone(match);
|
|
}
|
|
// Clear route state to avoid re-selecting on re-render
|
|
window.history.replaceState({}, '');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load drones', err);
|
|
showToast('Failed to load drones', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const showToast = (message: string, type: 'success' | 'error') => {
|
|
setToast({ message, type });
|
|
setTimeout(() => setToast(null), 5000);
|
|
};
|
|
|
|
const handleSelectDrone = (drone: DroneRegistration) => {
|
|
setSelectedDrone(drone);
|
|
setSelectedPid(null);
|
|
};
|
|
|
|
const handleTerminate = async () => {
|
|
if (!selectedDrone) return;
|
|
|
|
if (selectedDrone.status === 'busy') {
|
|
const confirmed = confirm('Are you sure you want to terminate the drone? Any work or operations currently in progress may be lost.');
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
setTerminating(selectedDrone._id);
|
|
try {
|
|
const result = await droneApi.terminate(selectedDrone._id);
|
|
|
|
if (result.message) {
|
|
showToast(result.message, result.success ? 'success' : 'error');
|
|
} else {
|
|
showToast('Drone terminated successfully', 'success');
|
|
}
|
|
|
|
await loadDrones();
|
|
|
|
if (selectedDrone.status !== 'offline') {
|
|
setSelectedDrone(null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to terminate drone', err);
|
|
showToast('Failed to terminate drone', 'error');
|
|
} finally {
|
|
setTerminating(null);
|
|
}
|
|
};
|
|
|
|
const onlineDrones = allDrones.filter(d => d.status === 'available' || d.status === 'busy');
|
|
const offlineDrones = allDrones.filter(d => d.status === 'offline' || d.status === 'starting');
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center bg-bg-primary">
|
|
<p className="text-text-muted">Please sign in to view drones.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
|
{toast && (
|
|
<div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${
|
|
toast.type === 'success'
|
|
? 'bg-green-900/80 border-green-600 text-green-100'
|
|
: 'bg-red-900/80 border-red-600 text-red-100'
|
|
}`}>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
|
|
<aside className="w-80 border-r border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
|
|
<div className="p-3 border-b border-border-subtle flex-shrink-0">
|
|
<h2 className="text-lg font-semibold text-text-primary">Drone Manager</h2>
|
|
</div>
|
|
|
|
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
|
|
<div className="p-2 border-b border-border-subtle flex-shrink-0 bg-bg-tertiary">
|
|
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
|
Online Drones ({onlineDrones.length})
|
|
</h3>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
|
|
{loading ? (
|
|
<p className="text-sm text-text-muted p-2">Loading...</p>
|
|
) : onlineDrones.length === 0 ? (
|
|
<div className="text-text-muted text-sm p-2">
|
|
No online drones.
|
|
</div>
|
|
) : (
|
|
onlineDrones.map((drone) => (
|
|
<div
|
|
key={drone._id}
|
|
onClick={() => handleSelectDrone(drone)}
|
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
|
selectedDrone?._id === drone._id
|
|
? 'border-brand bg-bg-elevated'
|
|
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
drone.status === 'available'
|
|
? 'bg-green-500'
|
|
: 'bg-yellow-500'
|
|
}`}
|
|
/>
|
|
<span className="font-mono text-sm font-medium text-text-primary">
|
|
{drone.hostname}
|
|
</span>
|
|
</div>
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
drone.status === 'available'
|
|
? 'bg-green-900/50 text-green-400'
|
|
: 'bg-yellow-900/50 text-yellow-400'
|
|
}`}>
|
|
{drone.status}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-text-muted truncate">
|
|
{drone.workspaceDir}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col min-h-0 flex-1" style={{ minHeight: 0 }}>
|
|
<div className="p-2 border-t border-border-subtle flex-shrink-0 bg-bg-tertiary">
|
|
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
|
Offline Drones ({offlineDrones.length})
|
|
</h3>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-2 min-h-0">
|
|
{loading ? (
|
|
<p className="text-sm text-text-muted p-2">Loading...</p>
|
|
) : offlineDrones.length === 0 ? (
|
|
<div className="text-text-muted text-sm p-2">
|
|
No offline drones.
|
|
</div>
|
|
) : (
|
|
offlineDrones.map((drone) => (
|
|
<div
|
|
key={drone._id}
|
|
onClick={() => handleSelectDrone(drone)}
|
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
|
selectedDrone?._id === drone._id
|
|
? 'border-brand bg-bg-elevated'
|
|
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-gray-500" />
|
|
<span className="font-mono text-sm font-medium text-text-primary">
|
|
{drone.hostname}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-900/50 text-gray-400">
|
|
{drone.status}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-text-muted truncate">
|
|
{drone.workspaceDir}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
|
|
{selectedDrone ? (
|
|
<>
|
|
<div className="p-6 border-b border-border-subtle flex-shrink-0">
|
|
<div className="max-w-3xl">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold text-text-primary">Drone Details</h2>
|
|
{selectedDrone.status !== 'offline' && (
|
|
<button
|
|
onClick={handleTerminate}
|
|
disabled={terminating === selectedDrone._id}
|
|
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
|
>
|
|
{terminating === selectedDrone._id ? 'Terminating...' : 'Terminate'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
|
<div className="text-sm text-text-muted mb-1">Hostname</div>
|
|
<div className="font-mono text-text-primary">{selectedDrone.hostname}</div>
|
|
</div>
|
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
|
<div className="text-sm text-text-muted mb-1">Status</div>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
selectedDrone.status === 'available'
|
|
? 'bg-green-500'
|
|
: selectedDrone.status === 'busy'
|
|
? 'bg-yellow-500'
|
|
: 'bg-gray-500'
|
|
}`}
|
|
/>
|
|
<span className="text-text-primary capitalize">{selectedDrone.status}</span>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
|
<div className="text-sm text-text-muted mb-1">Workspace</div>
|
|
<div className="font-mono text-text-primary text-sm truncate">
|
|
{selectedDrone.workspaceDir}
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
|
<div className="text-sm text-text-muted mb-1">Registered</div>
|
|
<div className="text-text-primary">
|
|
{new Date(selectedDrone.createdAt).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 min-h-0 space-y-4">
|
|
<div className="max-w-3xl space-y-4">
|
|
<SubProcessTable
|
|
processes={processStats}
|
|
onSelect={setSelectedPid}
|
|
selectedPid={selectedPid}
|
|
/>
|
|
<DroneMonitor visible={!!selectedDrone && selectedDrone.status !== 'offline'} />
|
|
</div>
|
|
|
|
<div className="flex flex-col min-h-[300px] max-h-[400px] border-t border-border-subtle">
|
|
<div className="p-2 bg-bg-secondary border-b border-border-subtle flex-shrink-0">
|
|
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
|
Drone Log {selectedDrone.status !== 'offline' && '(Live)'}
|
|
</h3>
|
|
</div>
|
|
<div
|
|
ref={logContainerRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto"
|
|
>
|
|
<LogRenderer logs={logEntries} containerClassName="min-h-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-text-muted">
|
|
<div className="text-center">
|
|
<p className="mb-2">Select a drone to view details</p>
|
|
<p className="text-sm">Choose from the online or offline drone lists</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|