UI/runtime polish, session rotation limit, login errors, docs update

This commit is contained in:
2026-04-21 16:05:15 +00:00
parent c97cf5308d
commit 6f9bc32440
14 changed files with 346 additions and 43 deletions
+198 -12
View File
@@ -1,6 +1,7 @@
import datetime as dt
import enum
import fcntl
import re
import logging
import os
from pathlib import Path
@@ -18,6 +19,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeTimedSerializer
from markupsafe import Markup, escape
from passlib.context import CryptContext
from sqlalchemy import (
Boolean,
@@ -47,6 +49,7 @@ PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0"))
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
ICON_UPLOAD_TYPES = {
"image/png": "png",
@@ -202,6 +205,35 @@ def normalize_web_target(url: str) -> str:
return f"http://{raw}"
def format_service_comment(raw_text: str) -> Markup:
raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not raw:
return Markup("")
escaped = str(escape(raw))
# Support pasted/plain markdown-like bold fragments.
escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped, flags=re.DOTALL)
# Allow a small safe subset of pasted HTML tags.
replacements = {
"&lt;b&gt;": "<b>",
"&lt;/b&gt;": "</b>",
"&lt;strong&gt;": "<strong>",
"&lt;/strong&gt;": "</strong>",
"&lt;i&gt;": "<i>",
"&lt;/i&gt;": "</i>",
"&lt;em&gt;": "<em>",
"&lt;/em&gt;": "</em>",
"&lt;u&gt;": "<u>",
"&lt;/u&gt;": "</u>",
"&lt;br&gt;": "<br>",
"&lt;br/&gt;": "<br>",
"&lt;br /&gt;": "<br>",
}
for src, dst in replacements.items():
escaped = escaped.replace(src, dst)
escaped = escaped.replace("\n", "<br>")
return Markup(escaped)
def parse_rdp_target(target: str) -> dict:
raw = (target or "").strip()
if not raw:
@@ -940,6 +972,22 @@ def stop_runtime_container(container_id: Optional[str]) -> None:
logger.exception("session_container_stop_failed container_id=%s", container_id)
def terminate_session_record(
db: Session,
sess: SessionModel,
new_status: SessionStatus = SessionStatus.TERMINATED,
*,
stop_container: bool = True,
) -> None:
if not sess or sess.status != SessionStatus.ACTIVE:
return
cid = sess.container_id or ""
if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:")):
stop_runtime_container(cid)
sess.status = new_status
sess.last_access_at = now_utc()
def ensure_schema_compatibility() -> None:
# PostgreSQL requires enum value addition to be committed before usage in constraints.
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
@@ -1179,7 +1227,13 @@ def get_active_sessions_count(db: Session, service_id: int) -> int:
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff,
)
return len(db.scalars(q).all())
sessions = db.scalars(q).all()
# Avoid inflated stats when pooled slot sessions were duplicated by race:
# for pooled sessions, occupancy is unique container_id.
pooled = [s for s in sessions if (s.container_id or "").startswith(("WEBPOOLIDX:", "POOLIDX:", "POOL:"))]
direct = [s for s in sessions if s not in pooled]
unique_pooled = len({s.container_id for s in pooled if s.container_id})
return unique_pooled + len(direct)
def find_active_session_for_service(db: Session, service_id: int) -> Optional[SessionModel]:
@@ -1196,6 +1250,50 @@ def find_active_session_for_service(db: Session, service_id: int) -> Optional[Se
return db.scalars(q).first()
def find_active_session_for_user_service(db: Session, user_id: int, service_id: int) -> Optional[SessionModel]:
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
q = (
select(SessionModel)
.where(
SessionModel.user_id == user_id,
SessionModel.service_id == service_id,
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff,
)
.order_by(SessionModel.created_at.desc())
)
return db.scalars(q).first()
def allocator_lock(db: Session, lock_id: int):
class _LockCtx:
def __enter__(self_nonlocal):
db.execute(text("SELECT pg_advisory_lock(:lid)"), {"lid": lock_id})
return self_nonlocal
def __exit__(self_nonlocal, exc_type, exc, tb):
db.execute(text("SELECT pg_advisory_unlock(:lid)"), {"lid": lock_id})
return False
return _LockCtx()
def terminate_active_slot_sessions(db: Session, container_id: str) -> None:
if not container_id:
return
db.execute(
text(
"""
UPDATE sessions
SET status = 'TERMINATED'
WHERE container_id = :cid
AND status = 'ACTIVE'
"""
),
{"cid": container_id},
)
def session_redirect_url(sess: SessionModel) -> str:
cid = sess.container_id or ""
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"):
@@ -1328,9 +1426,15 @@ def startup_event():
@app.get("/", response_class=HTMLResponse)
def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)):
session_closed = (request.query_params.get("session_closed") or "").strip().lower()
launch_error = (request.query_params.get("launch_error") or "").strip().lower()
session_notice = ""
if session_closed == "idle":
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
elif launch_error == "max_services":
session_notice = (
f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
"Освободите один сервис и попробуйте снова."
)
if not user:
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
response = templates.TemplateResponse(
@@ -1385,6 +1489,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
svc for svc in services
if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, []))
]
service_comment_html = {svc.id: format_service_comment(svc.comment) for svc in services}
return templates.TemplateResponse(
"dashboard.html",
@@ -1395,6 +1500,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"categories": categories,
"selected_category_slug": selected_category_slug,
"service_categories": service_categories,
"service_comment_html": service_comment_html,
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
"session_notice": session_notice,
},
@@ -1467,6 +1573,24 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"""
)
).mappings().all()
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
online_sessions = db.execute(
text(
"""
SELECT s.id, u.username, sv.name AS service_name, sv.slug AS service_slug,
sv.type AS service_type, s.container_id, s.created_at, s.last_access_at
FROM sessions s
JOIN users u ON u.id = s.user_id
JOIN services sv ON sv.id = s.service_id
WHERE s.status = 'ACTIVE'
AND s.last_access_at >= :cutoff
AND sv.type IN ('WEB','RDP')
ORDER BY s.last_access_at DESC, s.created_at DESC
LIMIT 500
"""
),
{"cutoff": cutoff},
).mappings().all()
return templates.TemplateResponse(
"admin.html",
{
@@ -1486,7 +1610,9 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"web_pool_buffer": WEB_POOL_BUFFER,
"recent_sessions": recent_sessions,
"open_stats": open_stats,
"online_sessions": online_sessions,
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
},
)
@@ -1505,7 +1631,19 @@ def login(
user = db.scalar(select(User).where(User.username == username))
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
response = templates.TemplateResponse(
"login.html",
{
"request": request,
"csrf_token": csrf,
"login_error": "Неверный логин или пароль",
"session_notice": "",
},
status_code=401,
)
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
return response
if not user_is_valid(user):
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
response = templates.TemplateResponse(
@@ -1544,6 +1682,36 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
raise HTTPException(status_code=410, detail="VNC services are deprecated")
if not has_access(db, user.id, service.id):
raise HTTPException(status_code=403, detail="ACL denied")
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
if existing_user_session:
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
active_rows = db.scalars(
select(SessionModel).where(
SessionModel.user_id == user.id,
SessionModel.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff,
)
).all()
active_rows = sorted(active_rows, key=lambda row: row.created_at)
active_service_ids = {row.service_id for row in active_rows}
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
oldest = next((row for row in active_rows if row.service_id != service.id), None)
if oldest:
terminate_session_record(db, oldest, SessionStatus.TERMINATED, stop_container=True)
db.commit()
logger.info(
"session_rotated user_id=%s closed_session=%s old_service_id=%s new_service_id=%s",
user.id,
oldest.id,
oldest.service_id,
service.id,
)
else:
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
if service.type == ServiceType.RDP:
active_owner = find_active_session_for_service(db, service.id)
if active_owner:
@@ -1559,47 +1727,53 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
session_id = str(uuid.uuid4())
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
try:
with allocator_lock(db, 91001):
ensure_web_pool()
slot = acquire_web_pool_slot(db)
slot_cid = f"WEBPOOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_web_pool_target(slot, service)
except Exception as exc:
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=f"WEBPOOLIDX:{slot}",
container_id=slot_cid,
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
except Exception as exc:
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
if service_uses_universal_pool(service):
try:
with allocator_lock(db, 91002):
ensure_universal_pool()
slot = acquire_universal_slot(db)
slot_cid = f"POOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_universal_target(slot, service)
except Exception as exc:
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=f"POOLIDX:{slot}",
container_id=slot_cid,
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
except Exception as exc:
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
@@ -1817,6 +1991,18 @@ def touch_session(session_id: str, user: User = Depends(require_user), db: Sessi
return {"ok": True}
@app.post("/api/sessions/{session_id}/close")
def close_session(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
sess = db.get(SessionModel, session_id)
if not sess or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
if sess.status != SessionStatus.ACTIVE:
return {"ok": True, "status": sess.status.value}
terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True)
db.commit()
return {"ok": True, "status": "TERMINATED"}
@app.get("/api/services/{slug}/status")
def service_status(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+12 -1
View File
@@ -322,7 +322,8 @@ button {
color: #fff;
}
.tile {
display: block;
display: flex;
flex-direction: column;
text-decoration: none;
background: var(--card);
color: inherit;
@@ -368,6 +369,16 @@ button {
margin-top: 0.45rem;
color: #4b6178;
}
.tile-comment {
max-height: 96px;
overflow: auto;
line-height: 1.35;
padding-right: 0.2rem;
}
.tile-comment b,
.tile-comment strong {
font-weight: 700;
}
.service-categories {
margin-top: 0.7rem;
display: flex;
+34 -1
View File
@@ -411,7 +411,7 @@
<section id="tab-stats" class="panel admin-tab" style="display:none;">
<h3>Статистика открытий</h3>
<div class="admin-intro">
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков.
Здесь видно кто, когда и какой сервис открывал, а также кто сейчас онлайн.
</div>
<div class="split">
<div>
@@ -473,6 +473,39 @@
</div>
</div>
</div>
<div style="margin-top:0.8rem;">
<div class="list-title">Сейчас онлайн (активные сессии)</div>
<div class="container-table-wrap" style="max-height:420px;">
<table class="admin-table">
<thead>
<tr>
<th>user</th>
<th>service</th>
<th>type</th>
<th>container</th>
<th>last_access</th>
<th>created</th>
<th>session_id</th>
</tr>
</thead>
<tbody>
{% for row in online_sessions %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.service_name }} ({{ row.service_slug }})</td>
<td>{{ row.service_type }}</td>
<td>{{ row.container_id }}</td>
<td>{{ row.last_access_at }}</td>
<td>{{ row.created_at }}</td>
<td>{{ row.id }}</td>
</tr>
{% else %}
<tr><td colspan="7">Сейчас нет активных сессий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
</main>
+1 -1
View File
@@ -48,7 +48,7 @@
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if service.comment %}
<small>{{ service.comment }}</small>
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %}
{% if svc_cats %}
<div class="service-categories">
+2 -1
View File
@@ -30,7 +30,7 @@ services:
api:
build:
context: ./app
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"]
environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY}
@@ -40,6 +40,7 @@ services:
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
- db
+39
View File
@@ -258,6 +258,45 @@ git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mo
- `404` если сессия не найдена/не принадлежит пользователю;
- `410` если сессия найдена, но уже не `ACTIVE`.
## 16) Обновления (2026-04-21, ночь)
1. Ограничение активных сервисов пользователя:
- Лимит оставлен `MAX_ACTIVE_SERVICES_PER_USER=4`.
- Поведение изменено на FIFO-ротацию:
- при открытии 5-го сервиса автоматически закрывается самый старый активный;
- при открытии 6-го — следующий по старшинству и т.д.
- Жесткий редирект с ошибкой теперь используется только как аварийный fallback.
2. Время простоя:
- Для обычного простоя подтверждено `SESSION_IDLE_SECONDS=300` (5 минут).
- Значения синхронизированы в `.env`, `docker-compose.yml`, `app/main.py`.
3. Runtime-навигация в сервисах:
- Кнопки оставлены символьные:
- `←` (назад)
- `⌂` (главная)
- Позиция обновлена: слева вверху, но чуть ниже прежнего:
- `kiosk`: `top:34px`
- `universal-runtime`: `top:64px` (ниже статусного блока)
4. UI карточек на главной:
- В описании карточки добавлена прокрутка (`max-height` + `overflow:auto`), если текст не влезает.
- Поддержаны переносы строк.
- Поддержано отображение жирного текста из:
- `**markdown**`
- простых HTML-тегов (`<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`), с безопасным экранированием остального.
5. Авторизация:
- При неверном логине/пароле теперь отображается явное сообщение на странице входа:
`Неверный логин или пароль`
(вместо немого 401 без человекочитаемого текста).
6. Производительность API:
- Увеличено число воркеров Uvicorn:
- было: `--workers 4`
- стало: `--workers 6`
- Изменение внесено в `docker-compose.yml`.
4. WEB pool (устойчивость при пике):
- Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`).
- Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором.
+15 -4
View File
@@ -29,7 +29,7 @@ cat > /opt/portal/index.html <<HTML
<style>
html,body,#screen{margin:0;height:100%;background:#111}
.nav-panel{
position:fixed;left:16px;top:16px;z-index:99;display:flex;gap:10px;
position:fixed;left:16px;top:34px;z-index:99;display:flex;gap:10px;
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));border:1px solid rgba(255,255,255,.22);backdrop-filter: blur(5px);
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
}
@@ -44,8 +44,8 @@ cat > /opt/portal/index.html <<HTML
<body>
<div id="screen"></div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button>
<button class="nav-btn" id="btn-home" type="button">Главная</button>
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
</div>
<script type="module">
import RFB from './core/rfb.js';
@@ -60,6 +60,7 @@ cat > /opt/portal/index.html <<HTML
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const SESSION_CLOSED_URL = '/?session_closed=idle';
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
function goSessionClosed() {
try {
if (window.top && window.top !== window) {
@@ -77,9 +78,19 @@ cat > /opt/portal/index.html <<HTML
}
} catch(e) {}
}
let closing = false;
async function closeSessionNow() {
if (closing) return;
closing = true;
try {
await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true});
} catch (e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
setInterval(touch, 15000);
touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
}
function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true);
+12 -1
View File
@@ -38,6 +38,7 @@ cat > /opt/portal/index.html <<HTML
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const SESSION_CLOSED_URL = '/?session_closed=idle';
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
function goSessionClosed() {
try {
if (window.top && window.top !== window) {
@@ -55,9 +56,19 @@ cat > /opt/portal/index.html <<HTML
}
} catch(e) {}
}
let closing = false;
async function closeSessionNow() {
if (closing) return;
closing = true;
try {
await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true});
} catch (e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
setInterval(touch, 15000);
touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
}
document.addEventListener('contextmenu', (e) => e.preventDefault());
</script>
Regular → Executable
+15 -4
View File
@@ -39,7 +39,7 @@ cat > /opt/portal/index.html <<'HTML'
}
.status.hidden{display:none}
.nav-panel{
position:fixed;left:16px;top:16px;z-index:99;display:flex;gap:10px;
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));border:1px solid rgba(255,255,255,.22);backdrop-filter: blur(5px);
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
}
@@ -55,8 +55,8 @@ cat > /opt/portal/index.html <<'HTML'
<div id="screen"></div>
<div id="status" class="status">Подключение к слоту...</div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button>
<button class="nav-btn" id="btn-home" type="button">Главная</button>
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
</div>
<script type="module">
import RFB from './core/rfb.js';
@@ -102,6 +102,7 @@ cat > /opt/portal/index.html <<'HTML'
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 CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : '';
function goSessionClosed() {
try {
if (window.top && window.top !== window) {
@@ -120,9 +121,19 @@ cat > /opt/portal/index.html <<'HTML'
}
} catch (e) {}
}
let closing = false;
async function closeSessionNow() {
if (!CLOSE_PATH || closing) return;
closing = true;
try {
await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true});
} catch (e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
setInterval(touch, 15000);
touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
}
function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true);