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