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"
|
||||
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,17 +521,29 @@ 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":
|
||||
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
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
d.containers.run(
|
||||
image=image,
|
||||
name=name,
|
||||
@@ -527,6 +554,15 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
|
||||
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()
|
||||
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()
|
||||
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,6 +2000,7 @@ 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")
|
||||
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||
ensure_warm_pool(service, 0)
|
||||
remove_icon_file(service.icon_path)
|
||||
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):
|
||||
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)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">
|
||||
<section class="login-shell">
|
||||
<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 %}
|
||||
<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 }}" />
|
||||
<label>Login</label>
|
||||
<input type="text" name="username" required />
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required />
|
||||
<button type="submit">Sign in</button>
|
||||
<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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user