Fleet P2a: in-dashboard remote terminal (PTY over the WSS channel)
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:
co-authored by
Claude Opus 4.8
parent
7332d69f8a
commit
f5f96508eb
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user