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)):
+
+ Metadata + Proměnné + Historie +
+ +
+

Metadata

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ +
+

Proměnné

+ + + + + + + + {variable_rows} +
KeyValueSecretAkce
+ +
+

Přidat proměnnou

+ + + + + +
+ +
+
+
+

Souhrn

+ + + + + + + + + + + + @@ -428,7 +639,7 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
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}
-
+

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;