Fleet: node hostname/IP on register + node terminal in the infra computer
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:
co-authored by
Claude Opus 4.8
parent
cfa16751c5
commit
cf6c331b02
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user