From 994171be1a3f33d6c778c58382345f5d0f760253 Mon Sep 17 00:00:00 2001
From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com>
Date: Mon, 1 Jun 2026 13:13:17 +0200
Subject: [PATCH] feat(portal): add app metadata and variables management
---
app/db/apps.py | 169 +++++++++++++++++++-
app/db/migrations.py | 58 +++++++
app/routes/apps.py | 353 +++++++++++++++++++++++++++++++++++++++++-
app/static/styles.css | 76 ++++++++-
4 files changed, 650 insertions(+), 6 deletions(-)
diff --git a/app/db/apps.py b/app/db/apps.py
index df3110f..8f611ab 100644
--- a/app/db/apps.py
+++ b/app/db/apps.py
@@ -1,4 +1,5 @@
from app.db.database import get_connection
+from app.db.migrations import run_migrations
SUCCESS_STATUSES = ("ok", "success", "succeeded", "done", "deployed", "completed")
@@ -7,6 +8,7 @@ RUNNING_STATUSES = ("running", "pending", "queued", "in_progress", "starting")
def get_apps():
+ run_migrations()
con = get_connection()
rows = con.execute(
@@ -44,11 +46,32 @@ def get_apps():
def get_app(app_id: str):
+ run_migrations()
con = get_connection()
row = con.execute(
"""
- SELECT id, name, language, version, status, memory, cpus, updated_at
+ SELECT
+ id,
+ name,
+ language,
+ version,
+ status,
+ memory,
+ cpus,
+ updated_at,
+ description,
+ owner,
+ template,
+ runtime,
+ repository_url,
+ repository_name,
+ default_branch,
+ domain,
+ health_url,
+ container_port,
+ COALESCE(is_public, 0) AS is_public,
+ COALESCE(is_enabled, 1) AS is_enabled
FROM apps
WHERE id = ?
""",
@@ -59,7 +82,151 @@ def get_app(app_id: str):
return dict(row) if row else None
+def get_app_templates():
+ run_migrations()
+ con = get_connection()
+
+ rows = con.execute(
+ """
+ SELECT name, runtime, description
+ FROM app_templates
+ ORDER BY name
+ """
+ ).fetchall()
+
+ con.close()
+ return [dict(row) for row in rows]
+
+
+def update_app_metadata(app_id: str, metadata: dict):
+ run_migrations()
+ con = get_connection()
+
+ con.execute(
+ """
+ UPDATE apps
+ SET name = ?,
+ description = ?,
+ owner = ?,
+ template = ?,
+ runtime = ?,
+ repository_url = ?,
+ repository_name = ?,
+ default_branch = ?,
+ domain = ?,
+ health_url = ?,
+ container_port = ?,
+ is_public = ?,
+ is_enabled = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (
+ metadata.get("name") or None,
+ metadata.get("description") or None,
+ metadata.get("owner") or None,
+ metadata.get("template") or None,
+ metadata.get("runtime") or None,
+ metadata.get("repository_url") or None,
+ metadata.get("repository_name") or None,
+ metadata.get("default_branch") or None,
+ metadata.get("domain") or None,
+ metadata.get("health_url") or None,
+ metadata.get("container_port"),
+ 1 if metadata.get("is_public") else 0,
+ 1 if metadata.get("is_enabled") else 0,
+ app_id,
+ ),
+ )
+
+ con.commit()
+ con.close()
+
+
+def get_app_variables(app_id: str):
+ run_migrations()
+ con = get_connection()
+
+ rows = con.execute(
+ """
+ SELECT id, app_id, "key", value, COALESCE(is_secret, 0) AS is_secret
+ FROM app_variables
+ WHERE app_id = ?
+ ORDER BY "key"
+ """,
+ (app_id,),
+ ).fetchall()
+
+ con.close()
+ return [dict(row) for row in rows]
+
+
+def create_app_variable(app_id: str, key: str, value: str, is_secret: bool):
+ run_migrations()
+ con = get_connection()
+
+ con.execute(
+ """
+ INSERT INTO app_variables (app_id, "key", value, is_secret, created_at, updated_at)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ """,
+ (app_id, key, value, 1 if is_secret else 0),
+ )
+
+ con.commit()
+ con.close()
+
+
+def update_app_variable(variable_id: int, app_id: str, key: str, value: str | None, is_secret: bool):
+ run_migrations()
+ con = get_connection()
+
+ if value is None:
+ con.execute(
+ """
+ UPDATE app_variables
+ SET "key" = ?,
+ is_secret = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND app_id = ?
+ """,
+ (key, 1 if is_secret else 0, variable_id, app_id),
+ )
+ else:
+ con.execute(
+ """
+ UPDATE app_variables
+ SET "key" = ?,
+ value = ?,
+ is_secret = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND app_id = ?
+ """,
+ (key, value, 1 if is_secret else 0, variable_id, app_id),
+ )
+
+ con.commit()
+ con.close()
+
+
+def delete_app_variable(variable_id: int, app_id: str):
+ run_migrations()
+ con = get_connection()
+
+ con.execute(
+ """
+ DELETE FROM app_variables
+ WHERE id = ? AND app_id = ?
+ """,
+ (variable_id, app_id),
+ )
+
+ con.commit()
+ con.close()
+
+
def update_app_resources(app_id: str, memory: str, cpus: str):
+ run_migrations()
con = get_connection()
con.execute(
diff --git a/app/db/migrations.py b/app/db/migrations.py
index 5ea35d0..b182d17 100644
--- a/app/db/migrations.py
+++ b/app/db/migrations.py
@@ -66,5 +66,63 @@ def run_migrations():
except Exception:
pass
+ for statement in (
+ "ALTER TABLE apps ADD COLUMN description TEXT",
+ "ALTER TABLE apps ADD COLUMN owner TEXT",
+ "ALTER TABLE apps ADD COLUMN template TEXT",
+ "ALTER TABLE apps ADD COLUMN runtime TEXT",
+ "ALTER TABLE apps ADD COLUMN repository_url TEXT",
+ "ALTER TABLE apps ADD COLUMN repository_name TEXT",
+ "ALTER TABLE apps ADD COLUMN default_branch TEXT",
+ "ALTER TABLE apps ADD COLUMN domain TEXT",
+ "ALTER TABLE apps ADD COLUMN health_url TEXT",
+ "ALTER TABLE apps ADD COLUMN container_port INTEGER",
+ "ALTER TABLE apps ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE apps ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1",
+ ):
+ try:
+ con.execute(statement)
+ except Exception:
+ pass
+
+ con.execute(
+ """
+ CREATE TABLE IF NOT EXISTS app_templates (
+ name TEXT PRIMARY KEY,
+ runtime TEXT,
+ description TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+ con.execute(
+ """
+ CREATE TABLE IF NOT EXISTS app_variables (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_id TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ value TEXT,
+ is_secret INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(app_id) REFERENCES apps(id)
+ )
+ """
+ )
+ for statement in (
+ "ALTER TABLE app_templates ADD COLUMN runtime TEXT",
+ "ALTER TABLE app_templates ADD COLUMN description TEXT",
+ "ALTER TABLE app_templates ADD COLUMN created_at TEXT",
+ "ALTER TABLE app_templates ADD COLUMN updated_at TEXT",
+ "ALTER TABLE app_variables ADD COLUMN created_at TEXT",
+ "ALTER TABLE app_variables ADD COLUMN updated_at TEXT",
+ ):
+ try:
+ con.execute(statement)
+ except Exception:
+ pass
+ con.execute("CREATE INDEX IF NOT EXISTS idx_app_variables_app_id ON app_variables(app_id)")
+
con.commit()
con.close()
diff --git a/app/routes/apps.py b/app/routes/apps.py
index 7b183d6..dcdb107 100644
--- a/app/routes/apps.py
+++ b/app/routes/apps.py
@@ -15,7 +15,18 @@ from ..config import (
NEW_APP_SCRIPT,
read_env_value,
)
-from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
+from ..db.apps import (
+ create_app_variable,
+ delete_app_variable,
+ get_app,
+ get_app_deployments,
+ get_app_templates,
+ get_app_variables,
+ get_apps,
+ update_app_metadata,
+ update_app_resources,
+ update_app_variable,
+)
from ..db.audit import log_audit_event
from ..db.health import get_latest_service_health, get_service_health, get_service_health_history
from ..db.jobs import create_job, get_jobs, has_active_deploy_job
@@ -25,6 +36,71 @@ from ..templates.layout import page, render_result
router = APIRouter()
DEFAULT_PAGE_SIZE = 20
+METADATA_FIELDS = (
+ "name",
+ "description",
+ "owner",
+ "template",
+ "runtime",
+ "repository_url",
+ "repository_name",
+ "default_branch",
+ "domain",
+ "health_url",
+ "container_port",
+ "is_public",
+ "is_enabled",
+)
+
+
+def clean_optional(value: str | None) -> str:
+ return (value or "").strip()
+
+
+def parse_optional_int(value: str | None) -> int | None:
+ value = clean_optional(value)
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Neplatne cislo")
+
+
+def bool_checked(value) -> str:
+ return " checked" if value else ""
+
+
+def render_template_options(templates: list[dict], selected_template: str) -> str:
+ options = ['']
+ for template in templates:
+ name = template.get("name", "") or ""
+ runtime = template.get("runtime", "") or ""
+ description = template.get("description", "") or ""
+ label_parts = [name]
+ if runtime:
+ label_parts.append(runtime)
+ if description:
+ label_parts.append(description)
+ selected = " selected" if selected_template == name else ""
+ options.append(
+ f''
+ )
+ return "".join(options)
+
+
+def diff_metadata(before: dict, after: dict) -> dict:
+ changes = {}
+ for field in METADATA_FIELDS:
+ old_value = before.get(field)
+ new_value = after.get(field)
+ if field in ("is_public", "is_enabled"):
+ changed = bool(old_value) != bool(new_value)
+ else:
+ changed = str(old_value or "") != str(new_value or "")
+ if changed:
+ changes[field] = {"old": old_value, "new": new_value}
+ return changes
def render_health_status(status: str | None) -> str:
@@ -324,6 +400,21 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
memory = html.escape(app.get("memory", "") or "")
cpus = html.escape(app.get("cpus", "") or "")
updated_at = html.escape(app.get("updated_at", "") or "")
+ description = html.escape(app.get("description", "") or "")
+ owner = html.escape(app.get("owner", "") or "")
+ template_value = html.escape(app.get("template", "") or "")
+ runtime = html.escape(app.get("runtime", "") or "")
+ repository_url = html.escape(app.get("repository_url", "") or "")
+ repository_name = html.escape(app.get("repository_name", "") or "")
+ default_branch = html.escape(app.get("default_branch", "") or "")
+ domain = html.escape(app.get("domain", "") or "")
+ health_url = html.escape(app.get("health_url", "") or "")
+ container_port = html.escape(str(app.get("container_port") or ""))
+ is_public = bool(app.get("is_public"))
+ is_enabled = bool(app.get("is_enabled"))
+ templates = get_app_templates()
+ template_options = render_template_options(templates, app.get("template", "") or "")
+ variables = get_app_variables(app.get("id", ""))
current_health = get_service_health(app.get("id", ""))
health_history = get_service_health_history(app.get("id", ""), limit=50)
@@ -387,8 +478,40 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
if not health_rows:
health_rows = '
| Zatím nejsou evidované žádné kontroly zdraví. |
'
+ variable_rows = ""
+ for variable in variables:
+ variable_id = html.escape(str(variable.get("id", "")))
+ variable_key = html.escape(variable.get("key", "") or "")
+ variable_value_raw = variable.get("value", "") or ""
+ variable_is_secret = bool(variable.get("is_secret"))
+ variable_value = "---" if variable_is_secret else html.escape(variable_value_raw)
+ value_input = "" if variable_is_secret else html.escape(variable_value_raw)
+ secret_checked = bool_checked(variable_is_secret)
+ secret_hint = ' placeholder="---"' if variable_is_secret else ""
+ variable_rows += f"""
+
+ | {variable_key} |
+ {variable_value} |
+ {"Ano" if variable_is_secret else "Ne"} |
+
+
+
+ |
+
+ """
+
+ if not variable_rows:
+ variable_rows = '| Zatím nejsou evidované žádné proměnné. |
'
+
return page(
- name or escaped_app_id,
+ "Detail slu\u017eby",
f"""
{escaped_app_id}
@@ -403,11 +526,99 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
+
+
+
+
+
+
Souhrn
| ID | {escaped_app_id} |
| Název | {name} |
+ | Popis | {description} |
+ | Vlastník | {owner} |
+ | Template | {template_value} |
+ | Runtime | {runtime} |
+ | Repository URL | {repository_url} |
+ | Repository Name | {repository_name} |
+ | Default Branch | {default_branch} |
+ | Domain | {domain} |
+ | Health URL | {health_url} |
+ | Container Port | {container_port} |
+ | Veřejná služba | {"Ano" if is_public else "Ne"} |
+ | Aktivní služba | {"Ano" if is_enabled else "Ne"} |
| Jazyk | {language} |
| Verze | {version} |
| Status | {status} |
@@ -428,7 +639,7 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
-
+
Historie kontrol
@@ -472,6 +683,142 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
)
+@router.post("/apps/{app_id}/metadata")
+def save_app_metadata(
+ app_id: str,
+ name: str = Form(...),
+ description: str = Form(""),
+ owner: str = Form(""),
+ template: str = Form(""),
+ runtime: str = Form(""),
+ repository_url: str = Form(""),
+ repository_name: str = Form(""),
+ default_branch: str = Form(""),
+ domain: str = Form(""),
+ health_url: str = Form(""),
+ container_port: str = Form(""),
+ is_public: str | None = Form(None),
+ is_enabled: str | None = Form(None),
+ user=Depends(require_user),
+):
+ app = get_app(app_id)
+ if not app:
+ raise HTTPException(status_code=404, detail="App not found")
+
+ metadata = {
+ "name": clean_optional(name),
+ "description": clean_optional(description),
+ "owner": clean_optional(owner),
+ "template": clean_optional(template),
+ "runtime": clean_optional(runtime),
+ "repository_url": clean_optional(repository_url),
+ "repository_name": clean_optional(repository_name),
+ "default_branch": clean_optional(default_branch),
+ "domain": clean_optional(domain),
+ "health_url": clean_optional(health_url),
+ "container_port": parse_optional_int(container_port),
+ "is_public": bool(is_public),
+ "is_enabled": bool(is_enabled),
+ }
+
+ changes = diff_metadata(app, metadata)
+ update_app_metadata(app_id, metadata)
+ if changes:
+ log_audit_event(
+ user,
+ action="app.metadata.updated",
+ target_type="app",
+ target_id=app_id,
+ metadata={"changes": changes},
+ )
+
+ return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#metadata", status_code=303)
+
+
+@router.post("/apps/{app_id}/variables")
+def add_app_variable(
+ app_id: str,
+ key: str = Form(...),
+ value: str = Form(""),
+ is_secret: str | None = Form(None),
+ user=Depends(require_user),
+):
+ app = get_app(app_id)
+ if not app:
+ raise HTTPException(status_code=404, detail="App not found")
+
+ variable_key = clean_optional(key)
+ if not variable_key:
+ raise HTTPException(status_code=400, detail="Key is required")
+
+ create_app_variable(app_id, variable_key, value, bool(is_secret))
+ log_audit_event(
+ user,
+ action="app.variable.created",
+ target_type="app",
+ target_id=app_id,
+ metadata={"key": variable_key, "is_secret": bool(is_secret)},
+ )
+
+ return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#promenne", status_code=303)
+
+
+@router.post("/apps/{app_id}/variables/{variable_id}/update")
+def save_app_variable(
+ app_id: str,
+ variable_id: int,
+ key: str = Form(...),
+ value: str = Form(""),
+ is_secret: str | None = Form(None),
+ user=Depends(require_user),
+):
+ app = get_app(app_id)
+ if not app:
+ raise HTTPException(status_code=404, detail="App not found")
+
+ existing = next((item for item in get_app_variables(app_id) if int(item.get("id")) == variable_id), None)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Variable not found")
+
+ variable_key = clean_optional(key)
+ if not variable_key:
+ raise HTTPException(status_code=400, detail="Key is required")
+
+ stored_value = None if existing.get("is_secret") and value == "" else value
+ update_app_variable(variable_id, app_id, variable_key, stored_value, bool(is_secret))
+ log_audit_event(
+ user,
+ action="app.variable.updated",
+ target_type="app",
+ target_id=app_id,
+ metadata={"key": variable_key, "is_secret": bool(is_secret)},
+ )
+
+ return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#promenne", status_code=303)
+
+
+@router.post("/apps/{app_id}/variables/{variable_id}/delete")
+def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user)):
+ app = get_app(app_id)
+ if not app:
+ raise HTTPException(status_code=404, detail="App not found")
+
+ existing = next((item for item in get_app_variables(app_id) if int(item.get("id")) == variable_id), None)
+ if not existing:
+ raise HTTPException(status_code=404, detail="Variable not found")
+
+ delete_app_variable(variable_id, app_id)
+ log_audit_event(
+ user,
+ action="app.variable.deleted",
+ target_type="app",
+ target_id=app_id,
+ metadata={"key": existing.get("key"), "is_secret": bool(existing.get("is_secret"))},
+ )
+
+ return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#promenne", status_code=303)
+
+
@router.post("/apps/{app_id}/redeploy")
def redeploy_app(app_id: str, user=Depends(require_user)):
app = get_app(app_id)
diff --git a/app/static/styles.css b/app/static/styles.css
index 28cb2b1..b8ec775 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -443,13 +443,77 @@ button:hover,
}
input,
-select {
+select,
+textarea {
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
}
+textarea {
+ min-height: 92px;
+ resize: vertical;
+}
+
+.metadata-form {
+ display: grid;
+ grid-template-columns: minmax(160px, 240px) minmax(280px, 1fr);
+ gap: 12px 16px;
+ align-items: center;
+ max-width: 980px;
+}
+
+.metadata-form label {
+ color: var(--secondary);
+ font-weight: 800;
+}
+
+.metadata-form input,
+.metadata-form select,
+.metadata-form textarea {
+ width: 100%;
+}
+
+.checkbox-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.checkbox-label input {
+ width: auto;
+}
+
+.form-actions {
+ grid-column: 2;
+}
+
+.detail-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 0 18px;
+}
+
+.add-variable-form {
+ margin-top: 18px;
+}
+
+.add-variable-form h3 {
+ grid-column: 1 / -1;
+ margin: 0;
+ color: var(--secondary);
+}
+
+.variable-form {
+ display: grid;
+ grid-template-columns: minmax(140px, 1fr) minmax(160px, 1fr) auto auto;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
input[readonly] {
width: 100%;
font-family: monospace;
@@ -844,18 +908,26 @@ pre {
}
.filter-form,
- .inline-form {
+ .inline-form,
+ .metadata-form,
+ .variable-form {
align-items: stretch;
+ display: flex;
flex-direction: column;
}
input,
select,
+ textarea,
button,
.btn {
width: 100%;
}
+ .form-actions {
+ grid-column: auto;
+ }
+
.btn + .btn {
margin-left: 0;
margin-top: 8px;