Add pilots: invite-only products with per-user RDP slots

New Service.is_pilot flag - a pilot is a Service (type RDP) that:
- is excluded from /api/public/services-by-category and
  /api/request-more-access (admin-granted only, no self-service)
- still shows as a locked card on the dashboard for users without
  access (Доступно по запросу section), but with a По приглашению
  badge instead of the self-request button/flow
- gets its own non-clickable teaser row on the public /login page
  (logos only, informational)

RdpSlot.assigned_user_id (nullable) - pilot slots are bound to one
specific user instead of being drawn from the shared pool; regular
RDP services are unaffected (field stays NULL, same pool behaviour
as before). The /go/ allocator branches on service.is_pilot to pick
the caller's assigned slot instead of any free one. Slots release
automatically (cleanup_loop) when the owning grant is revoked or
expires, and immediately on manual ACL revoke.

UserServiceAccess.expires_at (nullable) - per-grant access window,
used by pilots so their access can be shorter than the account's own
expires_at; NULL (unchanged default) means "follow the account".
has_access() and the dashboard's granted/locked split both honour it.

Admin UI: "Это пилот" checkbox on the RDP service form, an
assign-user dropdown on a pilot's slot table (replaces the
occupied-by column), and a per-pilot expiry date field in the user
ACL grid.

