gitea user and pswd, some pages removed, logs

This commit is contained in:
JiriUhlir
2026-06-17 11:39:52 +02:00
parent 23bbcad835
commit 7897e3b876
18 changed files with 601 additions and 457 deletions
+1 -18
View File
@@ -512,25 +512,8 @@ def apps_page(
return page(
"Služby",
f"""
<div class="grid">
<div class="card">
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Služby</h2>
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
</div>
<div class="card">
<h2><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Zálohy</h2>
<p class="muted">Vytváření, stažení a obnova záloh. Obnova je chráněná a dostupná jen administrátorům.</p>
<a class="btn" href="/portal/backups"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Spravovat zálohy</a>
</div>
<div class="card">
<h2><i class="fa-solid fa-rocket" aria-hidden="true"></i> Nasazení</h2>
<p class="muted">Historie posledních běhů nasazení, stavů a výstupů z deploy procesu.</p>
<a class="btn" href="/portal/deployments"><i class="fa-solid fa-rocket" aria-hidden="true"></i> Zobrazit nasazení</a>
</div>
</div>
<div class="card">
<h2>Nasazené služby</h2>
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Nasazené služby</h2>
{notice}
{manage_toolbar}
<form method="get" action="/portal/apps" class="filter-form">
-1
View File
@@ -255,7 +255,6 @@ async def deployments_page(
<div class="card">
<h2><i class="fa-solid fa-rocket" aria-hidden="true"></i> Historie nasazení</h2>
<p class="muted">Přehled posledních běhů nasazení a jejich výsledků.</p>
<a class="btn" href="/portal/operations">&larr; Zpět na přehled</a>
</div>
<div class="stats-grid">
+3 -7
View File
@@ -17,18 +17,14 @@ PORTAL_SECTIONS = [
"Přehled nasazených služeb: stav, dokumentace, prostředky (RAM/CPU), Git a akce.", "viewer (čtení) / developer / admin"),
("fa-list-check", "Úlohy", "/portal/jobs",
"Fronta a historie úloh (nasazení, skripty) včetně stavů a logů.", "developer / admin"),
("fa-file-lines", "Logy", "/portal/logs",
"Souhrnné čtení logů napříč všemi úlohami; filtr chyb a proklik na konkrétní řádek úlohy.", "developer / admin"),
("fa-calendar-days", "Plánované skripty", "/portal/scheduled-scripts",
"Cron-like skripty spouštěné na pozadí; úpravy jen pro administrátory.", "developer / admin"),
("fa-bell", "Alerting", "/portal/alerting/rules",
"Pravidla alertů a jejich skripty; úpravy jen pro administrátory.", "developer / admin"),
("fa-gauge-high", "Přehled", "/portal/operations",
"Operační přehled systému, běžící nasazení a stav služeb.", "admin"),
("fa-server", "Runtime Management", "/portal/admin/runtime",
"Redeploy core služeb (Portal, Worker, Webhook, Monitor, Gateway, Gitea, Registry) přes deploy-core-service.sh v job frontě.", "admin"),
("fa-sliders", "Environment", "/portal/admin/environment",
"Bezpečná úprava hlavního appfactory.env (backup, validace) + navazující restart/regenerate akce přes job frontu.", "admin"),
("fa-diagram-project", "Migration Readiness", "/portal/migration-readiness",
"Připravenost a deploy core služeb AppFactory.", "admin"),
"Bezpečná úprava hlavního appfactory.env (backup, validace) + navazující redeploy core služeb (Portal, Worker, Webhook, Monitor, Gateway, Gitea, Registry) přes job frontu.", "admin"),
("fa-rocket", "Nasazení", "/portal/deployments",
"Historie deploy běhů, stavů a výstupů z deploy procesu.", "developer / admin"),
("fa-triangle-exclamation", "Incidenty", "/portal/incidents",
+1 -1
View File
@@ -97,7 +97,7 @@ def _render_page(user: dict, entries: list[tuple[str, str]], message: str = "",
<div class="card">
<h2>Navazující akce</h2>
<p class="muted">Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v
<a href="/portal/admin/runtime">Runtime Management</a> a <a href="/portal/jobs">Úlohách</a>.</p>
<a href="/portal/jobs">Úlohách</a>.</p>
<div class="inline-form">
{action_buttons}
</div>
+47 -4
View File
@@ -325,7 +325,32 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
target_type = job.get("target_type", "") or ""
target_id = job.get("target_id", "") or ""
target_id_html = html.escape(target_id)
target_url = f"/portal/apps/{quote(target_id, safe='')}" if target_type == "app" else "/portal/jobs"
# Kontextová navigace: "Zpět" vede tam, odkud úloha typicky vznikla, a tlačítko na detail cíle
# se ukáže jen tehdy, když cíl má vlastní stránku (jinak nedávalo smysl a vedlo zpět na seznam úloh).
back_url = "/portal/jobs"
back_label = "Zpět na úlohy"
target_detail_button = ""
if target_type == "app" and target_id:
app_url = f"/portal/apps/{quote(target_id, safe='')}"
target_detail_button = (
f'<a class="btn btn-secondary" href="{app_url}">'
'<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Detail služby</a>'
)
elif target_type == "scheduled_script":
back_url = "/portal/scheduled-scripts"
back_label = "Zpět na plánované skripty"
try:
payload_obj = json.loads(job.get("payload_json") or "{}")
except (TypeError, ValueError):
payload_obj = {}
scheduled_script_id = payload_obj.get("scheduled_script_id")
if scheduled_script_id:
sid_html = html.escape(str(scheduled_script_id))
target_detail_button = (
f'<a class="btn btn-secondary" href="/portal/scheduled-scripts/{sid_html}">'
'<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Detail skriptu</a>'
)
duration = html.escape(calculate_duration(job.get("started_at"), job.get("finished_at")))
payload = html.escape(pretty_json(job.get("payload_json")))
result = html.escape(pretty_json(job.get("result_json")))
@@ -355,6 +380,7 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
log_blocks = ""
for log in get_job_logs(job_id):
log_id = html.escape(str(log.get("id", "")))
stream = html.escape(log.get("stream", "") or "system")
created_at = html.escape(log.get("created_at", "") or "")
message = html.escape(log.get("message", "") or "")
@@ -364,8 +390,9 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
class_name = "log-system"
else:
class_name = "log-stdout"
# id="log-{id}" umožní proklik z globálního prohlížeče logů přímo na konkrétní řádek.
log_blocks += f"""
<div class="job-log-entry">
<div class="job-log-entry" id="log-{log_id}">
<div class="muted">{created_at} · {stream}</div>
<pre class="log-viewer {class_name}">{message}</pre>
</div>
@@ -379,6 +406,10 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
(() => {{
const statusEl = document.getElementById("live-log-status");
const panel = document.getElementById("live-log-panel");
const banner = document.getElementById("job-finished-banner");
// Pokud úloha při načtení stránky ještě běžela, po jejím dokončení stránku obnovíme,
// aby se přepsal i horní stav a souhrn (jinak by zůstal viset na "běží").
const jobWasLive = {"true" if status_value in ("queued", "running", "cancelled_requested") else "false"};
if (!statusEl || !panel || !window.WebSocket) {{
if (statusEl) statusEl.textContent = "Disconnected";
return;
@@ -418,7 +449,18 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
}}
if (item.type === "job_finished") {{
statusEl.textContent = `Finished: ${{item.status}}`;
if (banner) {{
const labels = {{ success: "úspěšně", failed: "se selháním", cancelled: "zrušena" }};
banner.classList.toggle("alert-danger", item.status === "failed" || item.status === "cancelled");
banner.textContent = jobWasLive
? `Úloha doběhla (${{labels[item.status] || item.status}}). Načítám výsledek…`
: `Úloha doběhla (${{labels[item.status] || item.status}}).`;
banner.style.display = "block";
}}
socket.close();
if (jobWasLive) {{
setTimeout(() => window.location.reload(), 1200);
}}
}}
}});
socket.addEventListener("close", () => {{
@@ -440,9 +482,10 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
<div class="card">
<h2>{title}</h2>
<p>
<a class="btn" href="/portal/jobs">&larr; Zpět na úlohy</a>
<a class="btn btn-secondary" href="{target_url}"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Detail cíle</a>
<a class="btn" href="{back_url}">&larr; {back_label}</a>
{target_detail_button}
</p>
<p id="job-finished-banner" class="alert" style="display:none;"></p>
{retry_blocked_notice}
{actions}
</div>
+177
View File
@@ -0,0 +1,177 @@
import html
import math
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth import require_user
from app.db.jobs import count_job_logs, search_job_logs
from app.routes.jobs import render_job_status
from app.templates.layout import page
router = APIRouter()
STREAM_FILTERS = ("stdout", "stderr", "system")
PAGE_SIZE = 100
def _can_view_logs(user: dict) -> bool:
return (user.get("role") or "").lower() in ("admin", "developer")
def render_stream_pill(stream: str | None) -> str:
value = (stream or "system").lower()
if value == "stderr":
return '<span class="pill pill-danger">stderr</span>'
if value == "stdout":
return '<span class="pill pill-success">stdout</span>'
return '<span class="pill pill-muted">system</span>'
def render_stream_options(selected: str) -> str:
options = ['<option value="">Všechny streamy</option>']
for value in STREAM_FILTERS:
selected_attr = " selected" if selected == value else ""
options.append(f'<option value="{value}"{selected_attr}>{value}</option>')
return "".join(options)
@router.get("/logs", response_class=HTMLResponse)
def logs_page(
request: Request,
q: str = Query(""),
stream: str = Query(""),
job: str = Query(""),
page_number: int = Query(1, alias="page", ge=1),
user=Depends(require_user),
):
if not _can_view_logs(user):
raise HTTPException(status_code=403, detail="Logy mohou číst jen developer nebo admin")
query = q.strip()
selected_stream = stream.strip().lower()
if selected_stream not in STREAM_FILTERS:
selected_stream = ""
job_id: int | None = None
job_value = job.strip()
if job_value.isdigit():
job_id = int(job_value)
total = count_job_logs(query=query or None, stream=selected_stream or None, job_id=job_id)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
if page_number > total_pages:
page_number = total_pages
offset = (page_number - 1) * PAGE_SIZE
logs = search_job_logs(
query=query or None,
stream=selected_stream or None,
job_id=job_id,
limit=PAGE_SIZE,
offset=offset,
)
rows = ""
for log in logs:
log_id = html.escape(str(log.get("id", "")))
job_id_html = html.escape(str(log.get("job_id", "")))
created_at = html.escape(log.get("created_at", "") or "")
job_type = html.escape(log.get("job_type", "") or "")
target_type = html.escape(log.get("target_type", "") or "")
target_id = html.escape(log.get("target_id", "") or "")
message = log.get("message", "") or ""
# Náhled zprávy ať tabulka nepřeteče; plný text je po prokliku na úlohu.
preview = message if len(message) <= 400 else f"{message[:397]}..."
message_html = html.escape(preview)
# Proklik míří přímo na konkrétní řádek logu v detailu úlohy (kotva #log-{id}).
deep_link = f"/portal/jobs/{job_id_html}#log-{log_id}"
rows += f"""
<tr>
<td>{created_at}</td>
<td><strong><a href="/portal/jobs/{job_id_html}">#{job_id_html}</a></strong><br><span class="muted">{job_type}</span></td>
<td>{render_stream_pill(log.get("stream"))}</td>
<td>{render_job_status(log.get("job_status"))}<br><span class="muted">{target_type}: {target_id}</span></td>
<td><pre class="log-viewer log-inline">{message_html}</pre></td>
<td class="actions-cell"><a class="icon-action" href="{deep_link}" title="Zobrazit v úloze" aria-label="Zobrazit v úloze"><i class="fa-solid fa-up-right-from-square" aria-hidden="true"></i></a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Žádné logy neodpovídají filtru.</td></tr>'
def page_url(target_page: int) -> str:
params = {"page": target_page}
if query:
params["q"] = query
if selected_stream:
params["stream"] = selected_stream
if job_value:
params["job"] = job_value
return f"/portal/logs?{urlencode(params)}"
first_item = offset + 1 if total else 0
last_item = min(offset + len(logs), total)
previous_link = (
f'<a class="btn btn-secondary" href="{page_url(page_number - 1)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Předchozí</a>'
if page_number > 1
else ""
)
next_link = (
f'<a class="btn btn-secondary" href="{page_url(page_number + 1)}">Další <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>'
if page_number < total_pages
else ""
)
pagination = ""
if total_pages > 1:
pagination = f"""
<div class="pagination">
<span>Zobrazeno {first_item}-{last_item} z {total}</span>
<div class="pagination-actions">
{previous_link}
<span class="page-indicator">Strana {page_number} / {total_pages}</span>
{next_link}
</div>
</div>
"""
return page(
"Logy",
f"""
<div class="card">
<h2><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Logy</h2>
<p class="muted">
Souhrnné čtení logů napříč všemi úlohami. Vyfiltrujte si chyby (stream <strong>stderr</strong>)
nebo hledejte text a proklikněte se přímo na problematický řádek konkrétní úlohy.
</p>
<form method="get" action="/portal/logs" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Hledat v textu logu">
<select name="stream">{render_stream_options(selected_stream)}</select>
<input name="job" value="{html.escape(job_value)}" placeholder="ID úlohy" inputmode="numeric">
<input type="hidden" name="page" value="1">
<button type="submit"><i class="fa-solid fa-filter" aria-hidden="true"></i> Filtrovat</button>
<a class="btn btn-secondary" href="/portal/logs?stream=stderr"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> Jen chyby</a>
<a class="btn btn-secondary" href="/portal/logs"><i class="fa-solid fa-arrow-rotate-left" aria-hidden="true"></i> Reset</a>
</form>
</div>
<div class="card">
<h2>Záznamy</h2>
{pagination}
<table>
<tr>
<th>Čas</th>
<th>Úloha</th>
<th>Stream</th>
<th>Stav / cíl</th>
<th>Zpráva</th>
<th>Akce</th>
</tr>
{rows}
</table>
{pagination}
</div>
""",
user=user,
)
+5 -256
View File
@@ -1,23 +1,15 @@
import asyncio
import html
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.auth import current_user, require_user
from app.db.audit import log_audit_event
from app.db.operations import (
get_operations_snapshot,
)
from app.routes.deployments import render_status_pill
from app.routes.incidents import render_incident_history_rows, render_open_incident_rows
from app.routes.jobs import render_job_status
from app.templates.layout import page
from app.auth import current_user
from app.db.operations import get_operations_snapshot
router = APIRouter()
# Stránka "Přehled" (/portal/operations) byla odstraněna. Tento WebSocket ale dál pohání živé
# počty a tabulky na stránkách Úlohy, Workery a Incidenty (operations-live.js), proto zůstává.
@router.websocket("/ws/operations")
async def operations_websocket(websocket: WebSocket):
user = current_user(websocket)
@@ -32,246 +24,3 @@ async def operations_websocket(websocket: WebSocket):
await asyncio.sleep(2)
except WebSocketDisconnect:
return
def render_recent_jobs(jobs: list[dict]) -> str:
rows = ""
for job in jobs:
job_id = html.escape(str(job.get("id", "")))
job_type = html.escape(job.get("type", "") or "")
target_type = html.escape(job.get("target_type", "") or "")
target_id = html.escape(job.get("target_id", "") or "")
source = html.escape(job.get("source", "") or "")
created_at = html.escape(job.get("created_at", "") or "")
rows += f"""
<tr>
<td><strong><a href="/portal/jobs/{job_id}">#{job_id}</a></strong></td>
<td>{render_job_status(job.get("status"))}</td>
<td>{job_type}</td>
<td>{target_type}: <strong>{target_id}</strong></td>
<td>{source}</td>
<td>{created_at}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Zatím nejsou evidované žádné joby.</td></tr>'
return rows
def render_recent_deployments(deployments: list[dict]) -> str:
rows = ""
for deployment in deployments:
deployment_id = html.escape(str(deployment.get("id", "")))
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 "")
started_at = html.escape(deployment.get("started_at", "") or "")
finished_at = html.escape(deployment.get("finished_at", "") or "")
source = html.escape(deployment.get("trigger_source", "") or "")
rows += f"""
<tr>
<td><strong><a href="/portal/deployments/{deployment_id}">#{deployment_id}</a></strong></td>
<td><a href="/portal/apps/{app_url_id}">{app_id}</a></td>
<td>{kind}</td>
<td>{render_status_pill(deployment.get("status"))}</td>
<td>{source}</td>
<td>{started_at}</td>
<td>{finished_at}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>'
return rows
def render_recent_audit_events(events: list[dict]) -> str:
rows = ""
for event in events:
rows += f"""
<tr>
<td>{html.escape(event.get("created_at", "") or "")}</td>
<td>{html.escape(event.get("username", "") or "")}</td>
<td><span class="pill pill-muted">{html.escape(event.get("action", "") or "")}</span></td>
<td>{html.escape(event.get("target_type", "") or "")}</td>
<td>{html.escape(str(event.get("target_id") or ""))}</td>
<td>{html.escape(event.get("source", "") or "")}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>'
return rows
def render_health_status(status: str | None) -> str:
value = status or ""
normalized = value.lower()
labels = {
"healthy": "zdravá",
"unhealthy": "nezdravá",
"unreachable": "nedostupná",
}
class_name = "pill pill-muted"
if normalized == "healthy":
class_name = "pill pill-success"
elif normalized == "unhealthy":
class_name = "pill pill-warning"
elif normalized == "unreachable":
class_name = "pill pill-danger"
return f'<span class="{class_name}">{html.escape(labels.get(normalized, value))}</span>'
def render_health_problems(items: list[dict]) -> str:
rows = ""
for item in items:
service_id = html.escape(item.get("service_id", "") or "")
service_name = html.escape(item.get("service_name") or item.get("service_id", "") or "")
error_text = item.get("error_text", "") or ""
error_preview = error_text if len(error_text) <= 120 else f"{error_text[:117]}..."
rows += f"""
<tr>
<td><a href="/portal/apps/{quote(service_id, safe='')}">{service_name}</a></td>
<td>{render_health_status(item.get("status"))}</td>
<td>{html.escape(item.get("checked_at", "") or "")}</td>
<td>{html.escape(str(item.get("response_time_ms") or ""))}</td>
<td>{html.escape(error_preview)}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="5">Žádné služby nevyžadují pozornost.</td></tr>'
return rows
@router.get("/operations", response_class=HTMLResponse)
def operations_dashboard(request: Request, user=Depends(require_user)):
snapshot = get_operations_snapshot()
log_audit_event(
user,
action="operations.dashboard.view",
target_type="operations",
metadata={
"jobs": {
"queued": snapshot["jobs"]["queued"],
"running": snapshot["jobs"]["running"],
"failed_24h": snapshot["jobs"]["failed_24h"],
},
"workers": {
"online": snapshot["workers"]["online"],
"offline": snapshot["workers"]["offline"],
},
"deployments": {
"running": snapshot["deployments"]["running"],
"failed_24h": snapshot["deployments"]["failed_24h"],
},
"incidents": {
"open": snapshot["incidents"]["open"],
},
},
)
return page(
"Přehled",
f"""
<div class="card">
<h2><i class="fa-solid fa-gauge-high" aria-hidden="true"></i> Přehled systému</h2>
<p class="muted">Rychlá odpověď na otázku, zda jsou služby, úlohy a nasazení v pořádku.</p>
</div>
<div class="stats-grid">
<div class="stat-card stat-success"><span>Běžící služby</span><strong data-live-count="apps.active">{snapshot["apps"]["active"]}</strong></div>
<div class="stat-card stat-danger"><span>Problémové služby</span><strong data-live-count="apps.problematic">{snapshot["apps"]["problematic"]}</strong></div>
<div class="stat-card stat-success"><span>Zdravé služby</span><strong data-live-count="health.healthy">{snapshot["health"]["healthy"]}</strong></div>
<div class="stat-card stat-warning"><span>Nezdravé služby</span><strong data-live-count="health.unhealthy">{snapshot["health"]["unhealthy"]}</strong></div>
<div class="stat-card stat-danger"><span>Nedostupné služby</span><strong data-live-count="health.unreachable">{snapshot["health"]["unreachable"]}</strong></div>
<div class="stat-card stat-danger"><span>Aktivní incidenty</span><strong data-live-count="incidents.open">{snapshot["incidents"]["open"]}</strong></div>
<div class="stat-card stat-warning"><span>Aktivní úlohy</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</strong></div>
<div class="stat-card stat-total"><span>Nasazení dnes</span><strong data-live-count="deployments.today">{snapshot["deployments"]["today"]}</strong></div>
<div class="stat-card stat-success"><span>Online workery</span><strong data-live-count="workers.online">{snapshot["workers"]["online"]}</strong></div>
<div class="stat-card stat-danger"><span>Selhané úlohy (24 h)</span><strong data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</strong></div>
</div>
<div class="card">
<h2>Služby vyžadující pozornost</h2>
<table>
<tr>
<th>Služba</th>
<th>Status</th>
<th>Poslední kontrola</th>
<th>Odezva</th>
<th>Chyba</th>
</tr>
<tbody data-live-table="health.problems">{render_health_problems(snapshot["health"]["problems"])}</tbody>
</table>
</div>
<div class="card">
<h2>Aktivní incidenty</h2>
<table>
<tr>
<th>Služba</th>
<th>Název</th>
<th>Začátek</th>
<th>Trvání</th>
</tr>
<tbody data-live-table="incidents.active">{render_open_incident_rows(snapshot["incidents"]["active"])}</tbody>
</table>
</div>
<div class="card">
<h2>Poslední neúspěšná nasazení</h2>
<table>
<tr>
<th>ID</th>
<th>Služba</th>
<th>Typ</th>
<th>Status</th>
<th>Zdroj</th>
<th>Spuštěno</th>
<th>Dokončeno</th>
</tr>
<tbody data-live-table="deployments.failed">{render_recent_deployments(snapshot["deployments"]["recent_failed"])}</tbody>
</table>
</div>
<div class="card">
<h2>Poslední incidenty</h2>
<table>
<tr>
<th>Služba</th>
<th>Název</th>
<th>Začátek</th>
<th>Konec</th>
<th>Trvání</th>
<th>Stav</th>
</tr>
<tbody data-live-table="incidents.recent">{render_incident_history_rows(snapshot["incidents"]["recent"])}</tbody>
</table>
</div>
<div class="card">
<h2>Poslední auditní události</h2>
<table>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ cíle</th>
<th>Cíl</th>
<th>Zdroj</th>
</tr>
<tbody data-live-table="audit.recent">{render_recent_audit_events(snapshot["audit"]["recent"])}</tbody>
</table>
</div>
<script src="/portal/static/operations-live.js"></script>
""",
user=user,
)
+13 -155
View File
@@ -1,18 +1,19 @@
import html
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi.responses import RedirectResponse
from app.auth import require_user
from app.db.audit import log_audit_event
from app.db.jobs import create_job, get_jobs_by_target_ids
from app.routes.jobs import render_job_status
from app.templates.layout import page
from app.db.jobs import create_job
router = APIRouter()
# Samostatná stránka "Runtime Management" byla odstraněna překrývala se s Environment, odkud se
# tyto redeploy akce spouští. Zůstává jen backend: render_action_buttons() + akční endpoint, které
# používá stránka Environment (app/routes/environment.py).
#
# Portál NEMÁ vlastní deploy logiku. Všechny provozní akce deleguje do existujících shell skriptů
# v appfactory-tools přes existující job systém (job_type="run_script").
# Skripty leží v appfactory-tools/scripts/, proto je předáváme i s prefixem scripts/, jinak je
@@ -21,13 +22,13 @@ DEPLOY_CORE_SCRIPT = "scripts/deploy-core-service.sh"
CADDY_SCRIPT = "scripts/generate-caddyfile.sh"
ENABLED_APPS_SCRIPT = "scripts/redeploy-enabled-apps.sh"
# Skripty považované za "runtime operace" (filtr dashboardu).
RUNTIME_SCRIPTS = (DEPLOY_CORE_SCRIPT, CADDY_SCRIPT, ENABLED_APPS_SCRIPT)
ALL_CORE_KEY = "redeploy-all-core"
ALL_CORE_LABEL = "Redeploy All Core Services"
ALL_CORE_ICON = "fa-server"
# Cílová stránka pro návrat po akci (dříve Runtime Management, nyní Environment).
DEFAULT_ACTION_NEXT = "/portal/admin/environment"
# Core služby nasazované přes deploy-core-service.sh <service>. (key, name, icon, service_arg)
CORE_SERVICES = [
("redeploy-portal", "Portal", "fa-window-maximize", "appfactory-portal"),
@@ -58,15 +59,11 @@ ENV_ACTION_KEYS = [
"redeploy-registry",
ALL_CORE_KEY,
]
# Globální tlačítka na Runtime Management stránce (mimo tabulku core služeb).
RUNTIME_GLOBAL_ACTION_KEYS = ["regenerate-caddy", "redeploy-enabled-apps", ALL_CORE_KEY]
DASHBOARD_LIMIT = 20
def require_admin(user: dict) -> None:
if (user.get("role") or "").lower() != "admin":
raise HTTPException(status_code=403, detail="Runtime Management je dostupný pouze administrátorům")
raise HTTPException(status_code=403, detail="Runtime akce jsou dostupné pouze administrátorům")
def _is_valid_action(action_key: str) -> bool:
@@ -131,151 +128,12 @@ def render_action_buttons(keys: list[str], next_url: str) -> str:
return buttons
def _job_args(job: dict) -> list[str]:
try:
payload = json.loads(job.get("payload_json") or "{}")
except (TypeError, ValueError):
return []
args = payload.get("args") or payload.get("arguments") or []
return [str(a) for a in args] if isinstance(args, list) else []
def _job_script_label(job: dict) -> str:
target = job.get("target_id") or job.get("type") or ""
args = _job_args(job)
return f"{target} {' '.join(args)}".strip()
def _core_last_runs() -> dict:
"""Najde poslední deploy-core-service.sh job pro každou core službu (podle argumentu v payloadu)."""
latest: dict[str, dict] = {}
for job in get_jobs_by_target_ids([DEPLOY_CORE_SCRIPT], limit=80):
args = _job_args(job)
service = args[0] if args else None
if service and service not in latest:
latest[service] = job
return latest
def _render_last_run(job: dict | None) -> str:
if not job:
return '<span class="muted">—</span>'
job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "")
status = render_job_status(job.get("status"))
return f'{status}<br><a href="/portal/jobs/{job_id}"><small>{created_at} · #{job_id}</small></a>'
@router.get("/admin/runtime", response_class=HTMLResponse)
def runtime_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
require_admin(user)
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
last_runs = _core_last_runs()
component_rows = ""
for key, name, icon, service_arg in CORE_SERVICES:
component_rows += f"""
<tr>
<td><strong><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(name)}</strong></td>
<td><code>{html.escape(service_arg)}</code></td>
<td>{_render_last_run(last_runs.get(service_arg))}</td>
<td class="actions-cell service-actions">
<form method="post" action="/portal/admin/runtime/action/{key}" class="inline-form"
onsubmit="return confirm('Spustit redeploy služby {html.escape(name)}?');">
<input type="hidden" name="next" value="/portal/admin/runtime">
<button type="submit" class="icon-action" title="Redeploy" aria-label="Redeploy"><i class="fa-solid fa-rotate" aria-hidden="true"></i></button>
</form>
</td>
</tr>
"""
job_rows = ""
for job in get_jobs_by_target_ids(RUNTIME_SCRIPTS, limit=DASHBOARD_LIMIT):
job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "")
script_label = html.escape(_job_script_label(job))
status = render_job_status(job.get("status"))
result_text = job.get("error_text") or job.get("result_json") or ""
result_preview = result_text if len(result_text) <= 160 else f"{result_text[:157]}..."
result_cell = f"<code>{html.escape(result_preview)}</code>" if result_preview else '<span class="muted">—</span>'
job_rows += f"""
<tr>
<td>{created_at}</td>
<td><a href="/portal/jobs/{job_id}">{script_label}</a></td>
<td>{status}</td>
<td>{result_cell}</td>
</tr>
"""
if not job_rows:
job_rows = '<tr><td colspan="4">Zatím nebyly spuštěné žádné runtime operace.</td></tr>'
return page(
"Runtime Management",
f"""
<div class="card">
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</h2>
<p class="muted">
Centrální správa klíčových AppFactory komponent. Akce nespouští docker přímo —
vytvoří úlohu do fronty, kterou zpracuje worker (existující shell skripty v appfactory-tools).
Dostupné pouze administrátorům.
</p>
{notice}
</div>
<div class="card">
<h2><i class="fa-solid fa-cubes" aria-hidden="true"></i> Core služby</h2>
<p class="muted">Redeploy přes <code>{html.escape(DEPLOY_CORE_SCRIPT)} &lt;service&gt;</code>.</p>
<table>
<thead>
<tr>
<th>Komponenta</th>
<th>Service</th>
<th>Poslední spuštění</th>
<th>Akce</th>
</tr>
</thead>
<tbody>{component_rows}</tbody>
</table>
</div>
<div class="card">
<h2><i class="fa-solid fa-bolt" aria-hidden="true"></i> Globální akce</h2>
<div class="inline-form">
{render_action_buttons(RUNTIME_GLOBAL_ACTION_KEYS, "/portal/admin/runtime")}
</div>
</div>
<div class="card">
<h2><i class="fa-solid fa-list-check" aria-hidden="true"></i> Posledních {DASHBOARD_LIMIT} runtime operací</h2>
<table>
<thead>
<tr>
<th>Čas</th>
<th>Skript</th>
<th>Stav</th>
<th>Výsledek</th>
</tr>
</thead>
<tbody>{job_rows}</tbody>
</table>
</div>
""",
user=user,
)
@router.post("/admin/runtime/action/{action_key}")
def runtime_action(action_key: str, next: str = Form("/portal/admin/runtime"), user=Depends(require_user)):
def runtime_action(action_key: str, next: str = Form(DEFAULT_ACTION_NEXT), user=Depends(require_user)):
require_admin(user)
if not _is_valid_action(action_key):
raise HTTPException(status_code=404, detail="Neznámá akce")
message = run_action(action_key, user)
redirect_to = next if next.startswith("/portal/admin/") else "/portal/admin/runtime"
redirect_to = next if next.startswith("/portal/admin/") else DEFAULT_ACTION_NEXT
return RedirectResponse(url=f"{redirect_to}?message=" + quote(message), status_code=303)
+96 -3
View File
@@ -1,4 +1,5 @@
import html
import re
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, Request
@@ -6,12 +7,25 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user
from app.db.audit import log_audit_event
from app.db.users import ROLES, count_enabled_admins, delete_user, get_user, list_users, set_user_enabled, update_user_role
from app.gitea_provisioning import sync_gitea_user
from app.db.users import (
ROLES,
count_enabled_admins,
delete_user,
get_user,
list_users,
set_gitea_username,
set_user_enabled,
update_user_role,
)
from app.gitea_provisioning import apply_gitea_credentials, sync_gitea_user
from app.templates.layout import page
router = APIRouter()
# Gitea handle: začíná alfanumerickým znakem, dál povolen . _ -; max 40 znaků.
GITEA_USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$")
MIN_GITEA_PASSWORD_LEN = 8
def require_admin(user: dict) -> None:
if (user.get("role") or "").lower() != "admin":
@@ -100,11 +114,30 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
gitea_sync_status = managed_user.get("gitea_sync_status", "not_required") or "not_required"
gitea_sync_error = html.escape(managed_user.get("gitea_sync_error", "") or "")
gitea_synced_at = html.escape(managed_user.get("gitea_synced_at", "") or "")
gitea_username_value = (managed_user.get("gitea_username") or "").strip()
current_gitea_username = gitea_username_value or (f"portal-{user_id}" if gitea_user_id else "")
gitea_details = f"ID {html.escape(str(gitea_user_id))}" if gitea_user_id else "No account"
if current_gitea_username:
gitea_details += f"<br><small>@{html.escape(current_gitea_username)}</small>"
if gitea_synced_at:
gitea_details += f"<br><small>{gitea_synced_at}</small>"
if gitea_sync_error:
gitea_details += f'<br><small class="text-danger">{gitea_sync_error}</small>'
gitea_cred_form = f"""
<details class="gitea-cred">
<summary>Změnit jméno/heslo</summary>
<form method="post" action="/portal/admin/users/{user_id_html}/gitea-credentials" class="metadata-form"
onsubmit="return confirm('Změnit Gitea přihlašovací údaje uživatele {username}?');">
<label>Gitea jméno</label>
<input name="gitea_username" value="{html.escape(current_gitea_username, quote=True)}" placeholder="portal-{user_id_html}" autocomplete="off">
<label>Nové heslo</label>
<input type="password" name="gitea_password" placeholder="prázdné = beze změny" autocomplete="new-password">
<div class="form-actions">
<button type="submit"><i class="fa-solid fa-key" aria-hidden="true"></i> Uložit</button>
</div>
</form>
</details>
"""
toggle_label = "Disable" if enabled else "Enable"
toggle_action = "disable" if enabled else "enable"
@@ -123,7 +156,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend
</form>
</td>
<td>{render_enabled_pill(enabled)}</td>
<td>{render_sync_pill(gitea_sync_status)}<br><small>{gitea_details}</small></td>
<td>{render_sync_pill(gitea_sync_status)}<br><small>{gitea_details}</small>{gitea_cred_form}</td>
<td>{created_at}</td>
<td>{last_login_at}</td>
<td class="actions-cell service-actions">
@@ -297,6 +330,66 @@ def sync_gitea_user_action(user_id: int, user=Depends(require_user)):
return RedirectResponse(url="/portal/admin/users?message=" + quote("Gitea synchronized."), status_code=303)
@router.post("/admin/users/{user_id}/gitea-credentials")
def update_gitea_credentials_action(
user_id: int,
gitea_username: str = Form(""),
gitea_password: str = Form(""),
user=Depends(require_user),
):
require_admin(user)
target = get_user(user_id)
if not target:
raise HTTPException(status_code=404, detail="User not found")
new_username = (gitea_username or "").strip()
new_password = gitea_password or ""
if not new_username and not new_password:
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Zadejte Gitea jméno nebo nové heslo."),
status_code=303,
)
if new_username and not GITEA_USERNAME_RE.match(new_username):
return RedirectResponse(
url="/portal/admin/users?error=" + quote("Neplatné Gitea jméno (povoleno A-Z, 0-9, . _ -, max 40 znaků)."),
status_code=303,
)
if new_password and len(new_password) < MIN_GITEA_PASSWORD_LEN:
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"Heslo musí mít alespoň {MIN_GITEA_PASSWORD_LEN} znaků."),
status_code=303,
)
result = apply_gitea_credentials(target, new_username, new_password)
if result.get("status") == "error":
return RedirectResponse(
url="/portal/admin/users?error=" + quote(f"Gitea: {result.get('error')}"),
status_code=303,
)
applied = result.get("applied") or []
if "username" in applied:
set_gitea_username(user_id, result.get("gitea_username") or new_username)
log_audit_event(
user,
action="user.gitea.credentials_updated",
target_type="user",
target_id=user_id,
metadata={
"username": target.get("username"),
"gitea_username": result.get("gitea_username"),
"applied": applied, # heslo se nikdy nezaznamenává
},
)
changed = ", ".join(applied) if applied else "beze změny"
return RedirectResponse(
url="/portal/admin/users?message=" + quote(f"Gitea přihlašovací údaje aktualizovány ({changed})."),
status_code=303,
)
@router.post("/admin/users/{user_id}/delete")
def delete_user_action(user_id: int, user=Depends(require_user)):
require_admin(user)