Tune idle timeout, heartbeat redirect, and update project context
This commit is contained in:
+156
-37
@@ -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)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user