feat(portal): add app metadata and variables management
This commit is contained in:
+350
-3
@@ -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 š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ž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ím nejsou evidované žádné proměnné.</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žby">
|
||||
<a class="btn btn-secondary" href="#metadata">Metadata</a>
|
||||
<a class="btn btn-secondary" href="#promenne">Proměnné</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ázev</label>
|
||||
<input name="name" value="{name}" required>
|
||||
|
||||
<label>Popis</label>
|
||||
<textarea name="description" rows="3">{description}</textarea>
|
||||
|
||||
<label>Vlastní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řejná služba</label>
|
||||
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1"{bool_checked(is_enabled)}> Aktivní služba</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit">Uložit metadata</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="promenne">
|
||||
<h2>Proměnné</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řidat promě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řidat promě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í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řejná služba</th><td>{"Ano" if is_public else "Ne"}</td></tr>
|
||||
<tr><th>Aktivní služ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)
|
||||
|
||||
Reference in New Issue
Block a user