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
+11 -2
View File
@@ -95,11 +95,20 @@ def validate_csrf(request: Request) -> None:
def has_access(db: Session, user_id: int, service_id: int) -> bool:
q = select(UserServiceAccess).where(
access = db.scalar(
select(UserServiceAccess).where(
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 time
+147 -9
View File
@@ -980,9 +980,20 @@ async def startup_event():
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(
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()
@@ -1013,6 +1024,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"login_error": "",
"session_notice": session_notice,
"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="/")
@@ -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]))
.order_by(Service.name)
).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(
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]
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
# 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,
"services": services,
"locked_services": locked_services,
"pilot_ids": pilot_ids,
"categories": categories,
"category_counts": category_counts,
"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()
web_services = [s for s in services if s.type == ServiceType.WEB]
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}
if services:
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)
acl_rows = db.scalars(select(UserServiceAccess)).all()
acl = {}
acl_expires = {}
for row in acl_rows:
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:
acl[user_id] = sorted(acl[user_id])
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:
u = db.get(User, 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({
"id": slot.id,
"rdp_username": slot.rdp_username,
"container_name": slot.container_name or "",
"running": running,
"occupied_username": occupied_username,
"assigned_user_id": slot.assigned_user_id,
"assigned_username": assigned_username,
})
rdp_slots[svc.id] = slot_list
return templates.TemplateResponse(
@@ -1239,12 +1272,18 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"request": request,
"admin": admin,
"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,
"rdp_services": rdp_services,
"pilot_service_ids": pilot_service_ids,
"services": services,
"categories": categories,
"service_category_map": service_category_map,
"acl": acl,
"acl_expires": acl_expires,
"pool_status": pool_status,
"service_health": service_health,
"web_totals": web_totals,
@@ -1593,8 +1632,10 @@ def sitemap_xml(db: Session = Depends(get_db)):
@app.get("/api/public/services-by-category")
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(
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()
categories = db.execute(select(Category).order_by(Category.name)).scalars().all()
cat_map = {c.id: c.name for c in categories}
@@ -1830,10 +1871,13 @@ async def request_more_access(
).all()
}
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(
select(Service).where(
_func4.lower(Service.name).in_([p.lower() for p in requested]),
Service.active == True,
Service.is_pilot == False,
)
).all()
products = [svc.name for svc in matched if svc.name.lower() not in already_granted]
@@ -1919,6 +1963,7 @@ def login(
"login_error": "Неверный логин или пароль",
"session_notice": "",
"public_services": _login_wall_services(db),
"public_pilots": _login_wall_pilots(db),
},
status_code=401,
)
@@ -1934,6 +1979,7 @@ def login(
"login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
"session_notice": "",
"public_services": _login_wall_services(db),
"public_pilots": _login_wall_pilots(db),
},
status_code=403,
)
@@ -2069,6 +2115,27 @@ def go_service(
busy_slot_ids.add(int(row.container_id.split(":", 1)[1]))
except Exception:
pass
if service.is_pilot:
# Pilot slots are reserved per person (admin
# assigns the container in advance), never
# picked from a shared pool - the earlier
# existing_user_session check above already
# 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")
@@ -2609,6 +2676,7 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
svc_cred_hint=payload.get("svc_cred_hint", ""),
active=payload.get("active", True),
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
is_pilot=bool(payload.get("is_pilot", False)),
)
db.add(service)
db.flush()
@@ -2671,7 +2739,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
service = db.get(Service, service_id)
if not service:
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:
setattr(service, key, payload[key])
if "type" in payload:
@@ -2766,6 +2834,48 @@ def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_ad
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")
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
validate_csrf(request)
@@ -2860,16 +2970,44 @@ def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(req
if not user:
raise HTTPException(status_code=404, detail="User not found")
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_map = {x.service_id: x for x in existing}
rows_by_service = dict(existing_map)
for sid in service_ids:
if sid not in existing_map:
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
for sid, row in existing_map.items():
if sid not in service_ids:
db.delete(row)
new_row = UserServiceAccess(user_id=user_id, service_id=sid)
db.add(new_row)
rows_by_service[sid] = new_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()
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 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 auth import hash_password
from runtime import (
@@ -76,6 +76,30 @@ def cleanup_loop():
db.commit()
for slot_id in rdp_slots_to_restart:
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:
db.rollback()
logger.exception("cleanup_loop_failed")
+16
View File
@@ -54,6 +54,12 @@ class Service(Base):
active: Mapped[bool] = mapped_column(Boolean, default=True)
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))
# 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):
@@ -83,6 +89,11 @@ class UserServiceAccess(Base):
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)
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):
@@ -94,6 +105,11 @@ class RdpSlot(Base):
rdp_password: Mapped[str] = mapped_column(String(256), default="")
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))
# 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):
+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 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 ''"))
# 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(
text(
"""
+70 -6
View File
@@ -32,6 +32,11 @@
--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{
background:var(--av-bg) !important; color:var(--av-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;
@@ -259,7 +264,13 @@
<div class="list-title">ACL выбранного пользователя</div>
<div class="acl-grid">
{% 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 %}
</div>
<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')" />
<div class="list-box" id="rdp_list">
{% 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" />
<div>
<div>{{s.name}}</div>
<div>{{s.name}}{% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>{% endif %}</div>
<small>
<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}}
@@ -502,6 +513,10 @@
<input id="r_svc_cred_hint" 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>
<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 class="list-title">Категории</div>
<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_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<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 class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_r_categories">
@@ -737,8 +756,10 @@
<script>
const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }};
const aclExpiresMap = {{ acl_expires | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }};
const rdpSlotsMap = {{ rdp_slots | tojson }};
const usersList = {{ users_json | tojson }};
const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users';
@@ -911,14 +932,27 @@
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() {
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[userId] || []));
const expiresMap = aclExpiresMap[userId] || {};
document.querySelectorAll('.acl_service').forEach((box) => {
const sid = parseInt(box.value, 10);
box.checked = allowed.has(sid);
box.disabled = false;
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;
if (!userId) return alert('Сначала выберите пользователя');
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();
}
@@ -1036,9 +1077,14 @@
function renderRdpSlots(serviceId) {
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 slots = rdpSlotsMap[serviceId] || [];
const isPilot = document.getElementById('r_is_pilot').checked;
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 = '';
if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
@@ -1048,15 +1094,29 @@
const statusBadge = s.running
? '<span style="color:#4caf50">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; stopped</span>';
const tr = document.createElement('tr');
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>';
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>`;
}
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() {
const serviceId = document.getElementById('r_id').value;
if (!serviceId) return alert('Выберите RDP сервис');
@@ -1073,7 +1133,7 @@
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);
document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name;
@@ -1089,6 +1149,7 @@
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool;
document.getElementById('r_is_pilot').checked = !!isPilot;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block';
@@ -1112,6 +1173,7 @@
category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true',
is_pilot: document.getElementById('new_r_is_pilot').checked,
});
location.reload();
}
@@ -1132,6 +1194,7 @@
category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
is_pilot: document.getElementById('r_is_pilot').checked,
});
location.reload();
}
@@ -1141,6 +1204,7 @@
document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
document.getElementById('r_is_pilot').checked = false;
setCategoryChecks('.r_cat', []);
document.getElementById('r_icon_preview').src = placeholderIcon;
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;
}
.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;
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>
<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 class="dv-tile-name">{{ service.name }}{% if service.id in pilot_ids %} <span class="dv-pilot-badge">ПИЛОТ</span>{% endif %}</div>
</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>
@@ -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-tile-grid">
{% 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-top">
<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>
<span class="dv-tile-lock" title="Нет доступа">🔒</span>
</div>
{% endif %}
{% endfor %}
</div>
</section>
@@ -352,7 +371,10 @@
const modalBody = document.getElementById('req-modal-body');
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) {
+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:hover img{filter:none}
.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 a{color:inherit;text-decoration:none}
@@ -273,6 +277,18 @@
</div>
{% 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>
</aside>