fix: raise terminal per-user session cap #28

Merged
jimmy merged 2 commits from dev into main 2026-03-07 10:45:38 +08:00
2 changed files with 134 additions and 14 deletions
Showing only changes of commit 1b9ea140b6 - Show all commits
+43 -4
View File
@@ -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,
) )
+91 -10
View File
@@ -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