gitea user and pswd, some pages removed, logs

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