Phase 2: SubProcess observability in DroneManager and DroneInspector
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
This commit is contained in:
parent
d04453016d
commit
e155a7ffcf
@ -14,12 +14,15 @@ This document serves as a "Cheat Sheet" for AI agents and developers working on
|
||||
|
||||
## 2. Event Map Overview
|
||||
|
||||
Defined in `packages/api/src/messages/socket.ts`.
|
||||
Defined in `packages/api/src/messages/socket.ts`. Additional types in `packages/api/src/messages/subprocess.ts`.
|
||||
|
||||
### IDE -> Web (Client to Server)
|
||||
* `requestSessionLock`: Request to exclusive-lock a drone for a project session.
|
||||
* `requestWorkspaceMode`: Request a mode change (Idle, User, Agent).
|
||||
* `submitPrompt`: Submit a user prompt for agent processing.
|
||||
* `requestProcessStats`: Request subprocess stats from a drone by registration ID (no session lock required).
|
||||
* `subscribeDrone`: Subscribe to live log/status events from a drone (used by DroneManager/DroneInspector).
|
||||
* `unsubscribeDrone`: Unsubscribe from live drone events.
|
||||
|
||||
### Drone -> Web (Client to Server)
|
||||
* `thinking`: Stream reasoning/thought process text.
|
||||
@ -33,9 +36,12 @@ Defined in `packages/api/src/messages/socket.ts`.
|
||||
* `processWorkOrder`: Command to start processing a specific prompt/turn.
|
||||
* `crashRecoveryResponse`: Command to `discard` or `retry` a stalled work order.
|
||||
* `requestTermination`: Command to immediately terminate the drone process.
|
||||
* `requestProcessStats`: Request subprocess stats from the drone (responds with `SubProcessStat[]` via callback).
|
||||
|
||||
### Web -> IDE (Server to Client)
|
||||
* `sessionUpdated`: Notify the IDE that a chat session property has changed (e.g. auto-generated name).
|
||||
* `drone:log`: Broadcast log entry from a drone to monitoring IDE sessions (DroneManager, DroneInspector).
|
||||
* `drone:status`: Broadcast status update from a drone to monitoring IDE sessions.
|
||||
|
||||
---
|
||||
|
||||
@ -71,7 +77,25 @@ Defined in `packages/api/src/messages/socket.ts`.
|
||||
* Forwards event to **IDE**.
|
||||
* Clears `currentTurnId` from the drone session.
|
||||
|
||||
### 3.4 Drone Termination Flow
|
||||
### 3.4 SubProcess Monitoring Flow
|
||||
|
||||
1. **IDE** selects a drone in DroneManager or DroneInspector.
|
||||
2. **IDE** emits `subscribeDrone(registrationId)` → backend registers socket in `SocketService.droneMonitorIndex`.
|
||||
3. **IDE** starts a 1-second `setInterval` emitting `requestProcessStats(registrationId, cb)`.
|
||||
4. **Web (`CodeSession.ts`)**:
|
||||
* Looks up `DroneSession` via `SocketService.getDroneSessionByRegistrationId()` (no chat session required).
|
||||
* Forwards to drone: `droneSession.socket.emit("requestProcessStats", cb)`.
|
||||
5. **Drone (`gadget-drone.ts`)**:
|
||||
* Calls `SubProcessService.ps()` → `summarize()` → builds `SubProcessStat[]`.
|
||||
* Calls callback `cb(true, { processes })`.
|
||||
6. **IDE** receives `SubProcessStat[]` → renders `<SubProcessTable>` + updates process status.
|
||||
7. **Log streaming** (automatic, no polling):
|
||||
* Drone emits `log(timestamp, component, level, message)` as usual.
|
||||
* `DroneSession.onLog()` also broadcasts `drone:log` to all monitors via `SocketService.broadcastToMonitors()`.
|
||||
* IDE receives `drone:log` events → appends to log state → renders via `<LogRenderer>`.
|
||||
8. **Cleanup**: IDE emits `unsubscribeDrone(registrationId)` on unmount/deselect; interval cleared.
|
||||
|
||||
### 3.5 Drone Termination Flow
|
||||
1. **User** clicks "Terminate" button in Drone Manager UI.
|
||||
2. **IDE** calls `POST /api/v1/drone/registration/:id/terminate`.
|
||||
3. **Web (`DroneService.ts`)**:
|
||||
@ -151,6 +175,60 @@ type RequestTerminationMessage = (
|
||||
) => void;
|
||||
```
|
||||
|
||||
### Drone Monitor Events (Phase 2)
|
||||
```typescript
|
||||
// Defined in packages/api/src/messages/subprocess.ts
|
||||
interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
// IDE -> Web (request/response with callback)
|
||||
type RequestProcessStatsMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => void
|
||||
) => void;
|
||||
|
||||
// Web -> Drone
|
||||
type RequestProcessStatsMessageDrone = (
|
||||
cb: (success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => void
|
||||
) => void;
|
||||
|
||||
// Monitor subscribe/unsubscribe
|
||||
type SubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
type UnsubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
// Web -> IDE (broadcast)
|
||||
type DroneLogBroadcast = (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
|
||||
type DroneStatusBroadcast = (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Session Implementation Guide (Web Server)
|
||||
@ -177,6 +255,9 @@ The `SocketService` maintains multiple indexes for efficient session lookup:
|
||||
3. **`codeSessions`**: Map<socket.id, CodeSession> - Primary storage by socket ID
|
||||
4. **`codeSessionUserIndex`**: Map<user._id, CodeSession> - Lookup by user ID
|
||||
5. **`chatSessionIndex`**: Map<chatSessionId, CodeSession> - Reverse lookup from chat session to IDE
|
||||
6. **`droneMonitorIndex`**: Map<registration._id, Set<socket.id>> - Monitor subscriptions for log/status broadcast (Phase 2)
|
||||
|
||||
The `droneMonitorIndex` is maintained independently of chat sessions — it enables the DroneManager and DroneInspector to receive live log and status events from a drone without requiring a session lock. Cleanup happens automatically on socket disconnect.
|
||||
|
||||
All indexes are kept in sync during connection and disconnection.
|
||||
|
||||
@ -197,13 +278,19 @@ All indexes are kept in sync during connection and disconnection.
|
||||
## 7. Extending the Protocol
|
||||
|
||||
To add a new message:
|
||||
1. Add the message type to `packages/api/src/messages/ide.ts`, `drone.ts`, or `web.ts`.
|
||||
1. Add the message type to `packages/api/src/messages/ide.ts`, `drone.ts`, `web.ts`, or **create a new file** for a related group of types (e.g. `subprocess.ts`).
|
||||
2. Register it in `ClientToServerEvents` or `ServerToClientEvents` in `packages/api/src/messages/socket.ts`.
|
||||
3. Re-export from `packages/api/src/index.ts`.
|
||||
4. Implement the sender (emit) in the Client (`ide` or `drone`) or Server (`CodeSession`/`DroneSession`).
|
||||
5. Implement the handler in the corresponding class or frontend component.
|
||||
6. Implement the forward-path routing if needed.
|
||||
|
||||
### Patterns
|
||||
|
||||
**Request/Response (callback chain):** For "ask and answer" operations (file reads, process stats), use Socket.IO's built-in acknowledgement callback. The client emits with a callback function; the server receives it and calls the callback with the response. The backend proxies both directions.
|
||||
|
||||
**Subscribe/Broadcast:** For streaming events (logs, status), use a subscribe/unsubscribe pattern. The IDE registers interest in a drone's events via `subscribeDrone`. The backend maintains a monitor index and broadcasts events to all subscribers as they arrive from the drone. No polling needed for logs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Reconnection & Message Queuing
|
||||
|
||||
@ -140,6 +140,169 @@ Components:
|
||||
|
||||
Implementation: `frontend/src/pages/Home.tsx` - DashboardSidebar component
|
||||
|
||||
### Drone Inspector (Dashboard Inline)
|
||||
|
||||
When a user clicks a drone in the sidebar's Drones list, the main content area switches to the **Drone Inspector**:
|
||||
|
||||
```
|
||||
+-----------------------------------------------------+
|
||||
| Drone Inspector ← Back to |
|
||||
| Dashboard |
|
||||
+-----------------------------------------------------+
|
||||
| +-------------------------------------------------+ |
|
||||
| | Hostname | |
|
||||
| | drone-alpha (mono) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Workspace | |
|
||||
| | /path/to/workspace (mono) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Status | |
|
||||
| | ● available (or ● busy, ● offline) | |
|
||||
| +-------------------------------------------------+ |
|
||||
| | Registered | |
|
||||
| | 5/14/2026, 10:00:00 AM | |
|
||||
| +-------------------------------------------------+ |
|
||||
+-----------------------------------------------------+
|
||||
```
|
||||
|
||||
Implementation: `frontend/src/pages/Home.tsx` - DroneInspector component (lines 36-93)
|
||||
|
||||
The Drone Inspector is a simple read-only card view. For full drone operations (terminate, logs, monitoring), users navigate to the Drone Manager at `/drones` via the gear icon in the Drones sidebar header.
|
||||
|
||||
## Drone Manager View
|
||||
|
||||
Route: `/drones`
|
||||
|
||||
The Drone Manager is a full-page view for detailed drone inspection and operations. It replaces the main content area entirely.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
+----------------------------------+----------------------------------------+
|
||||
| Drone Manager | Drone Details [Terminate] |
|
||||
+----------------------------------+----------------------------------------+
|
||||
| Online Drones (N) | +-----------+ +-----------+ |
|
||||
| | | Hostname | | Status | |
|
||||
| +----------------------------+ | | drone-1 | | ● busy | |
|
||||
| | ● drone-1 available | | +-----------+ +-----------+ |
|
||||
| | /path/to/workspace | | +-----------+ +-----------+ |
|
||||
| +----------------------------+ | | Workspace | | Registered| |
|
||||
| +----------------------------+ | | /path/... | | 5/14/2026 | |
|
||||
| | ● drone-2 busy | | +-----------+ +-----------+ |
|
||||
| | /path/to/workspace | | |
|
||||
| +----------------------------+ | Drone Monitor |
|
||||
| | +--------------------------------------+ |
|
||||
| Offline Drones (N) | | Monitor charts coming soon. | |
|
||||
| | | Memory usage, AI operations, and log | |
|
||||
| +----------------------------+ | | production metrics will be displayed | |
|
||||
| | ○ drone-3 offline | | | here. | |
|
||||
| | /path/to/workspace | | +--------------------------------------+ |
|
||||
| +----------------------------+ | |
|
||||
| | Drone Log (Live) |
|
||||
| | +--------------------------------------+ |
|
||||
| | | [10:00:01] [PLACEHOLDER] Log entry 1 | |
|
||||
| | | [10:00:02] [PLACEHOLDER] Log entry 2 | |
|
||||
| | | [10:00:03] [PLACEHOLDER] Log entry 3 | |
|
||||
| | | ... | |
|
||||
| | +--------------------------------------+ |
|
||||
+----------------------------------+----------------------------------------+
|
||||
```
|
||||
|
||||
Current features (Phase 1):
|
||||
|
||||
- **Drone list** (left sidebar): Split into Online and Offline sections. Each list item shows status dot, hostname, and workspace path. Click to inspect.
|
||||
- **Drone details** (right panel, top): 2x2 grid showing hostname, status with dot, workspace, registration date. Terminate button (red) for non-offline drones.
|
||||
- **Drone Monitor** (right panel, middle): Placeholder for future monitoring charts.
|
||||
- **Drone Log** (right panel, bottom): Collapsible panel with auto-scrolling log viewer. Log entries show timestamp and message. Auto-scroll pauses when user scrolls up, resumes when scrolled to bottom. Max 200 entries shown. Placeholder entries currently used, awaiting live log streaming.
|
||||
|
||||
Implementation: `frontend/src/pages/DroneManager.tsx`
|
||||
|
||||
### Phase 2: SubProcess Monitor (Complete)
|
||||
|
||||
Phase 2 was implemented on the `feature/process-management` branch. The Drone Manager and Drone Inspector now show live SubProcess data and drone logs via Socket.IO.
|
||||
|
||||
#### Socket Protocol
|
||||
|
||||
Three new socket events added (defined in `packages/api/src/messages/subprocess.ts`):
|
||||
|
||||
| Event | Direction | Purpose | Mechanism |
|
||||
|-------|-----------|---------|-----------|
|
||||
| `requestProcessStats` | IDE → Backend → Drone | Request typed subprocess list | Callback-chain (like `fileTreeRequest`) |
|
||||
| `subscribeDrone` | IDE → Backend | Subscribe to live drone events (log, status) | Registers socket in monitor index |
|
||||
| `unsubscribeDrone` | IDE → Backend | Unsubscribe from live drone events | Removes socket from monitor index |
|
||||
|
||||
Broadcast events (backend → IDE, no request needed):
|
||||
|
||||
| Event | Payload | When |
|
||||
|-------|---------|------|
|
||||
| `drone:log` | `{ timestamp, level, component, message, metadata? }` | On every drone `log` emission |
|
||||
| `drone:status` | `{ timestamp, message }` | On every drone `status` emission |
|
||||
|
||||
#### SubProcess Table
|
||||
|
||||
Rendered by `frontend/src/components/SubProcessTable.tsx`. Features:
|
||||
- Monospace table: PID, CMD, PROJECT, STATUS, UPDATED
|
||||
- Status dots: green for running, yellow for halted, red for error
|
||||
- Clickable rows with selection highlight
|
||||
- Header shows running count: "SubProcess Monitor (3 running)"
|
||||
- Empty state: "No managed subprocesses. The agent has not spawned any processes for this session."
|
||||
|
||||
#### Resource Gauges (Canvas)
|
||||
|
||||
Rendered by `frontend/src/components/DroneMonitor.tsx` + `DroneMonitorGauge.tsx`:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ CPU │ │ NETWORK │ │ FILE I/O │
|
||||
│ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │ │ ▁▂▃▄▅▆▇█▇▆ │
|
||||
│ 42% │ │ 27% │ │ 15% │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
- Canvas-based waveform with glow effect (studio/scientific equipment aesthetic)
|
||||
- Brand red for CPU, cyan for NETWORK, green for FILE I/O
|
||||
- Grid lines, numeric readout, gradient fill under trace
|
||||
- Data is currently simulated (random walk) — placeholder for real metrics
|
||||
|
||||
#### Live Log Streaming
|
||||
|
||||
The Drone Log viewer now receives real log events via socket instead of placeholders:
|
||||
- Log events flow: `drone` → `DroneSession.onLog()` → broadcast to monitor sessions via `drone:log` event
|
||||
- Uses the same `LogRenderer` component as the ChatSession `LogPanel` (refactored in Phase 2)
|
||||
- Auto-scroll with pause-on-scroll-up behavior preserved
|
||||
- Max 200 entries maintained
|
||||
|
||||
#### DroneInspector (Dashboard)
|
||||
|
||||
The inline Drone Inspector in the Home page (`frontend/src/pages/Home.tsx`):
|
||||
- Shows SubProcess count summary ("3 running" or "No managed processes")
|
||||
- Contains a compact `LogRenderer` (last 50 entries, max-h-48)
|
||||
- "Open in Drone Manager →" link navigates to `/drones` with route state for auto-selection
|
||||
|
||||
#### Polling Behavior
|
||||
|
||||
- `requestProcessStats` polled every **1 second** while a drone is selected (both DroneManager and DroneInspector)
|
||||
- Subscribe/unsubscribe lifecycle tied to component mount/unmount
|
||||
- All timers and socket listeners cleaned up on unmount or drone deselection
|
||||
|
||||
#### Supporting Components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `SubProcessTable` | `frontend/src/components/SubProcessTable.tsx` | Process list table |
|
||||
| `DroneMonitor` | `frontend/src/components/DroneMonitor.tsx` | 3-gauge resource monitor container |
|
||||
| `DroneMonitorGauge` | `frontend/src/components/DroneMonitorGauge.tsx` | Canvas waveform gauge |
|
||||
| `LogRenderer` | `frontend/src/components/LogRenderer.tsx` | Reusable log rendering core |
|
||||
|
||||
#### Backend Routing
|
||||
|
||||
- `SocketService.addDroneMonitor()` / `removeDroneMonitor()` — manages monitor index (`Map<registrationId, Set<socketId>>`)
|
||||
- `SocketService.broadcastToMonitors()` — broadcasts events to all monitoring sockets
|
||||
- `SocketService.getDroneSessionByRegistrationId()` — lookup for non-chat-session drone routing
|
||||
- `DroneSession.onLog()` / `onStatus()` — extended to broadcast to monitors after chat-session routing
|
||||
- `CodeSession.onRequestProcessStats()` — proxies to drone with callback
|
||||
- `CodeSession.onSubscribeDrone()` / `onUnsubscribeDrone()` — registers/unregisters monitor
|
||||
|
||||
## Project Manager View
|
||||
|
||||
The Project Manager presents:
|
||||
|
||||
83
gadget-code/frontend/src/components/DroneMonitor.tsx
Normal file
83
gadget-code/frontend/src/components/DroneMonitor.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DroneMonitorGauge from './DroneMonitorGauge';
|
||||
|
||||
interface DroneMonitorProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function generateWave(base: number, variance: number, length: number): number[] {
|
||||
const wave: number[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
wave.push(base + Math.sin(i * 0.1) * variance + (Math.random() - 0.5) * variance * 0.5);
|
||||
}
|
||||
return wave;
|
||||
}
|
||||
|
||||
export default function DroneMonitor({ visible }: DroneMonitorProps) {
|
||||
const [cpuData, setCpuData] = useState<number[]>(() => generateWave(35, 20, 60));
|
||||
const [netData, setNetData] = useState<number[]>(() => generateWave(20, 15, 60));
|
||||
const [ioData, setIoData] = useState<number[]>(() => generateWave(10, 10, 60));
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCpuData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 10))];
|
||||
return next;
|
||||
});
|
||||
setNetData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 8))];
|
||||
return next;
|
||||
});
|
||||
setIoData((prev) => {
|
||||
const next = [...prev.slice(1), Math.max(0, Math.min(100, prev[prev.length - 1]! + (Math.random() - 0.5) * 6))];
|
||||
return next;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle bg-bg-tertiary">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Resource Monitor
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex gap-1 p-1 justify-center">
|
||||
<DroneMonitorGauge
|
||||
label="CPU"
|
||||
data={cpuData}
|
||||
unit="%"
|
||||
color="#c20600"
|
||||
/>
|
||||
<DroneMonitorGauge
|
||||
label="NETWORK"
|
||||
data={netData}
|
||||
unit="%"
|
||||
color="#06b6d4"
|
||||
/>
|
||||
<DroneMonitorGauge
|
||||
label="FILE I/O"
|
||||
data={ioData}
|
||||
unit="%"
|
||||
color="#22c55e"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
gadget-code/frontend/src/components/DroneMonitorGauge.tsx
Normal file
116
gadget-code/frontend/src/components/DroneMonitorGauge.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface DroneMonitorGaugeProps {
|
||||
label: string;
|
||||
data: number[];
|
||||
unit: string;
|
||||
color: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function DroneMonitorGauge({
|
||||
label,
|
||||
data,
|
||||
unit,
|
||||
color,
|
||||
width = 320,
|
||||
height = 160,
|
||||
}: DroneMonitorGaugeProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#1a1a1a';
|
||||
ctx.lineWidth = 1;
|
||||
for (let y = 0; y < 4; y++) {
|
||||
const yy = (height / 4) * y;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, yy);
|
||||
ctx.lineTo(width, yy);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Border frame — sharp edge like studio equipment
|
||||
ctx.strokeStyle = '#2a2a2a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
// Plot the waveform
|
||||
const padX = 8;
|
||||
const padY = 8;
|
||||
const plotW = width - padX * 2;
|
||||
const plotH = height - padY * 2;
|
||||
const maxVal = 100;
|
||||
|
||||
// Gradient fill under the trace
|
||||
const gradient = ctx.createLinearGradient(0, padY, 0, padY + plotH);
|
||||
gradient.addColorStop(0, color + '40');
|
||||
gradient.addColorStop(1, color + '05');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, padY + plotH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padX + (i / (data.length - 1)) * plotW;
|
||||
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padX + plotW, padY + plotH);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Trace line with glow
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padX + (i / (data.length - 1)) * plotW;
|
||||
const y = padY + plotH - (Math.min(Math.max(data[i] ?? 0, 0), maxVal) / maxVal) * plotH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Current value readout
|
||||
const latest = data[data.length - 1] ?? 0;
|
||||
ctx.fillStyle = '#d4d4d4';
|
||||
ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${Math.round(latest)}${unit}`, width / 2, height - 12);
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#737373';
|
||||
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, width / 2, 16);
|
||||
}, [data, label, unit, color, width, height]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width, height }}
|
||||
className="block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import LogRenderer from './LogRenderer';
|
||||
import type { LogRendererEntry } from './LogRenderer';
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
@ -15,48 +17,8 @@ interface LogPanelProps {
|
||||
onToggleExpand: () => void;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'text-gray-500',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
alert: 'text-red-400',
|
||||
error: 'bg-red-800 text-white',
|
||||
crit: 'bg-red-800 text-yellow-300',
|
||||
fatal: 'bg-red-800 text-gray-400',
|
||||
};
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const toggleMetadata = (id: string) => {
|
||||
setExpandedMetadata(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -76,50 +38,7 @@ export default function LogPanel({ logs, expanded, onToggleExpand }: LogPanelPro
|
||||
{expanded ? '▾' : '▴'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const levelClass = levelColors[entry.level] || 'text-text-secondary';
|
||||
return (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
|
||||
<span className="text-gray-600 shrink-0 select-none">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-bold ${levelClass}`}>
|
||||
{entry.level.toUpperCase().padEnd(5)}
|
||||
</span>
|
||||
<span className="text-cyan-400 shrink-0">
|
||||
{entry.component}
|
||||
</span>
|
||||
<span className="text-text-secondary break-all min-w-0">
|
||||
{entry.message}
|
||||
</span>
|
||||
{entry.metadata != null && (
|
||||
<button
|
||||
onClick={() => toggleMetadata(entry.id)}
|
||||
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
|
||||
>
|
||||
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
|
||||
</button>
|
||||
)}
|
||||
{entry.metadata != null && expandedMetadata.has(entry.id) && (
|
||||
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<LogRenderer ref={scrollRef} logs={logs as LogRendererEntry[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
114
gadget-code/frontend/src/components/LogRenderer.tsx
Normal file
114
gadget-code/frontend/src/components/LogRenderer.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
|
||||
export interface LogRendererEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
interface LogRendererProps {
|
||||
logs: LogRendererEntry[];
|
||||
maxEntries?: number;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
debug: 'text-gray-500',
|
||||
info: 'text-green-400',
|
||||
warn: 'text-yellow-400',
|
||||
alert: 'text-red-400',
|
||||
error: 'bg-red-800 text-white',
|
||||
crit: 'bg-red-800 text-yellow-300',
|
||||
fatal: 'bg-red-800 text-gray-400',
|
||||
};
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
const LogRenderer = forwardRef<HTMLDivElement, LogRendererProps>(
|
||||
({ logs, containerClassName }, ref) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedMetadata, setExpandedMetadata] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const target = ref && 'current' in ref ? ref : scrollRef;
|
||||
if (target.current) {
|
||||
target.current.scrollTop = target.current.scrollHeight;
|
||||
}
|
||||
}, [logs, ref]);
|
||||
|
||||
const toggleMetadata = (id: string) => {
|
||||
setExpandedMetadata(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref || scrollRef}
|
||||
className={`flex-1 overflow-y-auto p-2 font-mono text-xs leading-relaxed ${containerClassName ?? ''}`}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-text-muted">No log entries</div>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const levelClass = levelColors[entry.level] || 'text-text-secondary';
|
||||
return (
|
||||
<div key={entry.id} className="flex gap-2 py-0.5 hover:bg-bg-elevated/50">
|
||||
<span className="text-gray-600 shrink-0 select-none">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`shrink-0 font-bold ${levelClass}`}>
|
||||
{entry.level.toUpperCase().padEnd(5)}
|
||||
</span>
|
||||
<span className="text-cyan-400 shrink-0">
|
||||
{entry.component}
|
||||
</span>
|
||||
<span className="text-text-secondary break-all min-w-0">
|
||||
{entry.message}
|
||||
</span>
|
||||
{entry.metadata != null && (
|
||||
<button
|
||||
onClick={() => toggleMetadata(entry.id)}
|
||||
className="shrink-0 text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={expandedMetadata.has(entry.id) ? 'Hide metadata' : 'Show metadata'}
|
||||
>
|
||||
{expandedMetadata.has(entry.id) ? '⊟' : '⊞'}
|
||||
</button>
|
||||
)}
|
||||
{entry.metadata != null && expandedMetadata.has(entry.id) && (
|
||||
<div className="w-full text-text-muted pl-[1em] border-l border-border-subtle mt-0.5 mb-1">
|
||||
<pre className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LogRenderer.displayName = 'LogRenderer';
|
||||
|
||||
export default LogRenderer;
|
||||
84
gadget-code/frontend/src/components/SubProcessTable.tsx
Normal file
84
gadget-code/frontend/src/components/SubProcessTable.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import type { SubProcessStat } from '../lib/api';
|
||||
|
||||
interface SubProcessTableProps {
|
||||
processes: SubProcessStat[];
|
||||
onSelect?: (pid: number) => void;
|
||||
selectedPid?: number | null;
|
||||
}
|
||||
|
||||
const statusDot: Record<string, string> = {
|
||||
running: 'bg-green-500',
|
||||
stopped: 'bg-yellow-500',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
running: '● running',
|
||||
stopped: '● halted',
|
||||
error: '● error',
|
||||
};
|
||||
|
||||
export default function SubProcessTable({ processes, onSelect, selectedPid }: SubProcessTableProps) {
|
||||
if (processes.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded text-center">
|
||||
<p className="text-text-muted">No managed subprocesses.</p>
|
||||
<p className="text-text-muted text-sm mt-1">
|
||||
The agent has not spawned any processes for this session.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runningCount = processes.filter((p) => p.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary border border-border-default rounded overflow-hidden">
|
||||
<div className="p-2 border-b border-border-subtle bg-bg-tertiary flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
SubProcess Monitor ({runningCount} running)
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">{processes.length} total</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-text-muted">
|
||||
<th className="text-left p-2 font-semibold">PID</th>
|
||||
<th className="text-left p-2 font-semibold">CMD</th>
|
||||
<th className="text-left p-2 font-semibold">PROJECT</th>
|
||||
<th className="text-left p-2 font-semibold">STATUS</th>
|
||||
<th className="text-right p-2 font-semibold">UPDATED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processes.map((proc) => (
|
||||
<tr
|
||||
key={proc.pid}
|
||||
onClick={() => onSelect?.(proc.pid)}
|
||||
className={`border-b border-border-subtle last:border-0 transition-colors ${
|
||||
selectedPid === proc.pid
|
||||
? 'bg-bg-elevated'
|
||||
: onSelect
|
||||
? 'cursor-pointer hover:bg-bg-elevated/50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="p-2 text-text-primary">{proc.pid}</td>
|
||||
<td className="p-2 text-text-primary">{proc.command}</td>
|
||||
<td className="p-2 text-text-secondary">{proc.projectSlug}</td>
|
||||
<td className="p-2">
|
||||
<span className={`${statusDot[proc.status] ?? 'bg-gray-500'} inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle`} />
|
||||
<span className="text-text-secondary align-middle">{statusLabel[proc.status] ?? proc.status}</span>
|
||||
</td>
|
||||
<td className="p-2 text-text-muted text-right whitespace-nowrap">
|
||||
{new Date(proc.updatedAt).toLocaleTimeString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -258,6 +258,20 @@ export const projectApi = {
|
||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||
};
|
||||
|
||||
export interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export interface DroneRegistration {
|
||||
_id: string;
|
||||
hostname: string;
|
||||
|
||||
@ -135,6 +135,17 @@ export interface SocketEvents {
|
||||
fileReadResponse: (path: string, content: string | null, error?: string) => void;
|
||||
fileWriteResponse: (path: string, success: boolean, error?: string) => void;
|
||||
status: (content: string) => void;
|
||||
"drone:log": (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
"drone:status": (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
reconnect_attempt: (attempt: number) => void;
|
||||
reconnect_failed: () => void;
|
||||
reconnect: (attempt: number) => void;
|
||||
@ -303,6 +314,14 @@ class SocketClient {
|
||||
this.emit("status", content);
|
||||
});
|
||||
|
||||
this.socket.on("drone:log", (data: unknown) => {
|
||||
this.emit("drone:log", data as SocketEvents["drone:log"] extends (data: infer T) => void ? T : never);
|
||||
});
|
||||
|
||||
this.socket.on("drone:status", (data: unknown) => {
|
||||
this.emit("drone:status", data as SocketEvents["drone:status"] extends (data: infer T) => void ? T : never);
|
||||
});
|
||||
|
||||
this._socket.on("reconnect_attempt", (attempt: number) => {
|
||||
this.emit("reconnect_attempt", attempt);
|
||||
});
|
||||
@ -505,6 +524,48 @@ class SocketClient {
|
||||
});
|
||||
}
|
||||
|
||||
requestProcessStats(
|
||||
registrationId: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
processes?: { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: string; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string }[];
|
||||
message?: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"requestProcessStats",
|
||||
registrationId,
|
||||
(success: boolean, data?: { processes?: any[]; message?: string }) => {
|
||||
resolve({ success, processes: data?.processes, message: data?.message });
|
||||
},
|
||||
);
|
||||
} else {
|
||||
resolve({ success: false, message: "Socket not connected" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribeDrone(registrationId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit("subscribeDrone", registrationId, resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribeDrone(registrationId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit("unsubscribeDrone", registrationId, resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a single sessionHeartbeat event to the server (which relays
|
||||
* it to the drone). The drone resets its 120-second timeout timer
|
||||
|
||||
@ -1,30 +1,46 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { User, DroneRegistration } from '../lib/api';
|
||||
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 LogEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
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<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
|
||||
// Placeholder log entries for testing scroll behavior
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const logContainerRef = useRef<HTMLDivElement>(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) {
|
||||
@ -34,43 +50,81 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
loadDrones();
|
||||
}, [user]);
|
||||
|
||||
// Mock log generator for testing scroll behavior
|
||||
// TODO: Replace with live log streaming when backend is implemented
|
||||
// Socket lifecycle — subscribe, poll, listen when selectedDrone changes
|
||||
useEffect(() => {
|
||||
if (!selectedDrone) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Initialize with some placeholder entries
|
||||
const initialEntries: LogEntry[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i,
|
||||
timestamp: new Date(Date.now() - (50 - i) * 1000).toLocaleTimeString(),
|
||||
message: `[PLACEHOLDER] Log entry ${i + 1} - This is mock data for testing scroll behavior`,
|
||||
}));
|
||||
setLogEntries(initialEntries);
|
||||
const regId = selectedDrone._id;
|
||||
subscribedDroneRef.current = regId;
|
||||
setProcessStats([]);
|
||||
setLogEntries([]);
|
||||
|
||||
// Add a new entry every second for testing auto-scroll
|
||||
const interval = setInterval(() => {
|
||||
setLogEntries(prev => {
|
||||
const newEntry: LogEntry = {
|
||||
id: prev.length > 0 ? prev[prev.length - 1]!.id + 1 : 0,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `[PLACEHOLDER] New log entry at ${new Date().toLocaleTimeString()} - Auto-generated for testing`,
|
||||
// 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 updated = [...prev, newEntry];
|
||||
// Keep only last 200 entries as per spec
|
||||
if (updated.length > 200) {
|
||||
return updated.slice(updated.length - 200);
|
||||
const next = [...prev, entry];
|
||||
if (next.length > 200) {
|
||||
return next.slice(next.length - 200);
|
||||
}
|
||||
return updated;
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return () => clearInterval(interval);
|
||||
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 (if user hasn't scrolled up)
|
||||
// Auto-scroll log to bottom when new entries arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
@ -80,7 +134,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
// Check if user is near the bottom (within 100px)
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
|
||||
setAutoScroll(isNearBottom);
|
||||
}
|
||||
@ -90,6 +143,17 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
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');
|
||||
@ -105,13 +169,12 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
|
||||
const handleSelectDrone = (drone: DroneRegistration) => {
|
||||
setSelectedDrone(drone);
|
||||
setLogEntries([]); // Clear log when switching drones
|
||||
setSelectedPid(null);
|
||||
};
|
||||
|
||||
const handleTerminate = async () => {
|
||||
if (!selectedDrone) return;
|
||||
|
||||
// Show confirmation if drone is busy
|
||||
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;
|
||||
@ -127,10 +190,8 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
showToast('Drone terminated successfully', 'success');
|
||||
}
|
||||
|
||||
// Refresh drone list
|
||||
await loadDrones();
|
||||
|
||||
// Clear selection if drone is now offline
|
||||
if (selectedDrone.status !== 'offline') {
|
||||
setSelectedDrone(null);
|
||||
}
|
||||
@ -155,7 +216,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex bg-bg-primary overflow-hidden">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className={`fixed top-16 right-4 z-50 px-4 py-3 rounded border ${
|
||||
toast.type === 'success'
|
||||
@ -166,13 +226,11 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Panel - Drone Lists */}
|
||||
<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>
|
||||
|
||||
{/* Online Drones Section - 50% of list area */}
|
||||
<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">
|
||||
@ -227,7 +285,6 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline Drones Section - 50% of list area */}
|
||||
<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">
|
||||
@ -273,11 +330,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Panel - Drone Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-bg-primary">
|
||||
{selectedDrone ? (
|
||||
<>
|
||||
{/* Drone Info Card */}
|
||||
<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">
|
||||
@ -329,23 +384,16 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drone Monitor Placeholder */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||
<div className="max-w-3xl mb-6">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Drone Monitor
|
||||
</h3>
|
||||
<div className="p-8 bg-bg-secondary border border-border-default rounded text-center">
|
||||
<p className="text-text-muted">
|
||||
Monitor charts coming soon.
|
||||
</p>
|
||||
<p className="text-text-muted text-sm mt-2">
|
||||
Memory usage, AI operations, and log production metrics will be displayed here.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Log Viewer - Collapsible panel at bottom */}
|
||||
<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">
|
||||
@ -355,24 +403,9 @@ export default function DroneManager({ user }: DroneManagerProps) {
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto p-3 bg-bg-primary font-mono text-xs min-h-0"
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{logEntries.length === 0 ? (
|
||||
<div className="text-text-muted">
|
||||
<p>No log entries yet.</p>
|
||||
<p className="mt-2 text-text-secondary">
|
||||
{/* TODO: Remove this placeholder comment when live logs are implemented */}
|
||||
[PLACEHOLDER] Log entries will appear here when the drone is running.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
logEntries.map((entry) => (
|
||||
<div key={entry.id} className="py-1 border-b border-border-subtle last:border-0">
|
||||
<span className="text-text-muted mr-3">[{entry.timestamp}]</span>
|
||||
<span className="text-text-secondary">{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<LogRenderer logs={logEntries} containerClassName="min-h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import type { User, Project, DroneRegistration } from "../lib/api";
|
||||
import type { User, Project, DroneRegistration, SubProcessStat } from "../lib/api";
|
||||
import { projectApi, droneApi } from "../lib/api";
|
||||
import { socketClient } from "../lib/socket";
|
||||
import Clock from "../components/Clock";
|
||||
import GadgetGrid from "../components/GadgetGrid";
|
||||
import LogRenderer from "../components/LogRenderer";
|
||||
import type { LogRendererEntry } from "../components/LogRenderer";
|
||||
|
||||
function SystemReady() {
|
||||
return (
|
||||
@ -40,8 +43,59 @@ function DroneInspector({
|
||||
drone: DroneRegistration;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
|
||||
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const regId = drone._id;
|
||||
|
||||
if (drone.status === 'offline') return;
|
||||
|
||||
socketClient.subscribeDrone(regId);
|
||||
|
||||
const handleLog = (data: { timestamp: string; level: string; component: string; message: string }) => {
|
||||
if (!mountedRef.current) return;
|
||||
setLogEntries((prev) => {
|
||||
const next = [...prev, {
|
||||
id: `insp-log-${prev.length}`,
|
||||
timestamp: new Date(data.timestamp),
|
||||
level: data.level,
|
||||
component: data.component,
|
||||
message: data.message,
|
||||
}];
|
||||
return next.length > 100 ? next.slice(next.length - 100) : next;
|
||||
});
|
||||
};
|
||||
|
||||
socketClient.on('drone:log', handleLog);
|
||||
|
||||
const poll = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
const result = await socketClient.requestProcessStats(regId);
|
||||
if (result.success && result.processes && mountedRef.current) {
|
||||
setProcessStats(result.processes as SubProcessStat[]);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
pollRef.current = setInterval(poll, 1000);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
socketClient.off('drone:log', handleLog);
|
||||
socketClient.unsubscribeDrone(regId);
|
||||
};
|
||||
}, [drone._id, drone.status]);
|
||||
|
||||
const runningCount = processStats.filter((p) => p.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="flex-1 flex items-start justify-center p-8 overflow-y-auto">
|
||||
<div className="max-w-lg w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">Drone Inspector</h2>
|
||||
@ -80,6 +134,16 @@ function DroneInspector({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-text-muted">SubProcesses</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
{runningCount > 0
|
||||
? `${runningCount} running`
|
||||
: processStats.length > 0
|
||||
? 'All stopped'
|
||||
: 'No managed processes'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-text-muted">Registered</div>
|
||||
<div className="text-text-primary text-sm">
|
||||
@ -87,6 +151,30 @@ function DroneInspector({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drone.status !== 'offline' && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => navigate('/drones', { state: { droneId: drone._id } })}
|
||||
className="w-full px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors text-sm"
|
||||
>
|
||||
Open in Drone Manager →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drone.status !== 'offline' && logEntries.length > 0 && (
|
||||
<div className="mt-4 border border-border-default rounded overflow-hidden max-h-48">
|
||||
<div className="p-2 bg-bg-tertiary border-b border-border-subtle">
|
||||
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Live Log
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-40 overflow-y-auto">
|
||||
<LogRenderer logs={logEntries.slice(-50)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
WorkspaceMode,
|
||||
SubmitPromptCallback,
|
||||
AbortWorkOrderCallback,
|
||||
SubProcessStat,
|
||||
} from "@gadget/api";
|
||||
|
||||
import ChatSession from "../models/chat-session.ts";
|
||||
@ -64,6 +65,12 @@ export class CodeSession extends SocketSession {
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
this.socket.on(
|
||||
"requestProcessStats",
|
||||
this.onRequestProcessStats.bind(this),
|
||||
);
|
||||
this.socket.on("subscribeDrone", this.onSubscribeDrone.bind(this));
|
||||
this.socket.on("unsubscribeDrone", this.onUnsubscribeDrone.bind(this));
|
||||
|
||||
// Check for active session on connect
|
||||
this.checkAndReestablishActiveSession();
|
||||
@ -501,6 +508,62 @@ export class CodeSession extends SocketSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE sends a requestProcessStats event to request
|
||||
* subprocess status from a drone. Routes by registration ID (not by
|
||||
* selectedDrone) so the DroneManager can query without a session lock.
|
||||
*/
|
||||
onRequestProcessStats(
|
||||
registrationId: string,
|
||||
cb: (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void,
|
||||
): void {
|
||||
try {
|
||||
const droneSession =
|
||||
SocketService.getDroneSessionByRegistrationId(registrationId);
|
||||
droneSession.socket.emit(
|
||||
"requestProcessStats",
|
||||
(success: boolean, data?: { processes?: SubProcessStat[]; message?: string }) => {
|
||||
cb(success, data);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.error("failed to forward requestProcessStats to drone", {
|
||||
error,
|
||||
});
|
||||
cb(false, { message: "Drone not connected" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE wants to subscribe to live events (log, status)
|
||||
* from a drone for monitoring purposes (no chat session lock required).
|
||||
*/
|
||||
onSubscribeDrone(registrationId: string, cb: (success: boolean) => void): void {
|
||||
try {
|
||||
SocketService.addDroneMonitor(registrationId, this.socket.id);
|
||||
cb(true);
|
||||
} catch (error) {
|
||||
this.log.error("failed to subscribe to drone events", { error });
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE wants to unsubscribe from live drone events.
|
||||
*/
|
||||
onUnsubscribeDrone(registrationId: string, cb: (success: boolean) => void): void {
|
||||
try {
|
||||
SocketService.removeDroneMonitor(registrationId, this.socket.id);
|
||||
cb(true);
|
||||
} catch (error) {
|
||||
this.log.error("failed to unsubscribe from drone events", { error });
|
||||
cb(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the IDE sends a releaseSessionLock event to release a
|
||||
* previously-acquired session lock on a gadget-drone instance.
|
||||
|
||||
@ -81,47 +81,64 @@ export class DroneSession extends SocketSession {
|
||||
message: string,
|
||||
metadata?: unknown,
|
||||
): Promise<void> {
|
||||
if (!this.chatSessionId) {
|
||||
this.log.warn("log event received but no chat session is active");
|
||||
return;
|
||||
// Route to chat session if one is active
|
||||
if (this.chatSessionId) {
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.onLog(timestamp, component, level, message, metadata);
|
||||
} catch (error) {
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'log',
|
||||
args: [timestamp, component, level, message, metadata],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.onLog(timestamp, component, level, message, metadata);
|
||||
} catch (error) {
|
||||
// Routing failed - queue to Redis
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'log',
|
||||
args: [timestamp, component, level, message, metadata],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued log message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
// Broadcast to monitoring sessions (DroneManager, DroneInspector, etc.)
|
||||
SocketService.broadcastToMonitors(
|
||||
"drone:log",
|
||||
this.registration._id,
|
||||
{
|
||||
timestamp: timestamp instanceof Date ? timestamp.toISOString() : String(timestamp),
|
||||
level: String(level),
|
||||
component: typeof component === 'object' && component !== null ? (component as any).name ?? String(component) : String(component),
|
||||
message,
|
||||
metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onStatus(message: string): Promise<void> {
|
||||
if (!this.chatSessionId) {
|
||||
this.log.warn("status event received but no chat session is active");
|
||||
return;
|
||||
// Route to chat session if one is active
|
||||
if (this.chatSessionId) {
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.socket.emit("status", message);
|
||||
} catch (error) {
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'status',
|
||||
args: [message],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const codeSession = SocketService.getCodeSessionByChatSessionId(
|
||||
this.chatSessionId,
|
||||
);
|
||||
codeSession.socket.emit("status", message);
|
||||
} catch (error) {
|
||||
// Routing failed - queue to Redis
|
||||
await MessageQueue.enqueue(this.chatSessionId, {
|
||||
type: 'status',
|
||||
args: [message],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.log.debug("queued status message", { chatSessionId: this.chatSessionId });
|
||||
}
|
||||
// Broadcast to monitoring sessions
|
||||
SocketService.broadcastToMonitors(
|
||||
"drone:status",
|
||||
this.registration._id,
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -40,6 +40,13 @@ class SocketService extends DtpService {
|
||||
>();
|
||||
private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>();
|
||||
|
||||
/**
|
||||
* Tracks which code sessions (by socket ID) are monitoring which drones
|
||||
* (by registration ID). Used for broadcasting log/status events outside
|
||||
* the chat session routing path.
|
||||
*/
|
||||
private droneMonitorIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
private io?: Server<
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
@ -275,6 +282,62 @@ class SocketService extends DtpService {
|
||||
return session;
|
||||
}
|
||||
|
||||
getDroneSessionByRegistrationId(registrationId: string): DroneSession {
|
||||
const session = this.droneRegistrationIndex.get(registrationId);
|
||||
if (!session) {
|
||||
const error = new Error("drone session not found");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
addDroneMonitor(registrationId: string, socketId: string): void {
|
||||
let monitors = this.droneMonitorIndex.get(registrationId);
|
||||
if (!monitors) {
|
||||
monitors = new Set();
|
||||
this.droneMonitorIndex.set(registrationId, monitors);
|
||||
}
|
||||
monitors.add(socketId);
|
||||
}
|
||||
|
||||
removeDroneMonitor(registrationId: string, socketId: string): void {
|
||||
const monitors = this.droneMonitorIndex.get(registrationId);
|
||||
if (monitors) {
|
||||
monitors.delete(socketId);
|
||||
if (monitors.size === 0) {
|
||||
this.droneMonitorIndex.delete(registrationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToMonitors(
|
||||
event: string,
|
||||
registrationId: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
const monitorSocketIds = this.droneMonitorIndex.get(registrationId);
|
||||
if (!monitorSocketIds || monitorSocketIds.size === 0) return;
|
||||
|
||||
for (const socketId of monitorSocketIds) {
|
||||
const codeSession = this.codeSessions.get(socketId);
|
||||
if (!codeSession) {
|
||||
// Stale entry — clean up
|
||||
monitorSocketIds.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
if (!codeSession.socket.connected) {
|
||||
monitorSocketIds.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
(codeSession.socket as any).emit(event, ...args);
|
||||
}
|
||||
|
||||
if (monitorSocketIds.size === 0) {
|
||||
this.droneMonitorIndex.delete(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a code session by its chat session ID for reverse lookup.
|
||||
*/
|
||||
|
||||
@ -5,7 +5,7 @@ The Gadget Code agent manages child processes through gadget-drone's SubProcess
|
||||
## Phase Status
|
||||
|
||||
- **Phase 1 (Complete):** SubProcessService + SubprocessTool internal to drone.
|
||||
- **Phase 2 (Planned):** Expose SubProcess status and logs to gadget-code:frontend (IDE) via gadget-code:backend.
|
||||
- **Phase 2 (Complete):** SubProcess observability in DroneManager and DroneInspector. Live log streaming via subscribe/broadcast pattern. Canvas-based resource gauges (CPU, Network, File I/O — mocked). See [Phase 2 Details](#phase-2-subprocess-observability) below.
|
||||
|
||||
## SubProcessService
|
||||
|
||||
@ -142,3 +142,90 @@ All agent modes include a **Process Management** block in their system prompt. T
|
||||
|---|---|
|
||||
| `src/services/subprocess.test.ts` | SubProcessService: create, ps, killPid, killAll, haltPid, getLog, error cases |
|
||||
| `src/tools/system/subprocess.test.ts` | SubprocessTool: all 6 commands, parameter validation, service error handling |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: SubProcess Observability
|
||||
|
||||
### Architecture
|
||||
|
||||
Phase 2 exposes the drone's managed subprocesses to the frontend via a **callback-chain pattern** over Socket.IO, identical to the existing `fileTreeRequest`/`fileReadRequest` flow:
|
||||
|
||||
```
|
||||
IDE (DroneManager.tsx)
|
||||
──emit("requestProcessStats", registrationId, cb)──▶
|
||||
│
|
||||
CodeSession.onRequestProcessStats()
|
||||
──droneSession.socket.emit("requestProcessStats", cb)──▶
|
||||
│
|
||||
GadgetDrone.onRequestProcessStats()
|
||||
│ SubProcessService.ps()
|
||||
│ .summarize() each
|
||||
│ → SubProcessStat[]
|
||||
│
|
||||
◀──cb(true, { processes })──
|
||||
◀──ack callback fires──
|
||||
◀──cb(success, data)──
|
||||
```
|
||||
|
||||
### Socket Protocol
|
||||
|
||||
| Direction | Event | Signature |
|
||||
|-----------|-------|-----------|
|
||||
| IDE → Backend | `requestProcessStats` | `(registrationId: string, cb: RequestProcessStatsCallback) => void` |
|
||||
| Backend → Drone | `requestProcessStats` | `(cb: RequestProcessStatsCallback) => void` (no registrationId — drone identifies itself) |
|
||||
|
||||
**Callback type:**
|
||||
```typescript
|
||||
type RequestProcessStatsCallback = (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string }
|
||||
) => void;
|
||||
```
|
||||
|
||||
### `SubProcessStat` Type
|
||||
|
||||
Defined in `packages/api/src/messages/subprocess.ts`:
|
||||
|
||||
```typescript
|
||||
interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Behavior
|
||||
|
||||
The `onRequestProcessStats` handler in `gadget-drone.ts`:
|
||||
1. Calls `SubProcessService.ps()` to get all tracked subprocesses
|
||||
2. For each, calls `summarize()` for serializable fields
|
||||
3. Determines status: checks `sp.process.exitCode !== null || sp.process.killed`
|
||||
4. Extracts `command` and `args` from `sp.process.spawnargs`
|
||||
5. Returns `cb(true, { processes })` on success, `cb(false, { message })` on error
|
||||
|
||||
### Live Log Streaming (Subscribe/Broadcast)
|
||||
|
||||
In addition to request/response stats, the drone continuously emits `log` events through its `GadgetLogTransportSocket`. Phase 2 adds a **monitor broadcast** path:
|
||||
|
||||
1. **IDE sends** `subscribeDrone(registrationId)` → Backend adds IDE's socket to a monitor set
|
||||
2. **IDE sends** `unsubscribeDrone(registrationId)` → Backend removes from monitor set
|
||||
3. **Drone emits** `log` → `DroneSession.onLog()` forwards to chat session AND broadcasts `drone:log` to all monitoring sockets
|
||||
4. **Frontend** receives `drone:log` events and renders via `LogRenderer`
|
||||
|
||||
The monitor tracking lives in `SocketService.droneMonitorIndex` — a `Map<registrationId, Set<socketId>>`. Cleanup happens automatically on socket disconnect.
|
||||
|
||||
### Polling Interval
|
||||
|
||||
- **DroneManager**: 1-second interval polling `requestProcessStats`
|
||||
- **DroneInspector (dashboard)**: 1-second interval polling
|
||||
- Resource gauges (CPU, Network, File I/O): 200ms internal animation (mocked data)
|
||||
- All timers are killed on unmount or drone deselection
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
GadgetLog,
|
||||
GadgetLogTransportSocket,
|
||||
IUser,
|
||||
SubProcessStat,
|
||||
} from "@gadget/api";
|
||||
|
||||
import { GadgetProcess } from "./lib/process.ts";
|
||||
@ -263,6 +264,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
this.socket.on(
|
||||
"requestProcessStats",
|
||||
this.onRequestProcessStats.bind(this),
|
||||
);
|
||||
|
||||
/*
|
||||
* Handle socket disconnect: clear the heartbeat timer to prevent
|
||||
@ -1030,6 +1035,39 @@ class GadgetDrone extends GadgetProcess {
|
||||
cb(aborted, aborted ? "Abort signaled" : "No active work order to abort");
|
||||
}
|
||||
|
||||
async onRequestProcessStats(
|
||||
cb: (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const processes = SubProcessService.ps().map((sp) => {
|
||||
const summary = SubProcessService.summarize(sp);
|
||||
const hasExited = sp.process.exitCode !== null || sp.process.killed;
|
||||
const stat: SubProcessStat = {
|
||||
pid: summary.pid,
|
||||
command: sp.process.spawnargs[0] ?? "unknown",
|
||||
args: sp.process.spawnargs.slice(1),
|
||||
projectId: summary.project._id,
|
||||
projectSlug: summary.project.slug,
|
||||
projectName: summary.project.name,
|
||||
status: hasExited ? "stopped" : "running",
|
||||
createdAt: summary.createdAt.toISOString(),
|
||||
updatedAt: summary.updatedAt.toISOString(),
|
||||
stdoutFileName: summary.stdoutFileName,
|
||||
stderrFileName: summary.stderrFileName,
|
||||
};
|
||||
return stat;
|
||||
});
|
||||
|
||||
cb(true, { processes });
|
||||
} catch (error) {
|
||||
this.log.error("failed to collect process stats", { error });
|
||||
cb(false, { message: "Failed to collect process stats" });
|
||||
}
|
||||
}
|
||||
|
||||
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
|
||||
this.log.info("requestTermination received from platform", {
|
||||
registrationId: this.registration?._id,
|
||||
|
||||
@ -31,6 +31,7 @@ export * from "./interfaces/workspace.ts";
|
||||
|
||||
export * from "./messages/ide.ts";
|
||||
export * from "./messages/drone.ts";
|
||||
export * from "./messages/subprocess.ts";
|
||||
export * from "./messages/web.ts";
|
||||
export * from "./messages/socket.ts";
|
||||
|
||||
|
||||
@ -30,6 +30,12 @@ import {
|
||||
FileWriteRequestMessage,
|
||||
FileWriteResponseMessage,
|
||||
} from "./ide.ts";
|
||||
import {
|
||||
RequestProcessStatsMessage,
|
||||
RequestProcessStatsCallback,
|
||||
SubscribeDroneMessage,
|
||||
UnsubscribeDroneMessage,
|
||||
} from "./subprocess.ts";
|
||||
|
||||
/*
|
||||
There are two different kinds of clients that connect to the gadget-code:web
|
||||
@ -64,6 +70,14 @@ export interface ClientToServerEvents {
|
||||
fileReadRequest: FileReadRequestMessage;
|
||||
fileWriteRequest: FileWriteRequestMessage;
|
||||
|
||||
/*
|
||||
* Drone Monitor events (IDE => web => drone)
|
||||
*/
|
||||
|
||||
requestProcessStats: RequestProcessStatsMessage;
|
||||
subscribeDrone: SubscribeDroneMessage;
|
||||
unsubscribeDrone: UnsubscribeDroneMessage;
|
||||
|
||||
/*
|
||||
* gadget-drone => gadget-code:web
|
||||
*/
|
||||
@ -130,10 +144,32 @@ export interface ServerToClientEvents {
|
||||
fileReadRequest: FileReadRequestMessage;
|
||||
fileWriteRequest: FileWriteRequestMessage;
|
||||
|
||||
/*
|
||||
* Drone Monitor events (web => drone)
|
||||
* Note: no registrationId param — the drone socket already knows its identity
|
||||
*/
|
||||
|
||||
requestProcessStats: (
|
||||
cb: RequestProcessStatsCallback,
|
||||
) => void;
|
||||
|
||||
/*
|
||||
* gadget-code:web => gadget-code:ide
|
||||
*/
|
||||
|
||||
"drone:log": (data: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
}) => void;
|
||||
|
||||
"drone:status": (data: {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
}) => void;
|
||||
|
||||
log: LogMessage;
|
||||
status: StatusMessage;
|
||||
thinking: ThinkingMessage;
|
||||
|
||||
39
packages/api/src/messages/subprocess.ts
Normal file
39
packages/api/src/messages/subprocess.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// src/messages/subprocess.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import { GadgetId } from "../lib/gadget-id.ts";
|
||||
|
||||
export interface SubProcessStat {
|
||||
pid: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
projectId: GadgetId;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
status: "running" | "stopped" | "error";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
stdoutFileName: string;
|
||||
stderrFileName: string;
|
||||
}
|
||||
|
||||
export type RequestProcessStatsCallback = (
|
||||
success: boolean,
|
||||
data?: { processes?: SubProcessStat[]; message?: string },
|
||||
) => void;
|
||||
|
||||
export type RequestProcessStatsMessage = (
|
||||
registrationId: string,
|
||||
cb: RequestProcessStatsCallback,
|
||||
) => void;
|
||||
|
||||
export type SubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void,
|
||||
) => void;
|
||||
|
||||
export type UnsubscribeDroneMessage = (
|
||||
registrationId: string,
|
||||
cb: (success: boolean) => void,
|
||||
) => void;
|
||||
Loading…
Reference in New Issue
Block a user