chore: commit all pending changes and ignore project context
This commit is contained in:
+306
-138
@@ -47,12 +47,18 @@ 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()
|
||||
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
|
||||
GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0"))
|
||||
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0"))
|
||||
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4"))
|
||||
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
|
||||
POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
|
||||
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", "0"))
|
||||
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
|
||||
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
||||
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
|
||||
ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1"
|
||||
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||
ICON_UPLOAD_TYPES = {
|
||||
"image/png": "png",
|
||||
@@ -67,6 +73,7 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("portal")
|
||||
request_id_ctx = contextvars.ContextVar("request_id", default="-")
|
||||
maintenance_lock_file = None
|
||||
|
||||
|
||||
def _normalize_log_value(value):
|
||||
@@ -791,14 +798,14 @@ def dispatch_universal_target(slot: int, service: Service) -> None:
|
||||
raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
|
||||
|
||||
last_exc = None
|
||||
for _ in range(8):
|
||||
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=3)
|
||||
resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
||||
resp.raise_for_status()
|
||||
return
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.4)
|
||||
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
|
||||
@@ -808,14 +815,14 @@ def dispatch_web_pool_target(slot: int, service: Service) -> None:
|
||||
target_url = normalize_web_target(service.target)
|
||||
url = f"http://{name}:7000/open"
|
||||
last_exc = None
|
||||
for _ in range(8):
|
||||
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
||||
try:
|
||||
resp = requests.post(url, json={"url": target_url}, timeout=3)
|
||||
resp = requests.post(url, json={"url": target_url}, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
|
||||
resp.raise_for_status()
|
||||
return
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
time.sleep(0.4)
|
||||
time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
|
||||
@@ -1363,14 +1370,31 @@ def find_active_session_for_user_service(db: Session, user_id: int, service_id:
|
||||
return db.scalars(q).first()
|
||||
|
||||
|
||||
def allocator_lock(db: Session, lock_id: int):
|
||||
class LockTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def allocator_lock(db: Session, lock_id: int, timeout_seconds: Optional[float] = None, poll_seconds: float = 0.05):
|
||||
class _LockCtx:
|
||||
def __enter__(self_nonlocal):
|
||||
db.execute(text("SELECT pg_advisory_lock(:lid)"), {"lid": lock_id})
|
||||
self_nonlocal._acquired = False
|
||||
if timeout_seconds is None:
|
||||
db.execute(text("SELECT pg_advisory_xact_lock(:lid)"), {"lid": lock_id})
|
||||
self_nonlocal._acquired = True
|
||||
return self_nonlocal
|
||||
|
||||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||||
while time.monotonic() <= deadline:
|
||||
got = db.execute(text("SELECT pg_try_advisory_xact_lock(:lid)"), {"lid": lock_id}).scalar()
|
||||
if got:
|
||||
self_nonlocal._acquired = True
|
||||
return self_nonlocal
|
||||
time.sleep(max(0.01, poll_seconds))
|
||||
raise LockTimeoutError(f"advisory lock timeout lock_id={lock_id} timeout={timeout_seconds}")
|
||||
|
||||
return self_nonlocal
|
||||
|
||||
def __exit__(self_nonlocal, exc_type, exc, tb):
|
||||
db.execute(text("SELECT pg_advisory_unlock(:lid)"), {"lid": lock_id})
|
||||
return False
|
||||
|
||||
return _LockCtx()
|
||||
@@ -1492,17 +1516,36 @@ def bootstrap_admin():
|
||||
db.close()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
# Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
|
||||
# to avoid DDL races on first run and during schema extension.
|
||||
def try_acquire_maintenance_leader() -> bool:
|
||||
global maintenance_lock_file
|
||||
if maintenance_lock_file is not None:
|
||||
return True
|
||||
lock_file = open("/tmp/portal-maintenance.lock", "w")
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
lock_file.close()
|
||||
return False
|
||||
maintenance_lock_file = lock_file
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def run_maintenance_service() -> None:
|
||||
logger.info("maintenance_service_bootstrap_started")
|
||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_schema_compatibility()
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
ensure_icons_dir()
|
||||
bootstrap_admin()
|
||||
|
||||
maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
|
||||
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
|
||||
logger.info("maintenance_service_leader_acquired")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_universal_pool()
|
||||
@@ -1517,8 +1560,46 @@ def startup_event():
|
||||
ensure_warm_pool(svc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("maintenance_service_loop_started")
|
||||
cleanup_loop()
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
# Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
|
||||
# to avoid DDL races on first run and during schema extension.
|
||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_schema_compatibility()
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
ensure_icons_dir()
|
||||
bootstrap_admin()
|
||||
if not ENABLE_STARTUP_MAINTENANCE:
|
||||
logger.info("startup_maintenance_disabled")
|
||||
return
|
||||
if not try_acquire_maintenance_leader():
|
||||
logger.info("maintenance_leader_skipped")
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_universal_pool()
|
||||
ensure_web_pool()
|
||||
for svc in db.scalars(
|
||||
select(Service).where(
|
||||
Service.active == True,
|
||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||
)
|
||||
).all():
|
||||
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||
ensure_warm_pool(svc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
||||
thread.start()
|
||||
logger.info("maintenance_leader_started")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@@ -1778,6 +1859,23 @@ def logout(request: Request):
|
||||
|
||||
@app.get("/go/{slug}")
|
||||
def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||||
total_started = time.perf_counter()
|
||||
phase_ms = {}
|
||||
|
||||
def _mark(name: str, started: float) -> None:
|
||||
phase_ms[name] = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
def _emit(result: str, **extra) -> None:
|
||||
payload = {
|
||||
"user_id": user.id,
|
||||
"service_slug": slug,
|
||||
"result": result,
|
||||
"total_ms": int((time.perf_counter() - total_started) * 1000),
|
||||
}
|
||||
payload.update(phase_ms)
|
||||
payload.update(extra)
|
||||
log_event("go_service_timing", **payload)
|
||||
|
||||
log_event("session_open_requested", user_id=user.id, service_slug=slug)
|
||||
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
||||
if not service:
|
||||
@@ -1786,148 +1884,218 @@ 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")
|
||||
with allocator_lock(db, 92000 + int(user.id)):
|
||||
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
|
||||
if existing_user_session:
|
||||
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
|
||||
|
||||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||
active_rows = db.scalars(
|
||||
select(SessionModel).where(
|
||||
SessionModel.user_id == user.id,
|
||||
SessionModel.status == SessionStatus.ACTIVE,
|
||||
SessionModel.last_access_at >= cutoff,
|
||||
)
|
||||
).all()
|
||||
active_rows = sorted(active_rows, key=lambda row: row.created_at)
|
||||
active_service_ids = {row.service_id for row in active_rows}
|
||||
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
|
||||
oldest = next((row for row in active_rows if row.service_id != service.id), None)
|
||||
if oldest:
|
||||
terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
|
||||
db.commit()
|
||||
log_event(
|
||||
"session_rotated",
|
||||
user_id=user.id,
|
||||
closed_session_id=oldest.id,
|
||||
closed_service_id=oldest.service_id,
|
||||
new_service_id=service.id,
|
||||
user_lock_started = time.perf_counter()
|
||||
try:
|
||||
with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS):
|
||||
_mark("wait_user_lock_ms", user_lock_started)
|
||||
|
||||
t_existing = time.perf_counter()
|
||||
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
|
||||
_mark("check_existing_ms", t_existing)
|
||||
if existing_user_session:
|
||||
_emit("reuse_session", session_id=existing_user_session.id)
|
||||
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
|
||||
|
||||
t_limit = time.perf_counter()
|
||||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||
active_rows = db.scalars(
|
||||
select(SessionModel).where(
|
||||
SessionModel.user_id == user.id,
|
||||
SessionModel.status == SessionStatus.ACTIVE,
|
||||
SessionModel.last_access_at >= cutoff,
|
||||
)
|
||||
else:
|
||||
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
||||
|
||||
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:
|
||||
try:
|
||||
with allocator_lock(db, 91001):
|
||||
ensure_web_pool()
|
||||
slot = acquire_web_pool_slot(db)
|
||||
slot_cid = f"WEBPOOLIDX:{slot}"
|
||||
terminate_active_slot_sessions(db, slot_cid)
|
||||
dispatch_web_pool_target(slot, service)
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=slot_cid,
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
)
|
||||
db.add(session_obj)
|
||||
).all()
|
||||
active_rows = sorted(active_rows, key=lambda row: row.created_at)
|
||||
active_service_ids = {row.service_id for row in active_rows}
|
||||
_mark("check_limit_ms", t_limit)
|
||||
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
|
||||
oldest = next((row for row in active_rows if row.service_id != service.id), None)
|
||||
if oldest:
|
||||
t_rotate = time.perf_counter()
|
||||
terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
|
||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
|
||||
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
|
||||
if service_uses_universal_pool(service):
|
||||
try:
|
||||
with allocator_lock(db, 91002):
|
||||
ensure_universal_pool()
|
||||
slot = acquire_universal_slot(db)
|
||||
slot_cid = f"POOLIDX:{slot}"
|
||||
terminate_active_slot_sessions(db, slot_cid)
|
||||
dispatch_universal_target(slot, service)
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
_mark("rotate_oldest_ms", t_rotate)
|
||||
log_event(
|
||||
"session_rotated",
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=slot_cid,
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
closed_session_id=oldest.id,
|
||||
closed_service_id=oldest.service_id,
|
||||
new_service_id=service.id,
|
||||
)
|
||||
db.add(session_obj)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
|
||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
|
||||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
else:
|
||||
_emit("max_services_redirect")
|
||||
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
||||
|
||||
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
|
||||
ensure_warm_pool(service)
|
||||
open_warm_web_url(service, service.target)
|
||||
if service.type == ServiceType.RDP:
|
||||
t_rdp_owner = time.perf_counter()
|
||||
active_owner = find_active_session_for_service(db, service.id)
|
||||
_mark("check_rdp_owner_ms", t_rdp_owner)
|
||||
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}"
|
||||
_emit("rdp_busy", owner=owner_name)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
|
||||
)
|
||||
_emit("reuse_rdp_session", session_id=active_owner.id)
|
||||
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:
|
||||
try:
|
||||
t_pool_lock = time.perf_counter()
|
||||
with allocator_lock(db, 91001, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||||
_mark("wait_web_pool_lock_ms", t_pool_lock)
|
||||
t_ensure = time.perf_counter()
|
||||
ensure_web_pool()
|
||||
_mark("ensure_web_pool_ms", t_ensure)
|
||||
|
||||
t_acquire = time.perf_counter()
|
||||
slot = acquire_web_pool_slot(db)
|
||||
_mark("acquire_web_slot_ms", t_acquire)
|
||||
slot_cid = f"WEBPOOLIDX:{slot}"
|
||||
|
||||
t_dispatch = time.perf_counter()
|
||||
terminate_active_slot_sessions(db, slot_cid)
|
||||
dispatch_web_pool_target(slot, service)
|
||||
_mark("dispatch_web_target_ms", t_dispatch)
|
||||
|
||||
t_commit = time.perf_counter()
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=slot_cid,
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
)
|
||||
db.add(session_obj)
|
||||
db.commit()
|
||||
_mark("db_commit_ms", t_commit)
|
||||
except LockTimeoutError:
|
||||
_emit("web_pool_lock_timeout")
|
||||
raise HTTPException(status_code=503, detail="Пул WEB занят. Повторите через несколько секунд.")
|
||||
except Exception as exc:
|
||||
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
|
||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||
_emit("web_pool_create_failed", error=str(exc))
|
||||
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
|
||||
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||||
_emit("session_created_web_pool", session_id=session_id, slot=slot)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
|
||||
if service_uses_universal_pool(service):
|
||||
try:
|
||||
t_pool_lock = time.perf_counter()
|
||||
with allocator_lock(db, 91002, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||||
_mark("wait_universal_pool_lock_ms", t_pool_lock)
|
||||
t_ensure = time.perf_counter()
|
||||
ensure_universal_pool()
|
||||
_mark("ensure_universal_pool_ms", t_ensure)
|
||||
|
||||
t_acquire = time.perf_counter()
|
||||
slot = acquire_universal_slot(db)
|
||||
_mark("acquire_universal_slot_ms", t_acquire)
|
||||
slot_cid = f"POOLIDX:{slot}"
|
||||
|
||||
t_dispatch = time.perf_counter()
|
||||
terminate_active_slot_sessions(db, slot_cid)
|
||||
dispatch_universal_target(slot, service)
|
||||
_mark("dispatch_universal_target_ms", t_dispatch)
|
||||
|
||||
t_commit = time.perf_counter()
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=slot_cid,
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
)
|
||||
db.add(session_obj)
|
||||
db.commit()
|
||||
_mark("db_commit_ms", t_commit)
|
||||
except LockTimeoutError:
|
||||
_emit("universal_pool_lock_timeout")
|
||||
raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
|
||||
except Exception as exc:
|
||||
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
|
||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||
_emit("universal_pool_create_failed", error=str(exc))
|
||||
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
|
||||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||||
_emit("session_created_universal_pool", session_id=session_id, slot=slot)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
|
||||
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
|
||||
t_warm = time.perf_counter()
|
||||
ensure_warm_pool(service)
|
||||
open_warm_web_url(service, service.target)
|
||||
_mark("warm_pool_prepare_ms", t_warm)
|
||||
|
||||
t_commit = time.perf_counter()
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=f"POOL:{service.slug}",
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
)
|
||||
db.add(session_obj)
|
||||
db.commit()
|
||||
_mark("db_commit_ms", t_commit)
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
|
||||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
|
||||
_emit("session_created_warm_pool", session_id=session_id)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
|
||||
try:
|
||||
t_create = time.perf_counter()
|
||||
container_id = create_runtime_container(service, session_id)
|
||||
_mark("create_runtime_container_ms", t_create)
|
||||
except Exception as exc:
|
||||
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
|
||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
|
||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||
_emit("single_runtime_create_failed", error=str(exc))
|
||||
raise HTTPException(status_code=502, detail="Session runtime failed to start")
|
||||
|
||||
t_commit = time.perf_counter()
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=f"POOL:{service.slug}",
|
||||
container_id=container_id,
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
)
|
||||
db.add(session_obj)
|
||||
db.commit()
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
|
||||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
|
||||
_mark("db_commit_ms", t_commit)
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
|
||||
|
||||
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
|
||||
t_wait = time.perf_counter()
|
||||
ready = wait_for_session_route(session_id)
|
||||
_mark("wait_session_route_ms", t_wait)
|
||||
log_event("session_route_ready", session_id=session_id, ready=ready)
|
||||
_emit("session_created_single_runtime", session_id=session_id, ready=ready)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
|
||||
try:
|
||||
container_id = create_runtime_container(service, session_id)
|
||||
except Exception as exc:
|
||||
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
|
||||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
|
||||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||||
raise HTTPException(status_code=502, detail="Session runtime failed to start")
|
||||
|
||||
session_obj = SessionModel(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
service_id=service.id,
|
||||
container_id=container_id,
|
||||
status=SessionStatus.ACTIVE,
|
||||
created_at=now_utc(),
|
||||
last_access_at=now_utc(),
|
||||
)
|
||||
db.add(session_obj)
|
||||
db.commit()
|
||||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
|
||||
|
||||
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
|
||||
ready = wait_for_session_route(session_id)
|
||||
log_event("session_route_ready", session_id=session_id, ready=ready)
|
||||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||
except LockTimeoutError:
|
||||
_emit("user_lock_timeout")
|
||||
raise HTTPException(status_code=429, detail="Слишком много параллельных запусков. Повторите через несколько секунд.")
|
||||
|
||||
|
||||
@app.get("/svc/{slug}/", response_class=HTMLResponse)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main.run_maintenance_service()
|
||||
+78
-5
@@ -102,15 +102,26 @@ button {
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.grid.service-grid {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.admin-intro {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #f8fbfe;
|
||||
padding: 0.8rem 0.9rem;
|
||||
color: #2b4760;
|
||||
background: linear-gradient(180deg, #eff7fd 0%, #e0effa 100%);
|
||||
padding: 0.9rem 1rem;
|
||||
color: #123e60;
|
||||
line-height: 1.4;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
@@ -286,6 +297,13 @@ button {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.rules-banner-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rules-banner-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.service-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -370,8 +388,10 @@ button {
|
||||
color: #4b6178;
|
||||
}
|
||||
.tile-comment {
|
||||
max-height: 96px;
|
||||
overflow: auto;
|
||||
display: block;
|
||||
max-height: calc(1.35em * 15);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.35;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
@@ -394,6 +414,54 @@ button {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rules-banner {
|
||||
margin-top: 0.85rem;
|
||||
border: 1px solid #c5daec;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(180deg, #f2f8fd 0%, #e7f2fb 100%);
|
||||
}
|
||||
.rules-banner-title {
|
||||
font-weight: 700;
|
||||
color: #123c5d;
|
||||
}
|
||||
.rules-banner-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.rules-banner-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.rules-ack-btn {
|
||||
border: 1px solid #2c8c3f;
|
||||
border-radius: 999px;
|
||||
padding: 0.38rem 0.72rem;
|
||||
background: linear-gradient(180deg, #41bb5a 0%, #2f9745 100%);
|
||||
color: #fff;
|
||||
font-size: 0.81rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rules-banner-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.rules-pill {
|
||||
border: 1px solid #c7dbed;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 10px;
|
||||
padding: 0.48rem 0.55rem;
|
||||
color: #23465f;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.compact-grid {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
@@ -570,6 +638,11 @@ button {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
||||
}
|
||||
.dashboard-page .panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tile,
|
||||
.tile:hover,
|
||||
.made-by,
|
||||
|
||||
@@ -25,10 +25,24 @@
|
||||
</header>
|
||||
<main class="admin-layout">
|
||||
<section class="panel">
|
||||
<div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
|
||||
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
|
||||
{% if session_notice %}
|
||||
<div class="session-notice">{{ session_notice }}</div>
|
||||
{% endif %}
|
||||
<div class="rules-banner" id="rules-banner">
|
||||
<div class="rules-banner-head">
|
||||
<div class="rules-banner-title">Правила работы стенда</div>
|
||||
</div>
|
||||
<div class="rules-banner-grid">
|
||||
<div class="rules-pill">Лимит: до 4 сервисов одновременно. При открытии нового сверх лимита самый старый закрывается автоматически.</div>
|
||||
<div class="rules-pill">При бездействии более 5 минут сессия закрывается автоматически.</div>
|
||||
<div class="rules-pill">Все сервисы работают в защищённом контуре с резервированием и бэкапами.</div>
|
||||
<div class="rules-pill">Состояние сервисов возвращается к базовому каждую ночь в 00:00.</div>
|
||||
</div>
|
||||
<div class="rules-banner-actions">
|
||||
<button type="button" class="rules-ack-btn" id="rules-ack-btn">Ознакомлен</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if categories %}
|
||||
<div class="category-strip">
|
||||
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
|
||||
@@ -70,5 +84,22 @@
|
||||
</section>
|
||||
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
const username = {{ user.username|tojson }};
|
||||
const key = `rules_ack_${username}`;
|
||||
const banner = document.getElementById('rules-banner');
|
||||
const btn = document.getElementById('rules-ack-btn');
|
||||
if (!banner || !btn) return;
|
||||
if (localStorage.getItem(key) === '1') {
|
||||
banner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
btn.addEventListener('click', function () {
|
||||
localStorage.setItem(key, '1');
|
||||
banner.style.display = 'none';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user