cestina, UI, UX
This commit is contained in:
@@ -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
@@ -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
@@ -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">← Zpět na aplikace</a>
|
<a class="btn" href="/portal/apps">← 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">← Zpět</a></p>
|
<p><a href="/portal/apps">← 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
@@ -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
@@ -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">← Zpět na portál</a>
|
<a class="btn" href="/portal/operations">← 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">← Zpět na nasazení</a>
|
<a class="btn" href="/portal/deployments">← 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
@@ -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">← Zpět na joby</a>
|
<a class="btn" href="/portal/jobs">← 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
@@ -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
@@ -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">← Zpět na workers</a>
|
<a class="btn" href="/portal/workers">← 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user