add remote fleet terminal sessions

This commit is contained in:
lda
2026-07-15 07:33:37 +07:00 Verified
parent 4ecef8da38
commit 4d793136ba
24 changed files with 2386 additions and 26 deletions
+24 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { toast } from "sonner";
@@ -15,6 +15,7 @@ import {
setAgentNickname,
} from "@/api";
import { Toaster } from "@/components/ui/sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { AppLayout } from "@/layout/AppLayout";
import { AgentsPage } from "@/pages/AgentsPage";
import { AlertsPage } from "@/pages/AlertsPage";
@@ -23,6 +24,12 @@ import { CommandsPage } from "@/pages/CommandsPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { DevicesPage } from "@/pages/DevicesPage";
import { TokensPage } from "@/pages/TokensPage";
const TerminalPage = lazy(() =>
import("@/pages/TerminalPage").then((module) => ({
default: module.TerminalPage,
})),
);
import { WakeToolsPage } from "@/pages/WakeToolsPage";
type LoadState = "idle" | "loading" | "ready" | "error";
@@ -215,6 +222,22 @@ export function App() {
}
/>
<Route path="tokens" element={<TokensPage />} />
<Route
path="terminal"
element={
<Suspense
fallback={
<Skeleton className="h-[calc(100dvh-2.5rem)] w-full" />
}
>
<TerminalPage
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={setSelectedAgentId}
/>
</Suspense>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
+39
View File
@@ -2,6 +2,17 @@ export type Agent = {
agent_id: string;
connected: boolean;
nickname?: string | null;
capabilities: "terminal"[];
};
export type TerminalSession = {
terminal_id: string;
agent_id: string;
created_at_unix: number;
agent_attached: boolean;
operator_attached: boolean;
websocket_url: string;
attachment_token?: string;
};
export type Alert = {
@@ -225,6 +236,34 @@ export function fetchAgents(): Promise<Agent[]> {
return request<Agent[]>("/api/v1/control/agents");
}
export function createTerminal(
agentId: string,
rows: number,
cols: number,
): Promise<TerminalSession> {
return request<TerminalSession>("/api/v1/control/terminals", {
method: "POST",
body: JSON.stringify({ agent_id: agentId, rows, cols }),
});
}
export function attachTerminal(terminalId: string): Promise<TerminalSession> {
return request<TerminalSession>(
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
{ method: "POST" },
);
}
export async function closeTerminal(terminalId: string): Promise<void> {
const response = await fetch(
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error(`Failed to close terminal: ${response.status}`);
}
}
export function fetchAlerts(): Promise<Alert[]> {
return request<Alert[]>("/api/v1/control/alerts");
}
+2
View File
@@ -12,6 +12,7 @@ import {
FileText,
Key,
Terminal,
SquareTerminal,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
@@ -57,6 +58,7 @@ const navSections: NavSection[] = [
{ to: "/audit", label: "Audit", icon: FileText },
{ to: "/tokens", label: "Tokens", icon: Key },
{ to: "/commands", label: "Commands", icon: Terminal },
{ to: "/terminal", label: "Terminal", icon: SquareTerminal },
],
},
];
+1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import { App } from "@/App";
import "@xterm/xterm/css/xterm.css";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
+380
View File
@@ -0,0 +1,380 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTerm } from "@xterm/xterm";
import { Eraser, PlugZap, RotateCcw, Square, Terminal } from "lucide-react";
import { toast } from "sonner";
import {
type Agent,
type TerminalSession,
attachTerminal,
closeTerminal,
createTerminal,
} from "@/api";
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type Props = {
agents: Agent[];
selectedAgentId: string;
onSelectAgent: (agentId: string) => void;
};
type ConnectionState =
| "idle"
| "connecting"
| "ready"
| "disconnected"
| "exited";
function websocketUrl(path: string): string {
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${scheme}//${window.location.host}${path}`;
}
export function TerminalPage({
agents,
selectedAgentId,
onSelectAgent,
}: Props) {
const hostRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const intentionalCloseRef = useRef(false);
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
const [session, setSession] = useState<TerminalSession | null>(null);
const [connection, setConnection] = useState<ConnectionState>("idle");
const selectedAgent = agents.find(
(agent) => agent.agent_id === selectedAgentId,
);
const canStart =
selectedAgent?.connected &&
selectedAgent.capabilities.includes("terminal") &&
connection === "idle";
const sendResize = useCallback(() => {
const terminal = terminalRef.current;
const socket = socketRef.current;
if (!terminal || socket?.readyState !== WebSocket.OPEN) return;
if (
lastTerminalSizeRef.current.rows === terminal.rows &&
lastTerminalSizeRef.current.cols === terminal.cols
) {
return;
}
lastTerminalSizeRef.current = {
rows: terminal.rows,
cols: terminal.cols,
};
socket.send(
JSON.stringify({
type: "resize",
rows: terminal.rows,
cols: terminal.cols,
}),
);
}, []);
const connect = useCallback(
(nextSession: TerminalSession) => {
if (!nextSession.attachment_token) {
throw new Error(
"Control plane did not issue a terminal attachment token",
);
}
socketRef.current?.close();
intentionalCloseRef.current = false;
setConnection("connecting");
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
socket.binaryType = "arraybuffer";
socketRef.current = socket;
socket.onopen = () => {
socket.send(
JSON.stringify({
type: "attach",
attachment_token: nextSession.attachment_token,
}),
);
fitRef.current?.fit();
sendResize();
};
socket.onmessage = (event) => {
if (typeof event.data !== "string") {
terminalRef.current?.write(new Uint8Array(event.data as ArrayBuffer));
return;
}
try {
const control = JSON.parse(event.data) as {
type: string;
exit_code?: number | null;
message?: string;
};
if (control.type === "ready") {
setConnection("ready");
window.requestAnimationFrame(() => {
fitRef.current?.fit();
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
sendResize();
terminalRef.current?.focus();
});
} else if (control.type === "exited") {
setConnection("exited");
terminalRef.current?.writeln(
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
);
} else if (control.type === "error") {
setConnection("exited");
terminalRef.current?.writeln(
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
);
}
} catch {
terminalRef.current?.writeln("\r\n[invalid terminal control frame]");
}
};
socket.onerror = () => {
terminalRef.current?.writeln("\r\n[terminal transport error]");
};
socket.onclose = () => {
socketRef.current = null;
setConnection((current) => {
if (intentionalCloseRef.current || current === "exited")
return current;
return "disconnected";
});
};
},
[sendResize],
);
useEffect(() => {
if (!hostRef.current) return;
const terminal = new XTerm({
cursorBlink: true,
convertEol: false,
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
fontSize: 14,
lineHeight: 1.1,
scrollback: 5000,
theme: {
background: "#0b1117",
foreground: "#e5e7eb",
cursor: "#6ee7a8",
},
});
const fit = new FitAddon();
terminal.loadAddon(fit);
terminal.open(hostRef.current);
fit.fit();
terminalRef.current = terminal;
fitRef.current = fit;
let cancelled = false;
void document.fonts.ready.then(() => {
if (cancelled) return;
fit.fit();
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
sendResize();
});
const input = terminal.onData((data) => {
const socket = socketRef.current;
if (socket?.readyState === WebSocket.OPEN) {
socket.send(new TextEncoder().encode(data));
}
});
let resizeFrame = 0;
let lastHostWidth = 0;
let lastHostHeight = 0;
const resizeObserver = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width);
const height = Math.round(entry.contentRect.height);
if (width === lastHostWidth && height === lastHostHeight) return;
lastHostWidth = width;
lastHostHeight = height;
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(() => {
fit.fit();
sendResize();
});
});
resizeObserver.observe(hostRef.current);
return () => {
cancelled = true;
input.dispose();
resizeObserver.disconnect();
window.cancelAnimationFrame(resizeFrame);
socketRef.current?.close();
terminal.dispose();
terminalRef.current = null;
fitRef.current = null;
};
}, [sendResize]);
async function start() {
if (!selectedAgentId || !terminalRef.current) return;
setConnection("connecting");
terminalRef.current.clear();
try {
fitRef.current?.fit();
const created = await createTerminal(
selectedAgentId,
terminalRef.current.rows,
terminalRef.current.cols,
);
setSession(created);
connect(created);
} catch (error) {
setConnection("idle");
toast.error("Could not start terminal", { description: String(error) });
}
}
async function reconnect() {
if (!session) return;
try {
const attached = await attachTerminal(session.terminal_id);
setSession(attached);
connect(attached);
} catch (error) {
setConnection("exited");
toast.error("Terminal session is no longer available", {
description: String(error),
});
}
}
async function close() {
if (!session) return;
intentionalCloseRef.current = true;
socketRef.current?.send(JSON.stringify({ type: "close" }));
socketRef.current?.close();
try {
await closeTerminal(session.terminal_id);
} catch (error) {
toast.error("Terminal cleanup failed", { description: String(error) });
} finally {
setSession(null);
setConnection("idle");
terminalRef.current?.clear();
}
}
return (
<section className="terminal-page" aria-label="Remote terminal">
<header className="terminal-toolbar">
<div className="terminal-heading">
<Terminal className="size-5 text-primary" aria-hidden />
<h1>Remote Terminal</h1>
</div>
<div className="terminal-controls">
<AgentSelector
agents={agents.filter(
(agent) =>
agent.connected && agent.capabilities.includes("terminal"),
)}
value={selectedAgentId}
onChange={onSelectAgent}
disabled={connection !== "idle"}
className="w-full min-w-0 sm:w-64"
/>
{connection === "idle" ? (
<Button type="button" onClick={start} disabled={!canStart}>
<PlugZap className="size-4" aria-hidden />
Connect
</Button>
) : null}
</div>
</header>
{!selectedAgent && connection === "idle" ? (
<p className="terminal-notice">
No connected terminal-capable agent is selected.
</p>
) : selectedAgent && !selectedAgent.capabilities.includes("terminal") ? (
<p className="terminal-notice">
This agent has not enabled remote terminal capability.
</p>
) : null}
<div className="terminal-frame">
<div className="terminal-framebar">
<div className="terminal-session-label">
<Terminal className="size-4" aria-hidden />
<span>
{session && selectedAgent
? displayAgentLabel(selectedAgent)
: "No active session"}
</span>
<Badge
variant="outline"
className="terminal-status"
data-state={connection}
>
<span aria-hidden />
{connection}
</Badge>
</div>
<div className="terminal-frame-actions">
{connection === "disconnected" ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={reconnect}
>
<RotateCcw className="size-4" aria-hidden />
Reconnect
</Button>
) : null}
<Tooltip>
<TooltipTrigger>
<Button
type="button"
size="icon"
variant="ghost"
disabled={connection === "idle"}
aria-label="Clear terminal"
onClick={() => terminalRef.current?.clear()}
>
<Eraser className="size-4" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>Clear terminal</TooltipContent>
</Tooltip>
{session ? (
<Tooltip>
<TooltipTrigger>
<Button
type="button"
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive"
aria-label="Close terminal session"
onClick={close}
>
<Square className="size-3.5 fill-current" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>Close session</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
<div className="terminal-surface" ref={hostRef} />
</div>
</section>
);
}
+172
View File
@@ -192,6 +192,12 @@
transition: background 100ms ease-in-out;
}
/* xterm focuses an input near the active row. Keep that focus operation from
scrolling the page shell and moving the terminal toolbar off-screen. */
.app-main:has(.terminal-page) {
overflow: hidden;
}
/* ── Sidebar internals ────────────────────────────────────────────────── */
.sidebar-brand {
@@ -316,6 +322,172 @@
padding: 0.5rem;
}
/* ── Remote terminal ─────────────────────────────────────────────────── */
.terminal-page {
display: flex;
height: calc(100dvh - 2.5rem);
min-height: 0;
flex-direction: column;
gap: 1rem;
overflow: hidden;
}
.terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.terminal-heading {
display: flex;
align-items: center;
gap: 0.65rem;
}
.terminal-heading h1 {
margin: 0;
font-size: 1.0625rem;
font-weight: 650;
}
.terminal-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.terminal-status {
gap: 0.4rem;
height: 1.4rem;
padding-inline: 0.45rem;
font-size: 0.6875rem;
text-transform: capitalize;
}
.terminal-status > span {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background: var(--presence-unknown);
}
.terminal-status[data-state="ready"] > span {
background: var(--presence-online);
}
.terminal-status[data-state="connecting"] > span {
background: var(--presence-likely);
}
.terminal-status[data-state="disconnected"] > span,
.terminal-status[data-state="exited"] > span {
background: var(--presence-offline);
}
.terminal-notice {
margin: 0;
color: var(--muted-foreground);
font-size: 0.8125rem;
}
.terminal-frame {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #0b1117;
}
.terminal-framebar {
display: flex;
min-height: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.35rem 0.45rem 0.35rem 0.75rem;
border-bottom: 1px solid color-mix(in oklab, white 9%, transparent);
background: #101820;
color: #cbd5e1;
}
.terminal-session-label,
.terminal-frame-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 0.5rem;
}
.terminal-session-label > span:not(.terminal-status) {
overflow: hidden;
color: #dbe4ee;
font-size: 0.75rem;
font-weight: 550;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-framebar button {
color: #aab7c4;
}
.terminal-framebar button:hover {
color: #f1f5f9;
background: rgb(255 255 255 / 7%);
}
.terminal-surface {
flex: 1;
min-height: 0;
overflow: hidden;
background: #0b1117;
padding: 0;
}
.terminal-surface .xterm {
height: 100%;
box-sizing: border-box;
background: #0b1117;
padding: 0.75rem;
}
.terminal-surface .xterm-viewport {
/* xterm defaults this element to pure black. It remains visible around the
fitted canvas, so keep it on the same surface as the terminal theme. */
background-color: #0b1117;
overscroll-behavior: contain;
scrollbar-color: color-mix(in oklab, white 28%, transparent) transparent;
scrollbar-width: thin;
}
@media (max-width: 640px) {
.terminal-page {
height: calc(100dvh - 2.5rem);
}
.terminal-controls {
width: 100%;
justify-content: flex-start;
}
.terminal-surface {
min-height: 0;
}
.terminal-surface .xterm {
padding: 0.5rem;
}
}
/* ── Presence badges ──────────────────────────────────────────────────── */
.presence-dot {