Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v
- Runtime Management a Úlohách.
diff --git a/app/routes/jobs.py b/app/routes/jobs.py
index 480f67e..b1e962a 100644
--- a/app/routes/jobs.py
+++ b/app/routes/jobs.py
@@ -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'
'
+ )
+ 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'
'
+ )
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"""
-
+
{created_at} · {stream}
{message}
@@ -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)):
diff --git a/app/routes/logs.py b/app/routes/logs.py
new file mode 100644
index 0000000..68eec40
--- /dev/null
+++ b/app/routes/logs.py
@@ -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 '
stderr'
+ if value == "stdout":
+ return '
stdout'
+ return '
system'
+
+
+def render_stream_options(selected: str) -> str:
+ options = ['
']
+ for value in STREAM_FILTERS:
+ selected_attr = " selected" if selected == value else ""
+ options.append(f'
')
+ 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"""
+
+ | {created_at} |
+ #{job_id_html} {job_type} |
+ {render_stream_pill(log.get("stream"))} |
+ {render_job_status(log.get("job_status"))} {target_type}: {target_id} |
+ {message_html} |
+ |
+
+ """
+
+ if not rows:
+ rows = '
| Žádné logy neodpovídají filtru. |
'
+
+ 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'
Předchozí'
+ if page_number > 1
+ else ""
+ )
+ next_link = (
+ f'
Další '
+ if page_number < total_pages
+ else ""
+ )
+ pagination = ""
+ if total_pages > 1:
+ pagination = f"""
+
+ """
+
+ return page(
+ "Logy",
+ f"""
+
+
Logy
+
+ Souhrnné čtení logů napříč všemi úlohami. Vyfiltrujte si chyby (stream stderr)
+ nebo hledejte text a proklikněte se přímo na problematický řádek konkrétní úlohy.
+
+
+
+
+
+
Záznamy
+ {pagination}
+
+
+ | Čas |
+ Úloha |
+ Stream |
+ Stav / cíl |
+ Zpráva |
+ Akce |
+
+ {rows}
+
+ {pagination}
+
+ """,
+ user=user,
+ )
diff --git a/app/routes/operations.py b/app/routes/operations.py
index 687522d..4a2f678 100644
--- a/app/routes/operations.py
+++ b/app/routes/operations.py
@@ -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"""
-
- | #{job_id} |
- {render_job_status(job.get("status"))} |
- {job_type} |
- {target_type}: {target_id} |
- {source} |
- {created_at} |
-
- """
-
- if not rows:
- rows = '
| Zatím nejsou evidované žádné joby. |
'
-
- 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"""
-
- | #{deployment_id} |
- {app_id} |
- {kind} |
- {render_status_pill(deployment.get("status"))} |
- {source} |
- {started_at} |
- {finished_at} |
-
- """
-
- if not rows:
- rows = '
| Zatím nejsou evidovaná žádná nasazení. |
'
-
- return rows
-
-
-def render_recent_audit_events(events: list[dict]) -> str:
- rows = ""
- for event in events:
- rows += f"""
-
- | {html.escape(event.get("created_at", "") or "")} |
- {html.escape(event.get("username", "") or "")} |
- {html.escape(event.get("action", "") or "")} |
- {html.escape(event.get("target_type", "") or "")} |
- {html.escape(str(event.get("target_id") or ""))} |
- {html.escape(event.get("source", "") or "")} |
-
- """
-
- if not rows:
- rows = '
| Zatím nejsou evidované žádné auditní události. |
'
-
- 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'
{html.escape(labels.get(normalized, value))}'
-
-
-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"""
-
- | {service_name} |
- {render_health_status(item.get("status"))} |
- {html.escape(item.get("checked_at", "") or "")} |
- {html.escape(str(item.get("response_time_ms") or ""))} |
- {html.escape(error_preview)} |
-
- """
-
- if not rows:
- rows = '
| Žádné služby nevyžadují pozornost. |
'
- 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"""
-
-
Přehled systému
-
Rychlá odpověď na otázku, zda jsou služby, úlohy a nasazení v pořádku.
-
-
-
-
Běžící služby{snapshot["apps"]["active"]}
-
Problémové služby{snapshot["apps"]["problematic"]}
-
Zdravé služby{snapshot["health"]["healthy"]}
-
Nezdravé služby{snapshot["health"]["unhealthy"]}
-
Nedostupné služby{snapshot["health"]["unreachable"]}
-
Aktivní incidenty{snapshot["incidents"]["open"]}
-
Aktivní úlohy{snapshot["jobs"]["running"]}
-
Nasazení dnes{snapshot["deployments"]["today"]}
-
Online workery{snapshot["workers"]["online"]}
-
Selhané úlohy (24 h){snapshot["jobs"]["failed_24h"]}
-
-
-
-
Služby vyžadující pozornost
-
-
- | Služba |
- Status |
- Poslední kontrola |
- Odezva |
- Chyba |
-
- {render_health_problems(snapshot["health"]["problems"])}
-
-
-
-
-
Aktivní incidenty
-
-
- | Služba |
- Název |
- Začátek |
- Trvání |
-
- {render_open_incident_rows(snapshot["incidents"]["active"])}
-
-
-
-
-
Poslední neúspěšná nasazení
-
-
- | ID |
- Služba |
- Typ |
- Status |
- Zdroj |
- Spuštěno |
- Dokončeno |
-
- {render_recent_deployments(snapshot["deployments"]["recent_failed"])}
-
-
-
-
-
Poslední incidenty
-
-
- | Služba |
- Název |
- Začátek |
- Konec |
- Trvání |
- Stav |
-
- {render_incident_history_rows(snapshot["incidents"]["recent"])}
-
-
-
-
-
Poslední auditní události
-
-
- | Čas |
- Uživatel |
- Akce |
- Typ cíle |
- Cíl |
- Zdroj |
-
- {render_recent_audit_events(snapshot["audit"]["recent"])}
-
-
-
- """,
- user=user,
- )
diff --git a/app/routes/runtime.py b/app/routes/runtime.py
index 8fdebc6..b0900b9 100644
--- a/app/routes/runtime.py
+++ b/app/routes/runtime.py
@@ -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
. (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 '—'
- 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}
{created_at} · #{job_id}'
-
-
-@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'{html.escape(message)}
'
- if error:
- notice = f'{html.escape(error)}
'
-
- last_runs = _core_last_runs()
- component_rows = ""
- for key, name, icon, service_arg in CORE_SERVICES:
- component_rows += f"""
-
- | {html.escape(name)} |
- {html.escape(service_arg)} |
- {_render_last_run(last_runs.get(service_arg))} |
-
-
- |
-
- """
-
- 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"{html.escape(result_preview)}" if result_preview else '—'
- job_rows += f"""
-
- | {created_at} |
- {script_label} |
- {status} |
- {result_cell} |
-
- """
-
- if not job_rows:
- job_rows = '| Zatím nebyly spuštěné žádné runtime operace. |
'
-
- return page(
- "Runtime Management",
- f"""
-
-
Runtime Management
-
- 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.
-
- {notice}
-
-
-
-
Core služby
-
Redeploy přes {html.escape(DEPLOY_CORE_SCRIPT)} <service>.
-
-
-
- | Komponenta |
- Service |
- Poslední spuštění |
- Akce |
-
-
- {component_rows}
-
-
-
-
-
Globální akce
-
- {render_action_buttons(RUNTIME_GLOBAL_ACTION_KEYS, "/portal/admin/runtime")}
-
-
-
-
-
Posledních {DASHBOARD_LIMIT} runtime operací
-
-
-
- | Čas |
- Skript |
- Stav |
- Výsledek |
-
-
- {job_rows}
-
-
- """,
- 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)
diff --git a/app/routes/users.py b/app/routes/users.py
index 92e5323..5bd948c 100644
--- a/app/routes/users.py
+++ b/app/routes/users.py
@@ -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"
@{html.escape(current_gitea_username)}"
if gitea_synced_at:
gitea_details += f"
{gitea_synced_at}"
if gitea_sync_error:
gitea_details += f'
{gitea_sync_error}'
+ gitea_cred_form = f"""
+
+ Změnit jméno/heslo
+
+
+ """
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
{render_enabled_pill(enabled)} |
- {render_sync_pill(gitea_sync_status)} {gitea_details} |
+ {render_sync_pill(gitea_sync_status)} {gitea_details}{gitea_cred_form} |
{created_at} |
{last_login_at} |
@@ -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)
diff --git a/app/shell.py b/app/shell.py
index 1515938..d39a2ae 100644
--- a/app/shell.py
+++ b/app/shell.py
@@ -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):
diff --git a/app/static/styles.css b/app/static/styles.css
index de1077c..8b3f675 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -838,12 +838,28 @@ input[readonly] {
color: var(--text);
}
+/* Pozn.: .service-actions je vždy na | . 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,
diff --git a/app/templates/layout.py b/app/templates/layout.py
index cff719f..02b4f60 100644
--- a/app/templates/layout.py
+++ b/app/templates/layout.py
@@ -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 += (
' Úlohy'
+ ' Logy'
' Plánované skripty'
' Alerting'
' Pro vývojáře'
|