feat: categories, runtime nav, and UX updates
This commit is contained in:
+174
-5
@@ -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():
|
||||
# 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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
+84
-9
@@ -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;
|
||||
}
|
||||
|
||||
+107
-7
@@ -10,7 +10,7 @@
|
||||
<header class="header">
|
||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||
<div>МОНТ - инфра полигон | Админ: {{ admin.username }}</div>
|
||||
<div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
||||
</div>
|
||||
<a href="/" class="btn-link secondary">Главная панель</a>
|
||||
</header>
|
||||
@@ -27,6 +27,7 @@
|
||||
<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="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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -160,6 +161,12 @@
|
||||
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</label>
|
||||
</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">
|
||||
<button onclick="saveWebService()">Save</button>
|
||||
<button onclick="deleteService('w_id')">Delete</button>
|
||||
@@ -194,9 +201,8 @@
|
||||
<div class="icon-row">
|
||||
<img id="w_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
|
||||
<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;">
|
||||
<button onclick="uploadServiceIcon('w')">Upload</button>
|
||||
<button onclick="pasteServiceIcon('w')">Paste</button>
|
||||
<button onclick="removeServiceIcon('w')">Remove icon</button>
|
||||
</div>
|
||||
@@ -230,6 +236,12 @@
|
||||
<select id="new_w_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</label>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,6 +291,12 @@
|
||||
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
|
||||
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</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">
|
||||
<button onclick="saveRdpService()">Save</button>
|
||||
<button onclick="prewarmNow('r_id')">Prewarm now</button>
|
||||
@@ -313,9 +331,8 @@
|
||||
<div class="icon-row">
|
||||
<img id="r_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
|
||||
<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;">
|
||||
<button onclick="uploadServiceIcon('r')">Upload</button>
|
||||
<button onclick="pasteServiceIcon('r')">Paste</button>
|
||||
<button onclick="removeServiceIcon('r')">Remove icon</button>
|
||||
</div>
|
||||
@@ -345,11 +362,50 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</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;">
|
||||
<h3>Статистика открытий</h3>
|
||||
<div class="admin-intro">
|
||||
@@ -421,6 +477,7 @@
|
||||
<script>
|
||||
const csrf = "{{ csrf_token }}";
|
||||
const aclMap = {{ acl | tojson }};
|
||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||
const placeholderIcon = '/static/service-placeholder.svg';
|
||||
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) {
|
||||
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'
|
||||
@@ -598,6 +668,7 @@
|
||||
document.getElementById('w_comment').value = comment || '';
|
||||
document.getElementById('w_active').value = String(active);
|
||||
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
|
||||
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
|
||||
document.getElementById('w_health_box').style.display = 'block';
|
||||
markSelected('.web-item', 'data-service-id', id);
|
||||
refreshSelectedServiceStatus('web');
|
||||
@@ -617,6 +688,7 @@
|
||||
type: 'WEB',
|
||||
target: document.getElementById('new_w_target').value,
|
||||
comment: document.getElementById('new_w_comment').value,
|
||||
category_ids: checkedCategoryIds('.new_w_cat'),
|
||||
active: document.getElementById('new_w_active').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
@@ -632,6 +704,7 @@
|
||||
type: 'WEB',
|
||||
target: document.getElementById('w_target').value,
|
||||
comment: document.getElementById('w_comment').value,
|
||||
category_ids: checkedCategoryIds('.w_cat'),
|
||||
active: document.getElementById('w_active').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
@@ -640,6 +713,7 @@
|
||||
function clearWebForm() {
|
||||
['w_id','w_name','w_slug','w_target','w_comment'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('w_active').value = 'true';
|
||||
setCategoryChecks('.w_cat', []);
|
||||
document.getElementById('w_icon_preview').src = placeholderIcon;
|
||||
document.getElementById('w_health_box').style.display = 'none';
|
||||
document.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item'));
|
||||
@@ -699,6 +773,7 @@
|
||||
document.getElementById('r_comment').value = comment || '';
|
||||
document.getElementById('r_active').value = String(active);
|
||||
document.getElementById('r_pool').value = pool;
|
||||
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
|
||||
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'block';
|
||||
markSelected('.rdp-item', 'data-service-id', id);
|
||||
@@ -714,6 +789,7 @@
|
||||
type: 'RDP',
|
||||
target,
|
||||
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),
|
||||
active: document.getElementById('new_r_active').value === 'true',
|
||||
});
|
||||
@@ -730,6 +806,7 @@
|
||||
type: 'RDP',
|
||||
target,
|
||||
comment: document.getElementById('r_comment').value,
|
||||
category_ids: checkedCategoryIds('.r_cat'),
|
||||
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
|
||||
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 = '');
|
||||
document.getElementById('r_sec').value = '';
|
||||
document.getElementById('r_active').value = 'true';
|
||||
setCategoryChecks('.r_cat', []);
|
||||
document.getElementById('r_icon_preview').src = placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'none';
|
||||
document.querySelectorAll('.rdp-item').forEach((el) => el.classList.remove('selected-item'));
|
||||
@@ -804,6 +882,12 @@
|
||||
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) {
|
||||
const serviceId = document.getElementById(`${prefix}_id`).value;
|
||||
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(() => {
|
||||
if (activeTab === 'web' && document.getElementById('w_id').value) {
|
||||
refreshSelectedServiceStatus('web').catch(() => {});
|
||||
@@ -861,9 +961,9 @@
|
||||
|
||||
const hashTab = (window.location.hash || '').replace('#', '');
|
||||
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
|
||||
: (['users', 'web', 'rdp', 'stats'].includes(savedTab || '') ? savedTab : 'users');
|
||||
: (['users', 'web', 'rdp', 'categories', 'stats'].includes(savedTab || '') ? savedTab : 'users');
|
||||
showTab(initialTab);
|
||||
renderUserDays();
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфра полигон</title>
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -17,28 +17,50 @@
|
||||
<a href="/admin" class="btn-link secondary">Администрирование</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Logout</button>
|
||||
<button type="submit">Выход</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<main class="admin-layout">
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
<section class="grid">
|
||||
<section class="grid service-grid">
|
||||
{% for service in services %}
|
||||
{% set svc_cats = service_categories.get(service.id, []) %}
|
||||
<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" />
|
||||
</div>
|
||||
<h3>{{ service.name }}</h3>
|
||||
<p>Открыть сервис</p>
|
||||
{% if service.comment %}
|
||||
<small>{{ service.comment }}</small>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<div class="tile">Нет назначенных сервисов</div>
|
||||
<div class="tile">
|
||||
{% if selected_category_slug %}
|
||||
Нет сервисов в выбранной категории
|
||||
{% else %}
|
||||
Нет назначенных сервисов
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфра полигон</title>
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="center-box login-page">
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>Login</label>
|
||||
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
api:
|
||||
build:
|
||||
context: ./app
|
||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
SIGNING_KEY: ${SIGNING_KEY}
|
||||
|
||||
+104
-10
@@ -23,7 +23,7 @@
|
||||
|
||||
- Режим VNC как отдельный сервис больше не используется (deprecate).
|
||||
- Основной сценарий для пользователей: WEB и RDP.
|
||||
- Для WEB используются прогретые per-service пулы (`warm_pool_size` на сервис).
|
||||
- Для WEB используется общий пул `portal-webpool-*` (и авторасширение при нагрузке).
|
||||
- Для RDP используется универсальный пул слотов (`UNIVERSAL_POOL_SIZE`).
|
||||
- Сессии пользователя имеют UUID-ссылки (`/s/<uuid>/...`).
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
- `/s/<session_id>/` — страница ожидания старта
|
||||
- `/s/<session_id>/view` — сессионный view для WEB-пула
|
||||
- `/svc/<slug>/` — роут к warm runtime конкретного сервиса
|
||||
- `/w/<slot>/` — роут к WEB pool слоту
|
||||
- `/u/<slot>/` — роут к universal pool слоту
|
||||
- `/admin` — админка
|
||||
|
||||
## 5) Что важно помнить по инфраструктуре
|
||||
@@ -42,12 +44,14 @@
|
||||
Причина: динамические контейнеры создают labels во время работы, и именно Traefik маршрутизирует:
|
||||
- `/s/<session_id>/...`
|
||||
- `/svc/<slug>/...`
|
||||
- `/w/<slot>/...`
|
||||
- `/u/<slot>/...`
|
||||
|
||||
2. При Nginx Proxy Manager (NPM):
|
||||
- внешний домен -> NPM -> внутренний Traefik.
|
||||
- в `docker-compose.yml` Traefik опубликован локально:
|
||||
- `127.0.0.1:2288 -> 443`
|
||||
- `127.0.0.1:8288 -> 80`
|
||||
- в `docker-compose.yml` Traefik опубликован так:
|
||||
- `0.0.0.0:2288 -> 443`
|
||||
- `0.0.0.0:8288 -> 80`
|
||||
- в NPM обязательна опция `Websockets Support`.
|
||||
|
||||
3. Кнопка «Домой» в runtime UI:
|
||||
@@ -59,21 +63,21 @@
|
||||
Проверять:
|
||||
- что у noVNC корректный WebSocket endpoint (`.../websockify`);
|
||||
- что сессия active в БД;
|
||||
- что warm контейнер сервиса running;
|
||||
- что контейнер WEB-пула running;
|
||||
- что в NPM включен websocket proxy.
|
||||
|
||||
Быстрая проверка:
|
||||
- логи `portal-warm-<slug>-*`
|
||||
- логи `portal-webpool-*`
|
||||
- логи `portal-api-1`
|
||||
- содержимое `/opt/portal/index.html` внутри warm-контейнера.
|
||||
- содержимое `/opt/portal/index.html` внутри runtime-контейнера.
|
||||
|
||||
### B) "Соединение со слотом потеряно" в RDP
|
||||
Обычно не проблема портала, а проблема соединения `xfreerdp` до целевого host:port/cred/sec.
|
||||
Смотреть `/tmp/session-app.log`/`xfreerdp.log` в `portal-universal-*`.
|
||||
|
||||
### C) Изменения не видны сразу
|
||||
Если менялись runtime-скрипты, старые warm-контейнеры могут держать старую версию.
|
||||
Нужно пересобрать образ + пересоздать warm-пул.
|
||||
Если менялись runtime-скрипты, старые warm/pool контейнеры могут держать старую версию.
|
||||
Нужно пересобрать образ + пересоздать пул.
|
||||
|
||||
## 7) Где смотреть код
|
||||
|
||||
@@ -82,6 +86,7 @@
|
||||
- Пользовательский дашборд: `app/templates/dashboard.html`
|
||||
- WEB runtime: `kiosk/entrypoint.sh`, `kiosk/manager.py`
|
||||
- RDP runtime: `rdp-proxy/entrypoint.sh`
|
||||
- Universal runtime: `universal-runtime/entrypoint.sh`, `universal-runtime/manager.py`
|
||||
- Оркестрация: `docker-compose.yml`, `traefik/traefik.yml`
|
||||
|
||||
## 8) Операционные команды
|
||||
@@ -96,7 +101,16 @@ docker compose --profile build-only build kiosk-image rdp-proxy-image universal-
|
||||
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) Что еще можно улучшить
|
||||
|
||||
@@ -105,3 +119,83 @@ docker compose up -d --build
|
||||
- централизованный сбор логов и алерты;
|
||||
- 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
@@ -25,7 +25,7 @@ cat > /opt/portal/index.html <<HTML
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Инфра полигон МОНТ</title>
|
||||
<title>Инфрастуктурный полигон МОНТ</title>
|
||||
<style>
|
||||
html,body,#screen{margin:0;height:100%;background:#111}
|
||||
.nav-panel{
|
||||
@@ -45,16 +45,12 @@ cat > /opt/portal/index.html <<HTML
|
||||
<div id="screen"></div>
|
||||
<div class="nav-panel">
|
||||
<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>
|
||||
<script type="module">
|
||||
import RFB from './core/rfb.js';
|
||||
const XK_ALT_L = 0xffe9;
|
||||
const XK_CONTROL_L = 0xffe3;
|
||||
const XK_LEFT = 0xff51;
|
||||
const XK_RIGHT = 0xff53;
|
||||
const XK_ENTER = 0xff0d;
|
||||
const HOME_URL = ${HOME_URL@Q};
|
||||
const wsBase = location.pathname.replace(/\/+$/, '');
|
||||
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
|
||||
@@ -97,7 +93,6 @@ cat > /opt/portal/index.html <<HTML
|
||||
window.location.href = '/';
|
||||
}
|
||||
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.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,21 @@ CREATE TABLE user_service_access (
|
||||
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 (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -38,16 +38,33 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
color:#ffe3e3;
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="screen"></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">
|
||||
import RFB from './core/rfb.js';
|
||||
const basePath = location.pathname.replace(/\/+$/, '');
|
||||
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + basePath + '/websockify';
|
||||
const statusEl = document.getElementById('status');
|
||||
const XK_ALT_L = 0xffe9;
|
||||
const XK_LEFT = 0xff51;
|
||||
let connected = false;
|
||||
let connectTimer = null;
|
||||
|
||||
@@ -94,6 +111,26 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
setInterval(touch, 60000);
|
||||
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());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user