deployment observability, filters, stats, details
This commit is contained in:
+165
-4
@@ -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
@@ -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">← 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(
|
||||||
|
|||||||
+207
-25
@@ -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">← Zpět na portál</a>
|
<a class="btn" href="/portal">← 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">← Zpět na nasazení</a>
|
<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>
|
</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="card">
|
<div class="grid">
|
||||||
<h2>Výstup</h2>
|
<div class="card">
|
||||||
<pre>{stdout}</pre>
|
<h2>stdout</h2>
|
||||||
<h2>Chyba</h2>
|
<pre class="log-viewer log-stdout">{stdout}</pre>
|
||||||
<pre>{stderr}</pre>
|
</div>
|
||||||
|
<div class="card failed-log">
|
||||||
|
<h2>stderr</h2>
|
||||||
|
<pre class="log-viewer log-stderr">{stderr}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user