diff --git a/app/db/apps.py b/app/db/apps.py index fe4ba9c..298c309 100644 --- a/app/db/apps.py +++ b/app/db/apps.py @@ -1,6 +1,11 @@ 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(): con = get_connection() @@ -38,6 +43,22 @@ def get_apps(): 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): con = get_connection() @@ -60,17 +81,143 @@ def update_app_resources(app_id: str, memory: str, cpus: str): 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() 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 + WHERE app_id = ? ORDER BY id DESC LIMIT ? """, - (limit,), + (app_id, limit), ).fetchall() con.close() @@ -82,7 +229,21 @@ def get_deployment(deployment_id: int): 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 WHERE id = ? """, diff --git a/app/routes/apps.py b/app/routes/apps.py index 519e275..bb49f73 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -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)): +

Detail

+

Nasazení

+
+ +
@@ -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""" + + #{deployment_id} + {render_status_pill(deployment.get("status"))} + {started_at} + {triggered_by} + + """ + + if not rows: + rows = 'Zatím nejsou evidovaná žádná nasazení této aplikace.' + + return page( + f"Aplikace {escaped_app_id}", + f""" +
+

{escaped_app_id}

+

{name}

+

+ ← Zpět na aplikace + Nasazení aplikace +

+ + + +
+ +
+

Souhrn

+ + + + + + + + + +
ID{escaped_app_id}
Název{name}
Jazyk{language}
Verze{version}
Status{status}
Paměť{memory}
CPU{cpus}
Upraveno{updated_at}
+
+ +
+

Poslední deploymenty

+ + + + + + + + {rows} +
IDStatusTimestampTriggered by
+
+ """, + 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( diff --git a/app/routes/deployments.py b/app/routes/deployments.py index c99c786..1ea3e15 100644 --- a/app/routes/deployments.py +++ b/app/routes/deployments.py @@ -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'{html.escape(status_value)}' +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''] + for value in values: + selected_attr = " selected" if selected == value else "" + options.append(f'') + 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 = "" 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""" - + #{deployment_id}
{started_at} - {app_id} + {escaped_app_id} {kind} - {status} + {status_pill} + {source_label} + {triggered_by} {returncode_label} {finished_at} @@ -59,17 +173,41 @@ async def deployments_page(request: Request, user=Depends(require_user)): """ if not rows: - rows = 'Zatím nejsou evidovaná žádná nasazení.' + rows = 'Zatím nejsou evidovaná žádná nasazení.' + + 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}

Historie nasazení

Přehled posledních běhů nasazení a jejich výsledků.

← Zpět na portál
+
+
Total deploys{stats.get("total") or 0}
+
Failed deploys{stats.get("failed") or 0}
+
Running deploys{stats.get("running") or 0}
+
Success rate{stats.get("success_rate") or 0}%
+
+ +
+

Filtry

+
+ + + + + Reset +
+
+

Nasazení

@@ -78,6 +216,8 @@ async def deployments_page(request: Request, user=Depends(require_user)): + + @@ -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 = "" if is_running( + deployment.get("status") + ) else "" + failed_notice = ( + f'
Deployment selhal. Návratový kód: {returncode_label}
' + if is_failed(deployment.get("status")) + else "" + ) return page( title, f""" + {refresh}

{title}

-

Detail běhu nasazení včetně výstupu procesu.

+

Detail běhu nasazení včetně oddělených výstupů stdout a stderr.

+ {failed_notice}

← Zpět na nasazení - Zpět na portál + Detail aplikace + Raw logy

Souhrn

Aplikace Typ StatusZdrojSpustil Kód Dokončeno Akce
- + - + + + + + +
Aplikace{app_id}
Aplikace{app_id}
Typ{kind}
Status{status}
Návratový kód{returncode_label}
Trigger source{trigger_source}
Triggered by{triggered_by}
Commit author{commit_author}
Pusher{pusher}
Spuštěno{started_at}
Dokončeno{finished_at}
Duration{duration}
Návratový kód{returncode_label}
-
-

Výstup

-
{stdout}
-

Chyba

-
{stderr}
+
+
+

stdout

+
{stdout}
+
+
+

stderr

+
{stderr}
+
""", user=user, diff --git a/app/shell.py b/app/shell.py index 3862714..1515938 100644 --- a/app/shell.py +++ b/app/shell.py @@ -1,5 +1,21 @@ +import os import subprocess def run_command(args): 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, + ) diff --git a/app/static/styles.css b/app/static/styles.css index 75982d4..36bb1da 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -138,6 +138,36 @@ main { 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 { border-collapse: collapse; width: 100%; @@ -286,10 +316,19 @@ input[readonly] { } .actions-cell { - width: 110px; + width: 150px; white-space: nowrap; } +.actions-cell form, +.actions-cell p { + margin: 0 0 8px; +} + +.running-row td { + background: var(--warning-bg); +} + .backup-row td { vertical-align: middle; } @@ -308,6 +347,31 @@ pre { 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 { font-size: 12px; color: var(--muted); @@ -321,3 +385,10 @@ pre { align-items: center; flex-wrap: wrap; } + +.filter-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +}