Add unified operations realtime dashboard
- add /portal/ws/operations WebSocket snapshot endpoint
- add shared operations-live.js with reconnect and operations:snapshot events
- update Operations, Jobs, and Workers pages to use realtime snapshots
- keep job detail live logs on dedicated /ws/jobs/{job_id}/logs endpoint
- add Jobs and Workers pagination/filtering
- remove old page auto-refresh behavior from jobs/deployments
- normalize main navigation labels
- move New App CTA from menu to Apps page
- add backup download action for admins
- add Workers pages and operations dashboard data helpers
This commit is contained in:
+54
-9
@@ -248,6 +248,7 @@ def get_next_queued_job():
|
||||
|
||||
def get_jobs(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
status: str | None = None,
|
||||
job_type: str | None = None,
|
||||
target: str | None = None,
|
||||
@@ -282,32 +283,76 @@ def get_jobs(
|
||||
END,
|
||||
id DESC
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
""",
|
||||
(*params, limit),
|
||||
(*params, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
con.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def count_jobs(
|
||||
status: str | None = None,
|
||||
job_type: str | None = None,
|
||||
target: str | None = None,
|
||||
):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
filters = []
|
||||
params: list[Any] = []
|
||||
if status:
|
||||
filters.append("status = ?")
|
||||
params.append(status)
|
||||
if job_type:
|
||||
filters.append("type = ?")
|
||||
params.append(job_type)
|
||||
if target:
|
||||
filters.append("(target_type LIKE ? OR target_id LIKE ?)")
|
||||
target_pattern = f"%{target}%"
|
||||
params.extend([target_pattern, target_pattern])
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(filters)}" if filters else ""
|
||||
row = con.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM jobs
|
||||
{where_sql}
|
||||
""",
|
||||
params,
|
||||
).fetchone()
|
||||
|
||||
con.close()
|
||||
return row["count"] if row else 0
|
||||
|
||||
|
||||
def get_job_stats():
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*) AS count
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued,
|
||||
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success,
|
||||
SUM(CASE WHEN status = 'failed' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS failed_24h,
|
||||
SUM(CASE WHEN status = 'success' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS success_24h
|
||||
FROM jobs
|
||||
WHERE status IN ('queued', 'running', 'failed', 'success')
|
||||
GROUP BY status
|
||||
"""
|
||||
).fetchall()
|
||||
).fetchone()
|
||||
|
||||
con.close()
|
||||
stats = {"queued": 0, "running": 0, "failed": 0, "success": 0}
|
||||
for row in rows:
|
||||
stats[row["status"]] = row["count"]
|
||||
return stats
|
||||
return dict(rows) if rows else {
|
||||
"queued": 0,
|
||||
"running": 0,
|
||||
"failed": 0,
|
||||
"success": 0,
|
||||
"failed_24h": 0,
|
||||
"success_24h": 0,
|
||||
}
|
||||
|
||||
|
||||
def get_job(job_id: int):
|
||||
|
||||
@@ -2,6 +2,10 @@ from app.db.database import get_connection
|
||||
from app.db.migrations import run_migrations
|
||||
from app.db.workers import is_worker_online
|
||||
|
||||
DEPLOYMENT_SUCCESS_STATUSES = ("ok", "success", "succeeded", "done", "deployed", "completed")
|
||||
DEPLOYMENT_FAILED_STATUSES = ("failed", "failure", "error", "cancelled", "canceled")
|
||||
DEPLOYMENT_RUNNING_STATUSES = ("running", "pending", "queued", "in_progress", "starting")
|
||||
|
||||
|
||||
def _table_columns(con, table_name: str) -> set[str]:
|
||||
rows = con.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
@@ -142,3 +146,100 @@ def get_recent_audit_events(limit: int = 20):
|
||||
|
||||
con.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_operations_snapshot():
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
workers = con.execute(
|
||||
"""
|
||||
SELECT id, status, started_at, last_seen_at, current_job_id, metadata_json
|
||||
FROM workers
|
||||
ORDER BY last_seen_at DESC, id
|
||||
LIMIT 20
|
||||
"""
|
||||
).fetchall()
|
||||
recent_workers = []
|
||||
workers_online = 0
|
||||
for row in workers:
|
||||
worker = dict(row)
|
||||
worker["online"] = is_worker_online(worker.get("last_seen_at"))
|
||||
workers_online += 1 if worker["online"] else 0
|
||||
recent_workers.append(worker)
|
||||
|
||||
total_workers = con.execute("SELECT COUNT(*) AS count FROM workers").fetchone()["count"]
|
||||
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"]))
|
||||
|
||||
jobs_summary = con.execute(
|
||||
"""
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued,
|
||||
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
|
||||
SUM(CASE WHEN status = 'failed' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS failed_24h,
|
||||
SUM(CASE WHEN status = 'success' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS success_24h
|
||||
FROM jobs
|
||||
"""
|
||||
).fetchone()
|
||||
recent_jobs = con.execute(
|
||||
"""
|
||||
SELECT id, type, target_type, target_id, status, source, created_at, started_at, finished_at
|
||||
FROM jobs
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
deployments_summary = con.execute(
|
||||
f"""
|
||||
SELECT
|
||||
SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_RUNNING_STATUSES)}) THEN 1 ELSE 0 END) AS running,
|
||||
SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_FAILED_STATUSES)}) AND started_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS failed_24h,
|
||||
SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_SUCCESS_STATUSES)}) AND started_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS success_24h
|
||||
FROM deployments
|
||||
""",
|
||||
(*DEPLOYMENT_RUNNING_STATUSES, *DEPLOYMENT_FAILED_STATUSES, *DEPLOYMENT_SUCCESS_STATUSES),
|
||||
).fetchone()
|
||||
recent_deployments = con.execute(
|
||||
"""
|
||||
SELECT id, app_id, kind, status, started_at, finished_at, trigger_source
|
||||
FROM deployments
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
).fetchall()
|
||||
recent_audit = con.execute(
|
||||
"""
|
||||
SELECT id, username, action, target_type, target_id, source, created_at
|
||||
FROM audit_events
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
con.close()
|
||||
return {
|
||||
"type": "operations.snapshot",
|
||||
"jobs": {
|
||||
"queued": jobs_summary["queued"] or 0,
|
||||
"running": jobs_summary["running"] or 0,
|
||||
"failed_24h": jobs_summary["failed_24h"] or 0,
|
||||
"success_24h": jobs_summary["success_24h"] or 0,
|
||||
"recent": [dict(row) for row in recent_jobs],
|
||||
},
|
||||
"workers": {
|
||||
"online": workers_online,
|
||||
"offline": max(0, total_workers - workers_online),
|
||||
"recent": recent_workers,
|
||||
},
|
||||
"deployments": {
|
||||
"running": deployments_summary["running"] or 0,
|
||||
"failed_24h": deployments_summary["failed_24h"] or 0,
|
||||
"success_24h": deployments_summary["success_24h"] or 0,
|
||||
"recent": [dict(row) for row in recent_deployments],
|
||||
},
|
||||
"audit": {
|
||||
"recent": [dict(row) for row in recent_audit],
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -129,7 +129,6 @@ def index(request: Request, user=Depends(require_user)):
|
||||
<div class="card">
|
||||
<h2>Aplikace</h2>
|
||||
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
|
||||
<a class="btn" href="/portal/new-app">+ Nová aplikace</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Zálohy</h2>
|
||||
@@ -145,6 +144,7 @@ def index(request: Request, user=Depends(require_user)):
|
||||
|
||||
<div class="card">
|
||||
<h2>Nasazené aplikace</h2>
|
||||
<p><a class="btn" href="/portal/new-app">+ Nová aplikace</a></p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Aplikace</th>
|
||||
|
||||
@@ -161,9 +161,7 @@ async def deployments_page(
|
||||
)
|
||||
stats = get_deployment_stats()
|
||||
options = get_deployment_filter_options()
|
||||
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if any(
|
||||
is_running(item.get("status")) for item in deployments
|
||||
) else ""
|
||||
refresh = ""
|
||||
|
||||
rows = ""
|
||||
for deployment in deployments:
|
||||
@@ -320,9 +318,7 @@ async def deployment_detail_page(
|
||||
returncode_label = "" if returncode is None else html.escape(str(returncode))
|
||||
stdout = html.escape(deployment.get("stdout", "") or "")
|
||||
stderr = html.escape(deployment.get("stderr", "") or "")
|
||||
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if is_running(
|
||||
deployment.get("status")
|
||||
) else ""
|
||||
refresh = ""
|
||||
failed_notice = (
|
||||
f'<div class="alert alert-danger">Deployment selhal. Návratový kód: {returncode_label}</div>'
|
||||
if is_failed(deployment.get("status"))
|
||||
|
||||
+65
-10
@@ -1,14 +1,15 @@
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
import math
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth import current_user, require_user
|
||||
from app.db.audit import log_audit_event
|
||||
from app.db.jobs import cancel_job, get_job, get_job_logs, get_job_logs_after, get_job_stats, get_jobs, retry_failed_job
|
||||
from app.db.jobs import cancel_job, count_jobs, get_job, get_job_logs, get_job_logs_after, get_job_stats, get_jobs, retry_failed_job
|
||||
from app.routes.deployments import calculate_duration, render_status_pill
|
||||
from app.templates.layout import page
|
||||
|
||||
@@ -20,6 +21,7 @@ JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "succes
|
||||
JOB_FILTER_STATUSES = ("queued", "running", "success", "failed", "cancelled")
|
||||
JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service")
|
||||
IGNORED_RETRY_REPOSITORIES = {"appfactory-tools", "appfactory-infrastructure"}
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
|
||||
def render_job_status(status: str | None) -> str:
|
||||
@@ -130,6 +132,7 @@ def jobs_page(
|
||||
status: str = Query(""),
|
||||
job_type: str = Query("", alias="type"),
|
||||
target: str = Query(""),
|
||||
page_number: int = Query(1, alias="page", ge=1),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
selected_status = status.strip().lower()
|
||||
@@ -140,15 +143,25 @@ def jobs_page(
|
||||
if selected_type not in JOB_FILTER_TYPES:
|
||||
selected_type = ""
|
||||
|
||||
total_jobs = count_jobs(
|
||||
status=selected_status or None,
|
||||
job_type=selected_type or None,
|
||||
target=selected_target or None,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total_jobs / DEFAULT_PAGE_SIZE))
|
||||
if page_number > total_pages:
|
||||
page_number = total_pages
|
||||
offset = (page_number - 1) * DEFAULT_PAGE_SIZE
|
||||
|
||||
jobs = get_jobs(
|
||||
limit=DEFAULT_PAGE_SIZE,
|
||||
offset=offset,
|
||||
status=selected_status or None,
|
||||
job_type=selected_type or None,
|
||||
target=selected_target or None,
|
||||
)
|
||||
stats = get_job_stats()
|
||||
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if any(
|
||||
(job.get("status") or "").lower() in LIVE_STATUSES for job in jobs
|
||||
) else ""
|
||||
refresh = ""
|
||||
|
||||
rows = ""
|
||||
for job in jobs:
|
||||
@@ -187,6 +200,29 @@ def jobs_page(
|
||||
status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy")
|
||||
type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy")
|
||||
target_value = html.escape(selected_target)
|
||||
first_item = offset + 1 if total_jobs else 0
|
||||
last_item = min(offset + len(jobs), total_jobs)
|
||||
|
||||
def page_url(page: int) -> str:
|
||||
params = {"page": page}
|
||||
if selected_status:
|
||||
params["status"] = selected_status
|
||||
if selected_type:
|
||||
params["type"] = selected_type
|
||||
if selected_target:
|
||||
params["target"] = selected_target
|
||||
return f"/portal/jobs?{urlencode(params)}"
|
||||
|
||||
pagination = f"""
|
||||
<div class="pagination">
|
||||
<span>Zobrazeno {first_item}-{last_item} z {total_jobs}</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(
|
||||
"Joby",
|
||||
@@ -198,10 +234,10 @@ def jobs_page(
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-warning"><span>Queued</span><strong>{stats.get("queued") or 0}</strong></div>
|
||||
<div class="stat-card stat-warning"><span>Running</span><strong>{stats.get("running") or 0}</strong></div>
|
||||
<div class="stat-card stat-danger"><span>Failed</span><strong>{stats.get("failed") or 0}</strong></div>
|
||||
<div class="stat-card stat-success"><span>Success</span><strong>{stats.get("success") or 0}</strong></div>
|
||||
<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>
|
||||
|
||||
<div class="card">
|
||||
@@ -210,13 +246,30 @@ def jobs_page(
|
||||
<select name="status">{status_options}</select>
|
||||
<select name="type">{type_options}</select>
|
||||
<input name="target" value="{target_value}" placeholder="Target">
|
||||
<input type="hidden" name="page" value="1">
|
||||
<button type="submit">Filtrovat</button>
|
||||
<a class="btn btn-secondary" href="/portal/jobs">Reset</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent Jobs</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Source</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
<tbody data-live-table="jobs.recent"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Fronta</h2>
|
||||
{pagination}
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
@@ -229,9 +282,11 @@ def jobs_page(
|
||||
<th>Finished at</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
{rows}
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
{pagination}
|
||||
</div>
|
||||
<script src="/portal/static/operations-live.js"></script>
|
||||
""",
|
||||
user=user,
|
||||
)
|
||||
|
||||
+46
-20
@@ -1,16 +1,14 @@
|
||||
import asyncio
|
||||
import html
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth import require_user
|
||||
from app.auth import current_user, require_user
|
||||
from app.db.audit import log_audit_event
|
||||
from app.db.operations import (
|
||||
get_operations_summary,
|
||||
get_recent_audit_events,
|
||||
get_recent_deployments,
|
||||
get_recent_jobs,
|
||||
get_operations_snapshot,
|
||||
)
|
||||
from app.routes.deployments import render_status_pill
|
||||
from app.routes.jobs import render_job_status
|
||||
@@ -19,6 +17,22 @@ from app.templates.layout import page
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/operations")
|
||||
async def operations_websocket(websocket: WebSocket):
|
||||
user = current_user(websocket)
|
||||
if not user:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json(get_operations_snapshot())
|
||||
await asyncio.sleep(2)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
def render_recent_jobs(jobs: list[dict]) -> str:
|
||||
rows = ""
|
||||
for job in jobs:
|
||||
@@ -98,16 +112,27 @@ def render_recent_audit_events(events: list[dict]) -> str:
|
||||
|
||||
@router.get("/operations", response_class=HTMLResponse)
|
||||
def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
summary = get_operations_summary()
|
||||
snapshot = get_operations_snapshot()
|
||||
log_audit_event(
|
||||
user,
|
||||
action="operations.dashboard.view",
|
||||
target_type="operations",
|
||||
metadata=summary,
|
||||
metadata={
|
||||
"jobs": {
|
||||
"queued": snapshot["jobs"]["queued"],
|
||||
"running": snapshot["jobs"]["running"],
|
||||
"failed_24h": snapshot["jobs"]["failed_24h"],
|
||||
},
|
||||
"workers": {
|
||||
"online": snapshot["workers"]["online"],
|
||||
"offline": snapshot["workers"]["offline"],
|
||||
},
|
||||
"deployments": {
|
||||
"running": snapshot["deployments"]["running"],
|
||||
"failed_24h": snapshot["deployments"]["failed_24h"],
|
||||
},
|
||||
},
|
||||
)
|
||||
recent_jobs = get_recent_jobs()
|
||||
recent_deployments = get_recent_deployments()
|
||||
recent_audit_events = get_recent_audit_events()
|
||||
|
||||
return page(
|
||||
"Operations",
|
||||
@@ -118,12 +143,12 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-success"><span>Workers Online</span><strong>{summary.get("workers_online") or 0}</strong></div>
|
||||
<div class="stat-card stat-warning"><span>Running Jobs</span><strong>{summary.get("running_jobs") or 0}</strong></div>
|
||||
<div class="stat-card stat-warning"><span>Queued Jobs</span><strong>{summary.get("queued_jobs") or 0}</strong></div>
|
||||
<div class="stat-card stat-danger"><span>Failed Jobs (24h)</span><strong>{summary.get("failed_jobs_24h") or 0}</strong></div>
|
||||
<div class="stat-card stat-total"><span>Deployments Today</span><strong>{summary.get("deployments_today") or 0}</strong></div>
|
||||
<div class="stat-card stat-total"><span>Active Applications</span><strong>{summary.get("active_applications") or 0}</strong></div>
|
||||
<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>
|
||||
|
||||
<div class="card">
|
||||
@@ -137,7 +162,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
<th>Source</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
{render_recent_jobs(recent_jobs)}
|
||||
<tbody data-live-table="jobs.recent">{render_recent_jobs(snapshot["jobs"]["recent"])}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +178,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
<th>Spuštěno</th>
|
||||
<th>Dokončeno</th>
|
||||
</tr>
|
||||
{render_recent_deployments(recent_deployments)}
|
||||
<tbody data-live-table="deployments.recent">{render_recent_deployments(snapshot["deployments"]["recent"])}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +193,10 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
<th>Cíl</th>
|
||||
<th>Zdroj</th>
|
||||
</tr>
|
||||
{render_recent_audit_events(recent_audit_events)}
|
||||
<tbody data-live-table="audit.recent">{render_recent_audit_events(snapshot["audit"]["recent"])}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script src="/portal/static/operations-live.js"></script>
|
||||
""",
|
||||
user=user,
|
||||
)
|
||||
|
||||
+80
-14
@@ -1,8 +1,9 @@
|
||||
import html
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
import math
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth import require_user
|
||||
@@ -11,6 +12,7 @@ from app.db.workers import get_worker, get_workers
|
||||
from app.templates.layout import page
|
||||
|
||||
router = APIRouter()
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
|
||||
def render_worker_badge(online: bool) -> str:
|
||||
@@ -30,11 +32,35 @@ def pretty_json(value: str | None) -> str:
|
||||
|
||||
|
||||
@router.get("/workers", response_class=HTMLResponse)
|
||||
def workers_page(request: Request, user=Depends(require_user)):
|
||||
workers = get_workers()
|
||||
online_count = sum(1 for worker in workers if worker.get("online"))
|
||||
total_count = len(workers)
|
||||
def workers_page(
|
||||
request: Request,
|
||||
status: str = Query(""),
|
||||
q: str = Query(""),
|
||||
page_number: int = Query(1, alias="page", ge=1),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
all_workers = get_workers()
|
||||
selected_status = status.strip().lower()
|
||||
query = q.strip()
|
||||
online_count = sum(1 for worker in all_workers if worker.get("online"))
|
||||
total_count = len(all_workers)
|
||||
offline_count = total_count - online_count
|
||||
workers = all_workers
|
||||
if selected_status == "online":
|
||||
workers = [worker for worker in workers if worker.get("online")]
|
||||
elif selected_status == "offline":
|
||||
workers = [worker for worker in workers if not worker.get("online")]
|
||||
else:
|
||||
selected_status = ""
|
||||
if query:
|
||||
workers = [worker for worker in workers if query.lower() in (worker.get("id", "") or "").lower()]
|
||||
|
||||
total_filtered = len(workers)
|
||||
total_pages = max(1, math.ceil(total_filtered / DEFAULT_PAGE_SIZE))
|
||||
if page_number > total_pages:
|
||||
page_number = total_pages
|
||||
offset = (page_number - 1) * DEFAULT_PAGE_SIZE
|
||||
workers = workers[offset : offset + DEFAULT_PAGE_SIZE]
|
||||
|
||||
log_audit_event(
|
||||
user,
|
||||
@@ -63,13 +89,13 @@ def workers_page(request: Request, user=Depends(require_user)):
|
||||
current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
|
||||
|
||||
rows += f"""
|
||||
<tr>
|
||||
<tr data-worker-id="{worker_id}">
|
||||
<td>
|
||||
<strong><a href="/portal/workers/{worker_url_id}">{worker_id}</a></strong>
|
||||
</td>
|
||||
<td>{badge}<br><span class="muted">{status}</span></td>
|
||||
<td>{last_seen_at}</td>
|
||||
<td>{current_job}</td>
|
||||
<td data-worker-field="status">{badge}<br><span class="muted">{status}</span></td>
|
||||
<td data-worker-field="last_seen_at">{last_seen_at}</td>
|
||||
<td data-worker-field="current_job">{current_job}</td>
|
||||
<td>{started_at}</td>
|
||||
<td class="actions-cell"><a class="btn" href="/portal/workers/{worker_url_id}">Detail</a></td>
|
||||
</tr>
|
||||
@@ -77,6 +103,32 @@ def workers_page(request: Request, user=Depends(require_user)):
|
||||
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>'
|
||||
live_table_attr = ' data-live-table="workers.recent"' if not selected_status and not query and page_number == 1 else ""
|
||||
first_item = offset + 1 if total_filtered else 0
|
||||
last_item = min(offset + len(workers), total_filtered)
|
||||
|
||||
def page_url(page: int) -> str:
|
||||
params = {"page": page}
|
||||
if selected_status:
|
||||
params["status"] = selected_status
|
||||
if query:
|
||||
params["q"] = query
|
||||
return f"/portal/workers?{urlencode(params)}"
|
||||
|
||||
pagination = f"""
|
||||
<div class="pagination">
|
||||
<span>Zobrazeno {first_item}-{last_item} z {total_filtered}</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>
|
||||
"""
|
||||
status_options = "".join(
|
||||
f'<option value="{value}"{" selected" if selected_status == value else ""}>{label}</option>'
|
||||
for value, label in [("", "Všichni workeři"), ("online", "Online"), ("offline", "Offline")]
|
||||
)
|
||||
|
||||
return page(
|
||||
"Workers",
|
||||
@@ -87,13 +139,25 @@ def workers_page(request: Request, user=Depends(require_user)):
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-success"><span>Online Workers</span><strong>{online_count}</strong></div>
|
||||
<div class="stat-card stat-danger"><span>Offline Workers</span><strong>{offline_count}</strong></div>
|
||||
<div class="stat-card stat-total"><span>Total Workers</span><strong>{total_count}</strong></div>
|
||||
<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>
|
||||
|
||||
<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 type="hidden" name="page" value="1">
|
||||
<button type="submit">Filtrovat</button>
|
||||
<a class="btn btn-secondary" href="/portal/workers">Reset</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workers</h2>
|
||||
{pagination}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Worker ID</th>
|
||||
@@ -103,9 +167,11 @@ def workers_page(request: Request, user=Depends(require_user)):
|
||||
<th>Started At</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
{rows}
|
||||
<tbody{live_table_attr}>{rows}</tbody>
|
||||
</table>
|
||||
{pagination}
|
||||
</div>
|
||||
<script src="/portal/static/operations-live.js"></script>
|
||||
""",
|
||||
user=user,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
(() => {
|
||||
if (window.AppFactoryOperationsLive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = {
|
||||
socket: null,
|
||||
reconnectTimer: null,
|
||||
reconnectDelay: 1500,
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const statusBadge = (status) => {
|
||||
const value = String(status || "");
|
||||
const normalized = value.toLowerCase();
|
||||
let className = "pill pill-muted";
|
||||
if (["success", "ok", "succeeded", "done", "deployed", "completed"].includes(normalized)) {
|
||||
className = "pill pill-success";
|
||||
} else if (["queued", "running", "pending", "in_progress", "starting", "cancelled_requested"].includes(normalized)) {
|
||||
className = "pill pill-warning";
|
||||
} else if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||
className = "pill pill-danger";
|
||||
}
|
||||
return `<span class="${className}">${escapeHtml(value)}</span>`;
|
||||
};
|
||||
|
||||
const workerBadge = (online) => online
|
||||
? '<span class="pill pill-success">ONLINE</span>'
|
||||
: '<span class="pill pill-danger">OFFLINE</span>';
|
||||
|
||||
const setText = (selector, value) => {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
el.textContent = value ?? 0;
|
||||
});
|
||||
};
|
||||
|
||||
const renderJobs = (jobs) => {
|
||||
const rows = (jobs || []).map((job) => `
|
||||
<tr>
|
||||
<td><strong><a href="/portal/jobs/${escapeHtml(job.id)}">#${escapeHtml(job.id)}</a></strong></td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(job.type)}</td>
|
||||
<td>${escapeHtml(job.target_type)}: <strong>${escapeHtml(job.target_id)}</strong></td>
|
||||
<td>${escapeHtml(job.source)}</td>
|
||||
<td>${escapeHtml(job.created_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné joby.</td></tr>';
|
||||
};
|
||||
|
||||
const renderWorkers = (workers) => {
|
||||
const rows = (workers || []).map((worker) => {
|
||||
const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
|
||||
? ""
|
||||
: `<a href="/portal/jobs/${escapeHtml(worker.current_job_id)}">#${escapeHtml(worker.current_job_id)}</a>`;
|
||||
return `
|
||||
<tr data-worker-id="${escapeHtml(worker.id)}">
|
||||
<td><strong><a href="/portal/workers/${encodeURIComponent(worker.id || "")}">${escapeHtml(worker.id)}</a></strong></td>
|
||||
<td data-worker-field="status">${workerBadge(Boolean(worker.online))}<br><span class="muted">${escapeHtml(worker.status)}</span></td>
|
||||
<td data-worker-field="last_seen_at">${escapeHtml(worker.last_seen_at)}</td>
|
||||
<td data-worker-field="current_job">${currentJob}</td>
|
||||
<td>${escapeHtml(worker.started_at)}</td>
|
||||
<td class="actions-cell"><a class="btn" href="/portal/workers/${encodeURIComponent(worker.id || "")}">Detail</a></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
return rows || '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>';
|
||||
};
|
||||
|
||||
const renderDeployments = (deployments) => {
|
||||
const rows = (deployments || []).map((deployment) => `
|
||||
<tr>
|
||||
<td><strong><a href="/portal/deployments/${escapeHtml(deployment.id)}">#${escapeHtml(deployment.id)}</a></strong></td>
|
||||
<td><a href="/portal/apps/${encodeURIComponent(deployment.app_id || "")}">${escapeHtml(deployment.app_id)}</a></td>
|
||||
<td>${escapeHtml(deployment.kind)}</td>
|
||||
<td>${statusBadge(deployment.status)}</td>
|
||||
<td>${escapeHtml(deployment.trigger_source)}</td>
|
||||
<td>${escapeHtml(deployment.started_at)}</td>
|
||||
<td>${escapeHtml(deployment.finished_at)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
return rows || '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>';
|
||||
};
|
||||
|
||||
const renderAudit = (events) => {
|
||||
const rows = (events || []).map((event) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(event.created_at)}</td>
|
||||
<td>${escapeHtml(event.username)}</td>
|
||||
<td><span class="pill pill-muted">${escapeHtml(event.action)}</span></td>
|
||||
<td>${escapeHtml(event.target_type)}</td>
|
||||
<td>${escapeHtml(event.target_id)}</td>
|
||||
<td>${escapeHtml(event.source)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>';
|
||||
};
|
||||
|
||||
const renderSnapshot = (snapshot) => {
|
||||
setText("[data-live-count='jobs.queued']", snapshot.jobs?.queued);
|
||||
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='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);
|
||||
|
||||
document.querySelectorAll("[data-live-table='jobs.recent']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.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='audit.recent']").forEach((el) => { el.innerHTML = renderAudit(snapshot.audit?.recent); });
|
||||
|
||||
(snapshot.workers?.recent || []).forEach((worker) => {
|
||||
const row = Array.from(document.querySelectorAll("tr[data-worker-id]"))
|
||||
.find((item) => item.dataset.workerId === String(worker.id || ""));
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
|
||||
? ""
|
||||
: `<a href="/portal/jobs/${escapeHtml(worker.current_job_id)}">#${escapeHtml(worker.current_job_id)}</a>`;
|
||||
const statusCell = row.querySelector("[data-worker-field='status']");
|
||||
const lastSeenCell = row.querySelector("[data-worker-field='last_seen_at']");
|
||||
const currentJobCell = row.querySelector("[data-worker-field='current_job']");
|
||||
if (statusCell) statusCell.innerHTML = `${workerBadge(Boolean(worker.online))}<br><span class="muted">${escapeHtml(worker.status)}</span>`;
|
||||
if (lastSeenCell) lastSeenCell.textContent = worker.last_seen_at || "";
|
||||
if (currentJobCell) currentJobCell.innerHTML = currentJob;
|
||||
});
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
clearTimeout(state.reconnectTimer);
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
state.socket = new WebSocket(`${protocol}//${window.location.host}/portal/ws/operations`);
|
||||
|
||||
state.socket.addEventListener("message", (event) => {
|
||||
const snapshot = JSON.parse(event.data);
|
||||
if (snapshot.type !== "operations.snapshot") {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("operations:snapshot", { detail: snapshot }));
|
||||
renderSnapshot(snapshot);
|
||||
});
|
||||
|
||||
state.socket.addEventListener("close", () => {
|
||||
state.reconnectTimer = setTimeout(connect, state.reconnectDelay);
|
||||
});
|
||||
state.socket.addEventListener("error", () => {
|
||||
state.socket.close();
|
||||
});
|
||||
};
|
||||
|
||||
window.AppFactoryOperationsLive = { connect };
|
||||
connect();
|
||||
})();
|
||||
@@ -12,14 +12,13 @@ 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">Aplikace</a>
|
||||
<a href="/portal/new-app">Nová aplikace</a>
|
||||
<a href="/portal/deployments">Nasazení</a>
|
||||
<a href="/portal/jobs">Joby</a>
|
||||
<a href="/portal/operations">Operations -> Dashboard</a>
|
||||
<a href="/portal/workers">Operations -> Workers</a>
|
||||
<a href="/portal/backups">Zálohy</a>
|
||||
<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>
|
||||
<a href="/portal/audit">Audit</a>
|
||||
<a href="/portal/backups">Backups</a>
|
||||
</nav>
|
||||
"""
|
||||
user_panel = f"""
|
||||
|
||||
Reference in New Issue
Block a user