This commit is contained in:
JiriUhlir
2026-05-29 10:35:56 +02:00
parent 765c840120
commit 48a9b20379
5 changed files with 277 additions and 1 deletions
+13
View File
@@ -38,10 +38,23 @@ def run_migrations():
)
"""
)
con.execute(
"""
CREATE TABLE IF NOT EXISTS workers (
worker_id TEXT PRIMARY KEY,
status TEXT,
last_seen_at TEXT,
current_job_id INTEGER,
started_at TEXT,
metadata_json TEXT
)
"""
)
con.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status_created ON jobs(status, created_at)")
con.execute("CREATE INDEX IF NOT EXISTS idx_jobs_target_status ON jobs(target_type, target_id, status)")
con.execute("CREATE INDEX IF NOT EXISTS idx_job_logs_job_id ON job_logs(job_id, id)")
con.execute("CREATE INDEX IF NOT EXISTS idx_workers_last_seen ON workers(last_seen_at)")
try:
con.execute("ALTER TABLE jobs ADD COLUMN created_by_display_name TEXT")
+89
View File
@@ -0,0 +1,89 @@
from datetime import datetime, timedelta, timezone
from app.db.database import get_connection
from app.db.migrations import run_migrations
def parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.strip().replace("Z", "+00:00")
formats = [
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
]
try:
parsed = datetime.fromisoformat(normalized)
return parsed.astimezone(timezone.utc).replace(tzinfo=None) if parsed.tzinfo else parsed
except ValueError:
pass
for fmt in formats:
try:
return datetime.strptime(normalized, fmt)
except ValueError:
continue
return None
def is_worker_online(last_seen_at: str | None) -> bool:
last_seen = parse_timestamp(last_seen_at)
if not last_seen:
return False
return last_seen >= datetime.utcnow() - timedelta(seconds=30)
def with_online_status(worker: dict):
worker["online"] = is_worker_online(worker.get("last_seen_at"))
return worker
def get_workers():
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
worker_id,
status,
last_seen_at,
current_job_id,
started_at,
metadata_json
FROM workers
ORDER BY worker_id
"""
).fetchall()
con.close()
return [with_online_status(dict(row)) for row in rows]
def get_worker(worker_id: str):
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT
worker_id,
status,
last_seen_at,
current_job_id,
started_at,
metadata_json
FROM workers
WHERE worker_id = ?
""",
(worker_id,),
).fetchone()
con.close()
return with_online_status(dict(row)) if row else None
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from .config import read_env_value
from .routes import apps, audit, auth, backups, deployments, health, jobs
from .routes import apps, audit, auth, backups, deployments, health, jobs, workers
def create_app() -> FastAPI:
@@ -28,6 +28,7 @@ def create_app() -> FastAPI:
app.include_router(backups.router)
app.include_router(deployments.router)
app.include_router(jobs.router)
app.include_router(workers.router)
app.include_router(audit.router)
return app
+172
View File
@@ -0,0 +1,172 @@
import html
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth import require_user
from app.db.audit import log_audit_event
from app.db.workers import get_worker, get_workers
from app.templates.layout import page
router = APIRouter()
def render_worker_badge(online: bool) -> str:
if online:
return '<span class="pill pill-success">ONLINE</span>'
return '<span class="pill pill-danger">OFFLINE</span>'
def pretty_json(value: str | None) -> str:
if not value:
return "{}"
try:
return json.dumps(json.loads(value), ensure_ascii=False, indent=2, sort_keys=True)
except (TypeError, ValueError):
return value
@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)
offline_count = total_count - online_count
log_audit_event(
user,
action="workers.view",
target_type="workers",
metadata={
"online": online_count,
"offline": offline_count,
"total": total_count,
},
)
rows = ""
for worker in workers:
worker_id_raw = worker.get("worker_id", "") or ""
worker_id = html.escape(worker_id_raw)
worker_url_id = quote(worker_id_raw, safe="")
status = html.escape(worker.get("status", "") or "")
last_seen_at = html.escape(worker.get("last_seen_at", "") or "")
started_at = html.escape(worker.get("started_at", "") or "")
badge = render_worker_badge(bool(worker.get("online")))
current_job_id = worker.get("current_job_id")
current_job = ""
if current_job_id is not None:
current_job_label = html.escape(str(current_job_id))
current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
rows += f"""
<tr>
<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>{started_at}</td>
<td class="actions-cell"><a class="btn" href="/portal/workers/{worker_url_id}">Detail</a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>'
return page(
"Workers",
f"""
<div class="card">
<h2>Workers</h2>
<p class="muted">Přehled worker procesů obsluhujících AppFactory joby.</p>
</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>
<div class="card">
<h2>Workers</h2>
<table>
<tr>
<th>Worker ID</th>
<th>Status</th>
<th>Last Seen</th>
<th>Current Job</th>
<th>Started At</th>
<th>Akce</th>
</tr>
{rows}
</table>
</div>
""",
user=user,
)
@router.get("/workers/{worker_id}", response_class=HTMLResponse)
def worker_detail_page(worker_id: str, request: Request, user=Depends(require_user)):
worker = get_worker(worker_id)
if not worker:
raise HTTPException(status_code=404, detail="Worker not found")
log_audit_event(
user,
action="workers.view",
target_type="worker",
target_id=worker_id,
)
worker_id_html = html.escape(worker.get("worker_id", "") or "")
status = html.escape(worker.get("status", "") or "")
last_seen_at = html.escape(worker.get("last_seen_at", "") or "")
started_at = html.escape(worker.get("started_at", "") or "")
badge = render_worker_badge(bool(worker.get("online")))
current_job_id = worker.get("current_job_id")
current_job = ""
if current_job_id is not None:
current_job_label = html.escape(str(current_job_id))
current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
metadata = html.escape(pretty_json(worker.get("metadata_json")))
metadata_block = ""
if worker.get("metadata_json"):
metadata_block = f"""
<div class="card">
<h2>Metadata</h2>
<pre class="log-viewer log-stdout">{metadata}</pre>
</div>
"""
return page(
f"Worker {worker_id_html}",
f"""
<div class="card">
<h2>Worker {worker_id_html}</h2>
<p>
<a class="btn" href="/portal/workers">&larr; Zpět na workers</a>
</p>
</div>
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>Worker ID</th><td>{worker_id_html}</td></tr>
<tr><th>Status</th><td>{badge}<br><span class="muted">{status}</span></td></tr>
<tr><th>Last Seen</th><td>{last_seen_at}</td></tr>
<tr><th>Current Job</th><td>{current_job}</td></tr>
<tr><th>Started At</th><td>{started_at}</td></tr>
</table>
</div>
{metadata_block}
""",
user=user,
)
+1
View File
@@ -16,6 +16,7 @@ def page(title: str, body: str, user=None) -> str:
<a href="/portal/new-app">Nová aplikace</a>
<a href="/portal/deployments">Nasazení</a>
<a href="/portal/jobs">Joby</a>
<a href="/portal/workers">Operations -> Workers</a>
<a href="/portal/backups">Zálohy</a>
<a href="/portal/audit">Audit</a>
</nav>