Terminals: unify on a shared resilient-terminal core + deploy script for agent images
- frontend/.../terminal/core.ts: `useResilientTerminal` owns the xterm lifecycle (init, fit, debounced resize, reconnect, refit-on-visible) with a pluggable transport. Two connectors: `wsConnector` (agent terminal → container PTY over WS) and `nodeWebrtcConnector` (node terminal → host PTY, direct WebRTC DataChannel with WS-relay fallback, input buffered during the race). The agent terminal now gets the node terminal's robust reconnect for free; both share one xterm setup. - TerminalApp (agent) + NodeTerminalApp (node) reduced to thin wrappers over the core — net ~330 lines of duplicated transport/reconnect/xterm code removed. - scripts/deploy.sh: the deploys were manual, so the locally-built agent images (agent-base/browser/terminal — not in any registry) were never shipped and 404'd on provision. The script always (re)builds + loads them onto gw-04 AND every fleet node, with a skip-if-identical guard so unchanged images aren't re-transferred. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a3bb16d838
commit
1072ec159a
@@ -13,6 +13,7 @@ import type { Agent } from "@/lib/api/schemas";
|
|||||||
import { panelParsers } from "@/lib/url/panel-params";
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
import { useSubHeader } from "./AppShell";
|
import { useSubHeader } from "./AppShell";
|
||||||
|
import { useResilientTerminal, wsConnector } from "./terminal/core";
|
||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
@@ -261,8 +262,9 @@ export default function TerminalApp({ agent }: { agent: Agent }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One tab: an xterm bridged to a tmux session over a WebSocket. Kept mounted
|
/** One tab: an xterm bridged to a tmux session over a WebSocket (the shared
|
||||||
* (hidden when inactive) so its session + processes keep running. */
|
* resilient-terminal core). Kept mounted (hidden when inactive) so its session +
|
||||||
|
* processes keep running. */
|
||||||
function TerminalTab({
|
function TerminalTab({
|
||||||
agent,
|
agent,
|
||||||
session,
|
session,
|
||||||
@@ -272,157 +274,32 @@ function TerminalTab({
|
|||||||
session: string;
|
session: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
// Latest `active` for the core's callbacks without re-running the connect.
|
||||||
const termRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
|
||||||
const fitRef = useRef<import("@xterm/addon-fit").FitAddon | null>(null);
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
// Latest `active` for callbacks without re-running the connect effect.
|
|
||||||
const activeRef = useRef(active);
|
const activeRef = useRef(active);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRef.current = active;
|
activeRef.current = active;
|
||||||
});
|
});
|
||||||
|
const { hostRef, refit } = useResilientTerminal(
|
||||||
useEffect(() => {
|
{
|
||||||
let disposed = false;
|
connect: wsConnector(async () => {
|
||||||
let ro: ResizeObserver | null = null;
|
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
|
||||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
if (!res.ok) return null;
|
||||||
|
const { ticket } = (await res.json()) as { ticket: string };
|
||||||
(async () => {
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
return `${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(ticket)}&session=${encodeURIComponent(session)}`;
|
||||||
import("@xterm/xterm"),
|
}),
|
||||||
import("@xterm/addon-fit"),
|
visible: () => activeRef.current,
|
||||||
]);
|
autoFocus: true,
|
||||||
if (disposed || !hostRef.current) return;
|
},
|
||||||
|
[agent.id, session],
|
||||||
const term = new Terminal({
|
);
|
||||||
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
cursorBlink: true,
|
|
||||||
allowProposedApi: true,
|
|
||||||
scrollback: 5000,
|
|
||||||
theme: {
|
|
||||||
background: "#0b0b0e",
|
|
||||||
foreground: "#e6e6ea",
|
|
||||||
cursor: "#ff8a7a",
|
|
||||||
cursorAccent: "#0b0b0e",
|
|
||||||
selectionBackground: "rgba(255,138,122,.28)",
|
|
||||||
black: "#1c1c22",
|
|
||||||
red: "#ff6f61",
|
|
||||||
green: "#5fd08a",
|
|
||||||
yellow: "#e8b465",
|
|
||||||
blue: "#5ec8d8",
|
|
||||||
magenta: "#c98af0",
|
|
||||||
cyan: "#6fd0c0",
|
|
||||||
white: "#cfcfd5",
|
|
||||||
brightBlack: "#5a5a62",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(hostRef.current);
|
|
||||||
termRef.current = term;
|
|
||||||
fitRef.current = fit;
|
|
||||||
// Only fit when actually visible (a hidden tab has zero size).
|
|
||||||
if (hostRef.current.clientWidth > 0) {
|
|
||||||
try {
|
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
/* not laid out */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
term.onData((d) => {
|
|
||||||
const ws = wsRef.current;
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendResize = () => {
|
|
||||||
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
|
|
||||||
try {
|
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
const ws = wsRef.current;
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ro = new ResizeObserver(() => sendResize());
|
|
||||||
ro.observe(hostRef.current);
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
if (disposed) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
|
|
||||||
if (!res.ok) throw new Error(`ticket request failed (${res.status})`);
|
|
||||||
const { ticket } = (await res.json()) as { ticket: string };
|
|
||||||
if (disposed) return;
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const ws = new WebSocket(
|
|
||||||
`${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(
|
|
||||||
ticket,
|
|
||||||
)}&session=${encodeURIComponent(session)}`,
|
|
||||||
);
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
wsRef.current = ws;
|
|
||||||
ws.onopen = () => {
|
|
||||||
sendResize();
|
|
||||||
if (activeRef.current) term.focus();
|
|
||||||
};
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
if (typeof ev.data === "string") term.write(ev.data);
|
|
||||||
else term.write(new Uint8Array(ev.data as ArrayBuffer));
|
|
||||||
};
|
|
||||||
ws.onerror = () => ws.close();
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
if (disposed) return;
|
|
||||||
term.write("\r\n\x1b[90m[disconnected — reconnecting…]\x1b[0m\r\n");
|
|
||||||
retry = setTimeout(connect, 1500);
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (disposed) return;
|
|
||||||
term.write(`\r\n\x1b[31m[terminal: ${String(e)}]\x1b[0m\r\n`);
|
|
||||||
retry = setTimeout(connect, 2500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void connect();
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
if (retry) clearTimeout(retry);
|
|
||||||
ro?.disconnect();
|
|
||||||
wsRef.current?.close();
|
|
||||||
termRef.current?.dispose();
|
|
||||||
termRef.current = null;
|
|
||||||
fitRef.current = null;
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
}, [agent.id, session]);
|
|
||||||
|
|
||||||
// Re-fit + focus when this tab becomes visible (it may have had zero size).
|
// Re-fit + focus when this tab becomes visible (it may have had zero size).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(refit, 40);
|
||||||
const term = termRef.current;
|
|
||||||
const fit = fitRef.current;
|
|
||||||
const ws = wsRef.current;
|
|
||||||
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
|
|
||||||
try {
|
|
||||||
fit?.fit();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (term && ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
||||||
}
|
|
||||||
term?.focus();
|
|
||||||
}, 40);
|
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [active]);
|
}, [active, refit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,244 +7,30 @@
|
|||||||
|
|
||||||
import { Server, Terminal as TerminalIcon, Zap } from "lucide-react";
|
import { Server, Terminal as TerminalIcon, Zap } from "lucide-react";
|
||||||
import { useQueryStates } from "nuqs";
|
import { useQueryStates } from "nuqs";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useFetchJson } from "@/lib/api/use-fetch";
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
import { panelParsers } from "@/lib/url/panel-params";
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
import type { FleetNode } from "@/components/dashboard/fleet/FleetPanels";
|
import type { FleetNode } from "@/components/dashboard/fleet/FleetPanels";
|
||||||
|
|
||||||
|
import { nodeWebrtcConnector, useResilientTerminal } from "../terminal/core";
|
||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
const ICE_SERVERS: RTCIceServer[] = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
|
||||||
type Transport = "connecting" | "direct" | "relayed";
|
type Transport = "connecting" | "direct" | "relayed";
|
||||||
|
|
||||||
/** xterm bridged to a node's host shell, preferring a direct WebRTC DataChannel. */
|
/** xterm bridged to a node's host shell, preferring a direct WebRTC DataChannel.
|
||||||
|
* Transport + xterm lifecycle live in the shared resilient-terminal core. */
|
||||||
function NodeShell({ nodeId }: { nodeId: string }) {
|
function NodeShell({ nodeId }: { nodeId: string }) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [transport, setTransport] = useState<Transport>("connecting");
|
const [transport, setTransport] = useState<Transport>("connecting");
|
||||||
|
const { hostRef } = useResilientTerminal(
|
||||||
useEffect(() => {
|
{
|
||||||
let disposed = false;
|
connect: nodeWebrtcConnector(nodeId, setTransport),
|
||||||
let term: import("@xterm/xterm").Terminal | null = null;
|
theme: { background: "#0a0a0c", foreground: "#d4d4d8" },
|
||||||
let fit: import("@xterm/addon-fit").FitAddon | null = null;
|
autoFocus: true,
|
||||||
let ws: WebSocket | null = null;
|
},
|
||||||
let pc: RTCPeerConnection | null = null;
|
[nodeId],
|
||||||
let dc: RTCDataChannel | null = null;
|
);
|
||||||
let ro: ResizeObserver | null = null;
|
|
||||||
let resizeT: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let reconnectT: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let raceT: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let mode: Transport = "connecting";
|
|
||||||
let remoteSet = false;
|
|
||||||
const pendingIce: RTCIceCandidateInit[] = [];
|
|
||||||
const inputQueue: string[] = [];
|
|
||||||
|
|
||||||
const setMode = (m: Transport) => {
|
|
||||||
mode = m;
|
|
||||||
if (!disposed) setTransport(m);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendResize = () => {
|
|
||||||
if (ws?.readyState === WebSocket.OPEN && term && fit && hostRef.current?.clientWidth) {
|
|
||||||
fit.fit();
|
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Route keystrokes to whichever transport is live; buffer while connecting.
|
|
||||||
const sendInput = (d: string) => {
|
|
||||||
const bytes = new TextEncoder().encode(d);
|
|
||||||
if (mode === "direct" && dc?.readyState === "open") dc.send(bytes);
|
|
||||||
else if (mode === "relayed" && ws?.readyState === WebSocket.OPEN) ws.send(bytes);
|
|
||||||
else inputQueue.push(d);
|
|
||||||
};
|
|
||||||
const flushInput = () => {
|
|
||||||
const q = inputQueue.splice(0);
|
|
||||||
for (const d of q) sendInput(d);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Give up on the direct path → use the WS relay (today's behavior).
|
|
||||||
const goRelayed = () => {
|
|
||||||
if (disposed || mode !== "connecting") return;
|
|
||||||
clearTimeout(raceT);
|
|
||||||
setMode("relayed");
|
|
||||||
try {
|
|
||||||
dc?.close();
|
|
||||||
pc?.close();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
dc = null;
|
|
||||||
pc = null;
|
|
||||||
ws?.send(JSON.stringify({ type: "fallback", cols: term?.cols ?? 80, rows: term?.rows ?? 24 }));
|
|
||||||
flushInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
const startWebrtc = () => {
|
|
||||||
if (typeof RTCPeerConnection === "undefined") {
|
|
||||||
goRelayed();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
|
||||||
dc = pc.createDataChannel("term", { ordered: true });
|
|
||||||
dc.binaryType = "arraybuffer";
|
|
||||||
dc.onopen = () => {
|
|
||||||
if (disposed || mode !== "connecting") return;
|
|
||||||
clearTimeout(raceT);
|
|
||||||
setMode("direct");
|
|
||||||
flushInput();
|
|
||||||
sendResize();
|
|
||||||
};
|
|
||||||
dc.onmessage = (e) => {
|
|
||||||
if (typeof e.data === "string") term?.write(e.data);
|
|
||||||
else term?.write(new Uint8Array(e.data as ArrayBuffer));
|
|
||||||
};
|
|
||||||
dc.onclose = () => {
|
|
||||||
if (mode === "direct" && !disposed) ws?.close(); // lost direct → reconnect
|
|
||||||
};
|
|
||||||
pc.onicecandidate = (e) => {
|
|
||||||
if (e.candidate && ws?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "webrtc_ice",
|
|
||||||
candidate: e.candidate.candidate,
|
|
||||||
sdp_mid: e.candidate.sdpMid,
|
|
||||||
sdp_mline_index: e.candidate.sdpMLineIndex,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
pc.onconnectionstatechange = () => {
|
|
||||||
if (pc?.connectionState === "failed") goRelayed();
|
|
||||||
};
|
|
||||||
pc.createOffer()
|
|
||||||
.then((offer) => pc!.setLocalDescription(offer).then(() => {
|
|
||||||
ws?.send(JSON.stringify({ type: "webrtc_offer", sdp: offer.sdp }));
|
|
||||||
}))
|
|
||||||
.catch(() => goRelayed());
|
|
||||||
// Race: if no direct DataChannel within 2.5s, fall back.
|
|
||||||
raceT = setTimeout(goRelayed, 2500);
|
|
||||||
} catch {
|
|
||||||
goRelayed();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSignal = (m: { type: string; sdp?: string; candidate?: string; sdp_mid?: string | null; sdp_mline_index?: number | null }) => {
|
|
||||||
if (!pc) return;
|
|
||||||
if (m.type === "webrtc_answer" && m.sdp) {
|
|
||||||
pc.setRemoteDescription({ type: "answer", sdp: m.sdp })
|
|
||||||
.then(() => {
|
|
||||||
remoteSet = true;
|
|
||||||
for (const c of pendingIce.splice(0)) pc?.addIceCandidate(c).catch(() => {});
|
|
||||||
})
|
|
||||||
.catch(() => goRelayed());
|
|
||||||
} else if (m.type === "webrtc_ice" && m.candidate != null) {
|
|
||||||
const cand: RTCIceCandidateInit = {
|
|
||||||
candidate: m.candidate,
|
|
||||||
sdpMid: m.sdp_mid ?? undefined,
|
|
||||||
sdpMLineIndex: m.sdp_mline_index ?? undefined,
|
|
||||||
};
|
|
||||||
if (remoteSet) pc.addIceCandidate(cand).catch(() => {});
|
|
||||||
else pendingIce.push(cand);
|
|
||||||
} else if (m.type === "webrtc_failed") {
|
|
||||||
goRelayed();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
if (disposed || !term) return;
|
|
||||||
setMode("connecting");
|
|
||||||
pc = null;
|
|
||||||
dc = null;
|
|
||||||
remoteSet = false;
|
|
||||||
let ticket: string | undefined;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
|
|
||||||
const body = res.ok ? ((await res.json()) as { ticket?: string; error?: string }) : {};
|
|
||||||
ticket = body.ticket;
|
|
||||||
if (!ticket) {
|
|
||||||
term.writeln(`\r\n\x1b[2m[${body.error ?? "node offline"} — retrying…]\x1b[0m`);
|
|
||||||
reconnectT = setTimeout(connect, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
reconnectT = setTimeout(connect, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (disposed) return;
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
ws = new WebSocket(`${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(ticket)}`);
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
ws.onopen = () => startWebrtc();
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
if (typeof e.data === "string") {
|
|
||||||
try {
|
|
||||||
onSignal(JSON.parse(e.data));
|
|
||||||
} catch {
|
|
||||||
/* ignore malformed signaling */
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Binary = PTY output over the WS relay (fallback path).
|
|
||||||
term?.write(new Uint8Array(e.data as ArrayBuffer));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (disposed) return;
|
|
||||||
clearTimeout(raceT);
|
|
||||||
try {
|
|
||||||
dc?.close();
|
|
||||||
pc?.close();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
term?.writeln("\r\n\x1b[2m[reconnecting…]\x1b[0m");
|
|
||||||
reconnectT = setTimeout(connect, 1500);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
(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" },
|
|
||||||
});
|
|
||||||
fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(hostRef.current);
|
|
||||||
fit.fit();
|
|
||||||
term.focus();
|
|
||||||
term.onData(sendInput);
|
|
||||||
ro = new ResizeObserver(() => {
|
|
||||||
clearTimeout(resizeT);
|
|
||||||
resizeT = setTimeout(sendResize, 150);
|
|
||||||
});
|
|
||||||
ro.observe(hostRef.current);
|
|
||||||
connect();
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
clearTimeout(resizeT);
|
|
||||||
clearTimeout(reconnectT);
|
|
||||||
clearTimeout(raceT);
|
|
||||||
try {
|
|
||||||
dc?.close();
|
|
||||||
pc?.close();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
ro?.disconnect();
|
|
||||||
ws?.close();
|
|
||||||
term?.dispose();
|
|
||||||
};
|
|
||||||
}, [nodeId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-col bg-[#0a0a0c]">
|
<div className="relative flex h-full w-full flex-col bg-[#0a0a0c]">
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Shared resilient-terminal core used by BOTH the agent terminal (WS to a themed
|
||||||
|
// container PTY) and the node terminal (WebRTC DataChannel direct → fallback WS to
|
||||||
|
// a host PTY). The core owns the xterm lifecycle (init, fit, debounced resize,
|
||||||
|
// reconnect); each caller supplies a transport `connector`. This unifies the two
|
||||||
|
// terminals so they share one xterm setup + one robust reconnect loop.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { ITheme, Terminal } from "@xterm/xterm";
|
||||||
|
|
||||||
|
/** A live connection to a remote PTY. The core wires keystrokes + resize to it. */
|
||||||
|
export interface TermTransport {
|
||||||
|
sendInput: (data: Uint8Array<ArrayBuffer>) => void;
|
||||||
|
sendResize: (cols: number, rows: number) => void;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Establishes ONE connection: writes incoming PTY bytes to `term`, calls
|
||||||
|
* `onClosed` when the link drops (the core then reconnects), and returns the live
|
||||||
|
* transport — or null on failure (the core reconnects). */
|
||||||
|
export type TermConnector = (ctx: { term: Terminal; onClosed: () => void }) => Promise<TermTransport | null>;
|
||||||
|
|
||||||
|
export interface TerminalCoreOpts {
|
||||||
|
connect: TermConnector;
|
||||||
|
theme?: ITheme;
|
||||||
|
fontSize?: number;
|
||||||
|
scrollback?: number;
|
||||||
|
reconnectMs?: number;
|
||||||
|
/** Skip fit/focus while hidden (e.g. an inactive tab has zero size). */
|
||||||
|
visible?: () => boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TERMINAL_THEME: ITheme = {
|
||||||
|
background: "#0b0b0e",
|
||||||
|
foreground: "#e6e6ea",
|
||||||
|
cursor: "#ff8a7a",
|
||||||
|
cursorAccent: "#0b0b0e",
|
||||||
|
selectionBackground: "rgba(255,138,122,.28)",
|
||||||
|
black: "#1c1c22",
|
||||||
|
red: "#ff6f61",
|
||||||
|
green: "#5fd08a",
|
||||||
|
yellow: "#e8b465",
|
||||||
|
blue: "#5ec8d8",
|
||||||
|
magenta: "#c98af0",
|
||||||
|
cyan: "#6fd0c0",
|
||||||
|
white: "#cfcfd5",
|
||||||
|
brightBlack: "#5a5a62",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT = '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace';
|
||||||
|
|
||||||
|
/** Mount an xterm into `hostRef`, drive it through the supplied connector, and
|
||||||
|
* auto-reconnect on drop. Re-runs when `deps` change. */
|
||||||
|
export function useResilientTerminal(opts: TerminalCoreOpts, deps: unknown[]): {
|
||||||
|
hostRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Force a fit + resize + focus — e.g. when a hidden tab becomes visible. */
|
||||||
|
refit: () => void;
|
||||||
|
} {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const refitRef = useRef<() => void>(() => {});
|
||||||
|
// Latest opts (connect/visible) without re-running the connect effect.
|
||||||
|
const optsRef = useRef(opts);
|
||||||
|
useEffect(() => {
|
||||||
|
optsRef.current = opts;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let term: Terminal | null = null;
|
||||||
|
let fit: import("@xterm/addon-fit").FitAddon | null = null;
|
||||||
|
let transport: TermTransport | null = null;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
let resizeT: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let reconnectT: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const visible = () => optsRef.current.visible?.() ?? true;
|
||||||
|
|
||||||
|
const sendResize = () => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host || host.clientWidth === 0 || !term || !fit || !visible()) return;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
/* not laid out */
|
||||||
|
}
|
||||||
|
transport?.sendResize(term.cols, term.rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
term?.write("\r\n\x1b[90m[reconnecting…]\x1b[0m\r\n");
|
||||||
|
reconnectT = setTimeout(go, optsRef.current.reconnectMs ?? 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = async () => {
|
||||||
|
if (disposed || !term) return;
|
||||||
|
transport = await optsRef.current.connect({
|
||||||
|
term,
|
||||||
|
onClosed: () => {
|
||||||
|
transport = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (transport) {
|
||||||
|
sendResize();
|
||||||
|
if (optsRef.current.autoFocus && visible()) term.focus();
|
||||||
|
} else if (!disposed) {
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
]);
|
||||||
|
if (disposed || !hostRef.current) return;
|
||||||
|
term = new Terminal({
|
||||||
|
fontFamily: FONT,
|
||||||
|
fontSize: optsRef.current.fontSize ?? 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: optsRef.current.scrollback ?? 5000,
|
||||||
|
theme: optsRef.current.theme ?? TERMINAL_THEME,
|
||||||
|
});
|
||||||
|
fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(hostRef.current);
|
||||||
|
if (hostRef.current.clientWidth > 0) {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
/* not laid out */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term.onData((d) => transport?.sendInput(enc.encode(d)));
|
||||||
|
// Debounced: a pull-out / tab animates open, firing the observer per pixel.
|
||||||
|
ro = new ResizeObserver(() => {
|
||||||
|
clearTimeout(resizeT);
|
||||||
|
resizeT = setTimeout(sendResize, 120);
|
||||||
|
});
|
||||||
|
ro.observe(hostRef.current);
|
||||||
|
refitRef.current = () => {
|
||||||
|
sendResize();
|
||||||
|
if (optsRef.current.autoFocus && visible()) term?.focus();
|
||||||
|
};
|
||||||
|
void go();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
refitRef.current = () => {};
|
||||||
|
clearTimeout(resizeT);
|
||||||
|
clearTimeout(reconnectT);
|
||||||
|
transport?.dispose();
|
||||||
|
ro?.disconnect();
|
||||||
|
term?.dispose();
|
||||||
|
};
|
||||||
|
// The connector/visible callbacks are read via optsRef; `deps` is the caller's
|
||||||
|
// intentional re-run trigger (e.g. [agent.id, session] or [nodeId]).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return { hostRef, refit: () => refitRef.current() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A plain WebSocket transport: binary frames = keystrokes/PTY bytes, a JSON
|
||||||
|
* `{type:"resize"}` control frame for size. `getUrl` mints the ticket + builds
|
||||||
|
* the wss URL. */
|
||||||
|
export function wsConnector(getUrl: () => Promise<string | null>): TermConnector {
|
||||||
|
return ({ term, onClosed }) =>
|
||||||
|
new Promise<TermTransport | null>((resolve) => {
|
||||||
|
let opened = false;
|
||||||
|
void getUrl().then((url) => {
|
||||||
|
if (!url) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
ws.onopen = () => {
|
||||||
|
opened = true;
|
||||||
|
resolve({
|
||||||
|
sendInput: (d) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(d);
|
||||||
|
},
|
||||||
|
sendResize: (cols, rows) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
|
},
|
||||||
|
dispose: () => ws.close(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
if (typeof e.data === "string") term.write(e.data);
|
||||||
|
else term.write(new Uint8Array(e.data as ArrayBuffer));
|
||||||
|
};
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!opened) resolve(null);
|
||||||
|
else onClosed();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICE_SERVERS: RTCIceServer[] = [{ urls: ["stun:stun.l.google.com:19302"] }];
|
||||||
|
|
||||||
|
/** WebRTC transport for a fleet node's host PTY: tries a direct DataChannel
|
||||||
|
* (browser↔node, LAN speed) and falls back to the gateway WS relay if no direct
|
||||||
|
* path forms within ~2.5s. The same WS carries the WebRTC signaling. `onMode`
|
||||||
|
* reports the live transport so callers can show a direct/relayed indicator. */
|
||||||
|
export function nodeWebrtcConnector(nodeId: string, onMode?: (m: "connecting" | "direct" | "relayed") => void): TermConnector {
|
||||||
|
return ({ term, onClosed }) =>
|
||||||
|
new Promise<TermTransport | null>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let mode: "connecting" | "direct" | "relayed" = "connecting";
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let pc: RTCPeerConnection | null = null;
|
||||||
|
let dc: RTCDataChannel | null = null;
|
||||||
|
let raceT: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let remoteSet = false;
|
||||||
|
const pendingIce: RTCIceCandidateInit[] = [];
|
||||||
|
const inputQueue: Uint8Array<ArrayBuffer>[] = [];
|
||||||
|
const setMode = (m: typeof mode) => {
|
||||||
|
mode = m;
|
||||||
|
onMode?.(m);
|
||||||
|
};
|
||||||
|
// Keystrokes route to the live transport; buffered while still connecting
|
||||||
|
// (the server only PTY-relays once direct/fallback is chosen).
|
||||||
|
const flush = () => {
|
||||||
|
for (const b of inputQueue.splice(0)) {
|
||||||
|
if (mode === "direct" && dc?.readyState === "open") dc.send(b);
|
||||||
|
else if (ws?.readyState === WebSocket.OPEN) ws.send(b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sendResizeNow = () => {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
|
};
|
||||||
|
const transport: TermTransport = {
|
||||||
|
sendInput: (d) => {
|
||||||
|
if (mode === "direct" && dc?.readyState === "open") dc.send(d);
|
||||||
|
else if (mode === "relayed" && ws?.readyState === WebSocket.OPEN) ws.send(d);
|
||||||
|
else inputQueue.push(d);
|
||||||
|
},
|
||||||
|
sendResize: () => {
|
||||||
|
if (mode !== "connecting") sendResizeNow();
|
||||||
|
},
|
||||||
|
dispose: () => {
|
||||||
|
clearTimeout(raceT);
|
||||||
|
try {
|
||||||
|
dc?.close();
|
||||||
|
pc?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
ws?.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ready = () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(transport);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const goRelayed = () => {
|
||||||
|
if (mode !== "connecting") return;
|
||||||
|
clearTimeout(raceT);
|
||||||
|
setMode("relayed");
|
||||||
|
try {
|
||||||
|
dc?.close();
|
||||||
|
pc?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
dc = null;
|
||||||
|
pc = null;
|
||||||
|
ws?.send(JSON.stringify({ type: "fallback", cols: term.cols, rows: term.rows }));
|
||||||
|
flush();
|
||||||
|
};
|
||||||
|
const startWebrtc = () => {
|
||||||
|
if (typeof RTCPeerConnection === "undefined") {
|
||||||
|
goRelayed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||||
|
dc = pc.createDataChannel("term", { ordered: true });
|
||||||
|
dc.binaryType = "arraybuffer";
|
||||||
|
dc.onopen = () => {
|
||||||
|
if (mode !== "connecting") return;
|
||||||
|
clearTimeout(raceT);
|
||||||
|
setMode("direct");
|
||||||
|
flush();
|
||||||
|
sendResizeNow();
|
||||||
|
};
|
||||||
|
dc.onmessage = (e) => {
|
||||||
|
if (typeof e.data === "string") term.write(e.data);
|
||||||
|
else term.write(new Uint8Array(e.data as ArrayBuffer));
|
||||||
|
};
|
||||||
|
dc.onclose = () => {
|
||||||
|
if (mode === "direct") ws?.close();
|
||||||
|
};
|
||||||
|
pc.onicecandidate = (e) => {
|
||||||
|
if (e.candidate && ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "webrtc_ice", candidate: e.candidate.candidate, sdp_mid: e.candidate.sdpMid, sdp_mline_index: e.candidate.sdpMLineIndex }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (pc?.connectionState === "failed") goRelayed();
|
||||||
|
};
|
||||||
|
pc.createOffer()
|
||||||
|
.then((offer) => pc!.setLocalDescription(offer).then(() => ws?.send(JSON.stringify({ type: "webrtc_offer", sdp: offer.sdp }))))
|
||||||
|
.catch(() => goRelayed());
|
||||||
|
raceT = setTimeout(goRelayed, 2500);
|
||||||
|
} catch {
|
||||||
|
goRelayed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onSignal = (m: { type: string; sdp?: string; candidate?: string; sdp_mid?: string | null; sdp_mline_index?: number | null }) => {
|
||||||
|
if (!pc) return;
|
||||||
|
if (m.type === "webrtc_answer" && m.sdp) {
|
||||||
|
pc.setRemoteDescription({ type: "answer", sdp: m.sdp })
|
||||||
|
.then(() => {
|
||||||
|
remoteSet = true;
|
||||||
|
for (const c of pendingIce.splice(0)) pc?.addIceCandidate(c).catch(() => {});
|
||||||
|
})
|
||||||
|
.catch(() => goRelayed());
|
||||||
|
} else if (m.type === "webrtc_ice" && m.candidate != null) {
|
||||||
|
const cand: RTCIceCandidateInit = { candidate: m.candidate, sdpMid: m.sdp_mid ?? undefined, sdpMLineIndex: m.sdp_mline_index ?? undefined };
|
||||||
|
if (remoteSet) pc.addIceCandidate(cand).catch(() => {});
|
||||||
|
else pendingIce.push(cand);
|
||||||
|
} else if (m.type === "webrtc_failed") {
|
||||||
|
goRelayed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" })
|
||||||
|
.then((r) => (r.ok ? r.json() : {}))
|
||||||
|
.then((body: { ticket?: string }) => {
|
||||||
|
if (!body.ticket) {
|
||||||
|
resolve(null);
|
||||||
|
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";
|
||||||
|
ws.onopen = () => {
|
||||||
|
ready();
|
||||||
|
startWebrtc();
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
if (typeof e.data === "string") {
|
||||||
|
try {
|
||||||
|
onSignal(JSON.parse(e.data));
|
||||||
|
} catch {
|
||||||
|
/* malformed signaling */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
term.write(new Uint8Array(e.data as ArrayBuffer)); // relayed PTY output
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearTimeout(raceT);
|
||||||
|
if (!settled) resolve(null);
|
||||||
|
else onClosed();
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(() => resolve(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy ClawMates: build the server, frontend, node daemon, AND the agent runtime
|
||||||
|
# images on the build host (tank), then load + recreate on the gateway. The agent
|
||||||
|
# images (agent-base / agent-browser / agent-terminal) are locally-built dev images
|
||||||
|
# — not in any registry — so a `docker pull` can't fetch them. This script loads
|
||||||
|
# them onto the gateway AND every fleet node, so agent/terminal containers never
|
||||||
|
# 404 with "No such image" (the failure mode this script exists to prevent).
|
||||||
|
#
|
||||||
|
# Usage: scripts/deploy.sh # full deploy
|
||||||
|
# IMAGES_ONLY=1 scripts/deploy.sh # just (re)build + load the agent images
|
||||||
|
#
|
||||||
|
# Override hosts via env: BUILD_HOST, GW, NODES, TAG.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_HOST=${BUILD_HOST:-osobh@tank} # builds the images + the linux daemon
|
||||||
|
GW=${GW:-gw-04} # the gateway (server + frontend + postgres)
|
||||||
|
NODES=${NODES:-"morpheus osobh@tank"} # fleet nodes that provision agent containers
|
||||||
|
TAG=${TAG:-latest}
|
||||||
|
AGENT_IMAGES=(agent-base agent-browser agent-terminal)
|
||||||
|
|
||||||
|
load() { ssh "$BUILD_HOST" "docker save $1" | ssh "$2" "docker load"; }
|
||||||
|
|
||||||
|
# Load only if the target lacks the exact image (skips re-transferring unchanged
|
||||||
|
# multi-hundred-MB agent images to every node on each code deploy).
|
||||||
|
load_if_changed() {
|
||||||
|
local lid rid
|
||||||
|
lid=$(ssh "$BUILD_HOST" "docker image inspect -f '{{.Id}}' $1 2>/dev/null" || true)
|
||||||
|
rid=$(ssh "$2" "docker image inspect -f '{{.Id}}' $1 2>/dev/null" || true)
|
||||||
|
if [ -n "$lid" ] && [ "$lid" = "$rid" ]; then
|
||||||
|
echo " (unchanged — skip)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
load "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "→ sync to $BUILD_HOST"
|
||||||
|
rsync -az --delete --exclude target/ --exclude node_modules/ --exclude .git/ \
|
||||||
|
--exclude '.next/' --exclude 'frontend/public/dl/' --exclude '**/.DS_Store' \
|
||||||
|
./ "$BUILD_HOST":~/clawmates/
|
||||||
|
|
||||||
|
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||||
|
echo "→ build server + frontend + daemon on $BUILD_HOST"
|
||||||
|
ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
|
||||||
|
export PATH=$HOME/.cargo/bin:$PATH CARGO_NET_GIT_FETCH_WITH_CLI=true SQLX_OFFLINE=true
|
||||||
|
cargo build --release -p clawmates-node
|
||||||
|
cp target/release/clawmates-node frontend/public/dl/clawmates-node-linux-amd64
|
||||||
|
docker build -f images/server.Dockerfile -t clawmates/server:'"$TAG"' .
|
||||||
|
docker build -f images/frontend.Dockerfile -t clawmates/frontend:'"$TAG"' .'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ build agent runtime images on $BUILD_HOST"
|
||||||
|
ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
|
||||||
|
for i in '"${AGENT_IMAGES[*]}"'; do
|
||||||
|
docker build -f images/$i/Dockerfile -t clawmates/$i:dev images/$i/
|
||||||
|
done'
|
||||||
|
|
||||||
|
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||||
|
echo "→ load + recreate server + frontend on $GW"
|
||||||
|
ssh "$GW" "docker tag clawmates/server:$TAG clawmates/server:rollback 2>/dev/null || true
|
||||||
|
docker tag clawmates/frontend:$TAG clawmates/frontend:rollback 2>/dev/null || true"
|
||||||
|
load "clawmates/server:$TAG" "$GW"
|
||||||
|
load "clawmates/frontend:$TAG" "$GW"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ load agent runtime images onto $GW + every fleet node"
|
||||||
|
for img in "${AGENT_IMAGES[@]}"; do
|
||||||
|
for host in "$GW" $NODES; do
|
||||||
|
printf ' %-26s → %s\n' "clawmates/$img:dev" "$host"
|
||||||
|
load_if_changed "clawmates/$img:dev" "$host"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||||
|
echo "→ recreate server + frontend"
|
||||||
|
ssh "$GW" "cd /root/clawmates && docker-compose -p clawmates up -d --force-recreate server frontend"
|
||||||
|
sleep 6
|
||||||
|
curl -s -o /dev/null -w "edge HTTP %{http_code}\n" -m 10 https://clawmates.work/ || true
|
||||||
|
fi
|
||||||
|
echo "✓ deploy complete"
|
||||||
Reference in New Issue
Block a user