chore: commit all pending changes and ignore project context

This commit is contained in:
2026-04-24 12:41:37 +00:00
parent 850138890c
commit 7000c17d2b
20 changed files with 6482 additions and 640 deletions
+306 -138
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import main
if __name__ == "__main__":
main.run_maintenance_service()
+78 -5
View File
@@ -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,
+32 -1
View File
@@ -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>