cestina, UI, UX

This commit is contained in:
JiriUhlir
2026-05-29 11:34:07 +02:00
parent d145161e4e
commit 6109bdfe61
11 changed files with 354 additions and 131 deletions
+70
View File
@@ -172,6 +172,32 @@ def get_operations_snapshot():
online_all = con.execute("SELECT last_seen_at FROM workers").fetchall() online_all = con.execute("SELECT last_seen_at FROM workers").fetchall()
workers_online = sum(1 for worker in online_all if is_worker_online(worker["last_seen_at"])) workers_online = sum(1 for worker in online_all if is_worker_online(worker["last_seen_at"]))
app_columns = _table_columns(con, "apps")
if "enabled" in app_columns:
active_applications = con.execute(
"""
SELECT COUNT(*) AS count
FROM apps
WHERE enabled = 1
OR LOWER(CAST(enabled AS TEXT)) IN ('1', 'true', 'yes', 'enabled')
"""
).fetchone()["count"]
else:
active_applications = con.execute(
"""
SELECT COUNT(*) AS count
FROM apps
WHERE LOWER(COALESCE(status, '')) NOT IN ('disabled', 'deleted', 'archived')
"""
).fetchone()["count"]
problematic_applications = con.execute(
"""
SELECT COUNT(*) AS count
FROM apps
WHERE LOWER(COALESCE(status, '')) IN ('failed', 'error', 'disabled', 'deleted', 'archived')
"""
).fetchone()["count"]
jobs_summary = con.execute( jobs_summary = con.execute(
""" """
SELECT SELECT
@@ -190,6 +216,15 @@ def get_operations_snapshot():
LIMIT 20 LIMIT 20
""" """
).fetchall() ).fetchall()
recent_failed_jobs = con.execute(
"""
SELECT id, type, target_type, target_id, status, source, created_at, started_at, finished_at
FROM jobs
WHERE status = 'failed'
ORDER BY id DESC
LIMIT 10
"""
).fetchall()
deployments_summary = con.execute( deployments_summary = con.execute(
f""" f"""
@@ -201,6 +236,24 @@ def get_operations_snapshot():
""", """,
(*DEPLOYMENT_RUNNING_STATUSES, *DEPLOYMENT_FAILED_STATUSES, *DEPLOYMENT_SUCCESS_STATUSES), (*DEPLOYMENT_RUNNING_STATUSES, *DEPLOYMENT_FAILED_STATUSES, *DEPLOYMENT_SUCCESS_STATUSES),
).fetchone() ).fetchone()
deployment_columns = _table_columns(con, "deployments")
deployments_today = 0
if "created_at" in deployment_columns:
deployments_today = con.execute(
"""
SELECT COUNT(*) AS count
FROM deployments
WHERE date(created_at) = date('now')
"""
).fetchone()["count"]
elif "started_at" in deployment_columns:
deployments_today = con.execute(
"""
SELECT COUNT(*) AS count
FROM deployments
WHERE date(started_at) = date('now')
"""
).fetchone()["count"]
recent_deployments = con.execute( recent_deployments = con.execute(
""" """
SELECT id, app_id, kind, status, started_at, finished_at, trigger_source SELECT id, app_id, kind, status, started_at, finished_at, trigger_source
@@ -217,6 +270,16 @@ def get_operations_snapshot():
LIMIT 20 LIMIT 20
""" """
).fetchall() ).fetchall()
recent_failed_deployments = con.execute(
f"""
SELECT id, app_id, kind, status, started_at, finished_at, trigger_source
FROM deployments
WHERE LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_FAILED_STATUSES)})
ORDER BY id DESC
LIMIT 10
""",
DEPLOYMENT_FAILED_STATUSES,
).fetchall()
con.close() con.close()
return { return {
@@ -227,6 +290,11 @@ def get_operations_snapshot():
"failed_24h": jobs_summary["failed_24h"] or 0, "failed_24h": jobs_summary["failed_24h"] or 0,
"success_24h": jobs_summary["success_24h"] or 0, "success_24h": jobs_summary["success_24h"] or 0,
"recent": [dict(row) for row in recent_jobs], "recent": [dict(row) for row in recent_jobs],
"recent_failed": [dict(row) for row in recent_failed_jobs],
},
"apps": {
"active": active_applications,
"problematic": problematic_applications,
}, },
"workers": { "workers": {
"online": workers_online, "online": workers_online,
@@ -237,7 +305,9 @@ def get_operations_snapshot():
"running": deployments_summary["running"] or 0, "running": deployments_summary["running"] or 0,
"failed_24h": deployments_summary["failed_24h"] or 0, "failed_24h": deployments_summary["failed_24h"] or 0,
"success_24h": deployments_summary["success_24h"] or 0, "success_24h": deployments_summary["success_24h"] or 0,
"today": deployments_today,
"recent": [dict(row) for row in recent_deployments], "recent": [dict(row) for row in recent_deployments],
"recent_failed": [dict(row) for row in recent_failed_deployments],
}, },
"audit": { "audit": {
"recent": [dict(row) for row in recent_audit], "recent": [dict(row) for row in recent_audit],
+1 -1
View File
@@ -9,7 +9,7 @@ from .routes import apps, audit, auth, backups, deployments, health, jobs, opera
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="AppFactory Portal") app = FastAPI(title="CSBot Services Portal")
session_secret = read_env_value("PORTAL_SESSION_SECRET", "") or "dev-only-appfactory-session-secret" session_secret = read_env_value("PORTAL_SESSION_SECRET", "") or "dev-only-appfactory-session-secret"
app.add_middleware( app.add_middleware(
+131 -33
View File
@@ -1,7 +1,8 @@
import html import html
from urllib.parse import quote import math
from urllib.parse import quote, urlencode
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import require_user from ..auth import require_user
@@ -16,17 +17,46 @@ from ..config import (
) )
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
from ..db.audit import log_audit_event from ..db.audit import log_audit_event
from ..db.jobs import create_job, has_active_deploy_job from ..db.jobs import create_job, get_jobs, has_active_deploy_job
from ..routes.deployments import render_status_pill from ..routes.deployments import render_status_pill
from ..shell import run_command from ..shell import run_command
from ..templates.layout import page, render_result from ..templates.layout import page, render_result
router = APIRouter() router = APIRouter()
DEFAULT_PAGE_SIZE = 20
@router.get("/", response_class=HTMLResponse) @router.get("/")
def index(request: Request, user=Depends(require_user)): def portal_home(user=Depends(require_user)):
return RedirectResponse(url="/portal/operations", status_code=303)
@router.get("/apps", response_class=HTMLResponse)
def apps_page(
request: Request,
q: str = Query(""),
status: str = Query(""),
page_number: int = Query(1, alias="page", ge=1),
user=Depends(require_user),
):
apps = get_apps() apps = get_apps()
query = q.strip()
selected_status = status.strip()
if query:
apps = [
item for item in apps
if query.lower() in (item.get("id", "") or "").lower()
or query.lower() in (item.get("name", "") or "").lower()
]
if selected_status:
apps = [item for item in apps if (item.get("status", "") or "") == selected_status]
status_values = sorted({item.get("status", "") for item in get_apps() if item.get("status")})
total_apps = len(apps)
total_pages = max(1, math.ceil(total_apps / DEFAULT_PAGE_SIZE))
if page_number > total_pages:
page_number = total_pages
offset = (page_number - 1) * DEFAULT_PAGE_SIZE
apps = apps[offset : offset + DEFAULT_PAGE_SIZE]
gitea_url = read_env_value("GITEA_URL", "") gitea_url = read_env_value("GITEA_URL", "")
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG) gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
@@ -108,8 +138,8 @@ def index(request: Request, user=Depends(require_user)):
<td> <td>
<p><a class="btn" href="/portal/apps/{app_url_id}">Detail</a></p> <p><a class="btn" href="/portal/apps/{app_url_id}">Detail</a></p>
<p><a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení</a></p> <p><a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení</a></p>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {app_id}?');"> <form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {app_id}?');">
<button type="submit" class="btn-secondary">Redeploy</button> <button type="submit" class="btn-secondary">Nasadit znovu</button>
</form> </form>
<form method="post" action="/portal/delete-app" onsubmit="return confirm('Smazat {app_id}? Tím se odstraní kontejner, image, workspace, záznam v katalogu a Gitea repozitář.');"> <form method="post" action="/portal/delete-app" onsubmit="return confirm('Smazat {app_id}? Tím se odstraní kontejner, image, workspace, záznam v katalogu a Gitea repozitář.');">
<input type="hidden" name="app_id" value="{app_id}"> <input type="hidden" name="app_id" value="{app_id}">
@@ -120,14 +150,40 @@ def index(request: Request, user=Depends(require_user)):
""" """
if not rows: if not rows:
rows = '<tr><td colspan="6">Zatím nejsou nasazené žádné aplikace.</td></tr>' rows = '<tr><td colspan="6">Zatím nejsou nasazené žádné služby.</td></tr>'
status_options = ['<option value="">Všechny stavy</option>']
for value in status_values:
selected = " selected" if selected_status == value else ""
escaped_value = html.escape(value)
status_options.append(f'<option value="{escaped_value}"{selected}>{escaped_value}</option>')
first_item = offset + 1 if total_apps else 0
last_item = min(offset + len(apps), total_apps)
def page_url(page: int) -> str:
params = {"page": page}
if query:
params["q"] = query
if selected_status:
params["status"] = selected_status
return f"/portal/apps?{urlencode(params)}"
pagination = f"""
<div class="pagination">
<span>Zobrazeno {first_item}-{last_item} z {total_apps}</span>
<div class="pagination-actions">
<a class="btn btn-secondary{' disabled' if page_number <= 1 else ''}" href="{page_url(max(1, page_number - 1))}">Předchozí</a>
<span class="page-indicator">Strana {page_number} / {total_pages}</span>
<a class="btn btn-secondary{' disabled' if page_number >= total_pages else ''}" href="{page_url(min(total_pages, page_number + 1))}">Další</a>
</div>
</div>
"""
return page( return page(
"Aplikace", "Služby",
f""" f"""
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h2>Aplikace</h2> <h2>Služby</h2>
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p> <p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
</div> </div>
<div class="card"> <div class="card">
@@ -143,11 +199,19 @@ def index(request: Request, user=Depends(require_user)):
</div> </div>
<div class="card"> <div class="card">
<h2>Nasazené aplikace</h2> <h2>Nasazené služby</h2>
<p><a class="btn" href="/portal/new-app">+ Nová aplikace</a></p> <p><a class="btn" href="/portal/new-app">+ Nová služba</a></p>
<form method="get" action="/portal/apps" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
<select name="status">{"".join(status_options)}</select>
<input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/apps">Reset</a>
</form>
{pagination}
<table> <table>
<tr> <tr>
<th>Aplikace</th> <th>Služba</th>
<th>Status</th> <th>Status</th>
<th>Dokumentace</th> <th>Dokumentace</th>
<th>Prostředky</th> <th>Prostředky</th>
@@ -156,6 +220,7 @@ def index(request: Request, user=Depends(require_user)):
</tr> </tr>
{rows} {rows}
</table> </table>
{pagination}
</div> </div>
""", """,
user=user, user=user,
@@ -197,20 +262,39 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
""" """
if not rows: if not rows:
rows = '<tr><td colspan="4">Zatím nejsou evidovaná žádná nasazení této aplikace.</td></tr>' rows = '<tr><td colspan="4">Zatím nejsou evidovaná žádná nasazení této služby.</td></tr>'
job_rows = ""
for job in get_jobs(limit=100, target=app.get("id", "")):
if job.get("target_type") != "app" or job.get("target_id") != app.get("id", ""):
continue
job_id = html.escape(str(job.get("id", "")))
job_rows += f"""
<tr>
<td><a href="/portal/jobs/{job_id}">#{job_id}</a></td>
<td>{render_status_pill(job.get("status"))}</td>
<td>{html.escape(job.get("type", "") or "")}</td>
<td>{html.escape(job.get("created_at", "") or "")}</td>
</tr>
"""
if not job_rows:
job_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné úlohy této služby.</td></tr>'
return page( return page(
f"Aplikace {escaped_app_id}", f"Služba {escaped_app_id}",
f""" f"""
<div class="card"> <div class="card">
<h2>{escaped_app_id}</h2> <h2>{escaped_app_id}</h2>
<p class="muted">{name}</p> <p class="muted">{name}</p>
<p> <p>
<a class="btn" href="/portal">&larr; Zpět na aplikace</a> <a class="btn" href="/portal/apps">&larr; Zpět na služby</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení aplikace</a> <a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení služby</a>
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}">Úlohy služby</a>
</p> </p>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {escaped_app_id}?');"> <form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {escaped_app_id}?');">
<button type="submit">Redeploy</button> <button type="submit">Nasadit znovu</button>
</form> </form>
</div> </div>
@@ -225,21 +309,35 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
<tr><th>Paměť</th><td>{memory}</td></tr> <tr><th>Paměť</th><td>{memory}</td></tr>
<tr><th>CPU</th><td>{cpus}</td></tr> <tr><th>CPU</th><td>{cpus}</td></tr>
<tr><th>Upraveno</th><td>{updated_at}</td></tr> <tr><th>Upraveno</th><td>{updated_at}</td></tr>
<tr><th>Logy</th><td><a href="/portal/jobs?target={app_url_id}">Zobrazit úlohy a logy</a></td></tr>
</table> </table>
</div> </div>
<div class="card"> <div class="card">
<h2>Poslední deploymenty</h2> <h2>Historie nasazení</h2>
<table> <table>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Status</th> <th>Status</th>
<th>Timestamp</th> <th>Čas</th>
<th>Triggered by</th> <th>Spustil</th>
</tr> </tr>
{rows} {rows}
</table> </table>
</div> </div>
<div class="card">
<h2>Historie úloh</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Typ</th>
<th>Vytvořeno</th>
</tr>
{job_rows}
</table>
</div>
""", """,
user=user, user=user,
) )
@@ -277,20 +375,20 @@ def redeploy_app(app_id: str, user=Depends(require_user)):
@router.get("/new-app", response_class=HTMLResponse) @router.get("/new-app", response_class=HTMLResponse)
def new_app_form(request: Request, user=Depends(require_user)): def new_app_form(request: Request, user=Depends(require_user)):
return page( return page(
"Nová aplikace", "Nová služba",
""" """
<div class="card"> <div class="card">
<h2>Vytvořit novou aplikaci</h2> <h2>Vytvořit novou službu</h2>
<p class="muted">Vytvoří Gitea repozitář, webhook, lokální workspace, první commit a nasadí službu.</p> <p class="muted">Vytvoří Gitea repozitář, webhook, lokální workspace, první commit a nasadí službu.</p>
<form method="post" action="/portal/new-app"> <form method="post" action="/portal/new-app">
<p> <p>
<label>ID aplikace</label><br> <label>ID služby</label><br>
<input name="app_id" placeholder="gmail-service" required> <input name="app_id" placeholder="gmail-service" required>
</p> </p>
<p> <p>
<label>Název aplikace</label><br> <label>Název služby</label><br>
<input name="app_name" placeholder="Gmail služba" required> <input name="app_name" placeholder="Gmail služba" required>
</p> </p>
@@ -301,10 +399,10 @@ def new_app_form(request: Request, user=Depends(require_user)):
</select> </select>
</p> </p>
<button type="submit">Vytvořit aplikaci</button> <button type="submit">Vytvořit službu</button>
</form> </form>
<p><a href="/portal">&larr; Zpět</a></p> <p><a href="/portal/apps">&larr; Zpět</a></p>
</div> </div>
""", """,
user=user, user=user,
@@ -341,8 +439,8 @@ def create_app(
) )
return render_result( return render_result(
title=f"Vytvoření aplikace: {status}", title=f"Vytvoření služby: {status}",
back_url="/portal", back_url="/portal/apps",
sections=[ sections=[
("Výstup vytvoření", create_result.stdout), ("Výstup vytvoření", create_result.stdout),
("Chyba vytvoření", create_result.stderr), ("Chyba vytvoření", create_result.stderr),
@@ -372,8 +470,8 @@ def delete_app(app_id: str = Form(...), user=Depends(require_user)):
) )
return render_result( return render_result(
title=f"Smazání aplikace: {status}", title=f"Smazání služby: {status}",
back_url="/portal", back_url="/portal/apps",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)], sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user, user=user,
) )
@@ -418,7 +516,7 @@ def update_resources(
return render_result( return render_result(
title=f"Úprava prostředků: {status}", title=f"Úprava prostředků: {status}",
back_url="/portal", back_url="/portal/apps",
sections=[ sections=[
("Výstup katalogu", catalog_result.stdout), ("Výstup katalogu", catalog_result.stdout),
("Chyba katalogu", catalog_result.stderr), ("Chyba katalogu", catalog_result.stderr),
+4 -4
View File
@@ -13,7 +13,7 @@ router = APIRouter()
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
def login_form(request: Request): def login_form(request: Request):
if current_user(request): if current_user(request):
return RedirectResponse(url="/portal", status_code=303) return RedirectResponse(url="/portal/operations", status_code=303)
return _render_login() return _render_login()
@@ -35,7 +35,7 @@ def login(request: Request, username: str = Form(...), password: str = Form(...)
metadata={"username": user.get("username")}, metadata={"username": user.get("username")},
) )
return RedirectResponse(url="/portal", status_code=303) return RedirectResponse(url="/portal/operations", status_code=303)
@router.post("/logout") @router.post("/logout")
@@ -61,8 +61,8 @@ def _render_login(error: str | None = None) -> str:
"Přihlášení", "Přihlášení",
f""" f"""
<div class="auth-card card"> <div class="auth-card card">
<h2>Přihlášení do portálu</h2> <h2>CSBot Services Portal</h2>
<p class="muted">Přihlaste se interním účtem AppFactory.</p> <p class="muted">Přihlaste se interním účtem.</p>
{error_html} {error_html}
<form method="post" action="/portal/login"> <form method="post" action="/portal/login">
+29 -11
View File
@@ -49,7 +49,25 @@ def render_status_pill(status: str | None) -> str:
else: else:
class_name = "pill pill-muted" class_name = "pill pill-muted"
return f'<span class="{class_name}">{html.escape(status_value)}</span>' labels = {
"running": "běží",
"pending": "čeká",
"queued": "ve frontě",
"in_progress": "probíhá",
"starting": "startuje",
"success": "úspěšné",
"ok": "úspěšné",
"succeeded": "úspěšné",
"done": "hotovo",
"deployed": "nasazeno",
"completed": "dokončeno",
"failed": "selhalo",
"failure": "selhalo",
"error": "chyba",
"cancelled": "zrušeno",
"canceled": "zrušeno",
}
return f'<span class="{class_name}">{html.escape(labels.get(normalized, status_value))}</span>'
def parse_timestamp(value: str | None) -> datetime | None: def parse_timestamp(value: str | None) -> datetime | None:
@@ -229,14 +247,14 @@ async def deployments_page(
<div class="card"> <div class="card">
<h2>Historie nasazení</h2> <h2>Historie nasazení</h2>
<p class="muted">Přehled posledních běhů nasazení a jejich výsledků.</p> <p class="muted">Přehled posledních běhů nasazení a jejich výsledků.</p>
<a class="btn" href="/portal">&larr; Zpět na portál</a> <a class="btn" href="/portal/operations">&larr; Zpět na přehled</a>
</div> </div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-total"><span>Total deploys</span><strong>{stats.get("total") or 0}</strong></div> <div class="stat-card stat-total"><span>Celkem nasazení</span><strong>{stats.get("total") or 0}</strong></div>
<div class="stat-card stat-danger"><span>Failed deploys</span><strong>{stats.get("failed") or 0}</strong></div> <div class="stat-card stat-danger"><span>Selhaná nasazení</span><strong>{stats.get("failed") or 0}</strong></div>
<div class="stat-card stat-warning"><span>Running deploys</span><strong>{stats.get("running") or 0}</strong></div> <div class="stat-card stat-warning"><span>Běžící nasazení</span><strong>{stats.get("running") or 0}</strong></div>
<div class="stat-card stat-success"><span>Success rate</span><strong>{stats.get("success_rate") or 0}%</strong></div> <div class="stat-card stat-success"><span>Úspěšnost</span><strong>{stats.get("success_rate") or 0}%</strong></div>
</div> </div>
<div class="card"> <div class="card">
@@ -335,19 +353,19 @@ async def deployment_detail_page(
{failed_notice} {failed_notice}
<p> <p>
<a class="btn" href="/portal/deployments">&larr; Zpět na nasazení</a> <a class="btn" href="/portal/deployments">&larr; Zpět na nasazení</a>
<a class="btn btn-secondary" href="/portal/apps/{app_url_id}">Detail aplikace</a> <a class="btn btn-secondary" href="/portal/apps/{app_url_id}">Detail služby</a>
<a class="btn btn-secondary" href="/portal/deployments/{html.escape(str(deployment_id))}/logs/raw">Raw logy</a> <a class="btn btn-secondary" href="/portal/deployments/{html.escape(str(deployment_id))}/logs/raw">Surové logy</a>
</p> </p>
</div> </div>
<div class="card"> <div class="card">
<h2>Souhrn</h2> <h2>Souhrn</h2>
<table> <table>
<tr><th>Aplikace</th><td><a href="/portal/apps/{app_url_id}">{app_id}</a></td></tr> <tr><th>Služba</th><td><a href="/portal/apps/{app_url_id}">{app_id}</a></td></tr>
<tr><th>Typ</th><td>{kind}</td></tr> <tr><th>Typ</th><td>{kind}</td></tr>
<tr><th>Status</th><td>{status}</td></tr> <tr><th>Status</th><td>{status}</td></tr>
<tr><th>Trigger source</th><td>{trigger_source}</td></tr> <tr><th>Zdroj spuštění</th><td>{trigger_source}</td></tr>
<tr><th>Triggered by</th><td>{triggered_by}</td></tr> <tr><th>Spustil</th><td>{triggered_by}</td></tr>
<tr><th>Commit author</th><td>{commit_author}</td></tr> <tr><th>Commit author</th><td>{commit_author}</td></tr>
<tr><th>Pusher</th><td>{pusher}</td></tr> <tr><th>Pusher</th><td>{pusher}</td></tr>
<tr><th>Spuštěno</th><td>{started_at}</td></tr> <tr><th>Spuštěno</th><td>{started_at}</td></tr>
+37 -29
View File
@@ -39,7 +39,15 @@ def render_job_status(status: str | None) -> str:
else: else:
class_name = "pill pill-muted" class_name = "pill pill-muted"
return f'<span class="{class_name}">{html.escape(status_value)}</span>' labels = {
"queued": "čeká",
"running": "běží",
"cancelled_requested": "žádost o zrušení",
"cancelled": "zrušeno",
"success": "úspěšné",
"failed": "selhalo",
}
return f'<span class="{class_name}">{html.escape(labels.get(normalized, status_value))}</span>'
def pretty_json(value: str | None) -> str: def pretty_json(value: str | None) -> str:
@@ -195,7 +203,7 @@ def jobs_page(
""" """
if not rows: if not rows:
rows = '<tr><td colspan="9">Zatím nejsou evidované žádné joby.</td></tr>' rows = '<tr><td colspan="9">Zatím nejsou evidované žádné úlohy.</td></tr>'
status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy") status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy")
type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy") type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy")
@@ -225,19 +233,19 @@ def jobs_page(
""" """
return page( return page(
"Joby", "Úlohy",
f""" f"""
{refresh} {refresh}
<div class="card"> <div class="card">
<h2>Joby</h2> <h2>Úlohy</h2>
<p class="muted">Fronta portálových a webhook úloh připravená pro centrální worker.</p> <p class="muted">Fronta portálových a webhook úloh připravená pro centrální worker.</p>
</div> </div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-warning"><span>Queued</span><strong data-live-count="jobs.queued">{stats.get("queued") or 0}</strong></div> <div class="stat-card stat-warning"><span>Čekající</span><strong data-live-count="jobs.queued">{stats.get("queued") or 0}</strong></div>
<div class="stat-card stat-warning"><span>Running</span><strong data-live-count="jobs.running">{stats.get("running") or 0}</strong></div> <div class="stat-card stat-warning"><span>Běžící</span><strong data-live-count="jobs.running">{stats.get("running") or 0}</strong></div>
<div class="stat-card stat-danger"><span>Failed (24h)</span><strong data-live-count="jobs.failed_24h">{stats.get("failed_24h") or 0}</strong></div> <div class="stat-card stat-danger"><span>Selhané (24 h)</span><strong data-live-count="jobs.failed_24h">{stats.get("failed_24h") or 0}</strong></div>
<div class="stat-card stat-success"><span>Success (24h)</span><strong data-live-count="jobs.success_24h">{stats.get("success_24h") or 0}</strong></div> <div class="stat-card stat-success"><span>Úspěšné (24 h)</span><strong data-live-count="jobs.success_24h">{stats.get("success_24h") or 0}</strong></div>
</div> </div>
<div class="card"> <div class="card">
@@ -245,7 +253,7 @@ def jobs_page(
<form method="get" action="/portal/jobs" class="filter-form"> <form method="get" action="/portal/jobs" class="filter-form">
<select name="status">{status_options}</select> <select name="status">{status_options}</select>
<select name="type">{type_options}</select> <select name="type">{type_options}</select>
<input name="target" value="{target_value}" placeholder="Target"> <input name="target" value="{target_value}" placeholder="Cíl">
<input type="hidden" name="page" value="1"> <input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button> <button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/jobs">Reset</a> <a class="btn btn-secondary" href="/portal/jobs">Reset</a>
@@ -253,15 +261,15 @@ def jobs_page(
</div> </div>
<div class="card"> <div class="card">
<h2>Recent Jobs</h2> <h2>Poslední úlohy</h2>
<table> <table>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Status</th> <th>Status</th>
<th>Type</th> <th>Typ</th>
<th>Target</th> <th>Cíl</th>
<th>Source</th> <th>Zdroj</th>
<th>Created At</th> <th>Vytvořeno</th>
</tr> </tr>
<tbody data-live-table="jobs.recent"></tbody> <tbody data-live-table="jobs.recent"></tbody>
</table> </table>
@@ -274,10 +282,10 @@ def jobs_page(
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Status</th> <th>Status</th>
<th>Source</th> <th>Zdroj</th>
<th>Target</th> <th>Cíl</th>
<th>Created by</th> <th>Vytvořil</th>
<th>Created at</th> <th>Vytvořeno</th>
<th>Started at</th> <th>Started at</th>
<th>Finished at</th> <th>Finished at</th>
<th>Akce</th> <th>Akce</th>
@@ -300,7 +308,7 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
status_value = (job.get("status") or "").lower() status_value = (job.get("status") or "").lower()
refresh = "" refresh = ""
title = f"Job #{html.escape(str(job.get('id', job_id)))}" title = f"Úloha #{html.escape(str(job.get('id', job_id)))}"
status = render_job_status(job.get("status")) status = render_job_status(job.get("status"))
target_type = job.get("target_type", "") or "" target_type = job.get("target_type", "") or ""
target_id = job.get("target_id", "") or "" target_id = job.get("target_id", "") or ""
@@ -315,7 +323,7 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
if status_value == "failed" and can_retry_job(job): if status_value == "failed" and can_retry_job(job):
actions += f""" actions += f"""
<form method="post" action="/portal/jobs/{html.escape(str(job_id))}/retry" onsubmit="return confirm('Retry job #{html.escape(str(job_id))}?');"> <form method="post" action="/portal/jobs/{html.escape(str(job_id))}/retry" onsubmit="return confirm('Retry job #{html.escape(str(job_id))}?');">
<button type="submit">Retry Job</button> <button type="submit">Spustit znovu</button>
</form> </form>
""" """
elif status_value == "failed" and (job.get("target_id") or "") in IGNORED_RETRY_REPOSITORIES: elif status_value == "failed" and (job.get("target_id") or "") in IGNORED_RETRY_REPOSITORIES:
@@ -323,7 +331,7 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
if status_value in {"queued", "running"}: if status_value in {"queued", "running"}:
actions += f""" actions += f"""
<form method="post" action="/portal/jobs/{html.escape(str(job_id))}/cancel" onsubmit="return confirm('Cancel job #{html.escape(str(job_id))}?');"> <form method="post" action="/portal/jobs/{html.escape(str(job_id))}/cancel" onsubmit="return confirm('Cancel job #{html.escape(str(job_id))}?');">
<button type="submit" class="danger">Cancel Job</button> <button type="submit" class="danger">Zrušit úlohu</button>
</form> </form>
""" """
if actions: if actions:
@@ -420,8 +428,8 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
<div class="card"> <div class="card">
<h2>{title}</h2> <h2>{title}</h2>
<p> <p>
<a class="btn" href="/portal/jobs">&larr; Zpět na joby</a> <a class="btn" href="/portal/jobs">&larr; Zpět na úlohy</a>
<a class="btn btn-secondary" href="{target_url}">Detail targetu</a> <a class="btn btn-secondary" href="{target_url}">Detail cíle</a>
</p> </p>
{retry_blocked_notice} {retry_blocked_notice}
{actions} {actions}
@@ -432,10 +440,10 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
<table> <table>
<tr><th>Typ</th><td>{html.escape(job.get("type", "") or "")}</td></tr> <tr><th>Typ</th><td>{html.escape(job.get("type", "") or "")}</td></tr>
<tr><th>Status</th><td>{status}</td></tr> <tr><th>Status</th><td>{status}</td></tr>
<tr><th>Source</th><td>{html.escape(job.get("source", "") or "")}</td></tr> <tr><th>Zdroj</th><td>{html.escape(job.get("source", "") or "")}</td></tr>
<tr><th>Target</th><td>{html.escape(target_type)}: {target_id_html}</td></tr> <tr><th>Cíl</th><td>{html.escape(target_type)}: {target_id_html}</td></tr>
<tr><th>Created by</th><td>{html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")}</td></tr> <tr><th>Vytvořil</th><td>{html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")}</td></tr>
<tr><th>Created at</th><td>{html.escape(job.get("created_at", "") or "")}</td></tr> <tr><th>Vytvořeno</th><td>{html.escape(job.get("created_at", "") or "")}</td></tr>
<tr><th>Started at</th><td>{html.escape(job.get("started_at", "") or "")}</td></tr> <tr><th>Started at</th><td>{html.escape(job.get("started_at", "") or "")}</td></tr>
<tr><th>Finished at</th><td>{html.escape(job.get("finished_at", "") or "")}</td></tr> <tr><th>Finished at</th><td>{html.escape(job.get("finished_at", "") or "")}</td></tr>
<tr><th>Duration</th><td>{duration}</td></tr> <tr><th>Duration</th><td>{duration}</td></tr>
@@ -456,8 +464,8 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
</div> </div>
<div class="card"> <div class="card">
<h2>Live logy</h2> <h2>Živé logy</h2>
<p class="muted">WebSocket: <span id="live-log-status">Connecting</span></p> <p class="muted">WebSocket: <span id="live-log-status">Připojování</span></p>
<div id="live-log-panel" class="log-viewer log-stdout"></div> <div id="live-log-panel" class="log-viewer log-stdout"></div>
</div> </div>
+19 -19
View File
@@ -135,55 +135,55 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
) )
return page( return page(
"Operations", "Přehled",
f""" f"""
<div class="card"> <div class="card">
<h2>Operations Dashboard</h2> <h2>Přehled systému</h2>
<p class="muted">Provozní přehled workerů, jobů, deploymentů a auditních událostí.</p> <p class="muted">Rychlá odpověď na otázku, zda jsou služby, úlohy a nasazení v pořádku.</p>
</div> </div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-success"><span>Workers Online</span><strong data-live-count="workers.online">{snapshot["workers"]["online"]}</strong></div> <div class="stat-card stat-success"><span>Běžící služby</span><strong data-live-count="apps.active">{snapshot["apps"]["active"]}</strong></div>
<div class="stat-card stat-warning"><span>Running Jobs</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</strong></div> <div class="stat-card stat-danger"><span>Problémové služby</span><strong data-live-count="apps.problematic">{snapshot["apps"]["problematic"]}</strong></div>
<div class="stat-card stat-warning"><span>Queued Jobs</span><strong data-live-count="jobs.queued">{snapshot["jobs"]["queued"]}</strong></div> <div class="stat-card stat-warning"><span>Aktivní úlohy</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</strong></div>
<div class="stat-card stat-danger"><span>Failed Jobs (24h)</span><strong data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</strong></div> <div class="stat-card stat-total"><span>Nasazení dnes</span><strong data-live-count="deployments.today">{snapshot["deployments"]["today"]}</strong></div>
<div class="stat-card stat-warning"><span>Running Deployments</span><strong data-live-count="deployments.running">{snapshot["deployments"]["running"]}</strong></div> <div class="stat-card stat-success"><span>Online workery</span><strong data-live-count="workers.online">{snapshot["workers"]["online"]}</strong></div>
<div class="stat-card stat-danger"><span>Failed Deployments (24h)</span><strong data-live-count="deployments.failed_24h">{snapshot["deployments"]["failed_24h"]}</strong></div> <div class="stat-card stat-danger"><span>Selhané úlohy (24 h)</span><strong data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</strong></div>
</div> </div>
<div class="card"> <div class="card">
<h2>Recent Jobs</h2> <h2>Poslední incidenty</h2>
<table> <table>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Status</th> <th>Status</th>
<th>Type</th> <th>Typ</th>
<th>Target</th> <th>Cíl</th>
<th>Source</th> <th>Zdroj</th>
<th>Created At</th> <th>Vytvořeno</th>
</tr> </tr>
<tbody data-live-table="jobs.recent">{render_recent_jobs(snapshot["jobs"]["recent"])}</tbody> <tbody data-live-table="jobs.failed">{render_recent_jobs(snapshot["jobs"]["recent_failed"])}</tbody>
</table> </table>
</div> </div>
<div class="card"> <div class="card">
<h2>Recent Deployments</h2> <h2>Poslední neúspěšná nasazení</h2>
<table> <table>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Aplikace</th> <th>Služba</th>
<th>Typ</th> <th>Typ</th>
<th>Status</th> <th>Status</th>
<th>Zdroj</th> <th>Zdroj</th>
<th>Spuštěno</th> <th>Spuštěno</th>
<th>Dokončeno</th> <th>Dokončeno</th>
</tr> </tr>
<tbody data-live-table="deployments.recent">{render_recent_deployments(snapshot["deployments"]["recent"])}</tbody> <tbody data-live-table="deployments.failed">{render_recent_deployments(snapshot["deployments"]["recent_failed"])}</tbody>
</table> </table>
</div> </div>
<div class="card"> <div class="card">
<h2>Recent Audit Events</h2> <h2>Poslední auditní události</h2>
<table> <table>
<tr> <tr>
<th>Čas</th> <th>Čas</th>
+19 -19
View File
@@ -17,8 +17,8 @@ DEFAULT_PAGE_SIZE = 20
def render_worker_badge(online: bool) -> str: def render_worker_badge(online: bool) -> str:
if online: if online:
return '<span class="pill pill-success">ONLINE</span>' return '<span class="pill pill-success">online</span>'
return '<span class="pill pill-danger">OFFLINE</span>' return '<span class="pill pill-danger">offline</span>'
def pretty_json(value: str | None) -> str: def pretty_json(value: str | None) -> str:
@@ -131,24 +131,24 @@ def workers_page(
) )
return page( return page(
"Workers", "Workery",
f""" f"""
<div class="card"> <div class="card">
<h2>Workers</h2> <h2>Workery</h2>
<p class="muted">Přehled worker procesů obsluhujících AppFactory joby.</p> <p class="muted">Přehled worker procesů obsluhujících úlohy portálu.</p>
</div> </div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-success"><span>Online Workers</span><strong data-live-count="workers.online">{online_count}</strong></div> <div class="stat-card stat-success"><span>Online workery</span><strong data-live-count="workers.online">{online_count}</strong></div>
<div class="stat-card stat-danger"><span>Offline Workers</span><strong data-live-count="workers.offline">{offline_count}</strong></div> <div class="stat-card stat-danger"><span>Offline workery</span><strong data-live-count="workers.offline">{offline_count}</strong></div>
<div class="stat-card stat-total"><span>Total Workers</span><strong data-live-count="workers.total">{total_count}</strong></div> <div class="stat-card stat-total"><span>Celkem workerů</span><strong data-live-count="workers.total">{total_count}</strong></div>
</div> </div>
<div class="card"> <div class="card">
<h2>Filtry</h2> <h2>Filtry</h2>
<form method="get" action="/portal/workers" class="filter-form"> <form method="get" action="/portal/workers" class="filter-form">
<select name="status">{status_options}</select> <select name="status">{status_options}</select>
<input name="q" value="{html.escape(query)}" placeholder="Worker ID"> <input name="q" value="{html.escape(query)}" placeholder="ID workeru">
<input type="hidden" name="page" value="1"> <input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button> <button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/workers">Reset</a> <a class="btn btn-secondary" href="/portal/workers">Reset</a>
@@ -156,15 +156,15 @@ def workers_page(
</div> </div>
<div class="card"> <div class="card">
<h2>Workers</h2> <h2>Workery</h2>
{pagination} {pagination}
<table> <table>
<tr> <tr>
<th>Worker ID</th> <th>ID workeru</th>
<th>Status</th> <th>Status</th>
<th>Last Seen</th> <th>Naposledy viděn</th>
<th>Current Job</th> <th>Aktuální úloha</th>
<th>Started At</th> <th>Spuštěn</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
<tbody{live_table_attr}>{rows}</tbody> <tbody{live_table_attr}>{rows}</tbody>
@@ -217,18 +217,18 @@ def worker_detail_page(worker_id: str, request: Request, user=Depends(require_us
<div class="card"> <div class="card">
<h2>Worker {worker_id_html}</h2> <h2>Worker {worker_id_html}</h2>
<p> <p>
<a class="btn" href="/portal/workers">&larr; Zpět na workers</a> <a class="btn" href="/portal/workers">&larr; Zpět na workery</a>
</p> </p>
</div> </div>
<div class="card"> <div class="card">
<h2>Souhrn</h2> <h2>Souhrn</h2>
<table> <table>
<tr><th>Worker ID</th><td>{worker_id_html}</td></tr> <tr><th>ID workeru</th><td>{worker_id_html}</td></tr>
<tr><th>Status</th><td>{badge}<br><span class="muted">{status}</span></td></tr> <tr><th>Status</th><td>{badge}<br><span class="muted">{status}</span></td></tr>
<tr><th>Last Seen</th><td>{last_seen_at}</td></tr> <tr><th>Naposledy viděn</th><td>{last_seen_at}</td></tr>
<tr><th>Current Job</th><td>{current_job}</td></tr> <tr><th>Aktuální úloha</th><td>{current_job}</td></tr>
<tr><th>Started At</th><td>{started_at}</td></tr> <tr><th>Spuštěn</th><td>{started_at}</td></tr>
</table> </table>
</div> </div>
+21 -6
View File
@@ -1,5 +1,5 @@
(() => { (() => {
if (window.AppFactoryOperationsLive) { if (window.CSBotOperationsLive) {
return; return;
} }
@@ -19,6 +19,16 @@
const statusBadge = (status) => { const statusBadge = (status) => {
const value = String(status || ""); const value = String(status || "");
const normalized = value.toLowerCase(); const normalized = value.toLowerCase();
const labels = {
queued: "čeká",
running: "běží",
cancelled_requested: "žádost o zrušení",
cancelled: "zrušeno",
success: "úspěšné",
failed: "selhalo",
ok: "úspěšné",
error: "chyba",
};
let className = "pill pill-muted"; let className = "pill pill-muted";
if (["success", "ok", "succeeded", "done", "deployed", "completed"].includes(normalized)) { if (["success", "ok", "succeeded", "done", "deployed", "completed"].includes(normalized)) {
className = "pill pill-success"; className = "pill pill-success";
@@ -27,12 +37,12 @@
} else if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) { } else if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
className = "pill pill-danger"; className = "pill pill-danger";
} }
return `<span class="${className}">${escapeHtml(value)}</span>`; return `<span class="${className}">${escapeHtml(labels[normalized] || value)}</span>`;
}; };
const workerBadge = (online) => online const workerBadge = (online) => online
? '<span class="pill pill-success">ONLINE</span>' ? '<span class="pill pill-success">online</span>'
: '<span class="pill pill-danger">OFFLINE</span>'; : '<span class="pill pill-danger">offline</span>';
const setText = (selector, value) => { const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((el) => { document.querySelectorAll(selector).forEach((el) => {
@@ -51,7 +61,7 @@
<td>${escapeHtml(job.created_at)}</td> <td>${escapeHtml(job.created_at)}</td>
</tr> </tr>
`).join(""); `).join("");
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné joby.</td></tr>'; return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné úlohy.</td></tr>';
}; };
const renderWorkers = (workers) => { const renderWorkers = (workers) => {
@@ -107,16 +117,21 @@
setText("[data-live-count='jobs.running']", snapshot.jobs?.running); setText("[data-live-count='jobs.running']", snapshot.jobs?.running);
setText("[data-live-count='jobs.failed_24h']", snapshot.jobs?.failed_24h); setText("[data-live-count='jobs.failed_24h']", snapshot.jobs?.failed_24h);
setText("[data-live-count='jobs.success_24h']", snapshot.jobs?.success_24h); setText("[data-live-count='jobs.success_24h']", snapshot.jobs?.success_24h);
setText("[data-live-count='apps.active']", snapshot.apps?.active);
setText("[data-live-count='apps.problematic']", snapshot.apps?.problematic);
setText("[data-live-count='workers.online']", snapshot.workers?.online); setText("[data-live-count='workers.online']", snapshot.workers?.online);
setText("[data-live-count='workers.offline']", snapshot.workers?.offline); setText("[data-live-count='workers.offline']", snapshot.workers?.offline);
setText("[data-live-count='workers.total']", (snapshot.workers?.online || 0) + (snapshot.workers?.offline || 0)); setText("[data-live-count='workers.total']", (snapshot.workers?.online || 0) + (snapshot.workers?.offline || 0));
setText("[data-live-count='deployments.running']", snapshot.deployments?.running); setText("[data-live-count='deployments.running']", snapshot.deployments?.running);
setText("[data-live-count='deployments.failed_24h']", snapshot.deployments?.failed_24h); setText("[data-live-count='deployments.failed_24h']", snapshot.deployments?.failed_24h);
setText("[data-live-count='deployments.success_24h']", snapshot.deployments?.success_24h); setText("[data-live-count='deployments.success_24h']", snapshot.deployments?.success_24h);
setText("[data-live-count='deployments.today']", snapshot.deployments?.today);
document.querySelectorAll("[data-live-table='jobs.recent']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.recent); }); document.querySelectorAll("[data-live-table='jobs.recent']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.recent); });
document.querySelectorAll("[data-live-table='jobs.failed']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.recent_failed); });
document.querySelectorAll("[data-live-table='workers.recent']").forEach((el) => { el.innerHTML = renderWorkers(snapshot.workers?.recent); }); document.querySelectorAll("[data-live-table='workers.recent']").forEach((el) => { el.innerHTML = renderWorkers(snapshot.workers?.recent); });
document.querySelectorAll("[data-live-table='deployments.recent']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent); }); document.querySelectorAll("[data-live-table='deployments.recent']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent); });
document.querySelectorAll("[data-live-table='deployments.failed']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent_failed); });
document.querySelectorAll("[data-live-table='audit.recent']").forEach((el) => { el.innerHTML = renderAudit(snapshot.audit?.recent); }); document.querySelectorAll("[data-live-table='audit.recent']").forEach((el) => { el.innerHTML = renderAudit(snapshot.audit?.recent); });
(snapshot.workers?.recent || []).forEach((worker) => { (snapshot.workers?.recent || []).forEach((worker) => {
@@ -159,6 +174,6 @@
}); });
}; };
window.AppFactoryOperationsLive = { connect }; window.CSBotOperationsLive = { connect };
connect(); connect();
})(); })();
+8
View File
@@ -76,6 +76,14 @@ nav a {
border-radius: 8px; border-radius: 8px;
} }
.nav-group {
color: rgba(255, 255, 255, 0.62);
margin-left: 14px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.user-menu { .user-menu {
display: flex; display: flex;
align-items: center; align-items: center;
+15 -9
View File
@@ -2,6 +2,8 @@ import html
from ..config import PORTAL_PREFIX from ..config import PORTAL_PREFIX
APP_NAME = "CSBot Services Portal"
def page(title: str, body: str, user=None) -> str: def page(title: str, body: str, user=None) -> str:
nav = "" nav = ""
@@ -12,13 +14,17 @@ def page(title: str, body: str, user=None) -> str:
display_name = html.escape(user.get("display_name") or user.get("username", "")) display_name = html.escape(user.get("display_name") or user.get("username", ""))
nav = """ nav = """
<nav> <nav>
<a href="/portal/operations">Dashboard</a> <span class="nav-group">Přehled</span>
<a href="/portal">Apps</a> <a href="/portal/operations">Přehled</a>
<a href="/portal/jobs">Jobs</a> <span class="nav-group">Služby</span>
<a href="/portal/deployments">Deployments</a> <a href="/portal/apps">Služby</a>
<a href="/portal/workers">Workers</a> <span class="nav-group">Provoz</span>
<a href="/portal/jobs">Úlohy</a>
<a href="/portal/deployments">Nasazení</a>
<a href="/portal/workers">Workery</a>
<span class="nav-group">Správa</span>
<a href="/portal/audit">Audit</a> <a href="/portal/audit">Audit</a>
<a href="/portal/backups">Backups</a> <a href="/portal/backups">Zálohy</a>
</nav> </nav>
""" """
user_panel = f""" user_panel = f"""
@@ -34,15 +40,15 @@ def page(title: str, body: str, user=None) -> str:
return f""" return f"""
<html> <html>
<head> <head>
<title>{html.escape(title)}</title> <title>{html.escape(title)} | {APP_NAME}</title>
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css"> <link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css">
<script src="{PORTAL_PREFIX}/static/portal.js"></script> <script src="{PORTAL_PREFIX}/static/portal.js"></script>
</head> </head>
<body> <body>
<header> <header>
<a class="brand" href="/portal" aria-label="AppFactory Portal"> <a class="brand" href="/portal/operations" aria-label="{APP_NAME}">
<img src="{PORTAL_PREFIX}/static/csbot-logo.svg" alt="CSBOT"> <img src="{PORTAL_PREFIX}/static/csbot-logo.svg" alt="CSBOT">
<span>AppFactory</span> <span>{APP_NAME}</span>
</a> </a>
{nav} {nav}
{user_panel} {user_panel}