Fleet P2a: in-dashboard remote terminal (PTY over the WSS channel)
ci / gates (push) Failing after 5s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

You can now open a real shell on any connected node from the dashboard — the
daemon spawns a host PTY and streams it over the existing outbound control
channel (no inbound port, no Tailscale brokering needed).

Daemon:
- portable-pty host shell sessions: pty_open/pty_in/pty_resize/pty_close ops; a
  reader thread streams base64 pty_out frames. Outbound frames now funnel through
  one mpsc channel so PTY output and heartbeats interleave.

cm-api NodeHub:
- per-connection pty_sinks + sid multiplexing; open_terminal/terminal_input/
  terminal_resize/terminal_close; in-memory single-use terminal tickets (the
  browser WS can't carry a bearer, and the session is instance-local anyway).
- routes/nodes.rs: POST /api/nodes/{id}/terminal/ticket + GET .../terminal/ws
  (bridges browser xterm <-> node PTY: binary = keystrokes, text = resize).

Frontend:
- NodeTerminal xterm modal (reuses the agent Terminal's xterm setup); a Terminal
  button on each online node card opens a shell.

This proves the bidirectional streaming-over-channel mechanism the RemoteDriver
will reuse. Remaining P2: RemoteDriver + placement (run agent workloads on nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 12:13:24 -07:00
co-authored by Claude Opus 4.8
parent 7332d69f8a
commit f5f96508eb
9 changed files with 548 additions and 35 deletions
@@ -4,11 +4,12 @@
// comes from the nodes registry (`GET /api/nodes`), polled every 3s.
import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Trash2 } from "lucide-react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Terminal, Trash2 } from "lucide-react";
import { useFetchJson } from "@/lib/api/use-fetch";
import { ConnectHostWizard } from "../ConnectHostWizard";
import { NodeTerminal } from "./NodeTerminal";
const mono = "'Geist Mono', ui-monospace, monospace";
@@ -77,6 +78,7 @@ function Bar({ label, pct, detail, color }: { label: string; pct: number; detail
export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
const h = node.health;
const [term, setTerm] = useState(false);
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
const remove = useCallback(() => {
@@ -96,6 +98,9 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
{node.agentVersion ? <span>· v{node.agentVersion}</span> : null}
</div>
</div>
{node.status === "online" ? (
<button type="button" onClick={() => setTerm(true)} title="Open terminal" aria-label="Open terminal" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={14} /></button>
) : null}
<button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button>
</div>
@@ -119,6 +124,7 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
{node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"}
</div>
)}
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
</div>
);
}