Tune idle timeout, heartbeat redirect, and update project context

This commit is contained in:
2026-04-21 13:31:47 +00:00
parent 9c9b97374c
commit c97cf5308d
11 changed files with 400 additions and 56 deletions
+156 -37
View File
@@ -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"""
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Session Starting</title>
<title>{service_title}</title>
<style>
body {{ font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }}
.card {{ background: #fff; padding: 1rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.08); min-width: 340px; }}
@@ -1687,20 +1769,35 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
service = db.get(Service, sess.service_id)
if not service:
raise HTTPException(status_code=404, detail="Service not found")
iframe_src = None
if sess.container_id and sess.container_id.startswith("POOL:"):
iframe_src = f"/svc/{service.slug}/?sid={session_id}"
elif sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
try:
slot = int(sess.container_id.split(":", 1)[1])
iframe_src = f"/w/{slot}/?sid={session_id}"
except Exception:
iframe_src = None
elif sess.container_id and sess.container_id.startswith("POOLIDX:"):
try:
slot = int(sess.container_id.split(":", 1)[1])
iframe_src = f"/u/{slot}/?sid={session_id}"
except Exception:
iframe_src = None
if iframe_src:
return HTMLResponse(
content=f"""
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Session {session_id}</title>
<title>{service.name}</title>
<style>
html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }}
</style>
</head>
<body>
<iframe src="/svc/{service.slug}/?sid={session_id}" allow="clipboard-read; clipboard-write"></iframe>
<iframe src="{iframe_src}" allow="clipboard-read; clipboard-write"></iframe>
</body>
</html>
""".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)}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<defs>
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>
<stop offset='0%' stop-color='#1e6aa8'/>
<stop offset='100%' stop-color='#2f8ec8'/>
</linearGradient>
</defs>
<rect width='64' height='64' rx='14' fill='#eaf3fb'/>
<rect x='14' y='14' width='36' height='36' transform='rotate(45 32 32)' fill='url(#g)'/>
<rect x='34' y='9' width='14' height='14' transform='rotate(45 41 16)' fill='#b7c0c9'/>
</svg>

After

Width:  |  Height:  |  Size: 498 B

+128 -1
View File
@@ -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);
}
+2 -1
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Администрирование</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
</head>
<body>
<header class="header">
+5 -1
View File
@@ -5,7 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
</head>
<body class="dashboard-page">
<header class="header">
@@ -25,6 +26,9 @@
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
{% if session_notice %}
<div class="session-notice">{{ session_notice }}</div>
{% endif %}
{% if categories %}
<div class="category-strip">
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
+17 -11
View File
@@ -5,21 +5,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
</head>
<body>
<div class="login-corner-brand">МОНТ - инфрастуктурный полигон</div>
<main class="center-box login-page">
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
<h1 class="login-title">МОНТ - инфрастуктурный полигон</h1>
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login" class="panel">
<section class="login-shell">
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
<h1 class="login-title">Добро пожаловать</h1>
{% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login" class="panel login-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>Login</label>
<input type="text" name="username" required />
<label>Password</label>
<input type="password" name="password" required />
<button type="submit">Sign in</button>
</form>
<label>Логин</label>
<input type="text" name="username" placeholder="Введите логин" required />
<label>Пароль</label>
<input type="password" name="password" placeholder="Введите пароль" required />
<button type="submit">Войти</button>
</form>
</section>
</main>
<footer class="login-made-by-wrap"><a class="made-by login-made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
</body>
</html>
+2 -2
View File
@@ -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
+37
View File
@@ -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 к БД.
+14 -1
View File
@@ -59,9 +59,22 @@ cat > /opt/portal/index.html <<HTML
rfb.scaleViewport = true;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
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() {
try {
await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
if (!res.ok) {
goSessionClosed();
}
} catch(e) {}
}
if (enableHeartbeat) {
+14 -1
View File
@@ -37,9 +37,22 @@ cat > /opt/portal/index.html <<HTML
rfb.scaleViewport = true;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
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() {
try {
await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
if (!res.ok) {
goSessionClosed();
}
} catch(e) {}
}
if (enableHeartbeat) {
+14 -1
View File
@@ -101,10 +101,23 @@ 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';
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) {