Tune idle timeout, heartbeat redirect, and update project context
This commit is contained in:
+140
-21
@@ -39,12 +39,12 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db
|
|||||||
COOKIE_NAME = "portal_auth"
|
COOKIE_NAME = "portal_auth"
|
||||||
CSRF_COOKIE = "csrf_token"
|
CSRF_COOKIE = "csrf_token"
|
||||||
COOKIE_MAX_AGE = 8 * 60 * 60
|
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")
|
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
||||||
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0"))
|
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_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"))
|
||||||
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
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]}"
|
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:
|
def ensure_universal_pool() -> None:
|
||||||
if UNIVERSAL_POOL_SIZE <= 0:
|
if UNIVERSAL_POOL_SIZE <= 0:
|
||||||
return
|
return
|
||||||
@@ -506,17 +521,29 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
|
|||||||
"ENABLE_HEARTBEAT": "0",
|
"ENABLE_HEARTBEAT": "0",
|
||||||
"SESSION_ID": f"webpool-{i}",
|
"SESSION_ID": f"webpool-{i}",
|
||||||
}
|
}
|
||||||
|
should_create = False
|
||||||
try:
|
try:
|
||||||
c = d.containers.get(name)
|
c = d.containers.get(name)
|
||||||
if c.status != "running":
|
if c.status != "running":
|
||||||
|
try:
|
||||||
c.start()
|
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
|
continue
|
||||||
except docker.errors.NotFound:
|
except docker.errors.NotFound:
|
||||||
pass
|
should_create = True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("web_pool_check_failed slot=%s", i)
|
logger.exception("web_pool_check_failed slot=%s", i)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
d.containers.run(
|
d.containers.run(
|
||||||
image=image,
|
image=image,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -527,6 +554,15 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
|
|||||||
environment=env,
|
environment=env,
|
||||||
)
|
)
|
||||||
logger.info("web_pool_container_started slot=%s", i)
|
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:
|
def get_universal_pool_status() -> dict:
|
||||||
@@ -983,6 +1019,9 @@ def ensure_schema_compatibility() -> None:
|
|||||||
def desired_pool_size(service: Service) -> int:
|
def desired_pool_size(service: Service) -> int:
|
||||||
if not service.active:
|
if not service.active:
|
||||||
return 0
|
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):
|
if service_uses_universal_pool(service):
|
||||||
return UNIVERSAL_POOL_SIZE
|
return UNIVERSAL_POOL_SIZE
|
||||||
return service.warm_pool_size if service.warm_pool_size and service.warm_pool_size > 0 else PREWARM_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:
|
def get_pool_status_for_service(service: Service) -> dict:
|
||||||
if service.type == ServiceType.WEB:
|
if service.type == ServiceType.WEB:
|
||||||
return get_web_pool_status()
|
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):
|
if service_uses_universal_pool(service):
|
||||||
return get_universal_pool_status()
|
return get_universal_pool_status()
|
||||||
desired = desired_pool_size(service)
|
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())
|
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:
|
def open_warm_web_url(service: Service, target_url: str) -> None:
|
||||||
if service_uses_universal_pool(service):
|
if service_uses_universal_pool(service):
|
||||||
return
|
return
|
||||||
@@ -1186,9 +1248,7 @@ def cleanup_loop():
|
|||||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||||
)
|
)
|
||||||
).all():
|
).all():
|
||||||
if svc.type == ServiceType.WEB:
|
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||||
continue
|
|
||||||
if not service_uses_universal_pool(svc):
|
|
||||||
ensure_warm_pool(svc)
|
ensure_warm_pool(svc)
|
||||||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||||
q = select(SessionModel).where(
|
q = select(SessionModel).where(
|
||||||
@@ -1257,9 +1317,7 @@ def startup_event():
|
|||||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||||
)
|
)
|
||||||
).all():
|
).all():
|
||||||
if svc.type == ServiceType.WEB:
|
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||||
continue
|
|
||||||
if not service_uses_universal_pool(svc):
|
|
||||||
ensure_warm_pool(svc)
|
ensure_warm_pool(svc)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -1269,9 +1327,21 @@ 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_notice = ""
|
||||||
|
if session_closed == "idle":
|
||||||
|
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
|
||||||
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("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="/")
|
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -1326,6 +1396,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
|
|||||||
"selected_category_slug": selected_category_slug,
|
"selected_category_slug": selected_category_slug,
|
||||||
"service_categories": service_categories,
|
"service_categories": service_categories,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
"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")
|
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")
|
||||||
|
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())
|
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:
|
||||||
@@ -1496,7 +1578,7 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
|
|||||||
db.add(session_obj)
|
db.add(session_obj)
|
||||||
db.commit()
|
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"/w/{slot}/?sid={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:
|
||||||
@@ -1519,9 +1601,9 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
|
|||||||
db.add(session_obj)
|
db.add(session_obj)
|
||||||
db.commit()
|
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"/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)
|
ensure_warm_pool(service)
|
||||||
open_warm_web_url(service, service.target)
|
open_warm_web_url(service, service.target)
|
||||||
session_obj = SessionModel(
|
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")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if sess.status != SessionStatus.ACTIVE:
|
if sess.status != SessionStatus.ACTIVE:
|
||||||
raise HTTPException(status_code=410, detail="Session is not active")
|
raise HTTPException(status_code=410, detail="Session is not active")
|
||||||
redirect_target = f"/s/{session_id}/"
|
service = db.get(Service, sess.service_id)
|
||||||
if sess.container_id and sess.container_id.startswith("POOL:"):
|
service_title = service.name if service else "Сервис"
|
||||||
redirect_target = f"/s/{session_id}/view"
|
redirect_target = session_redirect_url(sess)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=f"""
|
content=f"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset='utf-8'>
|
<meta charset='utf-8'>
|
||||||
<title>Session Starting</title>
|
<title>{service_title}</title>
|
||||||
<style>
|
<style>
|
||||||
body {{ font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }}
|
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; }}
|
.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)
|
service = db.get(Service, sess.service_id)
|
||||||
if not service:
|
if not service:
|
||||||
raise HTTPException(status_code=404, detail="Service not found")
|
raise HTTPException(status_code=404, detail="Service not found")
|
||||||
|
iframe_src = None
|
||||||
if sess.container_id and sess.container_id.startswith("POOL:"):
|
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(
|
return HTMLResponse(
|
||||||
content=f"""
|
content=f"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset='utf-8'>
|
<meta charset='utf-8'>
|
||||||
<title>Session {session_id}</title>
|
<title>{service.name}</title>
|
||||||
<style>
|
<style>
|
||||||
html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }}
|
html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""".strip()
|
""".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")
|
@app.post("/api/sessions/{session_id}/touch")
|
||||||
def touch_session(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
def touch_session(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||||||
sess = db.get(SessionModel, session_id)
|
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")
|
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()
|
sess.last_access_at = now_utc()
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
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)
|
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)
|
pooled_web = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.WEB)
|
||||||
web_pool_idx = None
|
web_pool_idx = None
|
||||||
|
universal_pool_idx = None
|
||||||
if sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
|
if sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
|
||||||
try:
|
try:
|
||||||
web_pool_idx = int(sess.container_id.split(":", 1)[1])
|
web_pool_idx = int(sess.container_id.split(":", 1)[1])
|
||||||
except Exception:
|
except Exception:
|
||||||
web_pool_idx = None
|
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}/"
|
route_path = f"/svc/{service.slug}/" if pooled_web and service else f"/s/{session_id}/"
|
||||||
if web_pool_idx is not None:
|
if web_pool_idx is not None:
|
||||||
route_path = f"/w/{web_pool_idx}/"
|
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)
|
route_ok = route_ready(route_path)
|
||||||
running = container_running(sess.container_id)
|
running = container_running(sess.container_id)
|
||||||
ready = running and route_ok
|
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:
|
if pooled_web:
|
||||||
payload["redirect_url"] = f"/s/{session_id}/view"
|
payload["redirect_url"] = f"/s/{session_id}/view"
|
||||||
if web_pool_idx is not None:
|
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
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -1803,7 +1912,10 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
|
|||||||
db.flush()
|
db.flush()
|
||||||
set_service_categories(db, service.id, payload.get("category_ids", []))
|
set_service_categories(db, service.id, payload.get("category_ids", []))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||||
ensure_warm_pool(service)
|
ensure_warm_pool(service)
|
||||||
|
elif service_uses_universal_pool(service):
|
||||||
|
ensure_universal_pool()
|
||||||
return {"id": service.id}
|
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:
|
if "category_ids" in payload:
|
||||||
set_service_categories(db, service.id, payload.get("category_ids", []))
|
set_service_categories(db, service.id, payload.get("category_ids", []))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if service.type == ServiceType.WEB:
|
||||||
|
if WEB_POOL_SIZE <= 0:
|
||||||
ensure_warm_pool(service)
|
ensure_warm_pool(service)
|
||||||
open_warm_web_url(service, service.target)
|
open_warm_web_url(service, service.target)
|
||||||
|
elif service_uses_universal_pool(service):
|
||||||
|
ensure_universal_pool()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -1884,6 +2000,7 @@ def delete_service(service_id: int, request: Request, _: User = Depends(require_
|
|||||||
service = db.get(Service, service_id)
|
service = db.get(Service, service_id)
|
||||||
if not service:
|
if not service:
|
||||||
raise HTTPException(status_code=404, detail="Service not found")
|
raise HTTPException(status_code=404, detail="Service not found")
|
||||||
|
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||||
ensure_warm_pool(service, 0)
|
ensure_warm_pool(service, 0)
|
||||||
remove_icon_file(service.icon_path)
|
remove_icon_file(service.icon_path)
|
||||||
db.delete(service)
|
db.delete(service)
|
||||||
@@ -1903,6 +2020,8 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm
|
|||||||
if service_uses_universal_pool(service):
|
if service_uses_universal_pool(service):
|
||||||
ensure_universal_pool()
|
ensure_universal_pool()
|
||||||
return {"ok": True, "pool": get_universal_pool_status()}
|
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)
|
ensure_warm_pool(service)
|
||||||
return {"ok": True, "pool": get_pool_status_for_service(service)}
|
return {"ok": True, "pool": get_pool_status_for_service(service)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -14,7 +14,7 @@ body {
|
|||||||
.center-box {
|
.center-box {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -404,6 +404,17 @@ button {
|
|||||||
font-weight: 600;
|
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 {
|
.dashboard-page {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -564,3 +575,119 @@ button {
|
|||||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important;
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important;
|
||||||
border-color: transparent !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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Администрирование</title>
|
<title>Администрирование</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>МОНТ - инфрастуктурный полигон</title>
|
<title>МОНТ - инфрастуктурный полигон</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<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>
|
</head>
|
||||||
<body class="dashboard-page">
|
<body class="dashboard-page">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
<main class="admin-layout">
|
<main class="admin-layout">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
|
<div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
|
||||||
|
{% if session_notice %}
|
||||||
|
<div class="session-notice">{{ session_notice }}</div>
|
||||||
|
{% endif %}
|
||||||
{% if categories %}
|
{% if categories %}
|
||||||
<div class="category-strip">
|
<div class="category-strip">
|
||||||
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
|
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
|
||||||
|
|||||||
@@ -5,21 +5,27 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>МОНТ - инфрастуктурный полигон</title>
|
<title>МОНТ - инфрастуктурный полигон</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="login-corner-brand">МОНТ - инфрастуктурный полигон</div>
|
||||||
<main class="center-box login-page">
|
<main class="center-box login-page">
|
||||||
|
<section class="login-shell">
|
||||||
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
|
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
|
||||||
<h1 class="login-title">МОНТ - инфрастуктурный полигон</h1>
|
<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 %}
|
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %}
|
||||||
<form method="post" action="/login" class="panel">
|
<form method="post" action="/login" class="panel login-panel">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
<label>Login</label>
|
<label>Логин</label>
|
||||||
<input type="text" name="username" required />
|
<input type="text" name="username" placeholder="Введите логин" required />
|
||||||
<label>Password</label>
|
<label>Пароль</label>
|
||||||
<input type="password" name="password" required />
|
<input type="password" name="password" placeholder="Введите пароль" required />
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-2
@@ -37,9 +37,9 @@ services:
|
|||||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
SESSION_IDLE_SECONDS: 1800
|
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:-5}
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -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
|
- Стандартно: git add, git commit, git push origin main
|
||||||
- При необходимости HTTPS с явными credential:
|
- При необходимости HTTPS с явными credential:
|
||||||
git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mont main
|
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
@@ -59,9 +59,22 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
rfb.resizeSession = true;
|
rfb.resizeSession = true;
|
||||||
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
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() {
|
async function touch() {
|
||||||
try {
|
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) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
|
|||||||
+14
-1
@@ -37,9 +37,22 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
rfb.resizeSession = true;
|
rfb.resizeSession = true;
|
||||||
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
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() {
|
async function touch() {
|
||||||
try {
|
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) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
|
|||||||
@@ -101,10 +101,23 @@ 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';
|
||||||
|
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() {
|
async function touch() {
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
try {
|
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) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
|
|||||||
Reference in New Issue
Block a user