deployment observability, filters, stats, details

This commit is contained in:
JiriUhlir
2026-05-28 13:22:51 +02:00
parent 80d82d36dd
commit e6ffeb8fef
5 changed files with 585 additions and 34 deletions
+125 -4
View File
@@ -1,7 +1,8 @@
import html
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import require_user
from ..config import (
@@ -13,9 +14,10 @@ from ..config import (
NEW_APP_SCRIPT,
read_env_value,
)
from ..db.apps import 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 ..shell import run_command
from ..routes.deployments import render_status_pill
from ..shell import run_command, run_command_background
from ..templates.layout import page, render_result
router = APIRouter()
@@ -33,6 +35,7 @@ def index(request: Request, user=Depends(require_user)):
for item in apps:
app_id = html.escape(item.get("id", ""))
app_url_id = quote(item.get("id", ""), safe="")
status = html.escape(item.get("status", ""))
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
memory = item.get("memory", "")
@@ -102,6 +105,11 @@ def index(request: Request, user=Depends(require_user)):
</details>
</td>
<td>
<p><a class="btn" href="/portal/apps/{app_url_id}">Detail</a></p>
<p><a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení</a></p>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {app_id}?');">
<button type="submit" class="btn-secondary">Redeploy</button>
</form>
<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}">
<button type="submit" class="danger">Smazat</button>
@@ -153,6 +161,119 @@ def index(request: Request, user=Depends(require_user)):
)
@router.get("/apps/{app_id}", response_class=HTMLResponse)
def app_detail(app_id: str, request: Request, user=Depends(require_user)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
escaped_app_id = html.escape(app.get("id", ""))
app_url_id = quote(app.get("id", ""), safe="")
name = html.escape(app.get("name", "") or "")
language = html.escape(app.get("language", "") or "")
version = html.escape(app.get("version", "") or "")
status = html.escape(app.get("status", "") or "")
memory = html.escape(app.get("memory", "") or "")
cpus = html.escape(app.get("cpus", "") or "")
updated_at = html.escape(app.get("updated_at", "") or "")
rows = ""
for deployment in get_app_deployments(app.get("id", ""), limit=10):
deployment_id = html.escape(str(deployment.get("id", "")))
started_at = html.escape(deployment.get("started_at", "") or "")
triggered_by = html.escape(
deployment.get("triggered_by_display_name")
or deployment.get("triggered_by_username")
or ""
)
rows += f"""
<tr>
<td><a href="/portal/deployments/{deployment_id}">#{deployment_id}</a></td>
<td>{render_status_pill(deployment.get("status"))}</td>
<td>{started_at}</td>
<td>{triggered_by}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="4">Zatím nejsou evidovaná žádná nasazení této aplikace.</td></tr>'
return page(
f"Aplikace {escaped_app_id}",
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
<p class="muted">{name}</p>
<p>
<a class="btn" href="/portal">&larr; Zpět na aplikace</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení aplikace</a>
</p>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {escaped_app_id}?');">
<button type="submit">Redeploy</button>
</form>
</div>
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>ID</th><td>{escaped_app_id}</td></tr>
<tr><th>Název</th><td>{name}</td></tr>
<tr><th>Jazyk</th><td>{language}</td></tr>
<tr><th>Verze</th><td>{version}</td></tr>
<tr><th>Status</th><td><span class="pill">{status}</span></td></tr>
<tr><th>Paměť</th><td>{memory}</td></tr>
<tr><th>CPU</th><td>{cpus}</td></tr>
<tr><th>Upraveno</th><td>{updated_at}</td></tr>
</table>
</div>
<div class="card">
<h2>Poslední deploymenty</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Timestamp</th>
<th>Triggered by</th>
</tr>
{rows}
</table>
</div>
""",
user=user,
)
@router.post("/apps/{app_id}/redeploy")
def redeploy_app(app_id: str, user=Depends(require_user)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
process = run_command_background(
[DEPLOY_SCRIPT, app_id],
extra_env={
"APPFACTORY_TRIGGER_SOURCE": "portal",
"APPFACTORY_TRIGGERED_BY_USER_ID": user.get("id"),
"APPFACTORY_TRIGGERED_BY_USERNAME": user.get("username"),
"APPFACTORY_TRIGGERED_BY_DISPLAY_NAME": user.get("display_name") or user.get("username"),
},
)
log_audit_event(
user,
action="app.redeploy",
target_type="app",
target_id=app_id,
metadata={
"app_id": app_id,
"pid": process.pid,
"trigger_source": "portal",
},
)
return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}", status_code=303)
@router.get("/new-app", response_class=HTMLResponse)
def new_app_form(request: Request, user=Depends(require_user)):
return page(
+207 -25
View File
@@ -1,24 +1,47 @@
import html
from datetime import datetime
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from app.auth import require_user
from app.db.apps import get_deployment, get_deployments
from app.db.apps import (
get_deployment,
get_deployment_filter_options,
get_deployment_stats,
get_deployments,
)
from app.templates.layout import page
router = APIRouter()
SUCCESS_STATUSES = {"ok", "success", "succeeded", "done", "deployed", "completed"}
RUNNING_STATUSES = {"running", "pending", "queued", "in_progress", "starting"}
FAILED_STATUSES = {"failed", "failure", "error", "cancelled", "canceled"}
def normalize_status(status: str | None) -> str:
return (status or "").strip().lower()
def is_running(status: str | None) -> bool:
return normalize_status(status) in RUNNING_STATUSES
def is_failed(status: str | None) -> bool:
return normalize_status(status) in FAILED_STATUSES
def render_status_pill(status: str | None) -> str:
status_value = status or ""
normalized = status_value.strip().lower()
normalized = normalize_status(status)
if normalized in {"ok", "success", "succeeded", "done", "deployed", "completed"}:
if normalized in SUCCESS_STATUSES:
class_name = "pill pill-success"
elif normalized in {"running", "pending", "queued", "in_progress", "starting"}:
elif normalized in RUNNING_STATUSES:
class_name = "pill pill-warning"
elif normalized in {"failed", "failure", "error", "cancelled", "canceled"}:
elif normalized in FAILED_STATUSES:
class_name = "pill pill-danger"
else:
class_name = "pill pill-muted"
@@ -26,30 +49,121 @@ def render_status_pill(status: str | None) -> str:
return f'<span class="{class_name}">{html.escape(status_value)}</span>'
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:
return datetime.fromisoformat(normalized)
except ValueError:
pass
for fmt in formats:
try:
return datetime.strptime(normalized, fmt)
except ValueError:
continue
return None
def calculate_duration(started_at: str | None, finished_at: str | None) -> str:
if not started_at or not finished_at:
return ""
started = parse_timestamp(started_at)
finished = parse_timestamp(finished_at)
if not started or not finished:
return ""
seconds = int((finished - started).total_seconds())
if seconds < 0:
return ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
if hours:
return f"{hours}h {minutes}m {seconds}s"
if minutes:
return f"{minutes}m {seconds}s"
return f"{seconds}s"
def actor_label(deployment: dict) -> str:
display_name = deployment.get("triggered_by_display_name") or ""
username = deployment.get("triggered_by_username") or ""
if display_name and username:
return f"{display_name} (@{username})"
return display_name or username or ""
def render_options(values: list[str], selected: str | None, empty_label: str) -> str:
options = [f'<option value="">{html.escape(empty_label)}</option>']
for value in values:
selected_attr = " selected" if selected == value else ""
options.append(f'<option value="{html.escape(value)}"{selected_attr}>{html.escape(value)}</option>')
return "".join(options)
@router.get("/deployments", response_class=HTMLResponse)
async def deployments_page(request: Request, user=Depends(require_user)):
deployments = get_deployments()
async def deployments_page(
request: Request,
status: str = Query(""),
app_id: str = Query(""),
source: str = Query(""),
user=Depends(require_user),
):
selected_status = status.strip()
selected_app = app_id.strip()
selected_source = source.strip()
deployments = get_deployments(
status=selected_status or None,
app_id=selected_app or None,
source=selected_source or None,
)
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 ""
rows = ""
for deployment in deployments:
deployment_id = html.escape(str(deployment.get("id", "")))
app_id = html.escape(deployment.get("app_id", "") or "")
raw_app_id = deployment.get("app_id", "") or ""
escaped_app_id = html.escape(raw_app_id)
app_url_id = quote(raw_app_id, safe="")
kind = html.escape(deployment.get("kind", "") or "")
status = render_status_pill(deployment.get("status"))
status_pill = render_status_pill(deployment.get("status"))
started_at = html.escape(deployment.get("started_at", "") or "")
finished_at = html.escape(deployment.get("finished_at", "") or "")
source_label = html.escape(deployment.get("trigger_source", "") or "")
triggered_by = html.escape(actor_label(deployment))
returncode = deployment.get("returncode")
returncode_label = "" if returncode is None else html.escape(str(returncode))
row_class = ' class="running-row"' if is_running(deployment.get("status")) else ""
rows += f"""
<tr>
<tr{row_class}>
<td>
<strong>#{deployment_id}</strong><br>
<span class="muted">{started_at}</span>
</td>
<td>{app_id}</td>
<td><a href="/portal/apps/{app_url_id}">{escaped_app_id}</a></td>
<td>{kind}</td>
<td>{status}</td>
<td>{status_pill}</td>
<td>{source_label}</td>
<td>{triggered_by}</td>
<td>{returncode_label}</td>
<td>{finished_at}</td>
<td class="actions-cell">
@@ -59,17 +173,41 @@ async def deployments_page(request: Request, user=Depends(require_user)):
"""
if not rows:
rows = '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>'
rows = '<tr><td colspan="9">Zatím nejsou evidovaná žádná nasazení.</td></tr>'
status_values = ["running", "success", "failed"]
status_options = render_options(status_values, selected_status, "Všechny statusy")
app_options = render_options(options["apps"], selected_app, "Všechny aplikace")
source_options = render_options(options["sources"], selected_source, "Všechny zdroje")
return page(
"Nasazení",
f"""
{refresh}
<div class="card">
<h2>Historie nasazení</h2>
<p class="muted">Přehled posledních běhů nasazení a jejich výsledků.</p>
<a class="btn" href="/portal">&larr; Zpět na portál</a>
</div>
<div class="stats-grid">
<div class="stat-card"><span>Total deploys</span><strong>{stats.get("total") or 0}</strong></div>
<div class="stat-card"><span>Failed deploys</span><strong>{stats.get("failed") or 0}</strong></div>
<div class="stat-card"><span>Running deploys</span><strong>{stats.get("running") or 0}</strong></div>
<div class="stat-card"><span>Success rate</span><strong>{stats.get("success_rate") or 0}%</strong></div>
</div>
<div class="card">
<h2>Filtry</h2>
<form method="get" action="/portal/deployments" class="filter-form">
<select name="status">{status_options}</select>
<select name="app_id">{app_options}</select>
<select name="source">{source_options}</select>
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/deployments">Reset</a>
</form>
</div>
<div class="card">
<h2>Nasazení</h2>
<table>
@@ -78,6 +216,8 @@ async def deployments_page(request: Request, user=Depends(require_user)):
<th>Aplikace</th>
<th>Typ</th>
<th>Status</th>
<th>Zdroj</th>
<th>Spustil</th>
<th>Kód</th>
<th>Dokončeno</th>
<th>Akce</th>
@@ -90,6 +230,21 @@ async def deployments_page(request: Request, user=Depends(require_user)):
)
@router.get("/deployments/{deployment_id}/logs/raw", response_class=PlainTextResponse)
async def deployment_raw_logs(
deployment_id: int,
request: Request,
user=Depends(require_user),
):
deployment = get_deployment(deployment_id)
if not deployment:
raise HTTPException(status_code=404, detail="Deployment not found")
stdout = deployment.get("stdout", "") or ""
stderr = deployment.get("stderr", "") or ""
return PlainTextResponse(f"--- stdout ---\n{stdout}\n\n--- stderr ---\n{stderr}")
@router.get("/deployments/{deployment_id}", response_class=HTMLResponse)
async def deployment_detail_page(
deployment_id: int,
@@ -102,45 +257,72 @@ async def deployment_detail_page(
raise HTTPException(status_code=404, detail="Deployment not found")
title = f"Nasazení #{html.escape(str(deployment.get('id', deployment_id)))}"
app_id = html.escape(deployment.get("app_id", "") or "")
raw_app_id = deployment.get("app_id", "") or ""
app_id = html.escape(raw_app_id)
app_url_id = quote(raw_app_id, safe="")
kind = html.escape(deployment.get("kind", "") or "")
status = render_status_pill(deployment.get("status"))
started_at = html.escape(deployment.get("started_at", "") or "")
finished_at = html.escape(deployment.get("finished_at", "") or "")
duration = html.escape(calculate_duration(deployment.get("started_at"), deployment.get("finished_at")))
trigger_source = html.escape(deployment.get("trigger_source", "") or "")
triggered_by = html.escape(actor_label(deployment))
commit_author = html.escape(deployment.get("commit_author", "") or "")
pusher = html.escape(deployment.get("pusher", "") or "")
returncode = deployment.get("returncode")
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 ""
failed_notice = (
f'<div class="alert alert-danger">Deployment selhal. Návratový kód: {returncode_label}</div>'
if is_failed(deployment.get("status"))
else ""
)
return page(
title,
f"""
{refresh}
<div class="card">
<h2>{title}</h2>
<p class="muted">Detail běhu nasazení včetně výstupu procesu.</p>
<p class="muted">Detail běhu nasazení včetně oddělených výstupů stdout a stderr.</p>
{failed_notice}
<p>
<a class="btn" href="/portal/deployments">&larr; Zpět na nasazení</a>
<a class="btn btn-secondary" href="/portal">Zpět na portál</a>
<a class="btn btn-secondary" href="/portal/apps/{app_url_id}">Detail aplikace</a>
<a class="btn btn-secondary" href="/portal/deployments/{html.escape(str(deployment_id))}/logs/raw">Raw logy</a>
</p>
</div>
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>Aplikace</th><td>{app_id}</td></tr>
<tr><th>Aplikace</th><td><a href="/portal/apps/{app_url_id}">{app_id}</a></td></tr>
<tr><th>Typ</th><td>{kind}</td></tr>
<tr><th>Status</th><td>{status}</td></tr>
<tr><th>Návratový kód</th><td>{returncode_label}</td></tr>
<tr><th>Trigger source</th><td>{trigger_source}</td></tr>
<tr><th>Triggered by</th><td>{triggered_by}</td></tr>
<tr><th>Commit author</th><td>{commit_author}</td></tr>
<tr><th>Pusher</th><td>{pusher}</td></tr>
<tr><th>Spuštěno</th><td>{started_at}</td></tr>
<tr><th>Dokončeno</th><td>{finished_at}</td></tr>
<tr><th>Duration</th><td>{duration}</td></tr>
<tr><th>Návratový kód</th><td>{returncode_label}</td></tr>
</table>
</div>
<div class="card">
<h2>Výstup</h2>
<pre>{stdout}</pre>
<h2>Chyba</h2>
<pre>{stderr}</pre>
<div class="grid">
<div class="card">
<h2>stdout</h2>
<pre class="log-viewer log-stdout">{stdout}</pre>
</div>
<div class="card failed-log">
<h2>stderr</h2>
<pre class="log-viewer log-stderr">{stderr}</pre>
</div>
</div>
""",
user=user,