gitea user and pswd, some pages removed, logs
This commit is contained in:
@@ -2,6 +2,12 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Bez nastaveného locale dekóduje kontejner výstupy v C/ASCII a česká diakritika
|
||||||
|
# se rozbije. C.UTF-8 je v python:slim vždy k dispozici.
|
||||||
|
ENV LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
PYTHONIOENCODING=utf-8
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git curl \
|
&& apt-get install -y --no-install-recommends git curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -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]
|
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):
|
def has_active_deploy_job(target_type: str, target_id: str):
|
||||||
run_migrations()
|
run_migrations()
|
||||||
con = get_connection()
|
con = get_connection()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def ensure_user_columns(con) -> None:
|
|||||||
provider_subject TEXT,
|
provider_subject TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
gitea_user_id INTEGER,
|
gitea_user_id INTEGER,
|
||||||
|
gitea_username TEXT,
|
||||||
gitea_sync_status TEXT NOT NULL DEFAULT 'not_required',
|
gitea_sync_status TEXT NOT NULL DEFAULT 'not_required',
|
||||||
gitea_sync_error TEXT,
|
gitea_sync_error TEXT,
|
||||||
gitea_synced_at TEXT
|
gitea_synced_at TEXT
|
||||||
@@ -50,6 +51,7 @@ def ensure_user_columns(con) -> None:
|
|||||||
"provider_subject": "TEXT",
|
"provider_subject": "TEXT",
|
||||||
"avatar_url": "TEXT",
|
"avatar_url": "TEXT",
|
||||||
"gitea_user_id": "INTEGER",
|
"gitea_user_id": "INTEGER",
|
||||||
|
"gitea_username": "TEXT",
|
||||||
"gitea_sync_status": "TEXT NOT NULL DEFAULT 'not_required'",
|
"gitea_sync_status": "TEXT NOT NULL DEFAULT 'not_required'",
|
||||||
"gitea_sync_error": "TEXT",
|
"gitea_sync_error": "TEXT",
|
||||||
"gitea_synced_at": "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"
|
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"
|
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_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_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_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"
|
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},
|
{provider_subject_select},
|
||||||
{avatar_url_select},
|
{avatar_url_select},
|
||||||
{gitea_user_id_select},
|
{gitea_user_id_select},
|
||||||
|
{gitea_username_select},
|
||||||
{gitea_sync_status_select},
|
{gitea_sync_status_select},
|
||||||
{gitea_sync_error_select},
|
{gitea_sync_error_select},
|
||||||
{gitea_synced_at_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"
|
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"
|
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_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_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_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"
|
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},
|
{provider_subject_select},
|
||||||
{avatar_url_select},
|
{avatar_url_select},
|
||||||
{gitea_user_id_select},
|
{gitea_user_id_select},
|
||||||
|
{gitea_username_select},
|
||||||
{gitea_sync_status_select},
|
{gitea_sync_status_select},
|
||||||
{gitea_sync_error_select},
|
{gitea_sync_error_select},
|
||||||
{gitea_synced_at_select}
|
{gitea_synced_at_select}
|
||||||
@@ -164,6 +168,23 @@ def delete_user(user_id: int) -> None:
|
|||||||
con.close()
|
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(
|
def update_gitea_sync_state(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
status: str,
|
status: str,
|
||||||
|
|||||||
@@ -12,10 +12,80 @@ from app.db.users import update_gitea_sync_state
|
|||||||
GITEA_ACCOUNT_ROLES = {"admin", "developer"}
|
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:
|
def sync_gitea_user(user: dict) -> dict:
|
||||||
user_id = int(user["id"])
|
user_id = int(user["id"])
|
||||||
desired_active = _requires_gitea_account(user)
|
desired_active = _requires_gitea_account(user)
|
||||||
username = _gitea_username(user_id)
|
username = desired_gitea_username(user)
|
||||||
|
|
||||||
update_gitea_sync_state(user_id, "syncing")
|
update_gitea_sync_state(user_id, "syncing")
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .config import read_env_bool, read_env_value
|
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:
|
def create_app() -> FastAPI:
|
||||||
@@ -31,10 +31,10 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
app.include_router(incidents.router)
|
app.include_router(incidents.router)
|
||||||
app.include_router(alerting.router)
|
app.include_router(alerting.router)
|
||||||
app.include_router(migration_readiness.router)
|
|
||||||
app.include_router(operations.router)
|
app.include_router(operations.router)
|
||||||
app.include_router(scheduled_scripts.router)
|
app.include_router(scheduled_scripts.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
|
app.include_router(logs.router)
|
||||||
app.include_router(workers.router)
|
app.include_router(workers.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
|
|||||||
+1
-18
@@ -512,25 +512,8 @@ def apps_page(
|
|||||||
return page(
|
return page(
|
||||||
"Služby",
|
"Služby",
|
||||||
f"""
|
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">
|
<div class="card">
|
||||||
<h2>Nasazené služby</h2>
|
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Nasazené služby</h2>
|
||||||
{notice}
|
{notice}
|
||||||
{manage_toolbar}
|
{manage_toolbar}
|
||||||
<form method="get" action="/portal/apps" class="filter-form">
|
<form method="get" action="/portal/apps" class="filter-form">
|
||||||
|
|||||||
@@ -255,7 +255,6 @@ async def deployments_page(
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><i class="fa-solid fa-rocket" aria-hidden="true"></i> Historie nasazení</h2>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<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"),
|
"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",
|
("fa-list-check", "Úlohy", "/portal/jobs",
|
||||||
"Fronta a historie úloh (nasazení, skripty) včetně stavů a logů.", "developer / admin"),
|
"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",
|
("fa-calendar-days", "Plánované skripty", "/portal/scheduled-scripts",
|
||||||
"Cron-like skripty spouštěné na pozadí; úpravy jen pro administrátory.", "developer / admin"),
|
"Cron-like skripty spouštěné na pozadí; úpravy jen pro administrátory.", "developer / admin"),
|
||||||
("fa-bell", "Alerting", "/portal/alerting/rules",
|
("fa-bell", "Alerting", "/portal/alerting/rules",
|
||||||
"Pravidla alertů a jejich skripty; úpravy jen pro administrátory.", "developer / admin"),
|
"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",
|
("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"),
|
"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-diagram-project", "Migration Readiness", "/portal/migration-readiness",
|
|
||||||
"Připravenost a deploy core služeb AppFactory.", "admin"),
|
|
||||||
("fa-rocket", "Nasazení", "/portal/deployments",
|
("fa-rocket", "Nasazení", "/portal/deployments",
|
||||||
"Historie deploy běhů, stavů a výstupů z deploy procesu.", "developer / admin"),
|
"Historie deploy běhů, stavů a výstupů z deploy procesu.", "developer / admin"),
|
||||||
("fa-triangle-exclamation", "Incidenty", "/portal/incidents",
|
("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">
|
<div class="card">
|
||||||
<h2>Navazující akce</h2>
|
<h2>Navazující akce</h2>
|
||||||
<p class="muted">Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v
|
<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">
|
<div class="inline-form">
|
||||||
{action_buttons}
|
{action_buttons}
|
||||||
</div>
|
</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_type = job.get("target_type", "") or ""
|
||||||
target_id = job.get("target_id", "") or ""
|
target_id = job.get("target_id", "") or ""
|
||||||
target_id_html = html.escape(target_id)
|
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")))
|
duration = html.escape(calculate_duration(job.get("started_at"), job.get("finished_at")))
|
||||||
payload = html.escape(pretty_json(job.get("payload_json")))
|
payload = html.escape(pretty_json(job.get("payload_json")))
|
||||||
result = html.escape(pretty_json(job.get("result_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 = ""
|
log_blocks = ""
|
||||||
for log in get_job_logs(job_id):
|
for log in get_job_logs(job_id):
|
||||||
|
log_id = html.escape(str(log.get("id", "")))
|
||||||
stream = html.escape(log.get("stream", "") or "system")
|
stream = html.escape(log.get("stream", "") or "system")
|
||||||
created_at = html.escape(log.get("created_at", "") or "")
|
created_at = html.escape(log.get("created_at", "") or "")
|
||||||
message = html.escape(log.get("message", "") 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"
|
class_name = "log-system"
|
||||||
else:
|
else:
|
||||||
class_name = "log-stdout"
|
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"""
|
log_blocks += f"""
|
||||||
<div class="job-log-entry">
|
<div class="job-log-entry" id="log-{log_id}">
|
||||||
<div class="muted">{created_at} · {stream}</div>
|
<div class="muted">{created_at} · {stream}</div>
|
||||||
<pre class="log-viewer {class_name}">{message}</pre>
|
<pre class="log-viewer {class_name}">{message}</pre>
|
||||||
</div>
|
</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 statusEl = document.getElementById("live-log-status");
|
||||||
const panel = document.getElementById("live-log-panel");
|
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 || !panel || !window.WebSocket) {{
|
||||||
if (statusEl) statusEl.textContent = "Disconnected";
|
if (statusEl) statusEl.textContent = "Disconnected";
|
||||||
return;
|
return;
|
||||||
@@ -418,7 +449,18 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
|
|||||||
}}
|
}}
|
||||||
if (item.type === "job_finished") {{
|
if (item.type === "job_finished") {{
|
||||||
statusEl.textContent = `Finished: ${{item.status}}`;
|
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();
|
socket.close();
|
||||||
|
if (jobWasLive) {{
|
||||||
|
setTimeout(() => window.location.reload(), 1200);
|
||||||
|
}}
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
socket.addEventListener("close", () => {{
|
socket.addEventListener("close", () => {{
|
||||||
@@ -440,9 +482,10 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<p>
|
<p>
|
||||||
<a class="btn" href="/portal/jobs">← Zpět na úlohy</a>
|
<a class="btn" href="{back_url}">← {back_label}</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>
|
{target_detail_button}
|
||||||
</p>
|
</p>
|
||||||
|
<p id="job-finished-banner" class="alert" style="display:none;"></p>
|
||||||
{retry_blocked_notice}
|
{retry_blocked_notice}
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</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 asyncio
|
||||||
import html
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from app.auth import current_user, require_user
|
from app.auth import current_user
|
||||||
from app.db.audit import log_audit_event
|
from app.db.operations import get_operations_snapshot
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.websocket("/ws/operations")
|
||||||
async def operations_websocket(websocket: WebSocket):
|
async def operations_websocket(websocket: WebSocket):
|
||||||
user = current_user(websocket)
|
user = current_user(websocket)
|
||||||
@@ -32,246 +24,3 @@ async def operations_websocket(websocket: WebSocket):
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
return
|
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 html
|
||||||
import json
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
from fastapi import APIRouter, Depends, Form, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from app.auth import require_user
|
from app.auth import require_user
|
||||||
from app.db.audit import log_audit_event
|
from app.db.audit import log_audit_event
|
||||||
from app.db.jobs import create_job, get_jobs_by_target_ids
|
from app.db.jobs import create_job
|
||||||
from app.routes.jobs import render_job_status
|
|
||||||
from app.templates.layout import page
|
|
||||||
|
|
||||||
router = APIRouter()
|
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ů
|
# 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").
|
# 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
|
# 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"
|
CADDY_SCRIPT = "scripts/generate-caddyfile.sh"
|
||||||
ENABLED_APPS_SCRIPT = "scripts/redeploy-enabled-apps.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_KEY = "redeploy-all-core"
|
||||||
ALL_CORE_LABEL = "Redeploy All Core Services"
|
ALL_CORE_LABEL = "Redeploy All Core Services"
|
||||||
ALL_CORE_ICON = "fa-server"
|
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 služby nasazované přes deploy-core-service.sh <service>. (key, name, icon, service_arg)
|
||||||
CORE_SERVICES = [
|
CORE_SERVICES = [
|
||||||
("redeploy-portal", "Portal", "fa-window-maximize", "appfactory-portal"),
|
("redeploy-portal", "Portal", "fa-window-maximize", "appfactory-portal"),
|
||||||
@@ -58,15 +59,11 @@ ENV_ACTION_KEYS = [
|
|||||||
"redeploy-registry",
|
"redeploy-registry",
|
||||||
ALL_CORE_KEY,
|
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:
|
def require_admin(user: dict) -> None:
|
||||||
if (user.get("role") or "").lower() != "admin":
|
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:
|
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
|
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}")
|
@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)
|
require_admin(user)
|
||||||
if not _is_valid_action(action_key):
|
if not _is_valid_action(action_key):
|
||||||
raise HTTPException(status_code=404, detail="Neznámá akce")
|
raise HTTPException(status_code=404, detail="Neznámá akce")
|
||||||
|
|
||||||
message = run_action(action_key, user)
|
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)
|
return RedirectResponse(url=f"{redirect_to}?message=" + quote(message), status_code=303)
|
||||||
|
|||||||
+96
-3
@@ -1,4 +1,5 @@
|
|||||||
import html
|
import html
|
||||||
|
import re
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
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.auth import require_user
|
||||||
from app.db.audit import log_audit_event
|
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.db.users import (
|
||||||
from app.gitea_provisioning import sync_gitea_user
|
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
|
from app.templates.layout import page
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
def require_admin(user: dict) -> None:
|
||||||
if (user.get("role") or "").lower() != "admin":
|
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_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_sync_error = html.escape(managed_user.get("gitea_sync_error", "") or "")
|
||||||
gitea_synced_at = html.escape(managed_user.get("gitea_synced_at", "") 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"
|
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:
|
if gitea_synced_at:
|
||||||
gitea_details += f"<br><small>{gitea_synced_at}</small>"
|
gitea_details += f"<br><small>{gitea_synced_at}</small>"
|
||||||
if gitea_sync_error:
|
if gitea_sync_error:
|
||||||
gitea_details += f'<br><small class="text-danger">{gitea_sync_error}</small>'
|
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_label = "Disable" if enabled else "Enable"
|
||||||
toggle_action = "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>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>{render_enabled_pill(enabled)}</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>{created_at}</td>
|
||||||
<td>{last_login_at}</td>
|
<td>{last_login_at}</td>
|
||||||
<td class="actions-cell service-actions">
|
<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)
|
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")
|
@router.post("/admin/users/{user_id}/delete")
|
||||||
def delete_user_action(user_id: int, user=Depends(require_user)):
|
def delete_user_action(user_id: int, user=Depends(require_user)):
|
||||||
require_admin(user)
|
require_admin(user)
|
||||||
|
|||||||
+11
-1
@@ -3,7 +3,17 @@ import subprocess
|
|||||||
|
|
||||||
|
|
||||||
def run_command(args):
|
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):
|
def run_command_background(args, extra_env=None):
|
||||||
|
|||||||
+71
-5
@@ -838,12 +838,28 @@ input[readonly] {
|
|||||||
color: var(--text);
|
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 {
|
.service-actions {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 250px;
|
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 {
|
.service-actions .btn {
|
||||||
@@ -1005,6 +1021,44 @@ pre {
|
|||||||
line-height: 1.45;
|
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 {
|
.log-stdout {
|
||||||
background: #102a3a;
|
background: #102a3a;
|
||||||
color: #edf6f9;
|
color: #edf6f9;
|
||||||
@@ -1200,9 +1254,21 @@ pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.service-actions {
|
.service-actions {
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 180px;
|
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,
|
.service-actions .btn,
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ ADMIN_MENU_ITEMS = [
|
|||||||
("Audit", "/portal/audit", "fa-clipboard-list", False),
|
("Audit", "/portal/audit", "fa-clipboard-list", False),
|
||||||
("Environment", "/portal/admin/environment", "fa-sliders", True),
|
("Environment", "/portal/admin/environment", "fa-sliders", True),
|
||||||
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
|
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
|
||||||
("Migration Readiness", "/portal/migration-readiness", "fa-diagram-project", True),
|
|
||||||
("Nasazení", "/portal/deployments", "fa-rocket", False),
|
("Nasazení", "/portal/deployments", "fa-rocket", False),
|
||||||
("Plánované skripty", "/portal/scheduled-scripts", "fa-calendar-days", 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),
|
("Služby", "/portal/apps", "fa-server", False),
|
||||||
("Úlohy", "/portal/jobs", "fa-list-check", False),
|
("Úlohy", "/portal/jobs", "fa-list-check", False),
|
||||||
("Users", "/portal/admin/users", "fa-users", False),
|
("Users", "/portal/admin/users", "fa-users", False),
|
||||||
@@ -56,6 +53,7 @@ def page(title: str, body: str, user=None) -> str:
|
|||||||
if can_operate:
|
if can_operate:
|
||||||
nav_links += (
|
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/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/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/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>'
|
'<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