feat: categories, runtime nav, and UX updates

This commit is contained in:
2026-04-21 11:43:43 +00:00
parent 9eb3403f8c
commit 52d1991092
12 changed files with 560 additions and 51 deletions
+174 -5
View File
@@ -1,5 +1,6 @@
import datetime as dt import datetime as dt
import enum import enum
import fcntl
import logging import logging
import os import os
from pathlib import Path 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) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
app = FastAPI(title="МОНТ - инфра полигон") app = FastAPI(title="МОНТ - инфрастуктурный полигон")
app.mount("/static", StaticFiles(directory="static"), name="static") 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)) 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): class UserServiceAccess(Base):
__tablename__ = "user_service_access" __tablename__ = "user_service_access"
__table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),) __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: def service_uses_universal_pool(service: Service) -> bool:
return UNIVERSAL_POOL_SIZE > 0 and service.type == ServiceType.RDP 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 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 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("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. # Handle installs where service type is VARCHAR + CHECK.
conn.execute( conn.execute(
text( text(
@@ -1171,8 +1238,13 @@ def bootstrap_admin():
@app.on_event("startup") @app.on_event("startup")
def startup_event(): def startup_event():
# 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) Base.metadata.create_all(bind=engine)
ensure_schema_compatibility() ensure_schema_compatibility()
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
ensure_icons_dir() ensure_icons_dir()
bootstrap_admin() bootstrap_admin()
db = SessionLocal() 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)): def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)):
if not user: if not user:
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) 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="/") response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
return response return response
@@ -1213,18 +1285,67 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
) )
.order_by(Service.name) .order_by(Service.name)
).all() ).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( return templates.TemplateResponse(
"dashboard.html", "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) @app.get("/admin", response_class=HTMLResponse)
def admin_page(request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)): 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() 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() 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]
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_rows = db.scalars(select(UserServiceAccess)).all()
acl = {} acl = {}
for row in acl_rows: 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, "web_services": web_services,
"rdp_services": rdp_services, "rdp_services": rdp_services,
"services": services, "services": services,
"categories": categories,
"service_category_map": service_category_map,
"acl": acl, "acl": acl,
"pool_status": pool_status, "pool_status": pool_status,
"service_health": service_health, "service_health": service_health,
@@ -1310,8 +1433,21 @@ def login(
raise HTTPException(status_code=403, detail="CSRF failed") raise HTTPException(status_code=403, detail="CSRF failed")
user = db.scalar(select(User).where(User.username == username)) 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): if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials or expired user") 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) response = RedirectResponse(url="/", status_code=303)
issue_auth_cookie(response, user) 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))), warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
) )
db.add(service) db.add(service)
db.flush()
set_service_categories(db, service.id, payload.get("category_ids", []))
db.commit() db.commit()
ensure_warm_pool(service) ensure_warm_pool(service)
return {"id": service.id} 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) parse_rdp_target(service.target)
if "warm_pool_size" in payload: if "warm_pool_size" in payload:
service.warm_pool_size = max(0, int(payload["warm_pool_size"])) 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() db.commit()
ensure_warm_pool(service) ensure_warm_pool(service)
open_warm_web_url(service, service.target) 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)} 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") @app.put("/api/admin/web-pool-size")
def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)): def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)):
validate_csrf(request) validate_csrf(request)
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+84 -9
View File
@@ -242,10 +242,10 @@ button {
flex: 0 0 40px; flex: 0 0 40px;
} }
.service-icon-preview { .service-icon-preview {
width: 80px; width: 96px;
height: 80px; height: 96px;
border-radius: 10px; border-radius: 10px;
object-fit: cover; object-fit: contain;
border: 1px solid #d8e3ed; border: 1px solid #d8e3ed;
background: #edf3f9; background: #edf3f9;
} }
@@ -286,11 +286,43 @@ button {
.split { .split {
grid-template-columns: 1fr; 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 { .brand-logo-fullscreen {
width: min(42vw, 260px); width: min(42vw, 260px);
max-height: 20vh; 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 { .tile {
display: block; display: block;
text-decoration: none; text-decoration: none;
@@ -307,14 +339,22 @@ button {
border-color: #bdd3e6; border-color: #bdd3e6;
box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12); box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12);
} }
.tile-icon { .tile-icon-box {
width: 56px; width: min(100%, 336px);
height: 56px; aspect-ratio: 1 / 1;
border-radius: 10px; border-radius: 16px;
object-fit: cover;
border: 1px solid #d8e3ed; border: 1px solid #d8e3ed;
background: #edf3f9; 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 { .tile h3 {
margin: 0.1rem 0 0.25rem; margin: 0.1rem 0 0.25rem;
@@ -328,3 +368,38 @@ button {
margin-top: 0.45rem; margin-top: 0.45rem;
color: #4b6178; 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;
}
+107 -7
View File
@@ -10,7 +10,7 @@
<header class="header"> <header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;"> <div style="display:flex; align-items:center; gap:0.6rem;">
<img src="/static/logo.png" alt="MONT" class="header-logo" /> <img src="/static/logo.png" alt="MONT" class="header-logo" />
<div>МОНТ - инфра полигон | Админ: {{ admin.username }}</div> <div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
</div> </div>
<a href="/" class="btn-link secondary">Главная панель</a> <a href="/" class="btn-link secondary">Главная панель</a>
</header> </header>
@@ -27,6 +27,7 @@
<button class="tab-btn" data-tab="users" onclick="showTab('users')">Users</button> <button class="tab-btn" data-tab="users" onclick="showTab('users')">Users</button>
<button class="tab-btn" data-tab="web" onclick="showTab('web')">WEB</button> <button class="tab-btn" data-tab="web" onclick="showTab('web')">WEB</button>
<button class="tab-btn" data-tab="rdp" onclick="showTab('rdp')">RDP</button> <button class="tab-btn" data-tab="rdp" onclick="showTab('rdp')">RDP</button>
<button class="tab-btn" data-tab="categories" onclick="showTab('categories')">Categories</button>
<button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button> <button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button>
</div> </div>
</section> </section>
@@ -160,6 +161,12 @@
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
</label> </label>
</div> </div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="w_categories">
{% for c in categories %}
<label><input type="checkbox" class="w_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<div class="actions"> <div class="actions">
<button onclick="saveWebService()">Save</button> <button onclick="saveWebService()">Save</button>
<button onclick="deleteService('w_id')">Delete</button> <button onclick="deleteService('w_id')">Delete</button>
@@ -194,9 +201,8 @@
<div class="icon-row"> <div class="icon-row">
<img id="w_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" /> <img id="w_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div> <div>
<input id="w_icon_file" type="file" accept="image/png,image/jpeg,image/webp" /> <input id="w_icon_file" type="file" accept="image/png,image/jpeg,image/webp" onchange="autoUploadIcon('w')" />
<div class="actions" style="margin-top:0.4rem;"> <div class="actions" style="margin-top:0.4rem;">
<button onclick="uploadServiceIcon('w')">Upload</button>
<button onclick="pasteServiceIcon('w')">Paste</button> <button onclick="pasteServiceIcon('w')">Paste</button>
<button onclick="removeServiceIcon('w')">Remove icon</button> <button onclick="removeServiceIcon('w')">Remove icon</button>
</div> </div>
@@ -230,6 +236,12 @@
<select id="new_w_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_w_active"><option value="true">active</option><option value="false">inactive</option></select>
</label> </label>
</div> </div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_w_categories">
{% for c in categories %}
<label><input type="checkbox" class="new_w_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<button onclick="createWebService()">Add WEB</button> <button onclick="createWebService()">Add WEB</button>
</div> </div>
</div> </div>
@@ -279,6 +291,12 @@
<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>
</div> </div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="r_categories">
{% for c in categories %}
<label><input type="checkbox" class="r_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<div class="actions"> <div class="actions">
<button onclick="saveRdpService()">Save</button> <button onclick="saveRdpService()">Save</button>
<button onclick="prewarmNow('r_id')">Prewarm now</button> <button onclick="prewarmNow('r_id')">Prewarm now</button>
@@ -313,9 +331,8 @@
<div class="icon-row"> <div class="icon-row">
<img id="r_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" /> <img id="r_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div> <div>
<input id="r_icon_file" type="file" accept="image/png,image/jpeg,image/webp" /> <input id="r_icon_file" type="file" accept="image/png,image/jpeg,image/webp" onchange="autoUploadIcon('r')" />
<div class="actions" style="margin-top:0.4rem;"> <div class="actions" style="margin-top:0.4rem;">
<button onclick="uploadServiceIcon('r')">Upload</button>
<button onclick="pasteServiceIcon('r')">Paste</button> <button onclick="pasteServiceIcon('r')">Paste</button>
<button onclick="removeServiceIcon('r')">Remove icon</button> <button onclick="removeServiceIcon('r')">Remove icon</button>
</div> </div>
@@ -345,11 +362,50 @@
<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>
</div> </div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_r_categories">
{% for c in categories %}
<label><input type="checkbox" class="new_r_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<button onclick="createRdpService()">Add RDP</button> <button onclick="createRdpService()">Add RDP</button>
</div> </div>
</div> </div>
</section> </section>
<section id="tab-categories" class="panel admin-tab" style="display:none;">
<h3>Категории</h3>
<div class="admin-intro">
Добавляйте и удаляйте категории, чтобы аккуратно группировать сервисы на главной панели.
</div>
<div class="split">
<div>
<div class="list-title">Существующие категории</div>
<div class="list-box" id="categories_list">
{% for c in categories %}
<div class="list-item category-item-row">
<div>
<div>{{ c.name }}</div>
<small>{{ c.slug }}</small>
</div>
<button onclick="deleteCategory({{ c.id }}, {{ c.name|tojson }})">Delete</button>
</div>
{% else %}
<div class="list-item">Категории пока не созданы</div>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Добавить категорию</div>
<div class="form-grid">
<input id="cat_name" placeholder="Название категории" oninput="autogenSlug('cat_name','cat_slug')" />
<input id="cat_slug" placeholder="slug (латиницей)" />
</div>
<button onclick="createCategory()">Add Category</button>
</div>
</div>
</section>
<section id="tab-stats" class="panel admin-tab" style="display:none;"> <section id="tab-stats" class="panel admin-tab" style="display:none;">
<h3>Статистика открытий</h3> <h3>Статистика открытий</h3>
<div class="admin-intro"> <div class="admin-intro">
@@ -421,6 +477,7 @@
<script> <script>
const csrf = "{{ csrf_token }}"; const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }}; const aclMap = {{ acl | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }};
const placeholderIcon = '/static/service-placeholder.svg'; const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users'; let activeTab = 'users';
@@ -447,6 +504,19 @@
}); });
} }
function checkedCategoryIds(selector) {
return [...document.querySelectorAll(selector)]
.filter((x) => x.checked)
.map((x) => parseInt(x.value, 10));
}
function setCategoryChecks(selector, categoryIds) {
const setIds = new Set((categoryIds || []).map((x) => parseInt(x, 10)));
document.querySelectorAll(selector).forEach((box) => {
box.checked = setIds.has(parseInt(box.value, 10));
});
}
function slugifyRu(text) { function slugifyRu(text) {
const map = { const map = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya' 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'
@@ -598,6 +668,7 @@
document.getElementById('w_comment').value = comment || ''; document.getElementById('w_comment').value = comment || '';
document.getElementById('w_active').value = String(active); document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon; document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
document.getElementById('w_health_box').style.display = 'block'; document.getElementById('w_health_box').style.display = 'block';
markSelected('.web-item', 'data-service-id', id); markSelected('.web-item', 'data-service-id', id);
refreshSelectedServiceStatus('web'); refreshSelectedServiceStatus('web');
@@ -617,6 +688,7 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('new_w_target').value, target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value, comment: document.getElementById('new_w_comment').value,
category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true', active: document.getElementById('new_w_active').value === 'true',
}); });
location.reload(); location.reload();
@@ -632,6 +704,7 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('w_target').value, target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value, comment: document.getElementById('w_comment').value,
category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true', active: document.getElementById('w_active').value === 'true',
}); });
location.reload(); location.reload();
@@ -640,6 +713,7 @@
function clearWebForm() { function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment'].forEach(id => document.getElementById(id).value = ''); ['w_id','w_name','w_slug','w_target','w_comment'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true'; document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon; document.getElementById('w_icon_preview').src = placeholderIcon;
document.getElementById('w_health_box').style.display = 'none'; document.getElementById('w_health_box').style.display = 'none';
document.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item')); document.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item'));
@@ -699,6 +773,7 @@
document.getElementById('r_comment').value = comment || ''; document.getElementById('r_comment').value = comment || '';
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;
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';
markSelected('.rdp-item', 'data-service-id', id); markSelected('.rdp-item', 'data-service-id', id);
@@ -714,6 +789,7 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('new_r_comment').value, comment: document.getElementById('new_r_comment').value,
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',
}); });
@@ -730,6 +806,7 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('r_comment').value, comment: document.getElementById('r_comment').value,
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',
}); });
@@ -740,6 +817,7 @@
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = ''); ['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
document.getElementById('r_sec').value = ''; document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true'; document.getElementById('r_active').value = 'true';
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';
document.querySelectorAll('.rdp-item').forEach((el) => el.classList.remove('selected-item')); document.querySelectorAll('.rdp-item').forEach((el) => el.classList.remove('selected-item'));
@@ -804,6 +882,12 @@
renderStatusInto(prefix, data); renderStatusInto(prefix, data);
} }
async function autoUploadIcon(prefix) {
const input = document.getElementById(`${prefix}_icon_file`);
if (!input.files || !input.files[0]) return;
await uploadServiceIcon(prefix);
}
async function uploadServiceIcon(prefix) { async function uploadServiceIcon(prefix) {
const serviceId = document.getElementById(`${prefix}_id`).value; const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return alert('Сначала выберите сервис'); if (!serviceId) return alert('Сначала выберите сервис');
@@ -850,6 +934,22 @@
} }
} }
async function createCategory() {
const name = (document.getElementById('cat_name').value || '').trim();
const slugInput = document.getElementById('cat_slug');
const slug = (slugInput.value || slugifyRu(name)).trim();
if (!name) return alert('Введите название категории');
if (!slug) return alert('Введите slug категории');
await api('/api/admin/categories', 'POST', {name, slug});
location.reload();
}
async function deleteCategory(id, name) {
if (!confirm(`Удалить категорию ${name}?`)) return;
await api(`/api/admin/categories/${id}`, 'DELETE', {});
location.reload();
}
setInterval(() => { setInterval(() => {
if (activeTab === 'web' && document.getElementById('w_id').value) { if (activeTab === 'web' && document.getElementById('w_id').value) {
refreshSelectedServiceStatus('web').catch(() => {}); refreshSelectedServiceStatus('web').catch(() => {});
@@ -861,9 +961,9 @@
const hashTab = (window.location.hash || '').replace('#', ''); const hashTab = (window.location.hash || '').replace('#', '');
const savedTab = localStorage.getItem('admin_active_tab'); const savedTab = localStorage.getItem('admin_active_tab');
const initialTab = ['users', 'web', 'rdp', 'stats'].includes(hashTab) const initialTab = ['users', 'web', 'rdp', 'categories', 'stats'].includes(hashTab)
? hashTab ? hashTab
: (['users', 'web', 'rdp', 'stats'].includes(savedTab || '') ? savedTab : 'users'); : (['users', 'web', 'rdp', 'categories', 'stats'].includes(savedTab || '') ? savedTab : 'users');
showTab(initialTab); showTab(initialTab);
renderUserDays(); renderUserDays();
</script> </script>
+28 -6
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфра полигон</title> <title>МОНТ - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
</head> </head>
<body> <body>
@@ -17,28 +17,50 @@
<a href="/admin" class="btn-link secondary">Администрирование</a> <a href="/admin" class="btn-link secondary">Администрирование</a>
{% endif %} {% endif %}
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit">Logout</button> <button type="submit">Выход</button>
</form> </form>
</div> </div>
</header> </header>
<main class="admin-layout"> <main class="admin-layout">
<section class="panel"> <section class="panel">
<div class="admin-intro"> <div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
Выберите нужный сервис. После клика откроется готовый браузер/сеанс с заранее заданным адресом. {% if categories %}
<div class="category-strip">
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
{% for category in categories %}
<a class="category-chip {% if selected_category_slug == category.slug %}active{% endif %}" href="/?category={{ category.slug }}">{{ category.name }}</a>
{% endfor %}
</div> </div>
{% endif %}
</section> </section>
<section class="grid"> <section class="grid service-grid">
{% for service in services %} {% for service in services %}
{% set svc_cats = service_categories.get(service.id, []) %}
<a class="tile" href="/go/{{ service.slug }}"> <a class="tile" href="/go/{{ service.slug }}">
<div class="tile-icon-box">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
</div>
<h3>{{ service.name }}</h3> <h3>{{ service.name }}</h3>
<p>Открыть сервис</p> <p>Открыть сервис</p>
{% if service.comment %} {% if service.comment %}
<small>{{ service.comment }}</small> <small>{{ service.comment }}</small>
{% endif %} {% endif %}
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div>
{% endif %}
</a> </a>
{% else %} {% else %}
<div class="tile">Нет назначенных сервисов</div> <div class="tile">
{% if selected_category_slug %}
Нет сервисов в выбранной категории
{% else %}
Нет назначенных сервисов
{% endif %}
</div>
{% endfor %} {% endfor %}
</section> </section>
</main> </main>
+3 -2
View File
@@ -3,13 +3,14 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфра полигон</title> <title>МОНТ - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
</head> </head>
<body> <body>
<main class="center-box login-page"> <main class="center-box login-page">
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" /> <img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
<h1 class="login-title">МОНТ - инфра полигон</h1> <h1 class="login-title">МОНТ - инфрастуктурный полигон</h1>
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif}
<form method="post" action="/login" class="panel"> <form method="post" action="/login" class="panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>Login</label> <label>Login</label>
+1
View File
@@ -30,6 +30,7 @@ services:
api: api:
build: build:
context: ./app context: ./app
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
environment: environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY} SIGNING_KEY: ${SIGNING_KEY}
+104 -10
View File
@@ -23,7 +23,7 @@
- Режим VNC как отдельный сервис больше не используется (deprecate). - Режим VNC как отдельный сервис больше не используется (deprecate).
- Основной сценарий для пользователей: WEB и RDP. - Основной сценарий для пользователей: WEB и RDP.
- Для WEB используются прогретые per-service пулы (`warm_pool_size` на сервис). - Для WEB используется общий пул `portal-webpool-*` (и авторасширение при нагрузке).
- Для RDP используется универсальный пул слотов (`UNIVERSAL_POOL_SIZE`). - Для RDP используется универсальный пул слотов (`UNIVERSAL_POOL_SIZE`).
- Сессии пользователя имеют UUID-ссылки (`/s/<uuid>/...`). - Сессии пользователя имеют UUID-ссылки (`/s/<uuid>/...`).
@@ -34,6 +34,8 @@
- `/s/<session_id>/` — страница ожидания старта - `/s/<session_id>/` — страница ожидания старта
- `/s/<session_id>/view` — сессионный view для WEB-пула - `/s/<session_id>/view` — сессионный view для WEB-пула
- `/svc/<slug>/` — роут к warm runtime конкретного сервиса - `/svc/<slug>/` — роут к warm runtime конкретного сервиса
- `/w/<slot>/` — роут к WEB pool слоту
- `/u/<slot>/` — роут к universal pool слоту
- `/admin` — админка - `/admin` — админка
## 5) Что важно помнить по инфраструктуре ## 5) Что важно помнить по инфраструктуре
@@ -42,12 +44,14 @@
Причина: динамические контейнеры создают labels во время работы, и именно Traefik маршрутизирует: Причина: динамические контейнеры создают labels во время работы, и именно Traefik маршрутизирует:
- `/s/<session_id>/...` - `/s/<session_id>/...`
- `/svc/<slug>/...` - `/svc/<slug>/...`
- `/w/<slot>/...`
- `/u/<slot>/...`
2. При Nginx Proxy Manager (NPM): 2. При Nginx Proxy Manager (NPM):
- внешний домен -> NPM -> внутренний Traefik. - внешний домен -> NPM -> внутренний Traefik.
- в `docker-compose.yml` Traefik опубликован локально: - в `docker-compose.yml` Traefik опубликован так:
- `127.0.0.1:2288 -> 443` - `0.0.0.0:2288 -> 443`
- `127.0.0.1:8288 -> 80` - `0.0.0.0:8288 -> 80`
- в NPM обязательна опция `Websockets Support`. - в NPM обязательна опция `Websockets Support`.
3. Кнопка «Домой» в runtime UI: 3. Кнопка «Домой» в runtime UI:
@@ -59,21 +63,21 @@
Проверять: Проверять:
- что у noVNC корректный WebSocket endpoint (`.../websockify`); - что у noVNC корректный WebSocket endpoint (`.../websockify`);
- что сессия active в БД; - что сессия active в БД;
- что warm контейнер сервиса running; - что контейнер WEB-пула running;
- что в NPM включен websocket proxy. - что в NPM включен websocket proxy.
Быстрая проверка: Быстрая проверка:
- логи `portal-warm-<slug>-*` - логи `portal-webpool-*`
- логи `portal-api-1` - логи `portal-api-1`
- содержимое `/opt/portal/index.html` внутри warm-контейнера. - содержимое `/opt/portal/index.html` внутри runtime-контейнера.
### B) "Соединение со слотом потеряно" в RDP ### B) "Соединение со слотом потеряно" в RDP
Обычно не проблема портала, а проблема соединения `xfreerdp` до целевого host:port/cred/sec. Обычно не проблема портала, а проблема соединения `xfreerdp` до целевого host:port/cred/sec.
Смотреть `/tmp/session-app.log`/`xfreerdp.log` в `portal-universal-*`. Смотреть `/tmp/session-app.log`/`xfreerdp.log` в `portal-universal-*`.
### C) Изменения не видны сразу ### C) Изменения не видны сразу
Если менялись runtime-скрипты, старые warm-контейнеры могут держать старую версию. Если менялись runtime-скрипты, старые warm/pool контейнеры могут держать старую версию.
Нужно пересобрать образ + пересоздать warm-пул. Нужно пересобрать образ + пересоздать пул.
## 7) Где смотреть код ## 7) Где смотреть код
@@ -82,6 +86,7 @@
- Пользовательский дашборд: `app/templates/dashboard.html` - Пользовательский дашборд: `app/templates/dashboard.html`
- WEB runtime: `kiosk/entrypoint.sh`, `kiosk/manager.py` - WEB runtime: `kiosk/entrypoint.sh`, `kiosk/manager.py`
- RDP runtime: `rdp-proxy/entrypoint.sh` - RDP runtime: `rdp-proxy/entrypoint.sh`
- Universal runtime: `universal-runtime/entrypoint.sh`, `universal-runtime/manager.py`
- Оркестрация: `docker-compose.yml`, `traefik/traefik.yml` - Оркестрация: `docker-compose.yml`, `traefik/traefik.yml`
## 8) Операционные команды ## 8) Операционные команды
@@ -96,7 +101,16 @@ docker compose --profile build-only build kiosk-image rdp-proxy-image universal-
docker compose up -d --build docker compose up -d --build
``` ```
Пересоздать warm-пул (через API startup/ensure) — обычно перезапуск `api` и/или вызов prewarm из админки. Перезапуск только API:
```bash
docker compose up -d api
```
Проверка состояния:
```bash
docker compose ps
docker compose logs -f api traefik
```
## 9) Что еще можно улучшить ## 9) Что еще можно улучшить
@@ -105,3 +119,83 @@ docker compose up -d --build
- централизованный сбор логов и алерты; - централизованный сбор логов и алерты;
- e2e smoke-тесты на сценарии `/go -> /s/<uuid>/view`. - e2e smoke-тесты на сценарии `/go -> /s/<uuid>/view`.
## 10) Сервер и рабочие пути
- SSH сервер: `ruslan@10.17.39.3`
- Пароль `sudo` на сервере: `utOgbZ09ruslanstand`
- Рабочий каталог проекта на сервере: `/root/Stend_mont`
- Файл контекста на сервере: `/root/Stend_mont/docs/PROJECT_CONTEXT.md`
Базовый рабочий сценарий:
```bash
ssh ruslan@10.17.39.3
sudo -s
cd /root/Stend_mont
```
## 11) Git доступ и публикация
Репозиторий:
- `https://git.ruslan.xyz/ruslan/Stend_mont`
Учетные данные HTTPS (текущие):
- login: `ruslan@ipcom.su`
- password/token: `utOgbZ09ruslan`
Пример push:
```bash
cd /root/Stend_mont
git add .
git commit -m "your message"
git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mont main
```
## 12) Текущее runtime-состояние (на момент фиксации)
- API запущен с `uvicorn --workers 4` через `docker-compose.yml`.
- Для WEB используется `portal-webpool-*`.
- Для RDP используется `portal-universal-*`.
## 13) Последние изменения (2026-04-21)
1. UI/брендинг:
- Тексты в интерфейсе переведены на формулировку `инфрастуктурный полигон`.
- На главной панели приветствие в блоке `admin-intro`: `Добро пожаловать в инфрастуктурный полигон`.
- Кнопка выхода на дашборде: `Выход` (вместо `Logout`).
2. WEB runtime (браузерные сервисы):
- В панели управления runtime оставлены 2 кнопки:
- `Назад`
- `Главная` (ведет на главную панель портала `/`).
- Кнопка `Вперед` удалена.
- Изменения применены в `kiosk/entrypoint.sh` и `universal-runtime/entrypoint.sh`.
3. Логин и просроченные пользователи:
- Если пользователь найден и пароль верный, но аккаунт просрочен/неактивен, на экране входа показывается сообщение:
`Доступ к сервису приостоновлен, обратитесь к вашему менеджеру`.
- Сообщение рендерится в шаблоне `app/templates/login.html` через `login_error`.
4. Категории сервисов:
- Добавлены сущности и связи:
- `categories`
- `service_categories`
- Категории можно создавать/удалять в админке.
- При создании/редактировании WEB/RDP сервиса можно выбрать категории.
- На главной панели добавлен стильный фильтр по категориям (chips) и бейджи категорий на карточке сервиса.
5. Иконки сервисов:
- Иконки на главной панели увеличены примерно в 6 раз.
- Масштабирование иконок: `object-fit: contain`, чтобы картинка полностью влезала в рамку.
- В админке загрузка иконки стала автоматической при выборе файла (без кнопки Upload).
6. Многоворкерный API и startup:
- API работает с `uvicorn --workers 4`.
- Чтобы убрать гонку DDL на старте (при нескольких воркерах), добавлен file-lock на bootstrap схемы:
- lock-файл: `/tmp/portal-schema.lock`
- сериализуется выполнение `Base.metadata.create_all(...)` и `ensure_schema_compatibility()`.
7. Операционные заметки по применению runtime-изменений:
- После изменения `kiosk`/`universal-runtime` нужно:
1. пересобрать runtime-образы,
2. пересоздать `portal-webpool-*`, `portal-universal-*`, `portal-warm-*` контейнеры,
3. перезапустить `api`.
+2 -7
View File
@@ -25,7 +25,7 @@ cat > /opt/portal/index.html <<HTML
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Инфра полигон МОНТ</title> <title>Инфрастуктурный полигон МОНТ</title>
<style> <style>
html,body,#screen{margin:0;height:100%;background:#111} html,body,#screen{margin:0;height:100%;background:#111}
.nav-panel{ .nav-panel{
@@ -45,16 +45,12 @@ cat > /opt/portal/index.html <<HTML
<div id="screen"></div> <div id="screen"></div>
<div class="nav-panel"> <div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button> <button class="nav-btn" id="btn-back" type="button">Назад</button>
<button class="nav-btn" id="btn-forward" type="button">Вперед</button> <button class="nav-btn" id="btn-home" type="button">Главная</button>
<button class="nav-btn" id="btn-home" type="button">Домой</button>
</div> </div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
const XK_ALT_L = 0xffe9; const XK_ALT_L = 0xffe9;
const XK_CONTROL_L = 0xffe3;
const XK_LEFT = 0xff51; const XK_LEFT = 0xff51;
const XK_RIGHT = 0xff53;
const XK_ENTER = 0xff0d;
const HOME_URL = ${HOME_URL@Q}; const HOME_URL = ${HOME_URL@Q};
const wsBase = location.pathname.replace(/\/+$/, ''); const wsBase = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify'; const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
@@ -97,7 +93,6 @@ cat > /opt/portal/index.html <<HTML
window.location.href = '/'; window.location.href = '/';
} }
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, "AltLeft", "ArrowLeft")); document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, "AltLeft", "ArrowLeft"));
document.getElementById('btn-forward').addEventListener('click', () => chord(XK_ALT_L, XK_RIGHT, "AltLeft", "ArrowRight"));
document.getElementById('btn-home').addEventListener('click', goHome); document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
</script> </script>
+15
View File
@@ -29,6 +29,21 @@ CREATE TABLE user_service_access (
UNIQUE (user_id, service_id) UNIQUE (user_id, service_id)
); );
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE,
slug VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE 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)
);
CREATE TABLE sessions ( CREATE TABLE sessions (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+37
View File
@@ -38,16 +38,33 @@ cat > /opt/portal/index.html <<'HTML'
color:#ffe3e3; color:#ffe3e3;
} }
.status.hidden{display:none} .status.hidden{display:none}
.nav-panel{
position:fixed;right:14px;top:14px;z-index:99;display:flex;gap:8px;
background:rgba(12,18,26,.88);border:1px solid rgba(255,255,255,.14);
box-shadow:0 8px 22px rgba(0,0,0,.35);padding:8px;border-radius:10px
}
.nav-btn{
border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:8px 12px;cursor:pointer;
background:linear-gradient(180deg,#1a73b3,#0f5b94);color:#fff;font:600 13px/1 sans-serif
}
.nav-btn:hover{filter:brightness(1.08)}
.nav-btn:active{transform:translateY(1px)}
</style> </style>
</head> </head>
<body> <body>
<div id="screen"></div> <div id="screen"></div>
<div id="status" class="status">Подключение к слоту...</div> <div id="status" class="status">Подключение к слоту...</div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button>
<button class="nav-btn" id="btn-home" type="button">Главная</button>
</div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
const basePath = location.pathname.replace(/\/+$/, ''); const basePath = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + basePath + '/websockify'; const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + basePath + '/websockify';
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const XK_ALT_L = 0xffe9;
const XK_LEFT = 0xff51;
let connected = false; let connected = false;
let connectTimer = null; let connectTimer = null;
@@ -94,6 +111,26 @@ cat > /opt/portal/index.html <<'HTML'
setInterval(touch, 60000); setInterval(touch, 60000);
touch(); touch();
} }
function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true);
rfb.sendKey(keysym, code, false);
}
function chord(mod, key, modCode, keyCode) {
rfb.sendKey(mod, modCode, true);
keyTap(key, keyCode);
rfb.sendKey(mod, modCode, false);
}
function goHome() {
try {
if (window.top && window.top !== window) {
window.top.location.href = '/';
return;
}
} catch (e) {}
window.location.href = '/';
}
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
</script> </script>
</body> </body>