diff --git a/app/main.py b/app/main.py index 7bf301c..b7753fc 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import datetime as dt import enum +import fcntl import logging import os from pathlib import Path @@ -68,7 +69,7 @@ engine = create_engine(DATABASE_URL, pool_pre_ping=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) templates = Jinja2Templates(directory="templates") -app = FastAPI(title="МОНТ - инфра полигон") +app = FastAPI(title="МОНТ - инфрастуктурный полигон") app.mount("/static", StaticFiles(directory="static"), name="static") @@ -137,6 +138,25 @@ class Service(Base): created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(128), unique=True, index=True) + slug: Mapped[str] = mapped_column(String(64), unique=True, index=True) + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) + + +class ServiceCategory(Base): + __tablename__ = "service_categories" + __table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True) + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) + + class UserServiceAccess(Base): __tablename__ = "user_service_access" __table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),) @@ -217,6 +237,26 @@ def parse_rdp_target(target: str) -> dict: } +def set_service_categories(db: Session, service_id: int, category_ids: list[int]) -> None: + normalized = sorted({int(x) for x in (category_ids or [])}) + if normalized: + existing_ids = set(db.scalars(select(Category.id).where(Category.id.in_(normalized))).all()) + missing = sorted(set(normalized) - existing_ids) + if missing: + raise HTTPException(status_code=400, detail=f"Unknown category ids: {missing}") + + existing_links = db.scalars(select(ServiceCategory).where(ServiceCategory.service_id == service_id)).all() + current = {row.category_id: row for row in existing_links} + wanted = set(normalized) + + for cat_id in wanted: + if cat_id not in current: + db.add(ServiceCategory(service_id=service_id, category_id=cat_id)) + for cat_id, row in current.items(): + if cat_id not in wanted: + db.delete(row) + + def service_uses_universal_pool(service: Service) -> bool: return UNIVERSAL_POOL_SIZE > 0 and service.type == ServiceType.RDP @@ -886,6 +926,33 @@ def ensure_schema_compatibility() -> None: conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, + slug VARCHAR(64) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS service_categories ( + id SERIAL PRIMARY KEY, + service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (service_id, category_id) + ) + """ + ) + ) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_service_id ON service_categories(service_id)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_category_id ON service_categories(category_id)")) # Handle installs where service type is VARCHAR + CHECK. conn.execute( text( @@ -1171,8 +1238,13 @@ def bootstrap_admin(): @app.on_event("startup") def startup_event(): - Base.metadata.create_all(bind=engine) - ensure_schema_compatibility() + # Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap + # to avoid DDL races on first run and during schema extension. + with open("/tmp/portal-schema.lock", "w") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + Base.metadata.create_all(bind=engine) + ensure_schema_compatibility() + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) ensure_icons_dir() bootstrap_admin() db = SessionLocal() @@ -1199,7 +1271,7 @@ def startup_event(): def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)): if not user: csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) - response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf}) + response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf, "login_error": ""}) response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") return response @@ -1213,18 +1285,67 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db ) .order_by(Service.name) ).all() + + service_categories = {svc.id: [] for svc in services} + categories = [] + if services: + service_ids = [svc.id for svc in services] + rows = db.execute( + select(ServiceCategory.service_id, Category.id, Category.name, Category.slug) + .join(Category, Category.id == ServiceCategory.category_id) + .where(ServiceCategory.service_id.in_(service_ids)) + .order_by(Category.name) + ).all() + category_map = {} + for service_id, category_id, category_name, category_slug in rows: + service_categories.setdefault(service_id, []).append( + { + "id": category_id, + "name": category_name, + "slug": category_slug, + } + ) + if category_id not in category_map: + category_map[category_id] = {"id": category_id, "name": category_name, "slug": category_slug} + categories = sorted(category_map.values(), key=lambda x: x["name"].lower()) + + selected_category_slug = (request.query_params.get("category") or "").strip().lower() + if selected_category_slug: + services = [ + svc for svc in services + if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, [])) + ] + return templates.TemplateResponse( "dashboard.html", - {"request": request, "user": user, "services": services, "csrf_token": request.cookies.get(CSRF_COOKIE, "")}, + { + "request": request, + "user": user, + "services": services, + "categories": categories, + "selected_category_slug": selected_category_slug, + "service_categories": service_categories, + "csrf_token": request.cookies.get(CSRF_COOKIE, ""), + }, ) @app.get("/admin", response_class=HTMLResponse) def admin_page(request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)): users = db.scalars(select(User).order_by(User.id)).all() + categories = db.scalars(select(Category).order_by(Category.name)).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] rdp_services = [s for s in services if s.type == ServiceType.RDP] + service_category_map = {s.id: [] for s in services} + if services: + service_rows = db.execute( + select(ServiceCategory.service_id, ServiceCategory.category_id).where( + ServiceCategory.service_id.in_([s.id for s in services]) + ) + ).all() + for service_id, category_id in service_rows: + service_category_map.setdefault(service_id, []).append(category_id) acl_rows = db.scalars(select(UserServiceAccess)).all() acl = {} for row in acl_rows: @@ -1284,6 +1405,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi "web_services": web_services, "rdp_services": rdp_services, "services": services, + "categories": categories, + "service_category_map": service_category_map, "acl": acl, "pool_status": pool_status, "service_health": service_health, @@ -1310,8 +1433,21 @@ def login( raise HTTPException(status_code=403, detail="CSRF failed") user = db.scalar(select(User).where(User.username == username)) - if not user or not verify_password(password, user.password_hash) or not user_is_valid(user): - raise HTTPException(status_code=401, detail="Invalid credentials or expired user") + if not user or not verify_password(password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + if not user_is_valid(user): + csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) + response = templates.TemplateResponse( + "login.html", + { + "request": request, + "csrf_token": csrf, + "login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру", + }, + status_code=403, + ) + response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") + return response response = RedirectResponse(url="/", status_code=303) issue_auth_cookie(response, user) @@ -1664,6 +1800,8 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))), ) db.add(service) + db.flush() + set_service_categories(db, service.id, payload.get("category_ids", [])) db.commit() ensure_warm_pool(service) return {"id": service.id} @@ -1732,6 +1870,8 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep parse_rdp_target(service.target) if "warm_pool_size" in payload: service.warm_pool_size = max(0, int(payload["warm_pool_size"])) + if "category_ids" in payload: + set_service_categories(db, service.id, payload.get("category_ids", [])) db.commit() ensure_warm_pool(service) open_warm_web_url(service, service.target) @@ -1767,6 +1907,35 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm return {"ok": True, "pool": get_pool_status_for_service(service)} +@app.post("/api/admin/categories") +def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): + validate_csrf(request) + name = (payload.get("name") or "").strip() + slug = (payload.get("slug") or "").strip().lower().replace(" ", "-") + if not name: + raise HTTPException(status_code=400, detail="Category name is required") + if not slug: + raise HTTPException(status_code=400, detail="Category slug is required") + exists = db.scalar(select(Category).where((Category.name == name) | (Category.slug == slug))) + if exists: + raise HTTPException(status_code=409, detail="Category already exists") + category = Category(name=name, slug=slug) + db.add(category) + db.commit() + return {"id": category.id} + + +@app.delete("/api/admin/categories/{category_id}") +def delete_category(category_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): + validate_csrf(request) + category = db.get(Category, category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + db.delete(category) + db.commit() + return {"ok": True} + + @app.put("/api/admin/web-pool-size") def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)): validate_csrf(request) diff --git a/app/static/service-icons/svc_2_20260421_113825.png b/app/static/service-icons/svc_2_20260421_113825.png new file mode 100644 index 0000000..5797d97 Binary files /dev/null and b/app/static/service-icons/svc_2_20260421_113825.png differ diff --git a/app/static/service-icons/svc_3_20260421_113838.png b/app/static/service-icons/svc_3_20260421_113838.png new file mode 100644 index 0000000..fcd6ab1 Binary files /dev/null and b/app/static/service-icons/svc_3_20260421_113838.png differ diff --git a/app/static/style.css b/app/static/style.css index 6386966..dac2839 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -242,10 +242,10 @@ button { flex: 0 0 40px; } .service-icon-preview { - width: 80px; - height: 80px; + width: 96px; + height: 96px; border-radius: 10px; - object-fit: cover; + object-fit: contain; border: 1px solid #d8e3ed; background: #edf3f9; } @@ -286,11 +286,43 @@ button { .split { grid-template-columns: 1fr; } + .service-grid { + grid-template-columns: 1fr; + } + .tile-icon-box, + .tile-icon { + width: min(100%, 240px); + height: min(100%, 240px); + } .brand-logo-fullscreen { width: min(42vw, 260px); max-height: 20vh; } } +.service-grid { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); +} +.category-strip { + margin-top: 0.9rem; + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} +.category-chip { + text-decoration: none; + border: 1px solid #c8d9e8; + color: #20425f; + padding: 0.34rem 0.7rem; + border-radius: 999px; + background: #eef5fb; + font-size: 0.86rem; + font-weight: 600; +} +.category-chip.active { + background: #0f5b94; + border-color: #0f5b94; + color: #fff; +} .tile { display: block; text-decoration: none; @@ -307,14 +339,22 @@ button { border-color: #bdd3e6; box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12); } -.tile-icon { - width: 56px; - height: 56px; - border-radius: 10px; - object-fit: cover; +.tile-icon-box { + width: min(100%, 336px); + aspect-ratio: 1 / 1; + border-radius: 16px; border: 1px solid #d8e3ed; background: #edf3f9; - margin-bottom: 0.5rem; + display: grid; + place-items: center; + margin-bottom: 0.8rem; +} +.tile-icon { + width: min(100%, 336px); + height: min(100%, 336px); + border-radius: 14px; + object-fit: contain; + background: #edf3f9; } .tile h3 { margin: 0.1rem 0 0.25rem; @@ -328,3 +368,38 @@ button { margin-top: 0.45rem; color: #4b6178; } +.service-categories { + margin-top: 0.7rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.service-cat-badge { + border-radius: 999px; + border: 1px solid #c7d9e8; + background: #f0f6fc; + color: #234662; + padding: 0.2rem 0.5rem; + font-size: 0.76rem; + font-weight: 600; +} +.compact-grid { + margin-bottom: 0.7rem; +} +.category-item-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +.auth-error { + background: #ffe8e8; + border: 1px solid #f2b7b7; + color: #7a1f1f; + border-radius: 10px; + padding: 0.7rem 0.8rem; + max-width: min(520px, 92vw); + margin: 0 auto 0.3rem; + font-weight: 600; +} diff --git a/app/templates/admin.html b/app/templates/admin.html index f795dc8..da8fd6d 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -10,7 +10,7 @@
-
МОНТ - инфра полигон | Админ: {{ admin.username }}
+
МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}
Главная панель
@@ -27,6 +27,7 @@ + @@ -160,6 +161,12 @@ +
Категории
+
+ {% for c in categories %} + + {% endfor %} +
@@ -194,9 +201,8 @@
icon
- +
-
@@ -230,6 +236,12 @@
+
Категории
+
+ {% for c in categories %} + + {% endfor %} +
@@ -279,6 +291,12 @@ +
Категории
+
+ {% for c in categories %} + + {% endfor %} +
@@ -313,9 +331,8 @@
icon
- +
-
@@ -345,11 +362,50 @@
+
Категории
+
+ {% for c in categories %} + + {% endfor %} +
+ +