feat(portal): add app metadata and variables management

This commit is contained in:
JiriUhlir
2026-06-01 13:13:17 +02:00
parent 1ade865df4
commit 994171be1a
4 changed files with 650 additions and 6 deletions
+350 -3
View File
@@ -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 = ['<option value="">Bez &scaron;ablony</option>']
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'<option value="{html.escape(name)}"{selected}>{html.escape(" - ".join(label_parts))}</option>'
)
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 = '<tr><td colspan="5">Zatím nejsou evidované žádné kontroly zdraví.</td></tr>'
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"""
<tr>
<td>{variable_key}</td>
<td>{variable_value}</td>
<td>{"Ano" if variable_is_secret else "Ne"}</td>
<td>
<form method="post" action="/portal/apps/{app_url_id}/variables/{variable_id}/update" class="inline-form variable-form">
<input name="key" value="{variable_key}" required>
<input name="value" value="{value_input}"{secret_hint}>
<label class="checkbox-label"><input type="checkbox" name="is_secret" value="1"{secret_checked}> Secret</label>
<button type="submit" class="btn btn-compact">Ulo&zcaron;it</button>
</form>
<form method="post" action="/portal/apps/{app_url_id}/variables/{variable_id}/delete" class="inline-form" onsubmit="return confirm('Smazat promennou {variable_key}?');">
<button type="submit" class="btn btn-secondary btn-compact">Smazat</button>
</form>
</td>
</tr>
"""
if not variable_rows:
variable_rows = '<tr><td colspan="4">Zat&iacute;m nejsou evidovan&eacute; &zcaron;&aacute;dn&eacute; prom&ecaron;nn&eacute;.</td></tr>'
return page(
name or escaped_app_id,
"Detail slu\u017eby",
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
@@ -403,11 +526,99 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
</form>
</div>
<div class="detail-tabs" aria-label="Sekce detailu slu&zcaron;by">
<a class="btn btn-secondary" href="#metadata">Metadata</a>
<a class="btn btn-secondary" href="#promenne">Prom&ecaron;nn&eacute;</a>
<a class="btn btn-secondary" href="#historie">Historie</a>
</div>
<div class="card" id="metadata">
<h2>Metadata</h2>
<form method="post" action="/portal/apps/{app_url_id}/metadata" class="metadata-form">
<label>N&aacute;zev</label>
<input name="name" value="{name}" required>
<label>Popis</label>
<textarea name="description" rows="3">{description}</textarea>
<label>Vlastn&iacute;k</label>
<input name="owner" value="{owner}">
<label>Template</label>
<select name="template">{template_options}</select>
<label>Runtime</label>
<input name="runtime" value="{runtime}">
<label>Repository URL</label>
<input name="repository_url" value="{repository_url}">
<label>Repository Name</label>
<input name="repository_name" value="{repository_name}">
<label>Default Branch</label>
<input name="default_branch" value="{default_branch}">
<label>Domain</label>
<input name="domain" value="{domain}">
<label>Health URL</label>
<input name="health_url" value="{health_url}">
<label>Container Port</label>
<input name="container_port" value="{container_port}" inputmode="numeric">
<label class="checkbox-label"><input type="checkbox" name="is_public" value="1"{bool_checked(is_public)}> Ve&rcaron;ejn&aacute; slu&zcaron;ba</label>
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1"{bool_checked(is_enabled)}> Aktivn&iacute; slu&zcaron;ba</label>
<div class="form-actions">
<button type="submit">Ulo&zcaron;it metadata</button>
</div>
</form>
</div>
<div class="card" id="promenne">
<h2>Prom&ecaron;nn&eacute;</h2>
<table>
<tr>
<th>Key</th>
<th>Value</th>
<th>Secret</th>
<th>Akce</th>
</tr>
{variable_rows}
</table>
<form method="post" action="/portal/apps/{app_url_id}/variables" class="metadata-form add-variable-form">
<h3>P&rcaron;idat prom&ecaron;nnou</h3>
<label>Key</label>
<input name="key" required>
<label>Value</label>
<input name="value">
<label class="checkbox-label"><input type="checkbox" name="is_secret" value="1"> Secret</label>
<div class="form-actions">
<button type="submit">P&rcaron;idat prom&ecaron;nnou</button>
</div>
</form>
</div>
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>ID</th><td>{escaped_app_id}</td></tr>
<tr><th>Název</th><td>{name}</td></tr>
<tr><th>Popis</th><td>{description}</td></tr>
<tr><th>Vlastn&iacute;k</th><td>{owner}</td></tr>
<tr><th>Template</th><td>{template_value}</td></tr>
<tr><th>Runtime</th><td>{runtime}</td></tr>
<tr><th>Repository URL</th><td>{repository_url}</td></tr>
<tr><th>Repository Name</th><td>{repository_name}</td></tr>
<tr><th>Default Branch</th><td>{default_branch}</td></tr>
<tr><th>Domain</th><td>{domain}</td></tr>
<tr><th>Health URL</th><td>{health_url}</td></tr>
<tr><th>Container Port</th><td>{container_port}</td></tr>
<tr><th>Ve&rcaron;ejn&aacute; slu&zcaron;ba</th><td>{"Ano" if is_public else "Ne"}</td></tr>
<tr><th>Aktivn&iacute; slu&zcaron;ba</th><td>{"Ano" if is_enabled else "Ne"}</td></tr>
<tr><th>Jazyk</th><td>{language}</td></tr>
<tr><th>Verze</th><td>{version}</td></tr>
<tr><th>Status</th><td><span class="pill">{status}</span></td></tr>
@@ -428,7 +639,7 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
</table>
</div>
<div class="card">
<div class="card" id="historie">
<h2>Historie kontrol</h2>
<table>
<tr>
@@ -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)