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
+165 -4
View File
@@ -1,6 +1,11 @@
from app.db.database import get_connection from app.db.database import get_connection
SUCCESS_STATUSES = ("ok", "success", "succeeded", "done", "deployed", "completed")
FAILED_STATUSES = ("failed", "failure", "error", "cancelled", "canceled")
RUNNING_STATUSES = ("running", "pending", "queued", "in_progress", "starting")
def get_apps(): def get_apps():
con = get_connection() con = get_connection()
@@ -38,6 +43,22 @@ def get_apps():
return [dict(row) for row in rows] return [dict(row) for row in rows]
def get_app(app_id: str):
con = get_connection()
row = con.execute(
"""
SELECT id, name, language, version, status, memory, cpus, updated_at
FROM apps
WHERE id = ?
""",
(app_id,),
).fetchone()
con.close()
return dict(row) if row else None
def update_app_resources(app_id: str, memory: str, cpus: str): def update_app_resources(app_id: str, memory: str, cpus: str):
con = get_connection() con = get_connection()
@@ -60,17 +81,143 @@ def update_app_resources(app_id: str, memory: str, cpus: str):
con.close() con.close()
def get_deployments(limit: int = 100): def get_deployments(
limit: int = 100,
status: str | None = None,
app_id: str | None = None,
source: str | None = None,
):
con = get_connection()
filters = []
params = []
if status:
normalized_status = status.strip().lower()
if normalized_status == "success":
filters.append(f"LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in SUCCESS_STATUSES)})")
params.extend(SUCCESS_STATUSES)
elif normalized_status == "failed":
filters.append(f"LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in FAILED_STATUSES)})")
params.extend(FAILED_STATUSES)
elif normalized_status == "running":
filters.append(f"LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in RUNNING_STATUSES)})")
params.extend(RUNNING_STATUSES)
else:
filters.append("LOWER(status) = LOWER(?)")
params.append(status)
if app_id:
filters.append("app_id = ?")
params.append(app_id)
if source:
filters.append("trigger_source = ?")
params.append(source)
where = f"WHERE {' AND '.join(filters)}" if filters else ""
rows = con.execute(
f"""
SELECT
id,
app_id,
kind,
status,
started_at,
finished_at,
returncode,
trigger_source,
triggered_by_username,
triggered_by_display_name
FROM deployments
{where}
ORDER BY
CASE
WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in RUNNING_STATUSES)}) THEN 0
ELSE 1
END,
id DESC
LIMIT ?
""",
(*params, *RUNNING_STATUSES, limit),
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_deployment_filter_options():
con = get_connection()
apps = con.execute(
"""
SELECT DISTINCT app_id
FROM deployments
WHERE app_id IS NOT NULL AND app_id != ''
ORDER BY app_id
"""
).fetchall()
sources = con.execute(
"""
SELECT DISTINCT trigger_source
FROM deployments
WHERE trigger_source IS NOT NULL AND trigger_source != ''
ORDER BY trigger_source
"""
).fetchall()
con.close()
return {
"apps": [row["app_id"] for row in apps],
"sources": [row["trigger_source"] for row in sources],
}
def get_deployment_stats():
con = get_connection()
row = con.execute(
f"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in FAILED_STATUSES)}) THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in RUNNING_STATUSES)}) THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in SUCCESS_STATUSES)}) THEN 1 ELSE 0 END) AS success
FROM deployments
""",
(*FAILED_STATUSES, *RUNNING_STATUSES, *SUCCESS_STATUSES),
).fetchone()
con.close()
stats = dict(row) if row else {"total": 0, "failed": 0, "running": 0, "success": 0}
total = stats.get("total") or 0
success = stats.get("success") or 0
stats["success_rate"] = round((success / total) * 100, 1) if total else 0
return stats
def get_app_deployments(app_id: str, limit: int = 10):
con = get_connection() con = get_connection()
rows = con.execute( rows = con.execute(
""" """
SELECT id, app_id, kind, status, started_at, finished_at, returncode SELECT
id,
app_id,
kind,
status,
started_at,
finished_at,
returncode,
trigger_source,
triggered_by_username,
triggered_by_display_name
FROM deployments FROM deployments
WHERE app_id = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
""", """,
(limit,), (app_id, limit),
).fetchall() ).fetchall()
con.close() con.close()
@@ -82,7 +229,21 @@ def get_deployment(deployment_id: int):
row = con.execute( row = con.execute(
""" """
SELECT id, app_id, kind, status, started_at, finished_at, returncode, stdout, stderr SELECT
id,
app_id,
kind,
status,
started_at,
finished_at,
returncode,
stdout,
stderr,
trigger_source,
triggered_by_username,
triggered_by_display_name,
commit_author,
pusher
FROM deployments FROM deployments
WHERE id = ? WHERE id = ?
""", """,
+125 -4
View File
@@ -1,7 +1,8 @@
import html import html
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import require_user from ..auth import require_user
from ..config import ( from ..config import (
@@ -13,9 +14,10 @@ from ..config import (
NEW_APP_SCRIPT, NEW_APP_SCRIPT,
read_env_value, 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 ..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 from ..templates.layout import page, render_result
router = APIRouter() router = APIRouter()
@@ -33,6 +35,7 @@ def index(request: Request, user=Depends(require_user)):
for item in apps: for item in apps:
app_id = html.escape(item.get("id", "")) app_id = html.escape(item.get("id", ""))
app_url_id = quote(item.get("id", ""), safe="")
status = html.escape(item.get("status", "")) status = html.escape(item.get("status", ""))
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs")) docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
memory = item.get("memory", "") memory = item.get("memory", "")
@@ -102,6 +105,11 @@ def index(request: Request, user=Depends(require_user)):
</details> </details>
</td> </td>
<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ář.');"> <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}">
<button type="submit" class="danger">Smazat</button> <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) @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(
+206 -24
View File
@@ -1,24 +1,47 @@
import html import html
from datetime import datetime
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, PlainTextResponse
from app.auth import require_user 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 from app.templates.layout import page
router = APIRouter() 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: def render_status_pill(status: str | None) -> str:
status_value = status or "" 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" class_name = "pill pill-success"
elif normalized in {"running", "pending", "queued", "in_progress", "starting"}: elif normalized in RUNNING_STATUSES:
class_name = "pill pill-warning" class_name = "pill pill-warning"
elif normalized in {"failed", "failure", "error", "cancelled", "canceled"}: elif normalized in FAILED_STATUSES:
class_name = "pill pill-danger" class_name = "pill pill-danger"
else: else:
class_name = "pill pill-muted" 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>' 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) @router.get("/deployments", response_class=HTMLResponse)
async def deployments_page(request: Request, user=Depends(require_user)): async def deployments_page(
deployments = get_deployments() 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 = "" rows = ""
for deployment in deployments: for deployment in deployments:
deployment_id = html.escape(str(deployment.get("id", ""))) 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 "") 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 "") started_at = html.escape(deployment.get("started_at", "") or "")
finished_at = html.escape(deployment.get("finished_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 = deployment.get("returncode")
returncode_label = "" if returncode is None else html.escape(str(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""" rows += f"""
<tr> <tr{row_class}>
<td> <td>
<strong>#{deployment_id}</strong><br> <strong>#{deployment_id}</strong><br>
<span class="muted">{started_at}</span> <span class="muted">{started_at}</span>
</td> </td>
<td>{app_id}</td> <td><a href="/portal/apps/{app_url_id}">{escaped_app_id}</a></td>
<td>{kind}</td> <td>{kind}</td>
<td>{status}</td> <td>{status_pill}</td>
<td>{source_label}</td>
<td>{triggered_by}</td>
<td>{returncode_label}</td> <td>{returncode_label}</td>
<td>{finished_at}</td> <td>{finished_at}</td>
<td class="actions-cell"> <td class="actions-cell">
@@ -59,17 +173,41 @@ async def deployments_page(request: Request, user=Depends(require_user)):
""" """
if not rows: 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( return page(
"Nasazení", "Nasazení",
f""" f"""
{refresh}
<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">&larr; Zpět na portál</a> <a class="btn" href="/portal">&larr; Zpět na portál</a>
</div> </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"> <div class="card">
<h2>Nasazení</h2> <h2>Nasazení</h2>
<table> <table>
@@ -78,6 +216,8 @@ async def deployments_page(request: Request, user=Depends(require_user)):
<th>Aplikace</th> <th>Aplikace</th>
<th>Typ</th> <th>Typ</th>
<th>Status</th> <th>Status</th>
<th>Zdroj</th>
<th>Spustil</th>
<th>Kód</th> <th>Kód</th>
<th>Dokončeno</th> <th>Dokončeno</th>
<th>Akce</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) @router.get("/deployments/{deployment_id}", response_class=HTMLResponse)
async def deployment_detail_page( async def deployment_detail_page(
deployment_id: int, deployment_id: int,
@@ -102,45 +257,72 @@ async def deployment_detail_page(
raise HTTPException(status_code=404, detail="Deployment not found") raise HTTPException(status_code=404, detail="Deployment not found")
title = f"Nasazení #{html.escape(str(deployment.get('id', deployment_id)))}" 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 "") kind = html.escape(deployment.get("kind", "") or "")
status = render_status_pill(deployment.get("status")) status = render_status_pill(deployment.get("status"))
started_at = html.escape(deployment.get("started_at", "") or "") started_at = html.escape(deployment.get("started_at", "") or "")
finished_at = html.escape(deployment.get("finished_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 = deployment.get("returncode")
returncode_label = "" if returncode is None else html.escape(str(returncode)) returncode_label = "" if returncode is None else html.escape(str(returncode))
stdout = html.escape(deployment.get("stdout", "") or "") stdout = html.escape(deployment.get("stdout", "") or "")
stderr = html.escape(deployment.get("stderr", "") 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( return page(
title, title,
f""" f"""
{refresh}
<div class="card"> <div class="card">
<h2>{title}</h2> <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> <p>
<a class="btn" href="/portal/deployments">&larr; Zpět na nasazení</a> <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> </p>
</div> </div>
<div class="card"> <div class="card">
<h2>Souhrn</h2> <h2>Souhrn</h2>
<table> <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>Typ</th><td>{kind}</td></tr>
<tr><th>Status</th><td>{status}</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>Spuštěno</th><td>{started_at}</td></tr>
<tr><th>Dokončeno</th><td>{finished_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> </table>
</div> </div>
<div class="grid">
<div class="card"> <div class="card">
<h2>Výstup</h2> <h2>stdout</h2>
<pre>{stdout}</pre> <pre class="log-viewer log-stdout">{stdout}</pre>
<h2>Chyba</h2> </div>
<pre>{stderr}</pre> <div class="card failed-log">
<h2>stderr</h2>
<pre class="log-viewer log-stderr">{stderr}</pre>
</div>
</div> </div>
""", """,
user=user, user=user,
+16
View File
@@ -1,5 +1,21 @@
import os
import subprocess import subprocess
def run_command(args): def run_command(args):
return subprocess.run(args, capture_output=True, text=True) return subprocess.run(args, capture_output=True, text=True)
def run_command_background(args, extra_env=None):
env = os.environ.copy()
if extra_env:
env.update({key: "" if value is None else str(value) for key, value in extra_env.items()})
return subprocess.Popen(
args,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
+72 -1
View File
@@ -138,6 +138,36 @@ main {
gap: 16px; gap: 16px;
} }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px;
box-shadow: 0 8px 24px rgba(30, 81, 107, 0.07);
}
.stat-card span {
display: block;
color: var(--muted);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
.stat-card strong {
display: block;
margin-top: 8px;
font-size: 28px;
color: var(--secondary);
}
table { table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
@@ -286,10 +316,19 @@ input[readonly] {
} }
.actions-cell { .actions-cell {
width: 110px; width: 150px;
white-space: nowrap; white-space: nowrap;
} }
.actions-cell form,
.actions-cell p {
margin: 0 0 8px;
}
.running-row td {
background: var(--warning-bg);
}
.backup-row td { .backup-row td {
vertical-align: middle; vertical-align: middle;
} }
@@ -308,6 +347,31 @@ pre {
overflow: auto; overflow: auto;
} }
.log-viewer {
max-height: 520px;
min-height: 220px;
white-space: pre-wrap;
overflow: auto;
font-family: Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px;
line-height: 1.45;
}
.log-stdout {
background: #102a3a;
color: #edf6f9;
}
.log-stderr {
background: #3a111b;
color: #ffe5ea;
border: 1px solid #f3a8b7;
}
.failed-log {
border-color: #f3a8b7;
}
.resource-help { .resource-help {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
@@ -321,3 +385,10 @@ pre {
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filter-form {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}