feat(portal): add app metadata and variables management
This commit is contained in:
+168
-1
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+74
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user