From 7897e3b876927c59f18100d8c9dfcdf7cb23a24d Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:39:52 +0200 Subject: [PATCH] gitea user and pswd, some pages removed, logs --- Dockerfile | 6 + app/db/jobs.py | 73 +++++++++++ app/db/migrations.py | 2 + app/db/users.py | 21 +++ app/gitea_provisioning.py | 72 ++++++++++- app/main.py | 4 +- app/routes/apps.py | 19 +-- app/routes/deployments.py | 1 - app/routes/developers.py | 10 +- app/routes/environment.py | 2 +- app/routes/jobs.py | 51 +++++++- app/routes/logs.py | 177 ++++++++++++++++++++++++++ app/routes/operations.py | 261 +------------------------------------- app/routes/runtime.py | 168 ++---------------------- app/routes/users.py | 99 ++++++++++++++- app/shell.py | 12 +- app/static/styles.css | 76 ++++++++++- app/templates/layout.py | 4 +- 18 files changed, 601 insertions(+), 457 deletions(-) create mode 100644 app/routes/logs.py diff --git a/Dockerfile b/Dockerfile index 889e3b1..1a5d3be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/* diff --git a/app/db/jobs.py b/app/db/jobs.py index c159b52..999746a 100644 --- a/app/db/jobs.py +++ b/app/db/jobs.py @@ -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() diff --git a/app/db/migrations.py b/app/db/migrations.py index bd7518e..2711fb9 100644 --- a/app/db/migrations.py +++ b/app/db/migrations.py @@ -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", diff --git a/app/db/users.py b/app/db/users.py index 5e48e75..cfadc79 100644 --- a/app/db/users.py +++ b/app/db/users.py @@ -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, diff --git a/app/gitea_provisioning.py b/app/gitea_provisioning.py index 6e80958..3aacf43 100644 --- a/app/gitea_provisioning.py +++ b/app/gitea_provisioning.py @@ -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: diff --git a/app/main.py b/app/main.py index 8b5ff4d..f20feb1 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/routes/apps.py b/app/routes/apps.py index e47017d..dfcc3b4 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -512,25 +512,8 @@ def apps_page( return page( "Služby", f""" -
-
-

Služby

-

Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.

-
-
-

Zálohy

-

Vytváření, stažení a obnova záloh. Obnova je chráněná a dostupná jen administrátorům.

- Spravovat zálohy -
-
-

Nasazení

-

Historie posledních běhů nasazení, stavů a výstupů z deploy procesu.

- Zobrazit nasazení -
-
-
-

Nasazené služby

+

Nasazené služby

{notice} {manage_toolbar}
diff --git a/app/routes/deployments.py b/app/routes/deployments.py index 6c7f068..fd19564 100644 --- a/app/routes/deployments.py +++ b/app/routes/deployments.py @@ -255,7 +255,6 @@ async def deployments_page(

Historie nasazení

Přehled posledních běhů nasazení a jejich výsledků.

- ← Zpět na přehled
diff --git a/app/routes/developers.py b/app/routes/developers.py index 2cae5cc..72556bd 100644 --- a/app/routes/developers.py +++ b/app/routes/developers.py @@ -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", diff --git a/app/routes/environment.py b/app/routes/environment.py index 8eefcec..3e6fca8 100644 --- a/app/routes/environment.py +++ b/app/routes/environment.py @@ -97,7 +97,7 @@ def _render_page(user: dict, entries: list[tuple[str, str]], message: str = "",

Navazující akce

Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v - Runtime Management a Úlohách.

+ Úlohách.

{action_buttons}
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'' + ' Detail služby' + ) + 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'' + ' Detail skriptu' + ) 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)):

{title}

- ← Zpět na úlohy - Detail cíle + ← {back_label} + {target_detail_button}

+ {retry_blocked_notice} {actions}
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. +

+ + + + + + + Jen chyby + Reset + +
+ +
+

Záznamy

+ {pagination} + + + + + + + + + + {rows} +
ČasÚlohaStreamStav / cílZprávaAkce
+ {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

- - - - - - - - - {render_health_problems(snapshot["health"]["problems"])} -
SlužbaStatusPoslední kontrolaOdezvaChyba
-
- -
-

Aktivní incidenty

- - - - - - - - {render_open_incident_rows(snapshot["incidents"]["active"])} -
SlužbaNázevZačátekTrvání
-
- -
-

Poslední neúspěšná nasazení

- - - - - - - - - - - {render_recent_deployments(snapshot["deployments"]["recent_failed"])} -
IDSlužbaTypStatusZdrojSpuštěnoDokončeno
-
- -
-

Poslední incidenty

- - - - - - - - - - {render_incident_history_rows(snapshot["incidents"]["recent"])} -
SlužbaNázevZačátekKonecTrváníStav
-
- -
-

Poslední auditní události

- - - - - - - - - - {render_recent_audit_events(snapshot["audit"]["recent"])} -
ČasUživatelAkceTyp cíleCílZdroj
-
- - """, - 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>.

- - - - - - - - - - {component_rows} -
KomponentaServicePoslední spuštěníAkce
-
- -
-

Globální akce

-
- {render_action_buttons(RUNTIME_GLOBAL_ACTION_KEYS, "/portal/admin/runtime")} -
-
- -
-

Posledních {DASHBOARD_LIMIT} runtime operací

- - - - - - - - - - {job_rows} -
ČasSkriptStavVýsledek
-
- """, - 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'