deployment observability, filters, stats, details
This commit is contained in:
+125
-4
@@ -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">← 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
@@ -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">← 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">← 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,
|
||||
|
||||
Reference in New Issue
Block a user