gitea user and pswd, some pages removed, logs
This commit is contained in:
@@ -434,6 +434,79 @@ def get_job_logs_after(job_id: int, last_log_id: int = 0, limit: int = 100):
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _job_log_filters(query: str | None, stream: str | None, job_id: int | None) -> tuple[str, list[Any]]:
|
||||
filters = []
|
||||
params: list[Any] = []
|
||||
if query:
|
||||
filters.append("l.message LIKE ?")
|
||||
params.append(f"%{query}%")
|
||||
if stream:
|
||||
filters.append("l.stream = ?")
|
||||
params.append(stream)
|
||||
if job_id:
|
||||
filters.append("l.job_id = ?")
|
||||
params.append(job_id)
|
||||
where_sql = f"WHERE {' AND '.join(filters)}" if filters else ""
|
||||
return where_sql, params
|
||||
|
||||
|
||||
def search_job_logs(
|
||||
query: str | None = None,
|
||||
stream: str | None = None,
|
||||
job_id: int | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""Globální prohlížeč logů: hledá řádky napříč všemi úlohami a vrací je i s kontextem úlohy
|
||||
(typ, cíl, stav) seřazené od nejnovějších, aby se šlo proklikat na konkrétní řádek úlohy."""
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
where_sql, params = _job_log_filters(query, stream, job_id)
|
||||
rows = con.execute(
|
||||
f"""
|
||||
SELECT
|
||||
l.id,
|
||||
l.job_id,
|
||||
l.stream,
|
||||
l.message,
|
||||
l.created_at,
|
||||
j.type AS job_type,
|
||||
j.target_type AS target_type,
|
||||
j.target_id AS target_id,
|
||||
j.status AS job_status
|
||||
FROM job_logs l
|
||||
JOIN jobs j ON j.id = l.job_id
|
||||
{where_sql}
|
||||
ORDER BY l.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
).fetchall()
|
||||
con.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def count_job_logs(
|
||||
query: str | None = None,
|
||||
stream: str | None = None,
|
||||
job_id: int | None = None,
|
||||
) -> int:
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
where_sql, params = _job_log_filters(query, stream, job_id)
|
||||
row = con.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM job_logs l
|
||||
JOIN jobs j ON j.id = l.job_id
|
||||
{where_sql}
|
||||
""",
|
||||
params,
|
||||
).fetchone()
|
||||
con.close()
|
||||
return int(row["count"] or 0) if row else 0
|
||||
|
||||
|
||||
def has_active_deploy_job(target_type: str, target_id: str):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
@@ -25,6 +25,7 @@ def ensure_user_columns(con) -> None:
|
||||
provider_subject TEXT,
|
||||
avatar_url TEXT,
|
||||
gitea_user_id INTEGER,
|
||||
gitea_username TEXT,
|
||||
gitea_sync_status TEXT NOT NULL DEFAULT 'not_required',
|
||||
gitea_sync_error TEXT,
|
||||
gitea_synced_at TEXT
|
||||
@@ -50,6 +51,7 @@ def ensure_user_columns(con) -> None:
|
||||
"provider_subject": "TEXT",
|
||||
"avatar_url": "TEXT",
|
||||
"gitea_user_id": "INTEGER",
|
||||
"gitea_username": "TEXT",
|
||||
"gitea_sync_status": "TEXT NOT NULL DEFAULT 'not_required'",
|
||||
"gitea_sync_error": "TEXT",
|
||||
"gitea_synced_at": "TEXT",
|
||||
|
||||
@@ -22,6 +22,7 @@ def list_users() -> list[dict[str, Any]]:
|
||||
provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject"
|
||||
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
||||
gitea_user_id_select = "gitea_user_id" if "gitea_user_id" in columns else "NULL AS gitea_user_id"
|
||||
gitea_username_select = "gitea_username" if "gitea_username" in columns else "NULL AS gitea_username"
|
||||
gitea_sync_status_select = "gitea_sync_status" if "gitea_sync_status" in columns else "'not_required' AS gitea_sync_status"
|
||||
gitea_sync_error_select = "gitea_sync_error" if "gitea_sync_error" in columns else "NULL AS gitea_sync_error"
|
||||
gitea_synced_at_select = "gitea_synced_at" if "gitea_synced_at" in columns else "NULL AS gitea_synced_at"
|
||||
@@ -42,6 +43,7 @@ def list_users() -> list[dict[str, Any]]:
|
||||
{provider_subject_select},
|
||||
{avatar_url_select},
|
||||
{gitea_user_id_select},
|
||||
{gitea_username_select},
|
||||
{gitea_sync_status_select},
|
||||
{gitea_sync_error_select},
|
||||
{gitea_synced_at_select}
|
||||
@@ -63,6 +65,7 @@ def get_user(user_id: int) -> dict[str, Any] | None:
|
||||
provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject"
|
||||
avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url"
|
||||
gitea_user_id_select = "gitea_user_id" if "gitea_user_id" in columns else "NULL AS gitea_user_id"
|
||||
gitea_username_select = "gitea_username" if "gitea_username" in columns else "NULL AS gitea_username"
|
||||
gitea_sync_status_select = "gitea_sync_status" if "gitea_sync_status" in columns else "'not_required' AS gitea_sync_status"
|
||||
gitea_sync_error_select = "gitea_sync_error" if "gitea_sync_error" in columns else "NULL AS gitea_sync_error"
|
||||
gitea_synced_at_select = "gitea_synced_at" if "gitea_synced_at" in columns else "NULL AS gitea_synced_at"
|
||||
@@ -83,6 +86,7 @@ def get_user(user_id: int) -> dict[str, Any] | None:
|
||||
{provider_subject_select},
|
||||
{avatar_url_select},
|
||||
{gitea_user_id_select},
|
||||
{gitea_username_select},
|
||||
{gitea_sync_status_select},
|
||||
{gitea_sync_error_select},
|
||||
{gitea_synced_at_select}
|
||||
@@ -164,6 +168,23 @@ def delete_user(user_id: int) -> None:
|
||||
con.close()
|
||||
|
||||
|
||||
def set_gitea_username(user_id: int, gitea_username: str) -> None:
|
||||
"""Uloží vlastní Gitea uživatelské jméno (handle). Prázdná hodnota = vrácení na automatické portal-{id}."""
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
con.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET gitea_username = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
((gitea_username or "").strip() or None, user_id),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def update_gitea_sync_state(
|
||||
user_id: int,
|
||||
status: str,
|
||||
|
||||
@@ -12,10 +12,80 @@ from app.db.users import update_gitea_sync_state
|
||||
GITEA_ACCOUNT_ROLES = {"admin", "developer"}
|
||||
|
||||
|
||||
def desired_gitea_username(user: dict) -> str:
|
||||
"""Cílové Gitea uživatelské jméno: vlastní (nastavené adminem), jinak automatické portal-{id}."""
|
||||
custom = (user.get("gitea_username") or "").strip()
|
||||
if custom:
|
||||
return custom
|
||||
return _gitea_username(int(user["id"]))
|
||||
|
||||
|
||||
def apply_gitea_credentials(user: dict, new_username: str = "", new_password: str = "") -> dict:
|
||||
"""Admin nastaví Gitea uživatelské jméno a/nebo heslo přes admin API.
|
||||
Vrací {"status": "synced"/"error", "applied": [...], "gitea_username", "gitea_user_id"}.
|
||||
Heslo se nikam neukládá – jen se předá do Gitea."""
|
||||
new_username = (new_username or "").strip()
|
||||
new_password = new_password or ""
|
||||
|
||||
if not new_username and not new_password:
|
||||
return {"status": "error", "error": "Nebyla zadána žádná změna."}
|
||||
if not _requires_gitea_account(user):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "Uživatel nemá mít Gitea účet (potřebuje roli admin/developer a aktivní účet).",
|
||||
}
|
||||
|
||||
try:
|
||||
current_username = desired_gitea_username(user)
|
||||
gitea_user = _ensure_gitea_user(user, current_username)
|
||||
current_login = _gitea_login(gitea_user) or current_username
|
||||
|
||||
applied: list[str] = []
|
||||
if new_username and new_username != current_login:
|
||||
_rename_gitea_user(current_login, new_username)
|
||||
current_login = new_username
|
||||
applied.append("username")
|
||||
if new_password:
|
||||
_set_gitea_password(current_login, user, new_password, gitea_user=gitea_user)
|
||||
applied.append("password")
|
||||
|
||||
return {
|
||||
"status": "synced",
|
||||
"applied": applied,
|
||||
"gitea_username": current_login,
|
||||
"gitea_user_id": int(gitea_user["id"]),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"status": "error", "error": str(exc)[:500]}
|
||||
|
||||
|
||||
def _rename_gitea_user(current_username: str, new_username: str) -> dict:
|
||||
# Gitea admin rename endpoint (Gitea >= 1.20). Starší Gitea endpoint nemá → vrátí HTTP 404,
|
||||
# což apply_gitea_credentials zachytí a nahlásí jako srozumitelnou chybu.
|
||||
return _request_json(
|
||||
"POST",
|
||||
f"/api/v1/admin/users/{quote(current_username)}/rename",
|
||||
{"new_username": new_username},
|
||||
expected=(200, 204),
|
||||
)
|
||||
|
||||
|
||||
def _set_gitea_password(username: str, user: dict, password: str, gitea_user: dict | None = None) -> dict:
|
||||
payload = {
|
||||
"login_name": _gitea_login_name(gitea_user) or username,
|
||||
"source_id": _gitea_source_id(gitea_user),
|
||||
"email": _email(user),
|
||||
"full_name": _display_name(user),
|
||||
"password": password,
|
||||
"must_change_password": False,
|
||||
}
|
||||
return _request_json("PATCH", f"/api/v1/admin/users/{quote(username)}", payload, expected=(200,))
|
||||
|
||||
|
||||
def sync_gitea_user(user: dict) -> dict:
|
||||
user_id = int(user["id"])
|
||||
desired_active = _requires_gitea_account(user)
|
||||
username = _gitea_username(user_id)
|
||||
username = desired_gitea_username(user)
|
||||
|
||||
update_gitea_sync_state(user_id, "syncing")
|
||||
try:
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .config import read_env_bool, read_env_value
|
||||
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, migration_readiness, operations, runtime, scheduled_scripts, users, workers
|
||||
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, logs, operations, runtime, scheduled_scripts, users, workers
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -31,10 +31,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(deployments.router)
|
||||
app.include_router(incidents.router)
|
||||
app.include_router(alerting.router)
|
||||
app.include_router(migration_readiness.router)
|
||||
app.include_router(operations.router)
|
||||
app.include_router(scheduled_scripts.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(workers.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(audit.router)
|
||||
|
||||
+1
-18
@@ -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">
|
||||
|
||||
@@ -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">← Zpět na přehled</a>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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">← 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}">← {back_label}</a>
|
||||
{target_detail_button}
|
||||
</p>
|
||||
<p id="job-finished-banner" class="alert" style="display:none;"></p>
|
||||
{retry_blocked_notice}
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)} <service></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
@@ -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)
|
||||
|
||||
+11
-1
@@ -3,7 +3,17 @@ import subprocess
|
||||
|
||||
|
||||
def run_command(args):
|
||||
return subprocess.run(args, capture_output=True, text=True)
|
||||
# Skripty v /tools vypisují české hlášky v UTF-8. Bez explicitního kódování
|
||||
# se výstup dekóduje podle locale kontejneru (často C/ASCII) a diakritika se
|
||||
# rozsype na mojibake. errors="replace" navíc zaručí, že dekódování nikdy
|
||||
# nespadne na neočekávaném bajtu.
|
||||
return subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def run_command_background(args, extra_env=None):
|
||||
|
||||
+71
-5
@@ -838,12 +838,28 @@ input[readonly] {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Pozn.: .service-actions je vždy na <td>. display:flex na buňce ji vyřadí
|
||||
z layoutu tabulky (přestane být table-cell), zhroutí šířky sloupců a řádek
|
||||
"nesedí". Necháme buňku jako table-cell a obsah srovnáme inline-flex prvky. */
|
||||
.service-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
min-width: 250px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.service-actions > a,
|
||||
.service-actions > form,
|
||||
.service-actions > button,
|
||||
.service-actions > .btn,
|
||||
.service-actions > .action-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.service-actions > * + * {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.service-actions .btn {
|
||||
@@ -1005,6 +1021,44 @@ pre {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Kompaktní log v řádku tabulky (globální prohlížeč logů). */
|
||||
.log-inline {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
max-height: 160px;
|
||||
padding: 10px 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Zvýraznění řádku, na který se uživatel proklikl z globálního prohlížeče logů. */
|
||||
.job-log-entry:target {
|
||||
outline: 3px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Rozbalovací formulář pro změnu Gitea jména/hesla v tabulce uživatelů. */
|
||||
.gitea-cred {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gitea-cred summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.gitea-cred .metadata-form {
|
||||
margin-top: 8px;
|
||||
min-width: 200px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gitea-cred input {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.log-stdout {
|
||||
background: #102a3a;
|
||||
color: #edf6f9;
|
||||
@@ -1200,9 +1254,21 @@ pre {
|
||||
}
|
||||
|
||||
.service-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.service-actions > a,
|
||||
.service-actions > form,
|
||||
.service-actions > button,
|
||||
.service-actions > .btn,
|
||||
.service-actions > .action-menu {
|
||||
display: flex;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.service-actions > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.service-actions .btn,
|
||||
|
||||
@@ -14,11 +14,8 @@ ADMIN_MENU_ITEMS = [
|
||||
("Audit", "/portal/audit", "fa-clipboard-list", False),
|
||||
("Environment", "/portal/admin/environment", "fa-sliders", True),
|
||||
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
|
||||
("Migration Readiness", "/portal/migration-readiness", "fa-diagram-project", True),
|
||||
("Nasazení", "/portal/deployments", "fa-rocket", False),
|
||||
("Plánované skripty", "/portal/scheduled-scripts", "fa-calendar-days", False),
|
||||
("Přehled", "/portal/operations", "fa-gauge-high", False),
|
||||
("Runtime Management", "/portal/admin/runtime", "fa-server", True),
|
||||
("Služby", "/portal/apps", "fa-server", False),
|
||||
("Úlohy", "/portal/jobs", "fa-list-check", False),
|
||||
("Users", "/portal/admin/users", "fa-users", False),
|
||||
@@ -56,6 +53,7 @@ def page(title: str, body: str, user=None) -> str:
|
||||
if can_operate:
|
||||
nav_links += (
|
||||
'<a class="nav-link" href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy</a>'
|
||||
'<a class="nav-link" href="/portal/logs"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Logy</a>'
|
||||
'<a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</a>'
|
||||
'<a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>'
|
||||
'<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>'
|
||||
|
||||
Reference in New Issue
Block a user