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
+10
View File
@@ -133,6 +133,8 @@ fn heartbeat(sys: &mut System) -> String {
"t": "heartbeat", "t": "heartbeat",
"version": VERSION, "version": VERSION,
"tailscale_ip": tailscale_ip(), "tailscale_ip": tailscale_ip(),
"hostname": System::host_name(),
"local_ip": local_ip(),
"health": { "health": {
"cpu_pct": sys.global_cpu_usage() as f64, "cpu_pct": sys.global_cpu_usage() as f64,
"mem_total": mem_total, "mem_total": mem_total,
@@ -179,6 +181,14 @@ fn docker_count() -> i32 {
.unwrap_or(0) .unwrap_or(0)
} }
/// The node's primary outbound IPv4 (the interface a default route would use).
/// Uses a connectionless UDP socket — no packet is actually sent.
fn local_ip() -> Option<String> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
Some(sock.local_addr().ok()?.ip().to_string())
}
/// This node's Tailscale IP, if Tailscale is up (BYO tailnet). /// This node's Tailscale IP, if Tailscale is up (BYO tailnet).
fn tailscale_ip() -> Option<String> { fn tailscale_ip() -> Option<String> {
let out = std::process::Command::new("tailscale") let out = std::process::Command::new("tailscale")
+6
View File
@@ -190,6 +190,8 @@ enum Uplink {
Heartbeat { Heartbeat {
version: Option<String>, version: Option<String>,
tailscale_ip: Option<String>, tailscale_ip: Option<String>,
hostname: Option<String>,
local_ip: Option<String>,
health: HealthMsg, health: HealthMsg,
}, },
#[serde(rename = "result")] #[serde(rename = "result")]
@@ -243,6 +245,8 @@ pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socke
Ok(Uplink::Heartbeat { Ok(Uplink::Heartbeat {
version, version,
tailscale_ip, tailscale_ip,
hostname,
local_ip,
health, health,
}) => { }) => {
let h = NodeHealth { let h = NodeHealth {
@@ -263,6 +267,8 @@ pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socke
node_id, node_id,
version.as_deref(), version.as_deref(),
tailscale_ip.as_deref(), tailscale_ip.as_deref(),
hostname.as_deref(),
local_ip.as_deref(),
&h, &h,
) )
.await; .await;
+2
View File
@@ -30,6 +30,8 @@ fn node_json(n: &nodes::NodeRow) -> Value {
json!({ json!({
"id": n.id, "id": n.id,
"name": n.name, "name": n.name,
"hostname": n.hostname,
"localIp": n.local_ip,
"status": n.status, "status": n.status,
"agentVersion": n.agent_version, "agentVersion": n.agent_version,
"tailscaleIp": n.tailscale_ip, "tailscaleIp": n.tailscale_ip,
+13 -2
View File
@@ -29,6 +29,8 @@ pub struct NodeHealth {
pub struct NodeRow { pub struct NodeRow {
pub id: NodeId, pub id: NodeId,
pub name: String, pub name: String,
pub hostname: Option<String>,
pub local_ip: Option<String>,
pub status: String, pub status: String,
pub agent_version: Option<String>, pub agent_version: Option<String>,
pub tailscale_ip: Option<String>, pub tailscale_ip: Option<String>,
@@ -69,7 +71,7 @@ pub async fn auth(pool: &PgPool, token: &str) -> Result<Option<(NodeId, Workspac
})) }))
} }
const SELECT_WITH_HEALTH: &str = "SELECT n.id, n.name, n.status, n.agent_version, n.tailscale_ip, n.last_seen, n.created_at, const SELECT_WITH_HEALTH: &str = "SELECT n.id, n.name, n.hostname, n.local_ip, n.status, n.agent_version, n.tailscale_ip, n.last_seen, n.created_at,
h.node_id AS health_node, h.cpu_pct, h.mem_total, h.mem_used, h.mem_pressure, h.swap_used, h.node_id AS health_node, h.cpu_pct, h.mem_total, h.mem_used, h.mem_pressure, h.swap_used,
h.disk_total, h.disk_free, h.load1, h.load5, h.load15, h.container_count h.disk_total, h.disk_free, h.load1, h.load5, h.load15, h.container_count
FROM nodes n LEFT JOIN node_health h ON h.node_id = n.id"; FROM nodes n LEFT JOIN node_health h ON h.node_id = n.id";
@@ -103,22 +105,29 @@ pub async fn get(
/// Record a heartbeat: mark the node online + refresh its version/tailscale IP, /// Record a heartbeat: mark the node online + refresh its version/tailscale IP,
/// and upsert its latest host-health snapshot. /// and upsert its latest host-health snapshot.
#[allow(clippy::too_many_arguments)]
pub async fn heartbeat( pub async fn heartbeat(
pool: &PgPool, pool: &PgPool,
id: NodeId, id: NodeId,
agent_version: Option<&str>, agent_version: Option<&str>,
tailscale_ip: Option<&str>, tailscale_ip: Option<&str>,
hostname: Option<&str>,
local_ip: Option<&str>,
h: &NodeHealth, h: &NodeHealth,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
sqlx::query( sqlx::query(
"UPDATE nodes SET status = 'online', last_seen = now(), "UPDATE nodes SET status = 'online', last_seen = now(),
agent_version = COALESCE($2, agent_version), agent_version = COALESCE($2, agent_version),
tailscale_ip = COALESCE($3, tailscale_ip) tailscale_ip = COALESCE($3, tailscale_ip),
hostname = COALESCE($4, hostname),
local_ip = COALESCE($5, local_ip)
WHERE id = $1", WHERE id = $1",
) )
.bind(id.as_uuid()) .bind(id.as_uuid())
.bind(agent_version) .bind(agent_version)
.bind(tailscale_ip) .bind(tailscale_ip)
.bind(hostname)
.bind(local_ip)
.execute(pool) .execute(pool)
.await?; .await?;
sqlx::query( sqlx::query(
@@ -203,6 +212,8 @@ fn map_node(r: sqlx::postgres::PgRow) -> NodeRow {
NodeRow { NodeRow {
id: NodeId::from(r.get::<uuid::Uuid, _>("id")), id: NodeId::from(r.get::<uuid::Uuid, _>("id")),
name: r.get("name"), name: r.get("name"),
hostname: r.get("hostname"),
local_ip: r.get("local_ip"),
status: r.get("status"), status: r.get("status"),
agent_version: r.get("agent_version"), agent_version: r.get("agent_version"),
tailscale_ip: r.get("tailscale_ip"), tailscale_ip: r.get("tailscale_ip"),
@@ -0,0 +1,126 @@
"use client";
// The infra computer's Terminal app: a shell on a fleet node, rendered inside
// the pull-out (not a separate window). Targets the node in the ?node= param;
// shows a picker when none is set. PTY is proxied over the node control channel.
import { Server, Terminal as TerminalIcon } from "lucide-react";
import { useQueryStates } from "nuqs";
import { useEffect, useRef } from "react";
import { useFetchJson } from "@/lib/api/use-fetch";
import { panelParsers } from "@/lib/url/panel-params";
import type { FleetNode } from "@/components/dashboard/fleet/FleetPanels";
import "@xterm/xterm/css/xterm.css";
/** xterm bridged to a node's host shell, filling the app window. */
function NodeShell({ nodeId }: { nodeId: string }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let disposed = false;
let term: import("@xterm/xterm").Terminal | null = null;
let ws: WebSocket | null = null;
let ro: ResizeObserver | null = null;
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
term = new Terminal({
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
theme: { background: "#0a0a0c", foreground: "#d4d4d8" },
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
term.focus();
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
const body = res.ok ? ((await res.json()) as { ticket?: string; error?: string }) : {};
if (!body.ticket) {
term.writeln(`\r\n\x1b[31m${body.error ?? "could not open terminal"}\x1b[0m`);
return;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(body.ticket)}`);
ws.binaryType = "arraybuffer";
const sendResize = () => {
if (ws?.readyState === WebSocket.OPEN && term && hostRef.current?.clientWidth) {
fit.fit();
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
ws.onopen = () => sendResize();
ws.onmessage = (e) => {
if (typeof e.data === "string") term?.write(e.data);
else term?.write(new Uint8Array(e.data as ArrayBuffer));
};
ws.onclose = () => term?.writeln("\r\n\x1b[33m[disconnected]\x1b[0m");
term.onData((d) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(d));
});
ro = new ResizeObserver(() => sendResize());
ro.observe(hostRef.current);
})();
return () => {
disposed = true;
ro?.disconnect();
ws?.close();
term?.dispose();
};
}, [nodeId]);
return (
<div className="flex h-full w-full flex-col bg-[#0a0a0c]">
<div className="relative min-h-0 flex-1">
<div ref={hostRef} className="absolute inset-0 p-2" />
</div>
</div>
);
}
function NodePicker({ onPick }: { onPick: (id: string) => void }) {
const { data } = useFetchJson<{ nodes: FleetNode[] }>("/api/nodes");
const online = (data?.nodes ?? []).filter((n) => n.status === "online");
return (
<div className="flex h-full flex-col gap-2 overflow-auto bg-[#0a0a0c] p-5">
<div className="mb-1 flex items-center gap-2 text-sm font-semibold text-white/90">
<TerminalIcon size={15} /> Open a shell on
</div>
{online.length === 0 ? (
<div className="font-mono text-xs text-neutral-500">No online nodes. Connect a host under Local hardware.</div>
) : (
online.map((n) => (
<button
key={n.id}
type="button"
onClick={() => onPick(n.id)}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-[#101014] px-3 py-2.5 text-left transition-colors hover:border-cyan-400/40"
>
<Server size={16} className="text-cyan-400" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-neutral-100">{n.hostname ?? n.name}</div>
<div className="truncate font-mono text-[11px] text-neutral-500">{n.localIp ?? n.tailscaleIp ?? ""}</div>
</div>
</button>
))
)}
</div>
);
}
export function NodeTerminalApp() {
const [{ node }, setParams] = useQueryStates(panelParsers, { shallow: true });
if (!node) return <NodePicker onPick={(id) => setParams({ node: id })} />;
return <NodeShell key={node} nodeId={node} />;
}
export default NodeTerminalApp;
@@ -2,12 +2,13 @@
// computer, but its apps are cloud providers + infra system apps. Tiles are // computer, but its apps are cloud providers + infra system apps. Tiles are
// original colored lettermarks (not the providers' trademarked logos). // original colored lettermarks (not the providers' trademarked logos).
import { Activity, Cloud, Plus, Server, Settings } from "lucide-react"; import { Activity, Cloud, Plus, Server, Settings, Terminal } from "lucide-react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { AppId } from "@/lib/url/panel-params"; import type { AppId } from "@/lib/url/panel-params";
import { InfraApp } from "../apps/infra/InfraApp"; import { InfraApp } from "../apps/infra/InfraApp";
import { NodeTerminalApp } from "../apps/infra/NodeTerminalApp";
import type { ComputerCatalog } from "../catalog"; import type { ComputerCatalog } from "../catalog";
import { LetterTile } from "../tiles"; import { LetterTile } from "../tiles";
@@ -19,6 +20,7 @@ const TITLES: Record<string, string> = {
status: "Fleet Status", status: "Fleet Status",
settings: "Settings", settings: "Settings",
apps: "Add Cloud", apps: "Add Cloud",
terminal: "Terminal",
}; };
const notConnected = [ const notConnected = [
@@ -29,6 +31,8 @@ const notConnected = [
function render(app: AppId) { function render(app: AppId) {
switch (app) { switch (app) {
case "terminal":
return <NodeTerminalApp />;
case "aws": case "aws":
return <InfraApp icon={Cloud} accent="#ec7211" title="AWS" blurb="Connect an AWS account to run agents on EC2, ECS / Fargate and Lambda across your regions." rows={notConnected} />; return <InfraApp icon={Cloud} accent="#ec7211" title="AWS" blurb="Connect an AWS account to run agents on EC2, ECS / Fargate and Lambda across your regions." rows={notConnected} />;
case "gcp": case "gcp":
@@ -59,6 +63,7 @@ function render(app: AppId) {
export const INFRA_CATALOG: ComputerCatalog = { export const INFRA_CATALOG: ComputerCatalog = {
grid: [ grid: [
{ app: "terminal", label: "Terminal" },
{ app: "aws", label: "AWS" }, { app: "aws", label: "AWS" },
{ app: "gcp", label: "GCP" }, { app: "gcp", label: "GCP" },
{ app: "azure", label: "Azure" }, { app: "azure", label: "Azure" },
@@ -69,7 +74,7 @@ export const INFRA_CATALOG: ComputerCatalog = {
{ app: "status", label: "Status" }, { app: "status", label: "Status" },
{ app: "settings", label: "Settings" }, { app: "settings", label: "Settings" },
], ],
icon: { hosts: Server, status: Activity, settings: Settings, apps: Plus, aws: Cloud, gcp: Cloud, azure: Cloud }, icon: { hosts: Server, status: Activity, settings: Settings, apps: Plus, aws: Cloud, gcp: Cloud, azure: Cloud, terminal: Terminal },
tile: (app) => { tile: (app) => {
if (app === "aws") return <LetterTile text="AWS" gradient="linear-gradient(150deg,#ff9d3c 0%,#ec7211 55%,#a8430a 100%)" />; if (app === "aws") return <LetterTile text="AWS" gradient="linear-gradient(150deg,#ff9d3c 0%,#ec7211 55%,#a8430a 100%)" />;
if (app === "gcp") return <LetterTile text="GCP" gradient="linear-gradient(150deg,#5b9bff 0%,#1a73e8 55%,#0b3d91 100%)" />; if (app === "gcp") return <LetterTile text="GCP" gradient="linear-gradient(150deg,#5b9bff 0%,#1a73e8 55%,#0b3d91 100%)" />;
@@ -5,11 +5,12 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, ShieldCheck, Terminal, Trash2 } from "lucide-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 { useFetchJson } from "@/lib/api/use-fetch";
import { panelParsers } from "@/lib/url/panel-params";
import { ConnectHostWizard } from "../ConnectHostWizard"; import { ConnectHostWizard } from "../ConnectHostWizard";
import { NodeTerminal } from "./NodeTerminal";
const mono = "'Geist Mono', ui-monospace, monospace"; const mono = "'Geist Mono', ui-monospace, monospace";
@@ -30,6 +31,8 @@ export interface NodeHealth {
export interface FleetNode { export interface FleetNode {
id: string; id: string;
name: string; name: string;
hostname: string | null;
localIp: string | null;
status: "pending" | "online" | "offline" | "draining"; status: "pending" | "online" | "offline" | "draining";
agentVersion: string | null; agentVersion: string | null;
tailscaleIp: 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 }) { export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
const h = node.health; 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 [check, setCheck] = useState<{ ok: boolean; output: string } | null>(null);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const runCheck = useCallback(() => { const runCheck = useCallback(() => {
@@ -102,17 +105,18 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <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> <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={{ 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 }}> <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] }} /> <span style={{ width: 7, height: 7, borderRadius: "50%", background: STATUS_COLOR[node.status] }} />
{node.status.toUpperCase()} {node.status.toUpperCase()}
{node.localIp ? <span>· {node.localIp}</span> : null}
{node.agentVersion ? <span>· v{node.agentVersion}</span> : null} {node.agentVersion ? <span>· v{node.agentVersion}</span> : null}
</div> </div>
</div> </div>
{node.status === "online" ? ( {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={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} ) : 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> <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} ) : null}
</div> </div>
) : null} ) : null}
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
</div> </div>
); );
} }
@@ -1,87 +0,0 @@
"use client";
// An in-dashboard shell on a fleet node: xterm.js ⇄ a node-terminal WebSocket,
// whose PTY is proxied over the daemon's control channel. Mirrors the agent
// Terminal app's xterm setup, pointed at /api/nodes/{id}/terminal.
import { useEffect, useRef } from "react";
import { X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
export function NodeTerminal({ nodeId, nodeName, onClose }: { nodeId: string; nodeName: string; onClose: () => void }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let disposed = false;
let term: import("@xterm/xterm").Terminal | null = null;
let ws: WebSocket | null = null;
let onResize: (() => void) | null = null;
(async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
]);
if (disposed || !hostRef.current) return;
term = new Terminal({
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
cursorBlink: true,
theme: { background: "#0a0a0c", foreground: "#d4d4d8" },
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(hostRef.current);
fit.fit();
term.focus();
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
const body = res.ok ? ((await res.json()) as { ticket?: string; error?: string }) : {};
if (!body.ticket) {
term.writeln(`\r\n\x1b[31m${body.error ?? "could not open terminal"}\x1b[0m`);
return;
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(body.ticket)}`);
ws.binaryType = "arraybuffer";
const sendResize = () => {
if (ws?.readyState === WebSocket.OPEN && term) {
fit.fit();
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
};
ws.onopen = () => sendResize();
ws.onmessage = (e) => {
if (typeof e.data === "string") term?.write(e.data);
else term?.write(new Uint8Array(e.data as ArrayBuffer));
};
ws.onclose = () => term?.writeln("\r\n\x1b[33m[disconnected]\x1b[0m");
term.onData((d) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(d));
});
onResize = sendResize;
window.addEventListener("resize", onResize);
})();
return () => {
disposed = true;
if (onResize) window.removeEventListener("resize", onResize);
ws?.close();
term?.dispose();
};
}, [nodeId]);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 300, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 860, maxWidth: "100%", height: 520, maxHeight: "90vh", display: "flex", flexDirection: "column", borderRadius: 16, background: "#0a0a0c", border: "1px solid rgba(255,255,255,.12)", boxShadow: "0 24px 70px rgba(0,0,0,.6)", overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "11px 14px", borderBottom: "1px solid rgba(255,255,255,.08)" }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: "#5fd08a" }} />
<span style={{ fontFamily: "'Geist Mono', ui-monospace, monospace", fontSize: 12.5, color: "#cfcfd5", flex: 1 }}>{nodeName} shell</span>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 28, height: 28, borderRadius: 7, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={15} /></button>
</div>
<div ref={hostRef} style={{ flex: 1, minHeight: 0, padding: 8 }} />
</div>
</div>
);
}
+3
View File
@@ -8,6 +8,7 @@
import { import {
createParser, createParser,
createSearchParamsCache, createSearchParamsCache,
parseAsString,
parseAsStringLiteral, parseAsStringLiteral,
} from "nuqs/server"; } from "nuqs/server";
@@ -49,6 +50,8 @@ export type WizardStep = (typeof WIZARD_STEPS)[number];
export const panelParsers = { export const panelParsers = {
app: parseAsStringLiteral(APP_IDS), app: parseAsStringLiteral(APP_IDS),
// The fleet node a node-scoped app (the infra Terminal) targets.
node: parseAsString,
device: parseAsStringLiteral(DEVICE_SIZES).withDefault("tablet"), device: parseAsStringLiteral(DEVICE_SIZES).withDefault("tablet"),
sessions: parseAsFlag.withDefault(false), sessions: parseAsFlag.withDefault(false),
step: parseAsStringLiteral(WIZARD_STEPS).withDefault("identity"), step: parseAsStringLiteral(WIZARD_STEPS).withDefault("identity"),
+5
View File
@@ -0,0 +1,5 @@
-- The daemon reports the machine's real hostname + primary IP on heartbeat, so
-- the fleet shows the actual host (e.g. "ada-server / 10.0.0.5") instead of the
-- placeholder "New node". `name` remains user-overridable (rename).
ALTER TABLE nodes ADD COLUMN hostname TEXT;
ALTER TABLE nodes ADD COLUMN local_ip TEXT;