{{ service.name }}
Открыть сервис
{% if service.comment %} {{ service.comment }} {% endif %} + {% if svc_cats %} +
- 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 @@
-