Add multi-host terminal: NAS + VPS side-by-side
Deploy Dashboard / deploy (push) Failing after 3m5s

This commit is contained in:
Gan, Jimmy
2026-02-20 00:53:29 +08:00
parent 6047b2aade
commit 050793e0f0
6 changed files with 107 additions and 56 deletions
+4
View File
@@ -19,3 +19,7 @@ TOTP_SECRET = os.environ.get("TOTP_SECRET", "JBSWY3DPEHPK3PXP") # Base32 encoded
SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal") SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal")
SSH_USER = os.environ.get("SSH_USER", "zjgump") SSH_USER = os.environ.get("SSH_USER", "zjgump")
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519") SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519")
VPS_SSH_HOST = os.environ.get("VPS_SSH_HOST", "158.101.140.85")
VPS_SSH_USER = os.environ.get("VPS_SSH_USER", "jimmyg")
VPS_SSH_KEY_PATH = os.environ.get("VPS_SSH_KEY_PATH", "/app/ssh/vps_id_ed25519")
+9 -3
View File
@@ -5,8 +5,13 @@ import asyncssh
import jwt import jwt
import config import config
HOSTS = {
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
"vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH},
}
async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = Query("nas")):
try: try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("sub") != config.ADMIN_USER: if payload.get("sub") != config.ADMIN_USER:
@@ -18,10 +23,11 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
await websocket.accept() await websocket.accept()
h = HOSTS.get(host, HOSTS["nas"])
try: try:
conn = await asyncssh.connect( conn = await asyncssh.connect(
config.SSH_HOST, username=config.SSH_USER, h["host"], username=h["user"],
client_keys=[config.SSH_KEY_PATH], known_hosts=None, client_keys=[h["key"]], known_hosts=None,
) )
except Exception: except Exception:
await websocket.close(code=1011, reason="SSH connection failed") await websocket.close(code=1011, reason="SSH connection failed")
+3
View File
@@ -32,11 +32,14 @@ services:
- VOLUME_ROOT=/volume1 - VOLUME_ROOT=/volume1
- SSH_HOST=host.docker.internal - SSH_HOST=host.docker.internal
- SSH_USER=zjgump - SSH_USER=zjgump
- VPS_SSH_HOST=158.101.140.85
- VPS_SSH_USER=jimmyg
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
volumes: volumes:
- /volume1:/volume1 - /volume1:/volume1
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
- /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/system/stats')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/system/stats')"]
interval: 30s interval: 30s
+79 -49
View File
@@ -1,12 +1,17 @@
<script> <script>
import { onMount } from "svelte"; import { onDestroy } from "svelte";
let termEl; const hosts = [
let connected = $state(false); { id: "nas", label: "NAS" },
let error = $state(""); { id: "vps", label: "VPS" },
];
onMount(async () => { let mobileHost = $state("nas");
try { let terminals = $state({});
let cleanups = [];
function initTerminal(el, hostId) {
(async () => {
const { Terminal } = await import("@xterm/xterm"); const { Terminal } = await import("@xterm/xterm");
const { FitAddon } = await import("@xterm/addon-fit"); const { FitAddon } = await import("@xterm/addon-fit");
await import("@xterm/xterm/css/xterm.css"); await import("@xterm/xterm/css/xterm.css");
@@ -34,34 +39,17 @@
}); });
const fit = new FitAddon(); const fit = new FitAddon();
term.loadAddon(fit); term.loadAddon(fit);
term.open(termEl); term.open(el);
fit.fit(); fit.fit();
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token") || ""; const token = localStorage.getItem("token") || "";
const ws = new WebSocket(`${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}`); const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}&host=${hostId}`
);
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
ws.onopen = () => { function sendSize() {
connected = true;
const buf = new Uint8Array(5);
buf[0] = 0x01;
new DataView(buf.buffer).setUint16(1, term.cols);
new DataView(buf.buffer).setUint16(3, term.rows);
ws.send(buf);
};
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onclose = () => {
connected = false;
term.write("\r\n\x1b[90m[Connection closed]\x1b[0m");
};
ws.onerror = () => {
error = "WebSocket connection failed. Terminal requires Tailscale access.";
};
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
const ro = new ResizeObserver(() => {
fit.fit();
if (ws.readyState === 1) { if (ws.readyState === 1) {
const buf = new Uint8Array(5); const buf = new Uint8Array(5);
buf[0] = 0x01; buf[0] = 0x01;
@@ -69,39 +57,81 @@
new DataView(buf.buffer).setUint16(3, term.rows); new DataView(buf.buffer).setUint16(3, term.rows);
ws.send(buf); ws.send(buf);
} }
});
ro.observe(termEl);
} catch (e) {
error = "Failed to initialize terminal: " + e.message;
} }
ws.onopen = () => {
terminals[hostId] = { connected: true, error: "" };
sendSize();
};
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onclose = () => {
terminals[hostId] = { ...terminals[hostId], connected: false };
term.write("\r\n\x1b[90m[Connection closed]\x1b[0m");
};
ws.onerror = () => {
terminals[hostId] = { connected: false, error: "WebSocket connection failed" };
};
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
const ro = new ResizeObserver(() => {
fit.fit();
sendSize();
}); });
ro.observe(el);
cleanups.push(() => {
ro.disconnect();
ws.close();
term.dispose();
});
})();
}
onDestroy(() => cleanups.forEach((fn) => fn()));
function statusFor(hostId) {
const t = terminals[hostId];
if (!t) return { connected: false, error: "" };
return t;
}
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Terminal</h1> <h1 class="text-2xl font-bold text-surface-900 tracking-tight">Terminal</h1>
<p class="text-sm text-surface-400 mt-1"> <div class="mt-2 md:hidden">
{#if connected} <select
<span class="inline-flex items-center gap-1.5"> class="bg-surface-800 text-surface-200 border border-surface-600 rounded-lg px-3 py-1.5 text-sm"
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> bind:value={mobileHost}
Connected >
</span> {#each hosts as h}
{:else if error} <option value={h.id}>{h.label}</option>
<span class="text-rose-500">{error}</span> {/each}
{:else} </select>
<span class="inline-flex items-center gap-1.5"> </div>
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
Connecting...
</span>
{/if}
</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#each hosts as h (h.id)}
<div class="hidden md:block" class:!block={mobileHost === h.id}>
<div class="flex items-center gap-2 mb-2 text-sm text-surface-400">
<span class="font-medium text-surface-200">{h.label}</span>
{#if statusFor(h.id).connected}
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span> Connected
{:else if statusFor(h.id).error}
<span class="text-rose-500">{statusFor(h.id).error}</span>
{:else}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span> Connecting...
{/if}
</div>
<div <div
bind:this={termEl} use:initTerminal={h.id}
class="h-[calc(100vh-10rem)] rounded-xl overflow-hidden shadow-lg border border-surface-700" class="h-[calc(100vh-12rem)] rounded-xl overflow-hidden shadow-lg border border-surface-700"
style="background: #0f172a;" style="background: #0f172a;"
></div> ></div>
</div> </div>
{/each}
</div>
</div>
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACC6rZoMd8T2DBFC2hdfgWHhwnusiHtbZ753dLibmZDyNQAAAJiY03+ImNN/
iAAAAAtzc2gtZWQyNTUxOQAAACC6rZoMd8T2DBFC2hdfgWHhwnusiHtbZ753dLibmZDyNQ
AAAEBiLX9L0ASvdqTqmD/kOs/cuLpyUBgQl+j8+uHuVFrkN7qtmgx3xPYMEULaF1+BYeHC
e6yIe1tnvnd0uJuZkPI1AAAAEW5hcy1kYXNoYm9hcmQtdnBzAQIDBA==
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILqtmgx3xPYMEULaF1+BYeHCe6yIe1tnvnd0uJuZkPI1 nas-dashboard-vps