Fleet: node hostname/IP on register + node terminal in the infra computer
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

Hostname/IP:
- Daemon reports the machine's hostname (sysinfo) + primary outbound IPv4 on each
  heartbeat. migrations/0021 adds nodes.hostname/local_ip; cm-db heartbeat stores
  them; node JSON exposes them. Cards now title on the real hostname (falling back
  to name) + show the IP, instead of the "New node" placeholder. `name` stays
  user-overridable (rename).

Terminal moved into the pull-out computer (no more per-card modal):
- New infra computer app NodeTerminalApp (computer/apps/infra) — xterm bridged to
  a node's host shell over the node control channel, filling the app window
  (mirrors the agent Terminal's layout + ResizeObserver). Added "terminal" to the
  INFRA_CATALOG grid; a ?node= panel param targets a specific node (picker when
  unset). Clicking Terminal on a node card now opens the infra computer to that
  node's shell instead of a separate full-screen window. Deleted NodeTerminal.tsx.

Rebuilt + re-hosted both daemon binaries (hostname change).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 20:13:15 -07:00
co-authored by Claude Opus 4.8
parent cfa16751c5
commit cf6c331b02
10 changed files with 180 additions and 96 deletions
@@ -5,11 +5,12 @@
import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, ShieldCheck, Terminal, Trash2 } from "lucide-react";
import { useQueryStates } from "nuqs";
import { useFetchJson } from "@/lib/api/use-fetch";
import { panelParsers } from "@/lib/url/panel-params";
import { ConnectHostWizard } from "../ConnectHostWizard";
import { NodeTerminal } from "./NodeTerminal";
const mono = "'Geist Mono', ui-monospace, monospace";
@@ -30,6 +31,8 @@ export interface NodeHealth {
export interface FleetNode {
id: string;
name: string;
hostname: string | null;
localIp: string | null;
status: "pending" | "online" | "offline" | "draining";
agentVersion: string | null;
tailscaleIp: string | null;
@@ -78,7 +81,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 [, setParams] = useQueryStates(panelParsers, { shallow: true });
const [check, setCheck] = useState<{ ok: boolean; output: string } | null>(null);
const [checking, setChecking] = useState(false);
const runCheck = useCallback(() => {
@@ -102,17 +105,18 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 36, height: 36, borderRadius: 9, background: "rgba(94,200,216,.1)", border: "1px solid rgba(94,200,216,.25)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5ec8d8" }}><Server size={18} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#f3f3f5", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{node.name}</div>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#f3f3f5", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{node.hostname ?? node.name}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 10, color: "#7a7a82", marginTop: 2 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: STATUS_COLOR[node.status] }} />
{node.status.toUpperCase()}
{node.localIp ? <span>· {node.localIp}</span> : null}
{node.agentVersion ? <span>· v{node.agentVersion}</span> : null}
</div>
</div>
{node.status === "online" ? (
<>
<button type="button" onClick={runCheck} disabled={checking} title="Run sandbox readiness check" aria-label="Sandbox check" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(95,208,138,.3)", background: "rgba(95,208,138,.08)", color: "#5fd08a", cursor: checking ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><ShieldCheck size={14} /></button>
<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>
<button type="button" onClick={() => setParams({ app: "terminal", node: node.id, device: "phone" })} title="Open terminal in the computer" 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>
@@ -148,7 +152,6 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
) : null}
</div>
) : null}
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
</div>
);
}