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
+168 -1
View File
@@ -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(
+58
View File
@@ -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()
+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)
+74 -2
View File
@@ -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;