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 asyncio
|
||||||
import struct
|
import struct
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from fastapi import WebSocket, Query
|
from fastapi import WebSocket, Query
|
||||||
import asyncssh
|
import asyncssh
|
||||||
@@ -10,19 +11,28 @@ from auth import get_current_user_ws
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_SESSIONS = 5
|
MAX_SESSIONS = 5
|
||||||
MAX_SESSIONS_PER_USER = 2
|
MAX_SESSIONS_PER_USER = 5
|
||||||
_session_lock = asyncio.Lock()
|
_session_lock = asyncio.Lock()
|
||||||
_active_sessions: dict[int, str] = {}
|
_active_sessions: dict[int, str] = {}
|
||||||
|
PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:"
|
||||||
|
|
||||||
HOSTS = {
|
HOSTS = {
|
||||||
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
|
"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},
|
"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},
|
"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},
|
"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,
|
"claude-dev": {
|
||||||
"command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"},
|
"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
|
# Load known_hosts once at import time — fail closed if missing
|
||||||
_known_hosts_available = False
|
_known_hosts_available = False
|
||||||
try:
|
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)
|
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 def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
|
||||||
async with _session_lock:
|
async with _session_lock:
|
||||||
if len(_active_sessions) >= MAX_SESSIONS:
|
if len(_active_sessions) >= MAX_SESSIONS:
|
||||||
@@ -98,7 +137,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process = await conn.create_process(
|
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,
|
encoding=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { VoiceSession } from "../lib/voice.js";
|
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 = [
|
const hosts = [
|
||||||
{ id: "nas", label: "NAS" },
|
{ id: "nas", label: "NAS" },
|
||||||
{ id: "vps", label: "VPS" },
|
{ id: "vps", label: "VPS" },
|
||||||
{ id: "caddy-vps", label: "Caddy VPS" },
|
{ id: "caddy-vps", label: "Caddy VPS" },
|
||||||
{ id: "mac", label: "Mac" },
|
{ id: "mac", label: "Mac" },
|
||||||
{ id: "claude-dev", label: "Claude Dev" },
|
{ id: "claude-dev", label: "Claude Dev", persistent: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DARK_THEME = {
|
const DARK_THEME = {
|
||||||
@@ -38,6 +42,23 @@
|
|||||||
return isDark ? DARK_THEME : LIGHT_THEME;
|
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() {
|
function applyThemeToTerminals() {
|
||||||
const theme = currentTheme();
|
const theme = currentTheme();
|
||||||
tabData.forEach((d) => {
|
tabData.forEach((d) => {
|
||||||
@@ -56,6 +77,8 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
syncTheme();
|
syncTheme();
|
||||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
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) {
|
function cleanName(name) {
|
||||||
@@ -111,8 +134,16 @@
|
|||||||
|
|
||||||
function addTab(hostId) {
|
function addTab(hostId) {
|
||||||
const id = ++tabCounter;
|
const id = ++tabCounter;
|
||||||
const label = hosts.find((h) => h.id === hostId).label;
|
const host = hostById(hostId);
|
||||||
tabs = [...tabs, { id, hostId, label, connected: false, error: "" }];
|
tabs = [...tabs, {
|
||||||
|
id,
|
||||||
|
hostId,
|
||||||
|
label: host.label,
|
||||||
|
connected: false,
|
||||||
|
error: "",
|
||||||
|
statusBanner: host.persistent ? "Connecting to persistent session..." : "",
|
||||||
|
persistent: !!host.persistent,
|
||||||
|
}];
|
||||||
activeTab = id;
|
activeTab = id;
|
||||||
showMenu = false;
|
showMenu = false;
|
||||||
}
|
}
|
||||||
@@ -158,10 +189,11 @@
|
|||||||
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 = getToken() || "";
|
||||||
let heartbeat = null;
|
let heartbeat = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let closedManually = false;
|
let closedManually = false;
|
||||||
|
let reconnecting = false;
|
||||||
|
|
||||||
function clearHeartbeat() {
|
function clearHeartbeat() {
|
||||||
if (heartbeat) {
|
if (heartbeat) {
|
||||||
@@ -173,7 +205,12 @@
|
|||||||
function scheduleReconnect(reason) {
|
function scheduleReconnect(reason) {
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
if (closedManually || reconnectTimer) return;
|
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`);
|
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
@@ -193,13 +230,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
|
const token = getToken() || "";
|
||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
||||||
);
|
);
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
updateTab(tabId, { connected: true, error: "" });
|
updateTab(tabId, {
|
||||||
|
connected: true,
|
||||||
|
error: "",
|
||||||
|
statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
|
||||||
|
});
|
||||||
sendSize(ws);
|
sendSize(ws);
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
@@ -211,10 +253,25 @@
|
|||||||
d.heartbeat = heartbeat;
|
d.heartbeat = heartbeat;
|
||||||
d.reconnectTimer = reconnectTimer;
|
d.reconnectTimer = reconnectTimer;
|
||||||
d.closedManually = closedManually;
|
d.closedManually = closedManually;
|
||||||
|
d.reconnecting = reconnecting;
|
||||||
d.voice?.attach(term, ws);
|
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) => {
|
ws.onclose = (e) => {
|
||||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
const reason = e.reason || `Connection closed (code ${e.code})`;
|
||||||
const d = tabData.get(tabId);
|
const d = tabData.get(tabId);
|
||||||
@@ -239,6 +296,7 @@
|
|||||||
d.heartbeat = heartbeat;
|
d.heartbeat = heartbeat;
|
||||||
d.reconnectTimer = reconnectTimer;
|
d.reconnectTimer = reconnectTimer;
|
||||||
d.closedManually = closedManually;
|
d.closedManually = closedManually;
|
||||||
|
d.reconnecting = reconnecting;
|
||||||
}
|
}
|
||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
@@ -286,7 +344,7 @@
|
|||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
const voice = new VoiceSession(() => { voiceTick++; });
|
const voice = new VoiceSession(() => { voiceTick++; });
|
||||||
voice.attach(term, ws);
|
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>
|
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{tab.label}
|
{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
|
<span
|
||||||
class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
|
class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
|
||||||
role="button"
|
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]">
|
<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}
|
{#each hosts as h}
|
||||||
<button
|
<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)}
|
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>.
|
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>
|
</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}
|
{#if !tabs.length}
|
||||||
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
|
<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
|
Click + to open a terminal
|
||||||
|
|||||||
Reference in New Issue
Block a user