Souhrn
@@ -869,7 +953,7 @@ def save_app_metadata(
container_port: str = Form(""),
is_public: str | None = Form(None),
is_enabled: str | None = Form(None),
- user=Depends(require_user),
+ user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
@@ -911,7 +995,7 @@ def add_app_variable(
key: str = Form(...),
value: str = Form(""),
is_secret: str | None = Form(None),
- user=Depends(require_user),
+ user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
@@ -946,7 +1030,7 @@ def save_app_variable(
key: str = Form(...),
value: str = Form(""),
is_secret: str | None = Form(None),
- user=Depends(require_user),
+ user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
@@ -980,7 +1064,7 @@ def save_app_variable(
@router.post("/apps/{app_id}/variables/{variable_id}/delete")
-def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user)):
+def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
@@ -1007,7 +1091,7 @@ def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user
@router.post("/apps/{app_id}/environment/apply")
-def apply_app_environment_action(app_id: str, user=Depends(require_user)):
+def apply_app_environment_action(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
@@ -1028,7 +1112,7 @@ def apply_app_environment_action(app_id: str, user=Depends(require_user)):
@router.post("/apps/{app_id}/redeploy")
-def redeploy_app(app_id: str, user=Depends(require_user)):
+def redeploy_app(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
@@ -1062,7 +1146,7 @@ def redeploy_app(app_id: str, user=Depends(require_user)):
@router.get("/new-app", response_class=HTMLResponse)
-def new_app_form(request: Request, user=Depends(require_user)):
+def new_app_form(request: Request, user=Depends(require_developer)):
template_options = render_template_options(get_app_templates(create_enabled=True), "", include_blank=False)
return page(
"Nová služba",
@@ -1114,7 +1198,7 @@ def create_app(
owner: str = Form(""),
memory: str = Form(""),
cpus: str = Form(""),
- user=Depends(require_user),
+ user=Depends(require_developer),
):
selected_template = get_app_template(template, create_enabled=True)
if not selected_template:
@@ -1260,7 +1344,7 @@ def create_app(
@router.post("/delete-app", response_class=HTMLResponse)
-def delete_app(app_id: str = Form(...), user=Depends(require_user)):
+def delete_app(app_id: str = Form(...), user=Depends(require_admin)):
result = run_command([DELETE_APP_SCRIPT, app_id])
status = "OK" if result.returncode == 0 else "FAILED"
log_audit_event(
@@ -1288,7 +1372,7 @@ def update_resources(
app_id: str = Form(...),
memory: str = Form(""),
cpus: str = Form(""),
- user=Depends(require_user),
+ user=Depends(require_developer),
):
memory = memory.strip()
cpus = cpus.strip()
diff --git a/app/routes/backups.py b/app/routes/backups.py
index e0db1d3..b45d065 100644
--- a/app/routes/backups.py
+++ b/app/routes/backups.py
@@ -7,7 +7,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from ..auth import require_user
from ..backups import backup_dir, is_backup_path, list_backups
-from ..config import BACKUP_SCRIPT
+from ..config import BACKUP_SCRIPT, RESTORE_SCRIPT
from ..db.audit import log_audit_event
from ..shell import run_command
from ..templates.layout import page, render_result
@@ -30,14 +30,24 @@ def backups_page(request: Request, user=Depends(require_user)):
backup_path = html.escape(str(path), quote=True)
backup_url_name = quote(path.name, safe="")
download_action = (
- f'
Stáhnout
'
+ f'
'
+ f' Stáhnout '
if can_download
else ""
)
-
- restore_cmd = html.escape(
- f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}",
- quote=True,
+ # Restore overwrites live data, so it is admin-only and guarded by an explicit confirm.
+ restore_action = (
+ f"""
+
+ """
+ if can_download
+ else ""
)
rows += f"""
@@ -49,42 +59,46 @@ def backups_page(request: Request, user=Depends(require_user)):
- Kopírovat
+
+ Kopírovat
+
-
-
-
- Kopírovat
-
- Obnova je nebezpečná a musí se spustit ručně přes SSH.
-
{download_action}
+ {restore_action}
"""
if not rows:
- rows = '
Nebyly nalezeny žádné zálohy. '
+ rows = '
Nebyly nalezeny žádné zálohy. '
+
+ restore_hint = (
+ "Obnovu lze spustit přímo z portálu tlačítkem Obnovit (pouze administrátor)."
+ if can_download
+ else "Obnovu zálohy může spustit pouze administrátor."
+ )
return page(
"Zálohy",
f"""
-
Správa záloh
+
Správa záloh
Zálohy neobsahují samotný adresář se zálohami.
Obnova starší zálohy by neměla smazat novější soubory .tar.gz.
- Obnova zůstává ruční, aby nedošlo k nechtěnému přepsání dat.
+ {restore_hint} Obnova přepíše aktuální data a je nevratná.
@@ -94,7 +108,6 @@ def backups_page(request: Request, user=Depends(require_user)):
Záloha
Cesta
- Příkaz pro obnovu
Akce
{rows}
@@ -169,21 +182,38 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)):
return RedirectResponse(url="/portal/backups", status_code=303)
-@router.post("/backups/restore-trigger")
-def restore_trigger(backup_path: str = Form(...), user=Depends(require_user)):
- target = Path(backup_path)
+@router.post("/backups/restore", response_class=HTMLResponse)
+def restore_backup(backup_path: str = Form(...), user=Depends(require_user)):
+ # Restoring overwrites live data and may restart services, so it is restricted to admins.
+ if not is_admin(user):
+ raise HTTPException(status_code=403, detail="Only admins can restore backups")
+ target = Path(backup_path)
if not is_backup_path(target):
return HTMLResponse("Neplatná cesta k záloze", status_code=400)
+ target = target.resolve()
+ if not target.exists() or not target.is_file():
+ raise HTTPException(status_code=404, detail="Backup not found")
+
+ result = run_command([RESTORE_SCRIPT, "--force", str(target)])
+ status = "OK" if result.returncode == 0 else "FAILED"
+
log_audit_event(
user,
- action="restore_trigger",
+ action="backup_restore",
target_type="backup",
target_id=target.name,
metadata={
"backup_path": str(target),
+ "status": status,
+ "returncode": result.returncode,
},
)
- return RedirectResponse(url="/portal/backups", status_code=303)
+ return render_result(
+ title=f"Obnova zálohy: {status}",
+ back_url="/portal/backups",
+ sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
+ user=user,
+ )
diff --git a/app/routes/catalog.py b/app/routes/catalog.py
new file mode 100644
index 0000000..3e3f1fd
--- /dev/null
+++ b/app/routes/catalog.py
@@ -0,0 +1,26 @@
+from fastapi import APIRouter
+
+from ..catalog import load_apps
+
+router = APIRouter()
+
+
+@router.get("/catalog")
+def services_catalog():
+ """Public, unauthenticated JSON catalog of deployed services.
+
+ Returns each service's name and a link to its documentation. Intended to be exposed
+ publicly (the AppFactory reverse proxy maps the public ``/apps`` onto this endpoint).
+ The data comes from the generated catalog (catalog.yml) and contains no secrets.
+ """
+ services = []
+ for item in load_apps():
+ app_id = item.get("id", "")
+ if not app_id:
+ continue
+ services.append({
+ "name": item.get("name", app_id),
+ "docs": item.get("docs", f"/apps/{app_id}/docs"),
+ })
+
+ return {"services": services}
diff --git a/app/routes/developers.py b/app/routes/developers.py
new file mode 100644
index 0000000..4fe29db
--- /dev/null
+++ b/app/routes/developers.py
@@ -0,0 +1,139 @@
+import html
+
+from fastapi import APIRouter, Depends
+
+from ..auth import require_user
+from ..templates.layout import page
+
+router = APIRouter()
+
+
+# Portal sections (the admin/agenda links shown in the navigation). role: minimum role that can
+# actually use the section; viewers may see read-only data where a section allows it.
+PORTAL_SECTIONS = [
+ ("fa-server", "Služby", "/portal/apps",
+ "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-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-diagram-project", "Migration Readiness", "/portal/migration-readiness",
+ "Připravenost a deploy core služeb AppFactory.", "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",
+ "Evidované incidenty služeb.", "developer / admin"),
+ ("fa-gears", "Workery", "/portal/workers",
+ "Stav workerů zpracovávajících úlohy.", "admin"),
+ ("fa-users", "Users", "/portal/admin/users",
+ "Správa uživatelů a rolí (viewer / developer / admin).", "admin"),
+ ("fa-clipboard-list", "Audit", "/portal/audit",
+ "Auditní log akcí provedených v portálu.", "admin"),
+ ("fa-box-archive", "Zálohy", "/portal/backups",
+ "Vytváření, stažení a obnova záloh AppFactory. Obnova je jen pro administrátory.", "admin"),
+]
+
+# Public / API endpoints not gated by the portal login.
+PUBLIC_ENDPOINTS = [
+ ("GET", "/apps", "Veřejný JSON katalog služeb (název + odkaz na dokumentaci). Routuje se přes Caddy."),
+ ("GET", "/portal/health", "Health endpoint portálu (HTTP 200, pokud portál běží)."),
+ ("GET", "/apps/<app-id>/docs", "Swagger/OpenAPI dokumentace konkrétní služby."),
+ ("GET", "/apps/<app-id>/health", "Health endpoint konkrétní služby."),
+]
+
+# Roles model.
+ROLES = [
+ ("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání."),
+ ("developer", "Vše co viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace."),
+ ("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů, audit a admin-only operace."),
+]
+
+
+@router.get("/developers")
+def developers_page(user=Depends(require_user)):
+ section_rows = ""
+ for icon, title, href, desc, roles in PORTAL_SECTIONS:
+ section_rows += f"""
+
+ {html.escape(title)}
+ {html.escape(href)}
+ {html.escape(desc)}
+ {html.escape(roles)}
+
+ """
+
+ endpoint_rows = ""
+ for method, path, desc in PUBLIC_ENDPOINTS:
+ endpoint_rows += f"""
+
+ {html.escape(method)}
+ {path}
+ {html.escape(desc)}
+
+ """
+
+ role_rows = ""
+ for role, desc in ROLES:
+ role_rows += f"""
+
+ {html.escape(role)}
+ {html.escape(desc)}
+
+ """
+
+ return page(
+ "Pro vývojáře",
+ f"""
+
+
Pro vývojáře
+
+ Přehled všech sekcí portálu, veřejných endpointů a rolí. Portál běží za reverzní proxy
+ na cestě /portal; jednotlivé služby na /apps/<app-id>.
+
+
+
+
+
Sekce portálu (agendy)
+
+
+ Sekce
+ Odkaz
+ Popis
+ Role
+
+ {section_rows}
+
+
+
+
+
Veřejné / API endpointy
+
+
+ Metoda
+ Cesta
+ Popis
+
+ {endpoint_rows}
+
+
+ Veřejný katalog /apps vrací JSON se seznamem služeb a odkazy na jejich dokumentaci.
+
+
+
+
+
Role a oprávnění
+
+
+ Role
+ Co může
+
+ {role_rows}
+
+
+ """,
+ user=user,
+ )
diff --git a/app/routes/users.py b/app/routes/users.py
index 290fb88..cb1d545 100644
--- a/app/routes/users.py
+++ b/app/routes/users.py
@@ -45,9 +45,9 @@ def render_sync_pill(status: str) -> str:
def render_role_overview() -> str:
role_rows = (
- ("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."),
- ("developer", "Operational portal access for application and service workflows. No access to Administration / Users."),
- ("viewer", "Default Google role. Read-oriented portal access where routes allow it. No access to Administration / Users."),
+ ("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů a rolí, admin-only operace."),
+ ("developer", "Vidí běžící služby a dokumentaci jako viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace / Uživatelů."),
+ ("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání. Bez přístupu do Administrace / Uživatelů. Výchozí role pro Google přihlášení."),
)
rows = ""
for role, description in role_rows:
diff --git a/app/static/styles.css b/app/static/styles.css
index 85bbb2b..eed7937 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -436,19 +436,72 @@ button:hover,
}
.info-dot {
+ position: relative;
display: inline-flex;
width: 18px;
height: 18px;
margin-left: 4px;
align-items: center;
justify-content: center;
- border: 1px solid var(--muted);
- border-radius: 999px;
color: var(--muted);
- font-size: 12px;
- font-weight: 800;
+ font-size: 13px;
line-height: 1;
text-transform: none;
+ cursor: help;
+}
+
+.info-dot:hover,
+.info-dot:focus-visible {
+ color: var(--secondary-dark);
+ outline: none;
+}
+
+/* Custom tooltip bubble — the native title attribute is slow and easy to miss, so info hints
+ render as an immediate, styled popover on hover/focus. */
+.info-dot[data-tooltip]::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 20;
+ width: max-content;
+ max-width: 260px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: #1f2937;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 1.4;
+ text-align: left;
+ white-space: normal;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.12s ease;
+}
+
+.info-dot[data-tooltip]::before {
+ content: "";
+ position: absolute;
+ top: calc(100% + 2px);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 20;
+ border: 6px solid transparent;
+ border-bottom-color: #1f2937;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.12s ease;
+}
+
+.info-dot[data-tooltip]:hover::after,
+.info-dot[data-tooltip]:focus-visible::after,
+.info-dot[data-tooltip]:hover::before,
+.info-dot[data-tooltip]:focus-visible::before {
+ opacity: 1;
+ visibility: visible;
}
input,
diff --git a/app/templates/layout.py b/app/templates/layout.py
index 515d87c..56576ed 100644
--- a/app/templates/layout.py
+++ b/app/templates/layout.py
@@ -16,29 +16,30 @@ def page(title: str, body: str, user=None) -> str:
if (user.get("role") or "").lower() == "admin":
admin_menu = """
"""
nav = f"""
- Služby
- Úlohy
- Plánované skripty
- Alerting
+ Služby
+ Úlohy
+ Plánované skripty
+ Alerting
+ Pro vývojáře
{admin_menu}
"""
@@ -47,7 +48,7 @@ def page(title: str, body: str, user=None) -> str:
{display_name}
@{username}
"""