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