fix: raise terminal per-user session cap
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m44s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m44s
Allow more concurrent dashboard terminal tabs per user and use the current access token for websocket reconnects so refreshed sessions keep working. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import struct
|
||||
import logging
|
||||
import shlex
|
||||
from collections import Counter
|
||||
from fastapi import WebSocket, Query
|
||||
import asyncssh
|
||||
@@ -10,19 +11,28 @@ from auth import get_current_user_ws
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_SESSIONS = 5
|
||||
MAX_SESSIONS_PER_USER = 2
|
||||
MAX_SESSIONS_PER_USER = 5
|
||||
_session_lock = asyncio.Lock()
|
||||
_active_sessions: dict[int, str] = {}
|
||||
PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:"
|
||||
|
||||
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},
|
||||
"caddy-vps": {"host": config.CADDY_VPS_SSH_HOST, "user": config.CADDY_VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH},
|
||||
"mac": {"host": config.MAC_SSH_HOST, "user": config.MAC_SSH_USER, "key": config.MAC_SSH_KEY_PATH},
|
||||
"claude-dev": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH,
|
||||
"command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"},
|
||||
"claude-dev": {
|
||||
"host": config.SSH_HOST,
|
||||
"user": config.SSH_USER,
|
||||
"key": config.SSH_KEY_PATH,
|
||||
"persistent_session": {
|
||||
"container": "claude-dev",
|
||||
"session_name": "dashboard-terminal",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Load known_hosts once at import time — fail closed if missing
|
||||
_known_hosts_available = False
|
||||
try:
|
||||
@@ -33,6 +43,35 @@ except Exception:
|
||||
logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS)
|
||||
|
||||
|
||||
def build_host_command(host_config: dict) -> str | None:
|
||||
persistent = host_config.get("persistent_session")
|
||||
if not persistent:
|
||||
return host_config.get("command")
|
||||
|
||||
docker_bin = "/volume1/@appstore/ContainerManager/usr/bin/docker"
|
||||
container = persistent["container"]
|
||||
session_name = persistent["session_name"]
|
||||
quoted_container = shlex.quote(container)
|
||||
quoted_session = shlex.quote(session_name)
|
||||
quoted_marker = shlex.quote(PERSISTENCE_STATUS_PREFIX)
|
||||
attach_cmd = (
|
||||
f"{docker_bin} exec -it {quoted_container} "
|
||||
f"tmux new-session -A -s {quoted_session}"
|
||||
)
|
||||
|
||||
script = " ".join([
|
||||
"if",
|
||||
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
|
||||
"then",
|
||||
f"printf '%sattached\\n' {quoted_marker};",
|
||||
"else",
|
||||
f"printf '%screated\\n' {quoted_marker};",
|
||||
"fi;",
|
||||
f"exec {attach_cmd}",
|
||||
])
|
||||
return f"bash -lc {shlex.quote(script)}"
|
||||
|
||||
|
||||
async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
|
||||
async with _session_lock:
|
||||
if len(_active_sessions) >= MAX_SESSIONS:
|
||||
@@ -98,7 +137,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
||||
|
||||
try:
|
||||
process = await conn.create_process(
|
||||
h.get("command"), term_type="xterm-256color", term_size=(80, 24),
|
||||
build_host_command(h), term_type="xterm-256color", term_size=(80, 24),
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { VoiceSession } from "../lib/voice.js";
|
||||
import { getToken } from "../lib/api.js";
|
||||
|
||||
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
||||
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
||||
|
||||
const hosts = [
|
||||
{ id: "nas", label: "NAS" },
|
||||
{ id: "vps", label: "VPS" },
|
||||
{ id: "caddy-vps", label: "Caddy VPS" },
|
||||
{ id: "mac", label: "Mac" },
|
||||
{ id: "claude-dev", label: "Claude Dev" },
|
||||
{ id: "claude-dev", label: "Claude Dev", persistent: true },
|
||||
];
|
||||
|
||||
const DARK_THEME = {
|
||||
@@ -38,6 +42,23 @@
|
||||
return isDark ? DARK_THEME : LIGHT_THEME;
|
||||
}
|
||||
|
||||
function hostById(hostId) {
|
||||
return hosts.find((h) => h.id === hostId);
|
||||
}
|
||||
|
||||
function openFreshSession(tab) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("page", "terminal");
|
||||
url.searchParams.set("host", tab.hostId);
|
||||
window.open(`${url.pathname}${url.search}${url.hash}`, "_blank", "noopener");
|
||||
}
|
||||
|
||||
function persistenceLabel(status, reconnecting) {
|
||||
if (status === "created") return "created new persistent session";
|
||||
if (reconnecting) return "reattached to persistent session";
|
||||
return "attached to persistent session";
|
||||
}
|
||||
|
||||
function applyThemeToTerminals() {
|
||||
const theme = currentTheme();
|
||||
tabData.forEach((d) => {
|
||||
@@ -56,6 +77,8 @@
|
||||
onMount(() => {
|
||||
syncTheme();
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
const requestedHost = new URLSearchParams(window.location.search).get("host");
|
||||
if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
|
||||
});
|
||||
|
||||
function cleanName(name) {
|
||||
@@ -111,8 +134,16 @@
|
||||
|
||||
function addTab(hostId) {
|
||||
const id = ++tabCounter;
|
||||
const label = hosts.find((h) => h.id === hostId).label;
|
||||
tabs = [...tabs, { id, hostId, label, connected: false, error: "" }];
|
||||
const host = hostById(hostId);
|
||||
tabs = [...tabs, {
|
||||
id,
|
||||
hostId,
|
||||
label: host.label,
|
||||
connected: false,
|
||||
error: "",
|
||||
statusBanner: host.persistent ? "Connecting to persistent session..." : "",
|
||||
persistent: !!host.persistent,
|
||||
}];
|
||||
activeTab = id;
|
||||
showMenu = false;
|
||||
}
|
||||
@@ -158,10 +189,11 @@
|
||||
fit.fit();
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const token = localStorage.getItem("token") || "";
|
||||
const token = getToken() || "";
|
||||
let heartbeat = null;
|
||||
let reconnectTimer = null;
|
||||
let closedManually = false;
|
||||
let reconnecting = false;
|
||||
|
||||
function clearHeartbeat() {
|
||||
if (heartbeat) {
|
||||
@@ -173,7 +205,12 @@
|
||||
function scheduleReconnect(reason) {
|
||||
clearHeartbeat();
|
||||
if (closedManually || reconnectTimer) return;
|
||||
updateTab(tabId, { connected: false, error: `${reason} — reconnecting...` });
|
||||
reconnecting = true;
|
||||
updateTab(tabId, {
|
||||
connected: false,
|
||||
error: `${reason} — reconnecting...`,
|
||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||
});
|
||||
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
@@ -193,13 +230,18 @@
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = getToken() || "";
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
updateTab(tabId, { connected: true, error: "" });
|
||||
updateTab(tabId, {
|
||||
connected: true,
|
||||
error: "",
|
||||
statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
|
||||
});
|
||||
sendSize(ws);
|
||||
clearHeartbeat();
|
||||
heartbeat = setInterval(() => {
|
||||
@@ -211,10 +253,25 @@
|
||||
d.heartbeat = heartbeat;
|
||||
d.reconnectTimer = reconnectTimer;
|
||||
d.closedManually = closedManually;
|
||||
d.reconnecting = reconnecting;
|
||||
d.voice?.attach(term, ws);
|
||||
}
|
||||
};
|
||||
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
|
||||
ws.onmessage = (e) => {
|
||||
const data = new Uint8Array(e.data);
|
||||
const isPersistenceMessage = tab.persistent
|
||||
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
|
||||
&& PERSISTENCE_STATUS_PREFIX_BYTES.every((byte, index) => data[index] === byte);
|
||||
if (isPersistenceMessage) {
|
||||
const status = new TextDecoder().decode(data.slice(PERSISTENCE_STATUS_PREFIX_BYTES.length)).trim();
|
||||
updateTab(tabId, { statusBanner: persistenceLabel(status, reconnecting) });
|
||||
reconnecting = false;
|
||||
const d = tabData.get(tabId);
|
||||
if (d) d.reconnecting = reconnecting;
|
||||
return;
|
||||
}
|
||||
term.write(data);
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
||||
const d = tabData.get(tabId);
|
||||
@@ -239,6 +296,7 @@
|
||||
d.heartbeat = heartbeat;
|
||||
d.reconnectTimer = reconnectTimer;
|
||||
d.closedManually = closedManually;
|
||||
d.reconnecting = reconnecting;
|
||||
}
|
||||
return ws;
|
||||
}
|
||||
@@ -286,7 +344,7 @@
|
||||
ro.observe(el);
|
||||
const voice = new VoiceSession(() => { voiceTick++; });
|
||||
voice.attach(term, ws);
|
||||
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, handleDragOver, handleDrop, handlePaste });
|
||||
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, reconnecting, handleDragOver, handleDrop, handlePaste });
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -327,6 +385,18 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
{/if}
|
||||
{tab.label}
|
||||
{#if tab.persistent}
|
||||
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">tmux</span>
|
||||
{/if}
|
||||
<span
|
||||
class="ml-1 text-surface-500 hover:text-sky-500 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Open fresh session in new browser tab"
|
||||
aria-label={`Open fresh ${tab.label} session in new browser tab`}
|
||||
onclick={(e) => { e.stopPropagation(); openFreshSession(tab); }}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFreshSession(tab)}
|
||||
>↗</span>
|
||||
<span
|
||||
class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
|
||||
role="button"
|
||||
@@ -345,9 +415,14 @@
|
||||
<div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
|
||||
{#each hosts as h}
|
||||
<button
|
||||
class="block w-full text-left px-3 py-1.5 text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
|
||||
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
|
||||
onclick={() => addTab(h.id)}
|
||||
>{h.label}</button>
|
||||
>
|
||||
<span>{h.label}</span>
|
||||
{#if h.persistent}
|
||||
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">persistent</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -371,6 +446,12 @@
|
||||
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
|
||||
</p>
|
||||
|
||||
{#if activeTab && tabs.find(t => t.id === activeTab)?.statusBanner}
|
||||
<div class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-800 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
|
||||
{tabs.find(t => t.id === activeTab)?.statusBanner}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !tabs.length}
|
||||
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
|
||||
Click + to open a terminal
|
||||
|
||||
Reference in New Issue
Block a user