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()
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(
"""
SELECT
@@ -190,6 +216,15 @@ def get_operations_snapshot():
LIMIT 20
"""
).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(
f"""
@@ -201,6 +236,24 @@ def get_operations_snapshot():
""",
(*DEPLOYMENT_RUNNING_STATUSES, *DEPLOYMENT_FAILED_STATUSES, *DEPLOYMENT_SUCCESS_STATUSES),
).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(
"""
SELECT id, app_id, kind, status, started_at, finished_at, trigger_source
@@ -217,6 +270,16 @@ def get_operations_snapshot():
LIMIT 20
"""
).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()
return {
@@ -227,6 +290,11 @@ def get_operations_snapshot():
"failed_24h": jobs_summary["failed_24h"] or 0,
"success_24h": jobs_summary["success_24h"] or 0,
"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": {
"online": workers_online,
@@ -237,7 +305,9 @@ def get_operations_snapshot():
"running": deployments_summary["running"] or 0,
"failed_24h": deployments_summary["failed_24h"] or 0,
"success_24h": deployments_summary["success_24h"] or 0,
"today": deployments_today,
"recent": [dict(row) for row in recent_deployments],
"recent_failed": [dict(row) for row in recent_failed_deployments],
},
"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:
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"
app.add_middleware(
+131 -33
View File
@@ -1,7 +1,8 @@
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 ..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.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 ..shell import run_command
from ..templates.layout import page, render_result
router = APIRouter()
DEFAULT_PAGE_SIZE = 20
@router.get("/", response_class=HTMLResponse)
def index(request: Request, user=Depends(require_user)):
@router.get("/")
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()
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_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
@@ -108,8 +138,8 @@ def index(request: Request, user=Depends(require_user)):
<td>
<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>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {app_id}?');">
<button type="submit" class="btn-secondary">Redeploy</button>
<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">Nasadit znovu</button>
</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ář.');">
<input type="hidden" name="app_id" value="{app_id}">
@@ -120,14 +150,40 @@ def index(request: Request, user=Depends(require_user)):
"""
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(
"Aplikace",
"Služby",
f"""
<div class="grid">
<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>
</div>
<div class="card">
@@ -143,11 +199,19 @@ def index(request: Request, user=Depends(require_user)):
</div>
<div class="card">
<h2>Nasazené aplikace</h2>
<p><a class="btn" href="/portal/new-app">+ Nová aplikace</a></p>
<h2>Nasazené služby</h2>
<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>
<tr>
<th>Aplikace</th>
<th>Služba</th>
<th>Status</th>
<th>Dokumentace</th>
<th>Prostředky</th>
@@ -156,6 +220,7 @@ def index(request: Request, user=Depends(require_user)):
</tr>
{rows}
</table>
{pagination}
</div>
""",
user=user,
@@ -197,20 +262,39 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
"""
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(
f"Aplikace {escaped_app_id}",
f"Služba {escaped_app_id}",
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
<p class="muted">{name}</p>
<p>
<a class="btn" href="/portal">&larr; Zpět na aplikace</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení 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í služby</a>
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}">Úlohy služby</a>
</p>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {escaped_app_id}?');">
<button type="submit">Redeploy</button>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {escaped_app_id}?');">
<button type="submit">Nasadit znovu</button>
</form>
</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>CPU</th><td>{cpus}</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>
</div>
<div class="card">
<h2>Poslední deploymenty</h2>
<h2>Historie nasazení</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Timestamp</th>
<th>Triggered by</th>
<th>Čas</th>
<th>Spustil</th>
</tr>
{rows}
</table>
</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,
)
@@ -277,20 +375,20 @@ def redeploy_app(app_id: str, user=Depends(require_user)):
@router.get("/new-app", response_class=HTMLResponse)
def new_app_form(request: Request, user=Depends(require_user)):
return page(
"Nová aplikace",
"Nová služba",
"""
<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>
<form method="post" action="/portal/new-app">
<p>
<label>ID aplikace</label><br>
<label>ID služby</label><br>
<input name="app_id" placeholder="gmail-service" required>
</p>
<p>
<label>Název aplikace</label><br>
<label>Název služby</label><br>
<input name="app_name" placeholder="Gmail služba" required>
</p>
@@ -301,10 +399,10 @@ def new_app_form(request: Request, user=Depends(require_user)):
</select>
</p>
<button type="submit">Vytvořit aplikaci</button>
<button type="submit">Vytvořit službu</button>
</form>
<p><a href="/portal">&larr; Zpět</a></p>
<p><a href="/portal/apps">&larr; Zpět</a></p>
</div>
""",
user=user,
@@ -341,8 +439,8 @@ def create_app(
)
return render_result(
title=f"Vytvoření aplikace: {status}",
back_url="/portal",
title=f"Vytvoření služby: {status}",
back_url="/portal/apps",
sections=[
("Výstup vytvoření", create_result.stdout),
("Chyba vytvoření", create_result.stderr),
@@ -372,8 +470,8 @@ def delete_app(app_id: str = Form(...), user=Depends(require_user)):
)
return render_result(
title=f"Smazání aplikace: {status}",
back_url="/portal",
title=f"Smazání služby: {status}",
back_url="/portal/apps",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user,
)
@@ -418,7 +516,7 @@ def update_resources(
return render_result(
title=f"Úprava prostředků: {status}",
back_url="/portal",
back_url="/portal/apps",
sections=[
("Výstup katalogu", catalog_result.stdout),
("Chyba katalogu", catalog_result.stderr),
+4 -4
View File
@@ -13,7 +13,7 @@ router = APIRouter()
@router.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
if current_user(request):
return RedirectResponse(url="/portal", status_code=303)
return RedirectResponse(url="/portal/operations", status_code=303)
return _render_login()
@@ -35,7 +35,7 @@ def login(request: Request, username: str = Form(...), password: str = Form(...)
metadata={"username": user.get("username")},
)
return RedirectResponse(url="/portal", status_code=303)
return RedirectResponse(url="/portal/operations", status_code=303)
@router.post("/logout")
@@ -61,8 +61,8 @@ def _render_login(error: str | None = None) -> str:
"Přihlášení",
f"""
<div class="auth-card card">
<h2>Přihlášení do portálu</h2>
<p class="muted">Přihlaste se interním účtem AppFactory.</p>
<h2>CSBot Services Portal</h2>
<p class="muted">Přihlaste se interním účtem.</p>
{error_html}
<form method="post" action="/portal/login">
+29 -11
View File
@@ -49,7 +49,25 @@ def render_status_pill(status: str | None) -> str:
else:
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:
@@ -229,14 +247,14 @@ async def deployments_page(
<div class="card">
<h2>Historie nasazení</h2>
<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 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-danger"><span>Failed deploys</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-success"><span>Success rate</span><strong>{stats.get("success_rate") 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>Selhaná nasazení</span><strong>{stats.get("failed") 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>Úspěšnost</span><strong>{stats.get("success_rate") or 0}%</strong></div>
</div>
<div class="card">
@@ -335,19 +353,19 @@ async def deployment_detail_page(
{failed_notice}
<p>
<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/deployments/{html.escape(str(deployment_id))}/logs/raw">Raw logy</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">Surové logy</a>
</p>
</div>
<div class="card">
<h2>Souhrn</h2>
<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>Status</th><td>{status}</td></tr>
<tr><th>Trigger source</th><td>{trigger_source}</td></tr>
<tr><th>Triggered by</th><td>{triggered_by}</td></tr>
<tr><th>Zdroj spuštění</th><td>{trigger_source}</td></tr>
<tr><th>Spustil</th><td>{triggered_by}</td></tr>
<tr><th>Commit author</th><td>{commit_author}</td></tr>
<tr><th>Pusher</th><td>{pusher}</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:
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:
@@ -195,7 +203,7 @@ def jobs_page(
"""
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")
type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy")
@@ -225,19 +233,19 @@ def jobs_page(
"""
return page(
"Joby",
"Úlohy",
f"""
{refresh}
<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>
</div>
<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>Running</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-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-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>Běžící</span><strong data-live-count="jobs.running">{stats.get("running") 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>Úspěšné (24 h)</span><strong data-live-count="jobs.success_24h">{stats.get("success_24h") or 0}</strong></div>
</div>
<div class="card">
@@ -245,7 +253,7 @@ def jobs_page(
<form method="get" action="/portal/jobs" class="filter-form">
<select name="status">{status_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">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/jobs">Reset</a>
@@ -253,15 +261,15 @@ def jobs_page(
</div>
<div class="card">
<h2>Recent Jobs</h2>
<h2>Poslední úlohy</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Type</th>
<th>Target</th>
<th>Source</th>
<th>Created At</th>
<th>Typ</th>
<th>Cíl</th>
<th>Zdroj</th>
<th>Vytvořeno</th>
</tr>
<tbody data-live-table="jobs.recent"></tbody>
</table>
@@ -274,10 +282,10 @@ def jobs_page(
<tr>
<th>ID</th>
<th>Status</th>
<th>Source</th>
<th>Target</th>
<th>Created by</th>
<th>Created at</th>
<th>Zdroj</th>
<th>Cíl</th>
<th>Vytvořil</th>
<th>Vytvořeno</th>
<th>Started at</th>
<th>Finished at</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()
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"))
target_type = job.get("target_type", "") 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):
actions += f"""
<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>
"""
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"}:
actions += f"""
<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>
"""
if actions:
@@ -420,8 +428,8 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
<div class="card">
<h2>{title}</h2>
<p>
<a class="btn" href="/portal/jobs">&larr; Zpět na joby</a>
<a class="btn btn-secondary" href="{target_url}">Detail targetu</a>
<a class="btn" href="/portal/jobs">&larr; Zpět na úlohy</a>
<a class="btn btn-secondary" href="{target_url}">Detail cíle</a>
</p>
{retry_blocked_notice}
{actions}
@@ -432,10 +440,10 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
<table>
<tr><th>Typ</th><td>{html.escape(job.get("type", "") or "")}</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>Target</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>Created at</th><td>{html.escape(job.get("created_at", "") or "")}</td></tr>
<tr><th>Zdroj</th><td>{html.escape(job.get("source", "") or "")}</td></tr>
<tr><th>Cíl</th><td>{html.escape(target_type)}: {target_id_html}</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>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>Finished at</th><td>{html.escape(job.get("finished_at", "") or "")}</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 class="card">
<h2>Live logy</h2>
<p class="muted">WebSocket: <span id="live-log-status">Connecting</span></p>
<h2>Živé logy</h2>
<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>
+19 -19
View File
@@ -135,55 +135,55 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
)
return page(
"Operations",
"Přehled",
f"""
<div class="card">
<h2>Operations Dashboard</h2>
<p class="muted">Provozní přehled workerů, jobů, deploymentů a auditních událostí.</p>
<h2>Přehled systému</h2>
<p class="muted">Rychlá odpověď na otázku, zda jsou služby, úlohy a nasazení v pořádku.</p>
</div>
<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-warning"><span>Running Jobs</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</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-danger"><span>Failed Jobs (24h)</span><strong data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</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-danger"><span>Failed Deployments (24h)</span><strong data-live-count="deployments.failed_24h">{snapshot["deployments"]["failed_24h"]}</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-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>Aktivní úlohy</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</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-success"><span>Online workery</span><strong data-live-count="workers.online">{snapshot["workers"]["online"]}</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 class="card">
<h2>Recent Jobs</h2>
<h2>Poslední incidenty</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Type</th>
<th>Target</th>
<th>Source</th>
<th>Created At</th>
<th>Typ</th>
<th>Cíl</th>
<th>Zdroj</th>
<th>Vytvořeno</th>
</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>
</div>
<div class="card">
<h2>Recent Deployments</h2>
<h2>Poslední neúspěšná nasazení</h2>
<table>
<tr>
<th>ID</th>
<th>Aplikace</th>
<th>Služba</th>
<th>Typ</th>
<th>Status</th>
<th>Zdroj</th>
<th>Spuštěno</th>
<th>Dokončeno</th>
</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>
</div>
<div class="card">
<h2>Recent Audit Events</h2>
<h2>Poslední auditní události</h2>
<table>
<tr>
<th>Čas</th>
+19 -19
View File
@@ -17,8 +17,8 @@ DEFAULT_PAGE_SIZE = 20
def render_worker_badge(online: bool) -> str:
if online:
return '<span class="pill pill-success">ONLINE</span>'
return '<span class="pill pill-danger">OFFLINE</span>'
return '<span class="pill pill-success">online</span>'
return '<span class="pill pill-danger">offline</span>'
def pretty_json(value: str | None) -> str:
@@ -131,24 +131,24 @@ def workers_page(
)
return page(
"Workers",
"Workery",
f"""
<div class="card">
<h2>Workers</h2>
<p class="muted">Přehled worker procesů obsluhujících AppFactory joby.</p>
<h2>Workery</h2>
<p class="muted">Přehled worker procesů obsluhujících úlohy portálu.</p>
</div>
<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-danger"><span>Offline Workers</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-success"><span>Online workery</span><strong data-live-count="workers.online">{online_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>Celkem workerů</span><strong data-live-count="workers.total">{total_count}</strong></div>
</div>
<div class="card">
<h2>Filtry</h2>
<form method="get" action="/portal/workers" class="filter-form">
<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">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/workers">Reset</a>
@@ -156,15 +156,15 @@ def workers_page(
</div>
<div class="card">
<h2>Workers</h2>
<h2>Workery</h2>
{pagination}
<table>
<tr>
<th>Worker ID</th>
<th>ID workeru</th>
<th>Status</th>
<th>Last Seen</th>
<th>Current Job</th>
<th>Started At</th>
<th>Naposledy viděn</th>
<th>Aktuální úloha</th>
<th>Spuštěn</th>
<th>Akce</th>
</tr>
<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">
<h2>Worker {worker_id_html}</h2>
<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>
</div>
<div class="card">
<h2>Souhrn</h2>
<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>Last Seen</th><td>{last_seen_at}</td></tr>
<tr><th>Current Job</th><td>{current_job}</td></tr>
<tr><th>Started At</th><td>{started_at}</td></tr>
<tr><th>Naposledy viděn</th><td>{last_seen_at}</td></tr>
<tr><th>Aktuální úloha</th><td>{current_job}</td></tr>
<tr><th>Spuštěn</th><td>{started_at}</td></tr>
</table>
</div>
+21 -6
View File
@@ -1,5 +1,5 @@
(() => {
if (window.AppFactoryOperationsLive) {
if (window.CSBotOperationsLive) {
return;
}
@@ -19,6 +19,16 @@
const statusBadge = (status) => {
const value = String(status || "");
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";
if (["success", "ok", "succeeded", "done", "deployed", "completed"].includes(normalized)) {
className = "pill pill-success";
@@ -27,12 +37,12 @@
} else if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
className = "pill pill-danger";
}
return `<span class="${className}">${escapeHtml(value)}</span>`;
return `<span class="${className}">${escapeHtml(labels[normalized] || value)}</span>`;
};
const workerBadge = (online) => online
? '<span class="pill pill-success">ONLINE</span>'
: '<span class="pill pill-danger">OFFLINE</span>';
? '<span class="pill pill-success">online</span>'
: '<span class="pill pill-danger">offline</span>';
const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((el) => {
@@ -51,7 +61,7 @@
<td>${escapeHtml(job.created_at)}</td>
</tr>
`).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) => {
@@ -107,16 +117,21 @@
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.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.offline']", snapshot.workers?.offline);
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.failed_24h']", snapshot.deployments?.failed_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.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='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); });
(snapshot.workers?.recent || []).forEach((worker) => {
@@ -159,6 +174,6 @@
});
};
window.AppFactoryOperationsLive = { connect };
window.CSBotOperationsLive = { connect };
connect();
})();
+8
View File
@@ -76,6 +76,14 @@ nav a {
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 {
display: flex;
align-items: center;
+15 -9
View File
@@ -2,6 +2,8 @@ import html
from ..config import PORTAL_PREFIX
APP_NAME = "CSBot Services Portal"
def page(title: str, body: str, user=None) -> str:
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", ""))
nav = """
<nav>
<a href="/portal/operations">Dashboard</a>
<a href="/portal">Apps</a>
<a href="/portal/jobs">Jobs</a>
<a href="/portal/deployments">Deployments</a>
<a href="/portal/workers">Workers</a>
<span class="nav-group">Přehled</span>
<a href="/portal/operations">Přehled</a>
<span class="nav-group">Služby</span>
<a href="/portal/apps">Služby</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/backups">Backups</a>
<a href="/portal/backups">Zálohy</a>
</nav>
"""
user_panel = f"""
@@ -34,15 +40,15 @@ def page(title: str, body: str, user=None) -> str:
return f"""
<html>
<head>
<title>{html.escape(title)}</title>
<title>{html.escape(title)} | {APP_NAME}</title>
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css">
<script src="{PORTAL_PREFIX}/static/portal.js"></script>
</head>
<body>
<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">
<span>AppFactory</span>
<span>{APP_NAME}</span>
</a>
{nav}
{user_panel}