Schema is applied via the existing ensure_schema_compatibility()
idempotent ALTER TABLE pattern (no alembic in this project) - no
manual migration step needed, it runs at container startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 06:32:05 +00:00
parent 9e525a44c3
commit 46e5b5a41e
8 changed files with 327 additions and 31 deletions
+13 -4
View File
@@ -95,11 +95,20 @@ def validate_csrf(request: Request) -> None:
def has_access(db: Session, user_id: int, service_id: int) -> bool: def has_access(db: Session, user_id: int, service_id: int) -> bool:
q = select(UserServiceAccess).where( access = db.scalar(
UserServiceAccess.user_id == user_id, select(UserServiceAccess).where(
UserServiceAccess.service_id == service_id, UserServiceAccess.user_id == user_id,
UserServiceAccess.service_id == service_id,
)
) )
return db.scalar(q) is not None if access is None:
return False
# A per-grant expires_at (used by pilots, whose access window can be
# shorter than the account's own expires_at) overrides the account
# expiry for this one product. NULL means "follow the account".
if access.expires_at is not None and access.expires_at <= now_utc():
return False
return True
import threading import threading
import time import time
+153 -15
View File
@@ -980,9 +980,20 @@ async def startup_event():
def _login_wall_services(db: Session): def _login_wall_services(db: Session):
"""Active services shown as a logo wall on the public login page.""" """Active, non-pilot services shown as a logo wall on the public login
page - the self-service "pick a product" catalog. Pilots are invite-only
and get their own separate, non-clickable teaser (_login_wall_pilots)."""
return db.scalars( return db.scalars(
select(Service).where(Service.active == True).order_by(Service.name) select(Service).where(Service.active == True, Service.is_pilot == False).order_by(Service.name)
).all()
def _login_wall_pilots(db: Session):
"""Active pilot services shown as a purely informational logo row on the
public login page - awareness only, not part of the self-service catalog
(no request-access entry point; admin grants these by hand)."""
return db.scalars(
select(Service).where(Service.active == True, Service.is_pilot == True).order_by(Service.name)
).all() ).all()
@@ -1013,6 +1024,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"login_error": "", "login_error": "",
"session_notice": session_notice, "session_notice": session_notice,
"public_services": _login_wall_services(db), "public_services": _login_wall_services(db),
"public_pilots": _login_wall_pilots(db),
}, },
) )
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/") response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
@@ -1023,11 +1035,21 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
.where(Service.active == True, Service.type.in_([ServiceType.WEB, ServiceType.RDP])) .where(Service.active == True, Service.type.in_([ServiceType.WEB, ServiceType.RDP]))
.order_by(Service.name) .order_by(Service.name)
).all() ).all()
# Per-grant expires_at (used by pilots) can make a grant expired even
# though the row still exists and the account itself is still valid -
# exclude those rows here so an expired pilot grant falls back into
# locked_services instead of staying "granted".
granted_ids = set( granted_ids = set(
db.scalars(select(UserServiceAccess.service_id).where(UserServiceAccess.user_id == user.id)).all() db.scalars(
select(UserServiceAccess.service_id).where(
UserServiceAccess.user_id == user.id,
(UserServiceAccess.expires_at.is_(None)) | (UserServiceAccess.expires_at > now_utc()),
)
).all()
) )
services = [svc for svc in all_services if svc.id in granted_ids] services = [svc for svc in all_services if svc.id in granted_ids]
locked_services = [svc for svc in all_services if svc.id not in granted_ids] locked_services = [svc for svc in all_services if svc.id not in granted_ids]
pilot_ids = {svc.id for svc in all_services if svc.is_pilot}
# Categories are computed across the whole catalog (granted + locked) so # Categories are computed across the whole catalog (granted + locked) so
# the nav lets a user browse into a category they don't have access to # the nav lets a user browse into a category they don't have access to
@@ -1085,6 +1107,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"user": user, "user": user,
"services": services, "services": services,
"locked_services": locked_services, "locked_services": locked_services,
"pilot_ids": pilot_ids,
"categories": categories, "categories": categories,
"category_counts": category_counts, "category_counts": category_counts,
"total_catalog_count": len(all_services), "total_catalog_count": len(all_services),
@@ -1126,6 +1149,7 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all() services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all()
web_services = [s for s in services if s.type == ServiceType.WEB] web_services = [s for s in services if s.type == ServiceType.WEB]
rdp_services = [s for s in services if s.type == ServiceType.RDP] rdp_services = [s for s in services if s.type == ServiceType.RDP]
pilot_service_ids = {s.id for s in services if s.is_pilot}
service_category_map = {s.id: [] for s in services} service_category_map = {s.id: [] for s in services}
if services: if services:
service_rows = db.execute( service_rows = db.execute(
@@ -1137,8 +1161,11 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
service_category_map.setdefault(service_id, []).append(category_id) service_category_map.setdefault(service_id, []).append(category_id)
acl_rows = db.scalars(select(UserServiceAccess)).all() acl_rows = db.scalars(select(UserServiceAccess)).all()
acl = {} acl = {}
acl_expires = {}
for row in acl_rows: for row in acl_rows:
acl.setdefault(row.user_id, []).append(row.service_id) acl.setdefault(row.user_id, []).append(row.service_id)
if row.expires_at is not None:
acl_expires.setdefault(row.user_id, {})[row.service_id] = row.expires_at.isoformat()
for user_id in acl: for user_id in acl:
acl[user_id] = sorted(acl[user_id]) acl[user_id] = sorted(acl[user_id])
pool_status = {s.id: get_pool_status_for_service(s) for s in services} pool_status = {s.id: get_pool_status_for_service(s) for s in services}
@@ -1225,12 +1252,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
if active_sess: if active_sess:
u = db.get(User, active_sess.user_id) u = db.get(User, active_sess.user_id)
occupied_username = u.username if u else f"id={active_sess.user_id}" occupied_username = u.username if u else f"id={active_sess.user_id}"
assigned_username = None
if slot.assigned_user_id:
au = db.get(User, slot.assigned_user_id)
assigned_username = au.username if au else f"id={slot.assigned_user_id}"
slot_list.append({ slot_list.append({
"id": slot.id, "id": slot.id,
"rdp_username": slot.rdp_username, "rdp_username": slot.rdp_username,
"container_name": slot.container_name or "", "container_name": slot.container_name or "",
"running": running, "running": running,
"occupied_username": occupied_username, "occupied_username": occupied_username,
"assigned_user_id": slot.assigned_user_id,
"assigned_username": assigned_username,
}) })
rdp_slots[svc.id] = slot_list rdp_slots[svc.id] = slot_list
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -1239,12 +1272,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"request": request, "request": request,
"admin": admin, "admin": admin,
"users": users, "users": users,
"users_json": [
{"id": u.id, "label": (f"{u.first_name} {u.last_name}".strip() or u.username) + f" ({u.username})"}
for u in users
],
"web_services": web_services, "web_services": web_services,
"rdp_services": rdp_services, "rdp_services": rdp_services,
"pilot_service_ids": pilot_service_ids,
"services": services, "services": services,
"categories": categories, "categories": categories,
"service_category_map": service_category_map, "service_category_map": service_category_map,
"acl": acl, "acl": acl,
"acl_expires": acl_expires,
"pool_status": pool_status, "pool_status": pool_status,
"service_health": service_health, "service_health": service_health,
"web_totals": web_totals, "web_totals": web_totals,
@@ -1593,8 +1632,10 @@ def sitemap_xml(db: Session = Depends(get_db)):
@app.get("/api/public/services-by-category") @app.get("/api/public/services-by-category")
def public_services_by_category(db: Session = Depends(get_db)): def public_services_by_category(db: Session = Depends(get_db)):
# Pilots are invite-only - admin grants them by hand, so they must not
# be selectable from the self-service "request access" form.
services = db.execute( services = db.execute(
select(Service).where(Service.active == True).order_by(Service.name) select(Service).where(Service.active == True, Service.is_pilot == False).order_by(Service.name)
).scalars().all() ).scalars().all()
categories = db.execute(select(Category).order_by(Category.name)).scalars().all() categories = db.execute(select(Category).order_by(Category.name)).scalars().all()
cat_map = {c.id: c.name for c in categories} cat_map = {c.id: c.name for c in categories}
@@ -1830,10 +1871,13 @@ async def request_more_access(
).all() ).all()
} }
from sqlalchemy import func as _func4 from sqlalchemy import func as _func4
# is_pilot excluded even if the caller bypasses the UI and posts a pilot's
# name directly - pilots are admin-granted only, never self-service.
matched = db.scalars( matched = db.scalars(
select(Service).where( select(Service).where(
_func4.lower(Service.name).in_([p.lower() for p in requested]), _func4.lower(Service.name).in_([p.lower() for p in requested]),
Service.active == True, Service.active == True,
Service.is_pilot == False,
) )
).all() ).all()
products = [svc.name for svc in matched if svc.name.lower() not in already_granted] products = [svc.name for svc in matched if svc.name.lower() not in already_granted]
@@ -1919,6 +1963,7 @@ def login(
"login_error": "Неверный логин или пароль", "login_error": "Неверный логин или пароль",
"session_notice": "", "session_notice": "",
"public_services": _login_wall_services(db), "public_services": _login_wall_services(db),
"public_pilots": _login_wall_pilots(db),
}, },
status_code=401, status_code=401,
) )
@@ -1934,6 +1979,7 @@ def login(
"login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру", "login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
"session_notice": "", "session_notice": "",
"public_services": _login_wall_services(db), "public_services": _login_wall_services(db),
"public_pilots": _login_wall_pilots(db),
}, },
status_code=403, status_code=403,
) )
@@ -2069,13 +2115,34 @@ def go_service(
busy_slot_ids.add(int(row.container_id.split(":", 1)[1])) busy_slot_ids.add(int(row.container_id.split(":", 1)[1]))
except Exception: except Exception:
pass pass
free_slot = next((s for s in slots if s.id not in busy_slot_ids), None) if service.is_pilot:
if not free_slot: # Pilot slots are reserved per person (admin
_emit("rdp_all_slots_busy") # assigns the container in advance), never
raise HTTPException( # picked from a shared pool - the earlier
status_code=503, # existing_user_session check above already
detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.", # resumes an active session on this slot, so
# reaching here with it "busy" would mean two
# concurrent launches; treat that the same as
# "no slot available" rather than silently
# handing the user a different pilot's machine.
free_slot = next(
(s for s in slots if s.assigned_user_id == user.id and s.id not in busy_slot_ids),
None,
) )
if not free_slot:
_emit("pilot_slot_not_assigned")
raise HTTPException(
status_code=403,
detail="Вам не назначен слот для этого пилота. Обратитесь к администратору.",
)
else:
free_slot = next((s for s in slots if s.id not in busy_slot_ids), None)
if not free_slot:
_emit("rdp_all_slots_busy")
raise HTTPException(
status_code=503,
detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.",
)
session_obj = SessionModel( session_obj = SessionModel(
id=session_id, id=session_id,
user_id=user.id, user_id=user.id,
@@ -2609,6 +2676,7 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
svc_cred_hint=payload.get("svc_cred_hint", ""), svc_cred_hint=payload.get("svc_cred_hint", ""),
active=payload.get("active", True), active=payload.get("active", True),
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))), warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
is_pilot=bool(payload.get("is_pilot", False)),
) )
db.add(service) db.add(service)
db.flush() db.flush()
@@ -2671,7 +2739,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
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")
for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint"]: for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint", "is_pilot"]:
if key in payload: if key in payload:
setattr(service, key, payload[key]) setattr(service, key, payload[key])
if "type" in payload: if "type" in payload:
@@ -2766,6 +2834,48 @@ def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_ad
return {"ok": True} return {"ok": True}
@app.put("/api/admin/rdp-slots/{slot_id}/assign")
def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
"""Bind (or unbind, with user_id null) a pilot's RDP slot to one
specific person. Only meaningful for slots that belong to a pilot
service - a slot on a regular pooled RDP service doesn't need this,
since any free slot in the pool already works for anyone with access."""
validate_csrf(request)
slot = db.get(RdpSlot, slot_id)
if not slot:
raise HTTPException(status_code=404, detail="Slot not found")
service = db.get(Service, slot.service_id)
if not service or not service.is_pilot:
raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису")
raw_user_id = payload.get("user_id")
if raw_user_id in (None, ""):
slot.assigned_user_id = None
db.commit()
audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", user_id=None)
return {"ok": True, "assigned_user_id": None}
target_user = db.get(User, int(raw_user_id))
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
other = db.scalar(
select(RdpSlot).where(
RdpSlot.service_id == service.id,
RdpSlot.assigned_user_id == target_user.id,
RdpSlot.id != slot.id,
)
)
if other:
raise HTTPException(
status_code=409,
detail=f"У пользователя уже есть слот №{other.id} на этом пилоте",
)
slot.assigned_user_id = target_user.id
db.commit()
audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None)
return {"ok": True, "assigned_user_id": target_user.id}
@app.post("/api/admin/categories") @app.post("/api/admin/categories")
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
validate_csrf(request) validate_csrf(request)
@@ -2860,16 +2970,44 @@ def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(req
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
service_ids = set(payload.get("service_ids", [])) service_ids = set(payload.get("service_ids", []))
# Optional per-service expiry override, e.g. {"12": "2026-11-01T00:00:00+00:00"}
# or {"12": null} to clear it back to "follow the account expiry".
# Used for pilots, whose access window can be shorter than the account's.
expires_by_service = payload.get("expires_at_by_service") or {}
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all() existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
existing_map = {x.service_id: x for x in existing} existing_map = {x.service_id: x for x in existing}
rows_by_service = dict(existing_map)
for sid in service_ids: for sid in service_ids:
if sid not in existing_map: if sid not in existing_map:
db.add(UserServiceAccess(user_id=user_id, service_id=sid)) new_row = UserServiceAccess(user_id=user_id, service_id=sid)
for sid, row in existing_map.items(): db.add(new_row)
if sid not in service_ids: rows_by_service[sid] = new_row
db.delete(row) removed_ids = [sid for sid, row in existing_map.items() if sid not in service_ids]
for sid in removed_ids:
db.delete(existing_map[sid])
for sid_str, iso_value in expires_by_service.items():
try:
sid = int(sid_str)
except (TypeError, ValueError):
continue
row = rows_by_service.get(sid)
if row is None:
continue
row.expires_at = dt.datetime.fromisoformat(iso_value) if iso_value else None
if removed_ids:
# Revoking a pilot immediately frees any slot reserved for this user
# on it, instead of waiting for the next cleanup_loop sweep.
for slot in db.scalars(
select(RdpSlot).where(
RdpSlot.assigned_user_id == user_id,
RdpSlot.service_id.in_(removed_ids),
)
).all():
slot.assigned_user_id = None
db.commit() db.commit()
return {"ok": True} return {"ok": True}
+25 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE
from database import Base, SessionLocal, engine from database import Base, SessionLocal, engine
from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User, UserServiceAccess
from utils import ensure_icons_dir, now_utc from utils import ensure_icons_dir, now_utc
from auth import hash_password from auth import hash_password
from runtime import ( from runtime import (
@@ -76,6 +76,30 @@ def cleanup_loop():
db.commit() db.commit()
for slot_id in rdp_slots_to_restart: for slot_id in rdp_slots_to_restart:
threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start() threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start()
# Pilots: a slot assigned to a user whose grant for that pilot
# service has since been revoked or has expired (per-grant
# UserServiceAccess.expires_at, or the row is just gone) goes
# back into the pool so an admin can hand it to someone else.
assigned_slots = db.scalars(
select(RdpSlot).where(RdpSlot.assigned_user_id.is_not(None))
).all()
if assigned_slots:
now = now_utc()
freed = 0
for slot in assigned_slots:
access = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == slot.assigned_user_id,
UserServiceAccess.service_id == slot.service_id,
)
)
if access is None or (access.expires_at is not None and access.expires_at <= now):
slot.assigned_user_id = None
freed += 1
if freed:
db.commit()
logger.info("pilot_slots_released count=%s", freed)
except Exception: except Exception:
db.rollback() db.rollback()
logger.exception("cleanup_loop_failed") logger.exception("cleanup_loop_failed")
+16
View File
@@ -54,6 +54,12 @@ class Service(Base):
active: Mapped[bool] = mapped_column(Boolean, default=True) active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0) warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
# Pilot = invite-only product. Hidden from the public/request-more-access
# catalogs (admin grants access by hand instead of self-service), and its
# RdpSlot rows are bound to specific users (RdpSlot.assigned_user_id)
# rather than drawn from a shared pool. See _apply_access_decision /
# pilot slot allocation in main.py for where this flag is read.
is_pilot: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
class Category(Base): class Category(Base):
@@ -83,6 +89,11 @@ class UserServiceAccess(Base):
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True) service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
# Per-grant expiry, used by pilots so a pilot's access window can be
# shorter than the user's overall account expires_at. NULL (the default,
# and the only value regular non-pilot grants ever get) means "follow
# the account's own expires_at" - see has_access() in auth.py.
expires_at: Mapped[Optional[dt.datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
class RdpSlot(Base): class RdpSlot(Base):
@@ -94,6 +105,11 @@ class RdpSlot(Base):
rdp_password: Mapped[str] = mapped_column(String(256), default="") rdp_password: Mapped[str] = mapped_column(String(256), default="")
container_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) container_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
# Pilot slots are reserved for one specific person instead of being
# drawn from a shared pool - set only on slots that belong to a
# Service with is_pilot=True. NULL means "ordinary pooled slot",
# unchanged behaviour for every existing RDP service.
assigned_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
class SessionModel(Base): class SessionModel(Base):
+7
View File
@@ -776,6 +776,13 @@ def ensure_schema_compatibility() -> None:
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_notified BOOLEAN NOT NULL DEFAULT false")) conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_notified BOOLEAN NOT NULL DEFAULT false"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_message TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_message TEXT NOT NULL DEFAULT ''"))
# Pilots: invite-only products with per-user RDP slot assignment
# and a per-grant access window (see models.py for the full story).
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS is_pilot BOOLEAN NOT NULL DEFAULT false"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_services_is_pilot ON services (is_pilot)"))
conn.execute(text("ALTER TABLE rdp_slots ADD COLUMN IF NOT EXISTS assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_rdp_slots_assigned_user_id ON rdp_slots (assigned_user_id)"))
conn.execute(text("ALTER TABLE user_service_access ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ"))
conn.execute( conn.execute(
text( text(
""" """
+73 -9
View File
@@ -32,6 +32,11 @@
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f; --av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
} }
.pilot-badge{
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
color:#fff;background:var(--av-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;margin-left:4px;
}
body.admin-page-v2{ body.admin-page-v2{
background:var(--av-bg) !important; color:var(--av-fg); background:var(--av-bg) !important; color:var(--av-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif; font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;
@@ -259,7 +264,13 @@
<div class="list-title">ACL выбранного пользователя</div> <div class="list-title">ACL выбранного пользователя</div>
<div class="acl-grid"> <div class="acl-grid">
{% for s in services %} {% for s in services %}
<label><input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" /> {{s.name}} ({{s.slug}})<span class="acl-owner"></span></label> <label>
<input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" {% if s.id in pilot_service_ids %}data-pilot="1" onchange="toggleAclExpiryInput(this)"{% endif %} />
{{s.name}} ({{s.slug}}){% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>
<input type="date" class="acl-expiry" data-service="{{s.id}}" title="Доступ к пилоту истекает (необязательно — иначе по сроку аккаунта)" style="display:none;margin-left:.4rem;width:auto" />
{% endif %}
<span class="acl-owner"></span>
</label>
{% endfor %} {% endfor %}
</div> </div>
<button onclick="saveAclForSelectedUser()">Save ACL</button> <button onclick="saveAclForSelectedUser()">Save ACL</button>
@@ -466,10 +477,10 @@
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" /> <input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list"> <div class="list-box" id="rdp_list">
{% for s in rdp_services %} {% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'> <button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}}, {{(s.id in pilot_service_ids)|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}{% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>{% endif %}</div>
<small> <small>
<span class="status-dot status-{{ service_health[s.id].health }}"></span> <span class="status-dot status-{{ service_health[s.id].health }}"></span>
{{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}} {{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}}
@@ -502,6 +513,10 @@
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" /> <input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" /> <input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
<label class="field-col" style="flex-direction:row;align-items:center;gap:.4rem">
<input id="r_is_pilot" type="checkbox" style="width:auto" onchange="renderRdpSlots(document.getElementById('r_id').value)" />
<span>Это пилот (слоты закрепляются за конкретным пользователем, продукт скрыт из самостоятельного запроса доступа)</span>
</label>
</div> </div>
<div class="list-title">Категории</div> <div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="r_categories"> <div class="acl-grid compact-grid" id="r_categories">
@@ -588,6 +603,10 @@
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" /> <input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" /> <input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
<label class="field-col" style="flex-direction:row;align-items:center;gap:.4rem">
<input id="new_r_is_pilot" type="checkbox" style="width:auto" />
<span>Это пилот</span>
</label>
</div> </div>
<div class="list-title">Категории</div> <div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_r_categories"> <div class="acl-grid compact-grid" id="new_r_categories">
@@ -737,8 +756,10 @@
<script> <script>
const csrf = "{{ csrf_token }}"; const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }}; const aclMap = {{ acl | tojson }};
const aclExpiresMap = {{ acl_expires | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }}; const serviceCategoryMap = {{ service_category_map | tojson }};
const rdpSlotsMap = {{ rdp_slots | tojson }}; const rdpSlotsMap = {{ rdp_slots | tojson }};
const usersList = {{ users_json | tojson }};
const placeholderIcon = '/static/service-placeholder.svg'; const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users'; let activeTab = 'users';
@@ -911,14 +932,27 @@
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item')); document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
} }
function toggleAclExpiryInput(box) {
const input = box.closest('label').querySelector('.acl-expiry');
if (input) input.style.display = box.checked ? 'inline-block' : 'none';
}
function syncAclForSelectedUser() { function syncAclForSelectedUser() {
const userId = parseInt(document.getElementById('u_id').value || '0', 10); const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[userId] || [])); const allowed = new Set((aclMap[userId] || []));
const expiresMap = aclExpiresMap[userId] || {};
document.querySelectorAll('.acl_service').forEach((box) => { document.querySelectorAll('.acl_service').forEach((box) => {
const sid = parseInt(box.value, 10); const sid = parseInt(box.value, 10);
box.checked = allowed.has(sid); box.checked = allowed.has(sid);
box.disabled = false; box.disabled = false;
box.closest('label').style.opacity = ''; box.closest('label').style.opacity = '';
if (box.dataset.pilot) {
const input = box.closest('label').querySelector('.acl-expiry');
if (input) {
input.value = expiresMap[sid] ? expiresMap[sid].slice(0, 10) : '';
input.style.display = box.checked ? 'inline-block' : 'none';
}
}
}); });
} }
@@ -926,7 +960,14 @@
const userId = document.getElementById('u_id').value; const userId = document.getElementById('u_id').value;
if (!userId) return alert('Сначала выберите пользователя'); if (!userId) return alert('Сначала выберите пользователя');
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10)); const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds}); const expiresAtByService = {};
document.querySelectorAll('.acl-expiry').forEach((input) => {
const sid = input.dataset.service;
if (serviceIds.includes(parseInt(sid, 10))) {
expiresAtByService[sid] = input.value ? `${input.value}T23:59:59+00:00` : null;
}
});
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds, expires_at_by_service: expiresAtByService});
location.reload(); location.reload();
} }
@@ -1036,9 +1077,14 @@
function renderRdpSlots(serviceId) { function renderRdpSlots(serviceId) {
const box = document.getElementById('rdp_slots_box'); const box = document.getElementById('rdp_slots_box');
const thead = document.querySelector('#rdp_slots_table thead tr');
const tbody = document.querySelector('#rdp_slots_table tbody'); const tbody = document.querySelector('#rdp_slots_table tbody');
const slots = rdpSlotsMap[serviceId] || []; const slots = rdpSlotsMap[serviceId] || [];
const isPilot = document.getElementById('r_is_pilot').checked;
box.style.display = 'block'; box.style.display = 'block';
thead.innerHTML = isPilot
? '<th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Закреплён за</th><th></th>'
: '<th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th>';
tbody.innerHTML = ''; tbody.innerHTML = '';
if (!slots.length) { if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
@@ -1048,15 +1094,29 @@
const statusBadge = s.running const statusBadge = s.running
? '<span style="color:#4caf50">&#9679; running</span>' ? '<span style="color:#4caf50">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; stopped</span>'; : '<span style="color:#e07b39">&#9679; stopped</span>';
const occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`; if (isPilot) {
const options = ['<option value="">— свободен —</option>']
.concat(usersList.map(u => `<option value="${u.id}" ${s.assigned_user_id === u.id ? 'selected' : ''}>${u.label}</option>`))
.join('');
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td>` +
`<td><select onchange="assignRdpSlot(${s.id}, this.value)">${options}</select></td>` +
`<td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
} else {
const occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
}
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} }
async function assignRdpSlot(slotId, userId) {
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null});
location.reload();
}
async function addRdpSlot() { async function addRdpSlot() {
const serviceId = document.getElementById('r_id').value; const serviceId = document.getElementById('r_id').value;
if (!serviceId) return alert('Выберите RDP сервис'); if (!serviceId) return alert('Выберите RDP сервис');
@@ -1073,7 +1133,7 @@
location.reload(); location.reload();
} }
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) { function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint, isPilot) {
const cfg = parseRdpTarget(target); const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id; document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name; document.getElementById('r_name').value = name;
@@ -1089,6 +1149,7 @@
document.getElementById('r_svc_cred_hint').value = svcCredHint || ''; document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active); document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool; document.getElementById('r_pool').value = pool;
document.getElementById('r_is_pilot').checked = !!isPilot;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []); setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon; document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block'; document.getElementById('r_health_box').style.display = 'block';
@@ -1112,6 +1173,7 @@
category_ids: checkedCategoryIds('.new_r_cat'), category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true', active: document.getElementById('new_r_active').value === 'true',
is_pilot: document.getElementById('new_r_is_pilot').checked,
}); });
location.reload(); location.reload();
} }
@@ -1132,6 +1194,7 @@
category_ids: checkedCategoryIds('.r_cat'), category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true', active: document.getElementById('r_active').value === 'true',
is_pilot: document.getElementById('r_is_pilot').checked,
}); });
location.reload(); location.reload();
} }
@@ -1141,6 +1204,7 @@
document.getElementById('rdp_slots_box').style.display = 'none'; document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = ''; document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true'; document.getElementById('r_active').value = 'true';
document.getElementById('r_is_pilot').checked = false;
setCategoryChecks('.r_cat', []); setCategoryChecks('.r_cat', []);
document.getElementById('r_icon_preview').src = placeholderIcon; document.getElementById('r_icon_preview').src = placeholderIcon;
document.getElementById('r_health_box').style.display = 'none'; document.getElementById('r_health_box').style.display = 'none';
+24 -2
View File
@@ -138,6 +138,14 @@
border:1px dashed var(--dv-fg-faint);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer; border:1px dashed var(--dv-fg-faint);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer;
} }
.dv-tile-request:hover{border-color:var(--dv-accent);border-style:solid;color:var(--dv-accent)} .dv-tile-request:hover{border-color:var(--dv-accent);border-style:solid;color:var(--dv-accent)}
.dv-pilot-badge{
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
color:#0a1929;background:var(--dv-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;
}
.dv-tile-invite{
position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px;
border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;
}
.dv-page-footer{margin-top:50px;padding-top:20px;border-top:1px solid var(--dv-line);display:flex; .dv-page-footer{margin-top:50px;padding-top:20px;border-top:1px solid var(--dv-line);display:flex;
justify-content:space-between;flex-wrap:wrap;gap:8px;font:400 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)} justify-content:space-between;flex-wrap:wrap;gap:8px;font:400 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
@@ -252,7 +260,7 @@
<a class="dv-tile-hit go-link" href="/go/{{ service.slug }}" aria-label="Открыть {{ service.name }}"></a> <a class="dv-tile-hit go-link" href="/go/{{ service.slug }}" aria-label="Открыть {{ service.name }}"></a>
<div class="dv-tile-top"> <div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div> <div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div> <div class="dv-tile-name">{{ service.name }}{% if service.id in pilot_ids %} <span class="dv-pilot-badge">ПИЛОТ</span>{% endif %}</div>
</div> </div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div> <div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<a class="dv-tile-enter go-link" href="/go/{{ service.slug }}">Войти →</a> <a class="dv-tile-enter go-link" href="/go/{{ service.slug }}">Войти →</a>
@@ -270,6 +278,16 @@
<div class="dv-section-head"><h2>Доступно по запросу</h2><span class="dv-section-count">{{ '%02d'|format(locked_services|length) }}</span></div> <div class="dv-section-head"><h2>Доступно по запросу</h2><span class="dv-section-count">{{ '%02d'|format(locked_services|length) }}</span></div>
<div class="dv-tile-grid"> <div class="dv-tile-grid">
{% for service in locked_services %} {% for service in locked_services %}
{% if service.id in pilot_ids %}
<div class="dv-tile locked pilot-locked">
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<span class="dv-tile-invite" title="Доступ выдаёт администратор полигона">По приглашению</span>
</div>
{% else %}
<div class="dv-tile locked"> <div class="dv-tile locked">
<div class="dv-tile-top"> <div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div> <div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
@@ -279,6 +297,7 @@
<button type="button" class="dv-tile-request" data-product="{{ service.name }}">Запросить доступ</button> <button type="button" class="dv-tile-request" data-product="{{ service.name }}">Запросить доступ</button>
<span class="dv-tile-lock" title="Нет доступа">🔒</span> <span class="dv-tile-lock" title="Нет доступа">🔒</span>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
@@ -352,7 +371,10 @@
const modalBody = document.getElementById('req-modal-body'); const modalBody = document.getElementById('req-modal-body');
function allLockedTiles() { function allLockedTiles() {
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked')); // Pilots (.pilot-locked) are invite-only and must never end up in the
// self-service "request access" checklist, even via another product's
// request button pulling in every locked tile on the page.
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked:not(.pilot-locked)'));
} }
function openRequestModal(preselect) { function openRequestModal(preselect) {
+16
View File
@@ -115,6 +115,10 @@
.lg2-wall-tile img{width:100%;height:100%;object-fit:contain;filter:grayscale(1) opacity(.75)} .lg2-wall-tile img{width:100%;height:100%;object-fit:contain;filter:grayscale(1) opacity(.75)}
.lg2-wall-tile:hover img{filter:none} .lg2-wall-tile:hover img{filter:none}
.lg2-wall-tile:hover{border-color:var(--lg2-accent)} .lg2-wall-tile:hover{border-color:var(--lg2-accent)}
.lg2-pilots-sub{font:400 12.5px/1.5 "Ubuntu",sans-serif;color:var(--lg2-fg-faint);margin:-6px 0 14px;max-width:62ch}
.lg2-wall-tile-pilot{cursor:default}
.lg2-wall-tile-pilot:hover{border-color:var(--lg2-line)}
.lg2-wall-tile-pilot:hover img{filter:grayscale(1) opacity(.75)}
.lg2-pitch-footer{margin-top:auto;padding-top:36px;font:400 11px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint)} .lg2-pitch-footer{margin-top:auto;padding-top:36px;font:400 11px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint)}
.lg2-pitch-footer a{color:inherit;text-decoration:none} .lg2-pitch-footer a{color:inherit;text-decoration:none}
@@ -273,6 +277,18 @@
</div> </div>
{% endif %} {% endif %}
{% if public_pilots %}
<div class="lg2-wall-head"><h2>Пилотные проекты</h2><span class="lg2-wall-count">по приглашению</span></div>
<p class="lg2-pilots-sub">Отдельные среды для пилотных внедрений — доступ выдаёт ваш менеджер MONT, здесь их нельзя запросить самостоятельно.</p>
<div class="lg2-logo-wall lg2-logo-wall-pilots">
{% for service in public_pilots %}
<div class="lg2-wall-tile lg2-wall-tile-pilot" title="{{ service.name }}">
<img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="{{ service.name }}" />
</div>
{% endfor %}
</div>
{% endif %}
<div class="lg2-pitch-footer">MONT PROVING GROUND</div> <div class="lg2-pitch-footer">MONT PROVING GROUND</div>
</aside> </aside>