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
+216 -30
View File
@@ -1,6 +1,7 @@
import datetime as dt import datetime as dt
import enum import enum
import fcntl import fcntl
import re
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -18,6 +19,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeTimedSerializer from itsdangerous import BadSignature, URLSafeTimedSerializer
from markupsafe import Markup, escape
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy import ( from sqlalchemy import (
Boolean, 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")) UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5")) WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2")) 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_MAX_BYTES = 2 * 1024 * 1024
ICON_UPLOAD_TYPES = { ICON_UPLOAD_TYPES = {
"image/png": "png", "image/png": "png",
@@ -202,6 +205,35 @@ def normalize_web_target(url: str) -> str:
return f"http://{raw}" 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: def parse_rdp_target(target: str) -> dict:
raw = (target or "").strip() raw = (target or "").strip()
if not raw: 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) 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: def ensure_schema_compatibility() -> None:
# PostgreSQL requires enum value addition to be committed before usage in constraints. # PostgreSQL requires enum value addition to be committed before usage in constraints.
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: 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.status == SessionStatus.ACTIVE,
SessionModel.last_access_at >= cutoff, 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]: 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() 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: def session_redirect_url(sess: SessionModel) -> str:
cid = sess.container_id or "" cid = sess.container_id or ""
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"): if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"):
@@ -1328,9 +1426,15 @@ def startup_event():
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)): 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() session_closed = (request.query_params.get("session_closed") or "").strip().lower()
launch_error = (request.query_params.get("launch_error") or "").strip().lower()
session_notice = "" session_notice = ""
if session_closed == "idle": if session_closed == "idle":
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново." session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
elif launch_error == "max_services":
session_notice = (
f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
"Освободите один сервис и попробуйте снова."
)
if not user: if not user:
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
response = templates.TemplateResponse( response = templates.TemplateResponse(
@@ -1385,6 +1489,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
svc for svc in services svc for svc in services
if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, [])) 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( return templates.TemplateResponse(
"dashboard.html", "dashboard.html",
@@ -1395,6 +1500,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"categories": categories, "categories": categories,
"selected_category_slug": selected_category_slug, "selected_category_slug": selected_category_slug,
"service_categories": service_categories, "service_categories": service_categories,
"service_comment_html": service_comment_html,
"csrf_token": request.cookies.get(CSRF_COOKIE, ""), "csrf_token": request.cookies.get(CSRF_COOKIE, ""),
"session_notice": session_notice, "session_notice": session_notice,
}, },
@@ -1467,6 +1573,24 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
""" """
) )
).mappings().all() ).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( return templates.TemplateResponse(
"admin.html", "admin.html",
{ {
@@ -1486,7 +1610,9 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"web_pool_buffer": WEB_POOL_BUFFER, "web_pool_buffer": WEB_POOL_BUFFER,
"recent_sessions": recent_sessions, "recent_sessions": recent_sessions,
"open_stats": open_stats, "open_stats": open_stats,
"online_sessions": online_sessions,
"csrf_token": request.cookies.get(CSRF_COOKIE, ""), "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)) user = db.scalar(select(User).where(User.username == username))
if not user or not verify_password(password, user.password_hash): 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): if not user_is_valid(user):
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
response = templates.TemplateResponse( 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") raise HTTPException(status_code=410, detail="VNC services are deprecated")
if not has_access(db, user.id, service.id): if not has_access(db, user.id, service.id):
raise HTTPException(status_code=403, detail="ACL denied") 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: if service.type == ServiceType.RDP:
active_owner = find_active_session_for_service(db, service.id) active_owner = find_active_session_for_service(db, service.id)
if active_owner: 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()) session_id = str(uuid.uuid4())
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0: if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
try: try:
ensure_web_pool() with allocator_lock(db, 91001):
slot = acquire_web_pool_slot(db) ensure_web_pool()
dispatch_web_pool_target(slot, service) slot = acquire_web_pool_slot(db)
slot_cid = f"WEBPOOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_web_pool_target(slot, service)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
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: except Exception as exc:
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) 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) 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") 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}",
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) 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) return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
if service_uses_universal_pool(service): if service_uses_universal_pool(service):
try: try:
ensure_universal_pool() with allocator_lock(db, 91002):
slot = acquire_universal_slot(db) ensure_universal_pool()
dispatch_universal_target(slot, service) slot = acquire_universal_slot(db)
slot_cid = f"POOLIDX:{slot}"
terminate_active_slot_sessions(db, slot_cid)
dispatch_universal_target(slot, service)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
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: except Exception as exc:
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) 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) 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") 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}",
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) 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) 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} 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") @app.get("/api/services/{slug}/status")
def service_status(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)): 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)) 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; color: #fff;
} }
.tile { .tile {
display: block; display: flex;
flex-direction: column;
text-decoration: none; text-decoration: none;
background: var(--card); background: var(--card);
color: inherit; color: inherit;
@@ -368,6 +369,16 @@ button {
margin-top: 0.45rem; margin-top: 0.45rem;
color: #4b6178; 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 { .service-categories {
margin-top: 0.7rem; margin-top: 0.7rem;
display: flex; display: flex;
+34 -1
View File
@@ -411,7 +411,7 @@
<section id="tab-stats" class="panel admin-tab" style="display:none;"> <section id="tab-stats" class="panel admin-tab" style="display:none;">
<h3>Статистика открытий</h3> <h3>Статистика открытий</h3>
<div class="admin-intro"> <div class="admin-intro">
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков. Здесь видно кто, когда и какой сервис открывал, а также кто сейчас онлайн.
</div> </div>
<div class="split"> <div class="split">
<div> <div>
@@ -473,6 +473,39 @@
</div> </div>
</div> </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> </section>
</main> </main>
+1 -1
View File
@@ -48,7 +48,7 @@
<h3>{{ service.name }}</h3> <h3>{{ service.name }}</h3>
<p>Открыть сервис</p> <p>Открыть сервис</p>
{% if service.comment %} {% if service.comment %}
<small>{{ service.comment }}</small> <small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %} {% endif %}
{% if svc_cats %} {% if svc_cats %}
<div class="service-categories"> <div class="service-categories">
+2 -1
View File
@@ -30,7 +30,7 @@ services:
api: api:
build: build:
context: ./app 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: environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY} SIGNING_KEY: ${SIGNING_KEY}
@@ -40,6 +40,7 @@ services:
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300} SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on: depends_on:
- db - db
+39
View File
@@ -258,6 +258,45 @@ git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mo
- `404` если сессия не найдена/не принадлежит пользователю; - `404` если сессия не найдена/не принадлежит пользователю;
- `410` если сессия найдена, но уже не `ACTIVE`. - `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 (устойчивость при пике): 4. WEB pool (устойчивость при пике):
- Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`). - Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`).
- Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором. - Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором.
+15 -4
View File
@@ -29,7 +29,7 @@ cat > /opt/portal/index.html <<HTML
<style> <style>
html,body,#screen{margin:0;height:100%;background:#111} html,body,#screen{margin:0;height:100%;background:#111}
.nav-panel{ .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); 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 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> <body>
<div id="screen"></div> <div id="screen"></div>
<div class="nav-panel"> <div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button> <button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button">Главная</button> <button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
</div> </div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
@@ -60,6 +60,7 @@ cat > /opt/portal/index.html <<HTML
rfb.resizeSession = true; rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1"; const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const SESSION_CLOSED_URL = '/?session_closed=idle'; const SESSION_CLOSED_URL = '/?session_closed=idle';
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
function goSessionClosed() { function goSessionClosed() {
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
@@ -77,9 +78,19 @@ cat > /opt/portal/index.html <<HTML
} }
} catch(e) {} } 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) { if (enableHeartbeat) {
setInterval(touch, 60000); setInterval(touch, 15000);
touch(); touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
} }
function keyTap(keysym, code) { function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true); rfb.sendKey(keysym, code, true);
+12 -1
View File
@@ -38,6 +38,7 @@ cat > /opt/portal/index.html <<HTML
rfb.resizeSession = true; rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1"; const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const SESSION_CLOSED_URL = '/?session_closed=idle'; const SESSION_CLOSED_URL = '/?session_closed=idle';
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
function goSessionClosed() { function goSessionClosed() {
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
@@ -55,9 +56,19 @@ cat > /opt/portal/index.html <<HTML
} }
} catch(e) {} } 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) { if (enableHeartbeat) {
setInterval(touch, 60000); setInterval(touch, 15000);
touch(); touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
} }
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
</script> </script>
Regular → Executable
+15 -4
View File
@@ -39,7 +39,7 @@ cat > /opt/portal/index.html <<'HTML'
} }
.status.hidden{display:none} .status.hidden{display:none}
.nav-panel{ .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); 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 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="screen"></div>
<div id="status" class="status">Подключение к слоту...</div> <div id="status" class="status">Подключение к слоту...</div>
<div class="nav-panel"> <div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button> <button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button">Главная</button> <button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
</div> </div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; 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 enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0';
const sid = new URLSearchParams(location.search).get('sid'); const sid = new URLSearchParams(location.search).get('sid');
const SESSION_CLOSED_URL = '/?session_closed=idle'; const SESSION_CLOSED_URL = '/?session_closed=idle';
const CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : '';
function goSessionClosed() { function goSessionClosed() {
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
@@ -120,9 +121,19 @@ cat > /opt/portal/index.html <<'HTML'
} }
} catch (e) {} } 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) { if (enableHeartbeat) {
setInterval(touch, 60000); setInterval(touch, 15000);
touch(); touch();
window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow);
} }
function keyTap(keysym, code) { function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true); rfb.sendKey(keysym, code, true);