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:
+11
-2
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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">● running</span>'
|
||||
: '<span style="color:#e07b39">● 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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user