diff --git a/app/main.py b/app/main.py index b7753fc..53db54f 100644 --- a/app/main.py +++ b/app/main.py @@ -39,12 +39,12 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db COOKIE_NAME = "portal_auth" CSRF_COOKIE = "csrf_token" COOKIE_MAX_AGE = 8 * 60 * 60 -SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "1800")) +SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300")) PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik") PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0")) -UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "5")) +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")) ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 @@ -407,6 +407,21 @@ def session_router_name(session_id: str) -> str: return f"sess-{session_id.replace('-', '')[:16]}" +def _is_pool_name_conflict(exc: Exception) -> bool: + msg = str(exc).lower() + return ("already in use" in msg) or ("marked for removal" in msg) + + +def _remove_container_by_name(d, name: str) -> None: + try: + old = d.containers.get(name) + old.remove(force=True) + except docker.errors.NotFound: + return + except Exception: + logger.exception("pool_container_remove_failed name=%s", name) + + def ensure_universal_pool() -> None: if UNIVERSAL_POOL_SIZE <= 0: return @@ -506,27 +521,48 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None: "ENABLE_HEARTBEAT": "0", "SESSION_ID": f"webpool-{i}", } + should_create = False try: c = d.containers.get(name) if c.status != "running": - c.start() - continue + try: + c.start() + except docker.errors.APIError as exc: + if _is_pool_name_conflict(exc): + logger.warning("web_pool_recreate_needed slot=%s reason=name-conflict", i) + _remove_container_by_name(d, name) + should_create = True + else: + raise + if not should_create: + continue except docker.errors.NotFound: - pass + should_create = True except Exception: logger.exception("web_pool_check_failed slot=%s", i) continue - d.containers.run( - image=image, - name=name, - detach=True, - auto_remove=True, - network="portal_net", - labels=labels, - environment=env, - ) - logger.info("web_pool_container_started slot=%s", i) + for attempt in range(3): + try: + d.containers.run( + image=image, + name=name, + detach=True, + auto_remove=True, + network="portal_net", + labels=labels, + environment=env, + ) + logger.info("web_pool_container_started slot=%s", i) + break + except docker.errors.APIError as exc: + if _is_pool_name_conflict(exc) and attempt < 2: + logger.warning("web_pool_run_conflict_retry slot=%s attempt=%s", i, attempt + 1) + _remove_container_by_name(d, name) + time.sleep(0.25) + continue + logger.exception("web_pool_run_failed slot=%s", i) + break def get_universal_pool_status() -> dict: @@ -983,6 +1019,9 @@ def ensure_schema_compatibility() -> None: def desired_pool_size(service: Service) -> int: if not service.active: return 0 + if service.type == ServiceType.RDP and not service_uses_universal_pool(service): + # RDP runs on-demand per user session; no prewarmed pool. + return 0 if service_uses_universal_pool(service): return UNIVERSAL_POOL_SIZE return service.warm_pool_size if service.warm_pool_size and service.warm_pool_size > 0 else PREWARM_POOL_SIZE @@ -1005,6 +1044,8 @@ def get_warm_containers_for_service(service: Service) -> list: def get_pool_status_for_service(service: Service) -> dict: if service.type == ServiceType.WEB: return get_web_pool_status() + if service.type == ServiceType.RDP and not service_uses_universal_pool(service): + return {"desired": 0, "running": 0, "total": 0, "names": [], "health": "n/a"} if service_uses_universal_pool(service): return get_universal_pool_status() desired = desired_pool_size(service) @@ -1141,6 +1182,27 @@ def get_active_sessions_count(db: Session, service_id: int) -> int: return len(db.scalars(q).all()) +def find_active_session_for_service(db: Session, service_id: int) -> Optional[SessionModel]: + cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) + q = ( + select(SessionModel) + .where( + 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 session_redirect_url(sess: SessionModel) -> str: + cid = sess.container_id or "" + if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"): + return f"/s/{sess.id}/view" + return f"/s/{sess.id}/" + + def open_warm_web_url(service: Service, target_url: str) -> None: if service_uses_universal_pool(service): return @@ -1186,9 +1248,7 @@ def cleanup_loop(): Service.type.in_([ServiceType.WEB, ServiceType.RDP]), ) ).all(): - if svc.type == ServiceType.WEB: - continue - if not service_uses_universal_pool(svc): + if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: ensure_warm_pool(svc) cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) q = select(SessionModel).where( @@ -1257,9 +1317,7 @@ def startup_event(): Service.type.in_([ServiceType.WEB, ServiceType.RDP]), ) ).all(): - if svc.type == ServiceType.WEB: - continue - if not service_uses_universal_pool(svc): + if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: ensure_warm_pool(svc) finally: db.close() @@ -1269,9 +1327,21 @@ 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() + session_notice = "" + if session_closed == "idle": + session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново." if not user: csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) - response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf, "login_error": ""}) + response = templates.TemplateResponse( + "login.html", + { + "request": request, + "csrf_token": csrf, + "login_error": "", + "session_notice": session_notice, + }, + ) response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") return response @@ -1326,6 +1396,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db "selected_category_slug": selected_category_slug, "service_categories": service_categories, "csrf_token": request.cookies.get(CSRF_COOKIE, ""), + "session_notice": session_notice, }, ) @@ -1473,6 +1544,17 @@ 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") + if service.type == ServiceType.RDP: + active_owner = find_active_session_for_service(db, service.id) + if active_owner: + if active_owner.user_id != user.id: + owner = db.get(User, active_owner.user_id) + owner_name = owner.username if owner else f"id={active_owner.user_id}" + raise HTTPException( + status_code=409, + detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.", + ) + return RedirectResponse(url=session_redirect_url(active_owner), status_code=303) session_id = str(uuid.uuid4()) if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0: @@ -1496,7 +1578,7 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe 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) - return RedirectResponse(url=f"/w/{slot}/?sid={session_id}", status_code=303) + return RedirectResponse(url=f"/s/{session_id}/", status_code=303) if service_uses_universal_pool(service): try: @@ -1519,9 +1601,9 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe db.add(session_obj) db.commit() audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) - return RedirectResponse(url=f"/u/{slot}/?sid={session_id}", status_code=303) + return RedirectResponse(url=f"/s/{session_id}/", status_code=303) - if desired_pool_size(service) > 0: + if service.type == ServiceType.WEB and desired_pool_size(service) > 0: ensure_warm_pool(service) open_warm_web_url(service, service.target) session_obj = SessionModel( @@ -1625,16 +1707,16 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re raise HTTPException(status_code=404, detail="Session not found") if sess.status != SessionStatus.ACTIVE: raise HTTPException(status_code=410, detail="Session is not active") - redirect_target = f"/s/{session_id}/" - if sess.container_id and sess.container_id.startswith("POOL:"): - redirect_target = f"/s/{session_id}/view" + service = db.get(Service, sess.service_id) + service_title = service.name if service else "Сервис" + redirect_target = session_redirect_url(sess) return HTMLResponse( content=f""" - Session Starting + {service_title} - + """.strip() @@ -1711,8 +1808,10 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re @app.post("/api/sessions/{session_id}/touch") def touch_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 or sess.status != SessionStatus.ACTIVE: + if not sess or sess.user_id != user.id: raise HTTPException(status_code=404, detail="Session not found") + if sess.status != SessionStatus.ACTIVE: + raise HTTPException(status_code=410, detail="Session expired") sess.last_access_at = now_utc() db.commit() return {"ok": True} @@ -1752,14 +1851,22 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess service = db.get(Service, sess.service_id) pooled_web = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.WEB) web_pool_idx = None + universal_pool_idx = None if sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"): try: web_pool_idx = int(sess.container_id.split(":", 1)[1]) except Exception: web_pool_idx = None + if sess.container_id and sess.container_id.startswith("POOLIDX:"): + try: + universal_pool_idx = int(sess.container_id.split(":", 1)[1]) + except Exception: + universal_pool_idx = None route_path = f"/svc/{service.slug}/" if pooled_web and service else f"/s/{session_id}/" if web_pool_idx is not None: route_path = f"/w/{web_pool_idx}/" + if universal_pool_idx is not None: + route_path = f"/u/{universal_pool_idx}/" route_ok = route_ready(route_path) running = container_running(sess.container_id) ready = running and route_ok @@ -1775,7 +1882,9 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess if pooled_web: payload["redirect_url"] = f"/s/{session_id}/view" if web_pool_idx is not None: - payload["redirect_url"] = f"/w/{web_pool_idx}/?sid={session_id}" + payload["redirect_url"] = f"/s/{session_id}/view" + if universal_pool_idx is not None: + payload["redirect_url"] = f"/s/{session_id}/view" return payload @@ -1803,7 +1912,10 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad db.flush() set_service_categories(db, service.id, payload.get("category_ids", [])) db.commit() - ensure_warm_pool(service) + if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: + ensure_warm_pool(service) + elif service_uses_universal_pool(service): + ensure_universal_pool() return {"id": service.id} @@ -1873,8 +1985,12 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep if "category_ids" in payload: set_service_categories(db, service.id, payload.get("category_ids", [])) db.commit() - ensure_warm_pool(service) - open_warm_web_url(service, service.target) + if service.type == ServiceType.WEB: + if WEB_POOL_SIZE <= 0: + ensure_warm_pool(service) + open_warm_web_url(service, service.target) + elif service_uses_universal_pool(service): + ensure_universal_pool() return {"ok": True} @@ -1884,7 +2000,8 @@ def delete_service(service_id: int, request: Request, _: User = Depends(require_ service = db.get(Service, service_id) if not service: raise HTTPException(status_code=404, detail="Service not found") - ensure_warm_pool(service, 0) + if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: + ensure_warm_pool(service, 0) remove_icon_file(service.icon_path) db.delete(service) db.commit() @@ -1903,6 +2020,8 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm if service_uses_universal_pool(service): ensure_universal_pool() return {"ok": True, "pool": get_universal_pool_status()} + if service.type == ServiceType.RDP: + return {"ok": True, "pool": get_pool_status_for_service(service), "message": "RDP запускается on-demand"} ensure_warm_pool(service) return {"ok": True, "pool": get_pool_status_for_service(service)} diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..fc35ef7 --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/static/style.css b/app/static/style.css index 3d1fcac..87db4ce 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -14,7 +14,7 @@ body { .center-box { min-height: 100vh; display: grid; - place-content: center; + place-items: center; gap: 1rem; padding: 1.2rem; } @@ -404,6 +404,17 @@ button { font-weight: 600; } +.session-notice { + background: #e8f4ff; + border: 1px solid #b8d8f2; + color: #1f4868; + border-radius: 10px; + padding: 0.7rem 0.8rem; + max-width: min(520px, 92vw); + margin: 0 auto 0.6rem; + font-weight: 600; +} + .dashboard-page { background: transparent; @@ -564,3 +575,119 @@ button { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important; border-color: transparent !important; } + +/* 4-up desktop grid with adaptive breakpoints */ +.service-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +@media (max-width: 1400px) { + .service-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 1050px) { + .service-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 700px) { + .service-grid { + grid-template-columns: 1fr; + } +} + +/* Stylish login page */ +.login-page { + position: relative; + background: + radial-gradient(circle at 12% 15%, rgba(255, 255, 255, 0.55) 0, rgba(255, 255, 255, 0) 34%), + radial-gradient(circle at 88% 82%, rgba(255, 255, 255, 0.45) 0, rgba(255, 255, 255, 0) 32%), + linear-gradient(145deg, #0f4c7c 0%, #1a77b8 48%, #5db2de 100%); +} +.login-shell { + width: min(560px, 94vw); + margin: 0 auto; + display: grid; + justify-items: center; + border-radius: 18px; + padding: clamp(1.1rem, 2.4vw, 2rem); + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: 0 18px 46px rgba(9, 44, 72, 0.28); + backdrop-filter: blur(6px); +} +.login-title { + color: #0f3553; + font-weight: 700; + letter-spacing: 0.01em; +} +.login-subtitle { + margin: -0.35rem 0 0.85rem; + text-align: center; + color: #355a77; + font-size: 0.96rem; +} +.login-panel { + width: 100% !important; + justify-self: center; + min-width: 0; + background: #ffffff; + border: 1px solid #d3e4f2; + box-shadow: 0 10px 26px rgba(20, 66, 101, 0.12); +} +.login-panel label { + font-size: 0.88rem; + color: #234a68; + font-weight: 600; +} +.login-panel input { + background: #f8fbfe; + border: 1px solid #bfd5e8; +} +.login-panel input:focus { + outline: none; + border-color: #2a82c0; + box-shadow: 0 0 0 3px rgba(42, 130, 192, 0.16); +} +.login-panel button { + margin-top: 0.3rem; + font-weight: 700; + background: linear-gradient(180deg, #1675b4 0%, #0f5b94 100%); +} +.login-page .auth-error { + margin-bottom: 0.8rem; +} +@media (max-width: 700px) { + .login-shell { + border-radius: 14px; + padding: 1rem; + backdrop-filter: none; + } + .login-subtitle { + font-size: 0.9rem; + } +} + +.login-corner-brand { + position: fixed; + top: 14px; + left: 16px; + z-index: 20; + color: #e8f4ff; + font-weight: 700; + letter-spacing: 0.01em; + text-shadow: 0 2px 8px rgba(9, 44, 72, 0.35); +} +.login-made-by-wrap { + position: fixed; + left: 0; + right: 0; + bottom: 10px; + z-index: 20; + display: flex; + justify-content: center; +} +.login-made-by { + color: rgba(240, 248, 255, 0.95); + text-shadow: 0 2px 10px rgba(9, 44, 72, 0.45); +} diff --git a/app/templates/admin.html b/app/templates/admin.html index b01ad4b..efc7124 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -5,7 +5,8 @@ Администрирование - + +
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index f67c376..6df3d5e 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -5,7 +5,8 @@ МОНТ - инфрастуктурный полигон - + +
@@ -25,6 +26,9 @@
Добро пожаловать в инфрастуктурный полигон
+ {% if session_notice %} +
{{ session_notice }}
+ {% endif %} {% if categories %}
Все сервисы diff --git a/app/templates/login.html b/app/templates/login.html index c511999..3d0d7f8 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -5,21 +5,27 @@ МОНТ - инфрастуктурный полигон - + + +
- -

