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:
|
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.user_id == user_id,
|
||||||
UserServiceAccess.service_id == service_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
|
||||||
|
|||||||
+147
-9
@@ -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,6 +2115,27 @@ 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
|
||||||
|
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)
|
free_slot = next((s for s in slots if s.id not in busy_slot_ids), None)
|
||||||
if not free_slot:
|
if not free_slot:
|
||||||
_emit("rdp_all_slots_busy")
|
_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", ""),
|
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
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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">● running</span>'
|
? '<span style="color:#4caf50">● running</span>'
|
||||||
: '<span style="color:#e07b39">● stopped</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
|
const occupiedCell = s.occupied_username
|
||||||
? `<span style="color:#e07b39">${s.occupied_username}</span>`
|
? `<span style="color:#e07b39">${s.occupied_username}</span>`
|
||||||
: '<span style="color:#888">свободен</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>`;
|
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';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user