feat: improve session limit handling and add k6 load testing

This commit is contained in:
2026-04-23 05:17:53 +00:00
parent 47f46d5c5b
commit 1438dee21a
6 changed files with 687 additions and 156 deletions
+119 -22
View File
@@ -65,8 +65,15 @@ cat > /opt/portal/index.html <<'HTML'
const statusEl = document.getElementById('status');
const XK_ALT_L = 0xffe9;
const XK_LEFT = 0xff51;
let rfb = null;
let connected = false;
let connectTimer = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 12;
const RECONNECT_DELAYS_MS = [1000, 2000, 3000, 5000, 8000];
let manualDisconnect = false;
function showStatus(text, isError = false) {
statusEl.textContent = text;
@@ -78,73 +85,160 @@ cat > /opt/portal/index.html <<'HTML'
statusEl.classList.add('hidden');
}
showStatus('Подключение к слоту...');
connectTimer = setTimeout(() => {
if (!connected) {
showStatus('Нет подключения к экрану слота. Откройте сервис заново из дашборда.', true);
function clearConnectTimer() {
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
}, 8000);
}
const rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
connected = true;
if (connectTimer) clearTimeout(connectTimer);
hideStatus();
});
rfb.addEventListener('disconnect', () => {
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function reconnectDelay(attemptNumber) {
const idx = Math.min(Math.max(attemptNumber - 1, 0), RECONNECT_DELAYS_MS.length - 1);
return RECONNECT_DELAYS_MS[idx];
}
function scheduleConnectTimeout() {
clearConnectTimer();
connectTimer = setTimeout(() => {
if (!connected && !manualDisconnect) {
scheduleReconnect('Нет подключения к экрану слота. Пробуем переподключиться...');
}
}, 8000);
}
function scheduleReconnect(reasonText) {
if (manualDisconnect) return;
clearConnectTimer();
clearReconnectTimer();
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showStatus('Соединение со слотом потеряно. Переподключение не удалось. Откройте сервис заново.', true);
return;
}
const nextAttempt = reconnectAttempts + 1;
const delayMs = reconnectDelay(nextAttempt);
const delaySec = Math.ceil(delayMs / 1000);
showStatus(`${reasonText} Повтор ${nextAttempt}/${MAX_RECONNECT_ATTEMPTS} через ${delaySec} сек.`, true);
reconnectTimer = setTimeout(() => {
reconnectAttempts = nextAttempt;
connectRfb('Переподключение к слоту...');
}, delayMs);
}
function attachRfb() {
if (rfb) {
try { rfb.disconnect(); } catch (e) {}
}
rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
connected = true;
reconnectAttempts = 0;
clearConnectTimer();
clearReconnectTimer();
hideStatus();
});
rfb.addEventListener('disconnect', () => {
connected = false;
if (manualDisconnect) return;
scheduleReconnect('Соединение со слотом потеряно.');
});
}
function connectRfb(statusText) {
if (manualDisconnect) return;
connected = false;
showStatus('Соединение со слотом потеряно. Запустите сервис заново.', true);
});
showStatus(statusText || 'Подключение к слоту...');
attachRfb();
scheduleConnectTimeout();
}
const enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0';
const sid = new URLSearchParams(location.search).get('sid');
const SESSION_CLOSED_URL = '/?session_closed=idle';
const SESSION_CLOSED_URL_BASE = '/?session_closed=';
const CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : '';
function goSessionClosed() {
function goSessionClosed(reason = 'idle') {
const safeReason = reason === 'limit' ? 'limit' : 'idle';
const target = `${SESSION_CLOSED_URL_BASE}${safeReason}`;
try {
if (window.top && window.top !== window) {
window.top.location.href = SESSION_CLOSED_URL;
window.top.location.href = target;
return;
}
} catch (e) {}
window.location.href = SESSION_CLOSED_URL;
window.location.href = target;
}
async function touch() {
if (!sid) return;
try {
const res = await fetch(`/api/sessions/${sid}/touch`, {method:'POST', credentials:'include'});
if (!res.ok) {
goSessionClosed();
let reason = 'idle';
try {
const payload = await res.json();
if (payload && typeof payload.reason === 'string') {
reason = payload.reason;
}
} catch (e) {}
goSessionClosed(reason);
}
} catch (e) {}
}
let closing = false;
async function closeSessionNow() {
if (!CLOSE_PATH || closing) return;
closing = true;
manualDisconnect = true;
clearConnectTimer();
clearReconnectTimer();
try {
if (rfb) rfb.disconnect();
} catch (e) {}
try {
await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true});
} catch (e) {}
}
if (enableHeartbeat) {
setInterval(touch, 15000);
touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
}
function keyTap(keysym, code) {
if (!rfb) return;
rfb.sendKey(keysym, code, true);
rfb.sendKey(keysym, code, false);
}
function chord(mod, key, modCode, keyCode) {
if (!rfb) return;
rfb.sendKey(mod, modCode, true);
keyTap(key, keyCode);
rfb.sendKey(mod, modCode, false);
}
function goHome() {
manualDisconnect = true;
clearConnectTimer();
clearReconnectTimer();
try {
if (window.top && window.top !== window) {
window.top.location.href = '/';
@@ -153,9 +247,12 @@ cat > /opt/portal/index.html <<'HTML'
} catch (e) {}
window.location.href = '/';
}
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault());
connectRfb('Подключение к слоту...');
</script>
</body>
</html>