МОНТ - инфрастуктурный полигон

- {% if login_error %}
{{ login_error }}
{% endif %} -
+
+ diff --git a/docker-compose.yml b/docker-compose.yml index 8e0e1e9..0b3f0f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,9 +37,9 @@ services: PUBLIC_HOST: ${PUBLIC_HOST} ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_PASSWORD: ${ADMIN_PASSWORD} - SESSION_IDLE_SECONDS: 1800 + SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} - UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-5} + UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} LOG_LEVEL: ${LOG_LEVEL:-INFO} depends_on: - db diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 0d75fe2..c9485f9 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -234,3 +234,40 @@ git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mo - Стандартно: git add, git commit, git push origin main - При необходимости HTTPS с явными credential: git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mont main + +## 15) Обновления (2026-04-21, таймаут и пулы) + +1. Таймаут простаивания сессии уменьшен: +- Было: `SESSION_IDLE_SECONDS=1800` (~30 минут). +- Стало: `SESSION_IDLE_SECONDS=300` (~5 минут). +- Источник значения: + - `.env`: `SESSION_IDLE_SECONDS=300` + - `docker-compose.yml`: `SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}` + - fallback в `app/main.py`: `300`. + +2. Поведение при простое (heartbeat): +- В runtime-страницах (`kiosk`, `universal-runtime`, `rdp-proxy`) heartbeat теперь проверяет HTTP-статус `touch`. +- Если `touch` возвращает не `2xx` (например, `410 Session expired`), клиент делает редирект на: + `/?session_closed=idle` +- На `/` добавлено уведомление: + `Сессия была закрыта из-за простоя. Откройте сервис заново.` +- Уведомление показывается и на login-page, и на dashboard. + +3. Изменение API для touch: +- `POST /api/sessions/{id}/touch`: + - `404` если сессия не найдена/не принадлежит пользователю; + - `410` если сессия найдена, но уже не `ACTIVE`. + +4. WEB pool (устойчивость при пике): +- Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`). +- Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором. +- Это закрывает сценарий, когда буфер (`WEB_POOL_BUFFER`) должен расширять пул, но упирается в конфликт имени контейнера. + +5. RDP режим приведен к on-demand модели: +- `UNIVERSAL_POOL_SIZE=0` в `.env`. +- default в `docker-compose.yml`: `${UNIVERSAL_POOL_SIZE:-0}`. +- Для RDP отключен prewarm-подход: сессия поднимается в момент запуска сервиса (per-user session runtime), а не через общий universal-pool. +- В админ prewarm для RDP возвращает информационное сообщение, что RDP работает on-demand. + +6. Важный операционный урок: +- При работе с `docker compose` обязательно сохранять `.env` заполненным; пустой `.env` приводит к запуску со значениями по умолчанию (пустые креды/хост), что ломает подключение API к БД. diff --git a/kiosk/entrypoint.sh b/kiosk/entrypoint.sh index 2350f4a..37e7131 100755 --- a/kiosk/entrypoint.sh +++ b/kiosk/entrypoint.sh @@ -59,9 +59,22 @@ cat > /opt/portal/index.html < /opt/portal/index.html < /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'; + function goSessionClosed() { + try { + if (window.top && window.top !== window) { + window.top.location.href = SESSION_CLOSED_URL; + return; + } + } catch (e) {} + window.location.href = SESSION_CLOSED_URL; + } async function touch() { if (!sid) return; try { - await fetch(`/api/sessions/${sid}/touch`, {method:'POST', credentials:'include'}); + const res = await fetch(`/api/sessions/${sid}/touch`, {method:'POST', credentials:'include'}); + if (!res.ok) { + goSessionClosed(); + } } catch (e) {} } if (enableHeartbeat) {