Files
appfactory-portal/app/routes/apps.py
T
2026-06-17 11:39:52 +02:00

1595 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import html
import math
from urllib.parse import quote, urlencode
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import is_admin, is_developer, require_admin, require_developer, require_user
from ..config import (
DEFAULT_GITEA_ORG,
DELETE_APP_SCRIPT,
DEPLOY_SCRIPT,
GENERATE_COMPOSE_SCRIPT,
get_appfactory_host,
get_gitea_public_url,
read_env_value,
)
from ..db.apps import (
create_app_ip_access_rule,
create_app_variable,
delete_app_ip_access_rule,
delete_app_variable,
get_app,
get_app_ip_access_rule,
get_app_ip_access_rules,
get_app_template,
get_app_deployments,
get_app_templates,
get_app_variables,
get_apps,
update_app_ip_access_rule,
update_app_metadata,
update_app_resources,
upsert_created_app,
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.incidents import get_service_incidents
from ..db.jobs import create_job, get_jobs, has_active_deploy_job
from ..environment import AppEnvironmentError, apply_all_app_environments, apply_app_environment, validate_environment_key
from ..routes.deployments import render_status_pill
from ..routes.incidents import render_incident_history_rows
from ..shell import run_command
from ..templates.layout import page, render_result
router = APIRouter()
DEFAULT_PAGE_SIZE = 20
# Selectable method presets for IP access rules. WRITE = POST,PUT,PATCH,DELETE; ALL = every
# common HTTP method including GET. The Caddy generator will later interpret these values.
IP_ACCESS_METHOD_OPTIONS = (
"WRITE",
"ALL",
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"GET,POST",
"POST,PATCH,DELETE",
)
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 app_detail_url(app_id: str, anchor: str = "", message: str = "", error: str = "") -> str:
params = {}
if message:
params["message"] = message
if error:
params["error"] = error
url = f"/portal/apps/{quote(app_id, safe='')}"
if params:
url += f"?{urlencode(params)}"
if anchor:
url += f"#{anchor}"
return url
def redirect_app_detail(app_id: str, anchor: str = "", message: str = "", error: str = "") -> RedirectResponse:
return RedirectResponse(url=app_detail_url(app_id, anchor=anchor, message=message, error=error), status_code=303)
def apply_environment_message(app_id: str) -> str:
result = apply_app_environment(app_id)
if not result.get("changed"):
return "Environment unchanged."
if result.get("restarted"):
return "Environment applied and container restarted."
return "Environment file regenerated. Container was not running."
def apply_all_environments_message() -> str:
results = apply_all_app_environments()
changed = sum(1 for result in results.values() if result.get("changed"))
restarted = sum(1 for result in results.values() if result.get("restarted"))
return f"Regenerated environments for {len(results)} apps. Changed: {changed}. Restarted: {restarted}."
def ensure_variable_key_allowed(app_id: str, key: str, variable_id: int | None = None) -> None:
try:
validate_environment_key(key)
except AppEnvironmentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for variable in get_app_variables(app_id):
if variable_id is not None and int(variable.get("id")) == variable_id:
continue
if (variable.get("key") or "").strip() == key:
raise HTTPException(status_code=400, detail="Variable key already exists")
def render_template_options(templates: list[dict], selected_template: str, include_blank: bool = True) -> str:
options = ['<option value="">Bez šablony</option>'] if include_blank else []
selected_exists = not selected_template
for template in templates:
template_id = template.get("id", "") or ""
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 == template_id else ""
if selected:
selected_exists = True
options.append(
f'<option value="{html.escape(template_id)}"{selected}>{html.escape(" - ".join(label_parts))}</option>'
)
if not selected_exists:
options.append(
f'<option value="{html.escape(selected_template)}" selected>Neznámá šablona</option>'
)
return "".join(options)
def render_template_label(templates: list[dict], template_id: str) -> str:
if not template_id:
return ""
for template in templates:
if template.get("id") == template_id:
return html.escape(template.get("name", "") or template_id)
return "Neznámá šablona"
def render_environment_usage_example(language: str, runtime: str, template: str) -> str:
normalized = " ".join((language or "", runtime or "", template or "")).lower()
title = "Použití v kódu"
if "fastapi" in normalized or "python" in normalized:
code = """import os
value = os.environ["MY_VARIABLE"]
optional_value = os.getenv("OPTIONAL_VARIABLE", "default")"""
description = "Python / FastAPI čte Variables i Secrets ze systémového prostředí."
elif "dotnet" in normalized or ".net" in normalized or "csharp" in normalized or "c#" in normalized:
code = """var value = Environment.GetEnvironmentVariable("MY_VARIABLE");
var optionalValue = builder.Configuration["OPTIONAL_VARIABLE"] ?? "default";"""
description = ".NET čte Variables i Secrets z environment variables, případně přes Configuration."
elif "node" in normalized or "javascript" in normalized or "typescript" in normalized:
code = """const value = process.env.MY_VARIABLE;
const optionalValue = process.env.OPTIONAL_VARIABLE ?? "default";"""
description = "Node.js čte Variables i Secrets přes process.env."
elif "php" in normalized:
code = """$value = getenv('MY_VARIABLE');
$optionalValue = getenv('OPTIONAL_VARIABLE') ?: 'default';"""
description = "PHP čte Variables i Secrets ze systémového prostředí."
elif "java" in normalized or "spring" in normalized:
code = """String value = System.getenv("MY_VARIABLE");
String optionalValue = System.getenv().getOrDefault("OPTIONAL_VARIABLE", "default");"""
description = "Java čte Variables i Secrets přes System.getenv."
else:
code = """MY_VARIABLE is available as an environment variable inside the container."""
description = "Variables i Secrets jsou dostupné jako environment variables v kontejneru."
return f"""
<div class="env-usage-example">
<h3>{html.escape(title)}</h3>
<p class="muted">{html.escape(description)}</p>
<pre><code>{html.escape(code)}</code></pre>
</div>
"""
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:
value = status or ""
normalized = value.lower()
labels = {
"healthy": "zdravá",
"unhealthy": "nezdravá",
"unreachable": "nedostupná",
}
class_name = "pill pill-muted"
if normalized == "healthy":
class_name = "pill pill-success"
elif normalized == "unhealthy":
class_name = "pill pill-warning"
elif normalized == "unreachable":
class_name = "pill pill-danger"
return f'<span class="{class_name}">{html.escape(labels.get(normalized, value or "neznámá"))}</span>'
def render_health_dot(status: str | None, checked_at: str | None) -> str:
normalized = (status or "").lower()
labels = {
"healthy": "zdravá",
"unhealthy": "nezdravá",
"unreachable": "nedostupná",
}
class_name = "health-dot health-dot-muted"
if normalized == "healthy":
class_name = "health-dot health-dot-success"
elif normalized == "unhealthy":
class_name = "health-dot health-dot-warning"
elif normalized == "unreachable":
class_name = "health-dot health-dot-danger"
label = labels.get(normalized, "zdraví neznámé")
title = label
if checked_at:
title = f"{label}, poslední kontrola: {checked_at}"
return f'<span class="{class_name}" title="{html.escape(title)}" aria-label="{html.escape(title)}"></span>'
@router.get("/")
def portal_home(user=Depends(require_user)):
return RedirectResponse(url="/portal/apps", status_code=303)
@router.get("/apps", response_class=HTMLResponse)
def apps_page(
request: Request,
q: str = Query(""),
status: str = Query(""),
message: str = Query(""),
error: str = Query(""),
page_number: int = Query(1, alias="page", ge=1),
user=Depends(require_user),
):
apps = get_apps()
latest_health = get_latest_service_health()
query = q.strip()
selected_status = status.strip()
if query:
apps = [
item for item in apps
if query.lower() in (item.get("id", "") or "").lower()
or query.lower() in (item.get("name", "") or "").lower()
]
if selected_status:
apps = [item for item in apps if (item.get("status", "") or "") == selected_status]
sort = request.query_params.get("sort", "").strip()
if sort == "health":
order = {"unreachable": 0, "unhealthy": 1, "healthy": 2}
apps = sorted(
apps,
key=lambda item: (
order.get((latest_health.get(item.get("id", "")) or {}).get("status"), 3),
item.get("id", ""),
),
)
status_values = sorted({item.get("status", "") for item in get_apps() if item.get("status")})
total_apps = len(apps)
total_pages = max(1, math.ceil(total_apps / DEFAULT_PAGE_SIZE))
if page_number > total_pages:
page_number = total_pages
offset = (page_number - 1) * DEFAULT_PAGE_SIZE
apps = apps[offset : offset + DEFAULT_PAGE_SIZE]
gitea_url = get_gitea_public_url()
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
host = get_appfactory_host(request.url.hostname or "")
# Role-based visibility: a viewer only sees which services run and their documentation.
# Developers also get Git/clone and operational actions; only admins see the delete button.
can_manage = is_developer(user)
can_delete = is_admin(user)
rows = ""
for index, item in enumerate(apps, start=1):
app_id = html.escape(item.get("id", ""))
app_url_id = quote(item.get("id", ""), safe="")
resource_modal_id = f"resources-{page_number}-{index}"
status = html.escape(item.get("status", ""))
health = latest_health.get(item.get("id", "")) or {}
health_checked_at = html.escape(health.get("checked_at", "") or "")
health_dot = render_health_dot(health.get("status"), health.get("checked_at"))
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
memory = html.escape(item.get("memory", "") or "")
cpus = html.escape(item.get("cpus", "") or "")
memory_label = memory or "výchozí"
cpus_label = cpus or "výchozí"
http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git") if gitea_url else ""
ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git") if host else ""
# Resources: developers/admins can edit; viewers see only the read-only summary.
if can_manage:
resource_edit = f"""
<button type="button" class="btn btn-compact btn-secondary" onclick="openResourceModal('{resource_modal_id}')"><i class="fa-solid fa-gear" aria-hidden="true"></i> Upravit</button>
</div>
<dialog class="resource-modal" id="{resource_modal_id}">
<form method="post" action="/portal/update-resources">
<input type="hidden" name="app_id" value="{app_id}">
<div class="modal-header">
<h3>Prostředky služby</h3>
<button type="button" class="icon-button" onclick="closeResourceModal('{resource_modal_id}')" aria-label="Zavřít">×</button>
</div>
<p class="muted">Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro.</p>
<label>Paměť</label>
<input name="memory" value="{memory}" placeholder="např. 512m, 1g nebo prázdné pro výchozí">
<label>CPU</label>
<input name="cpus" value="{cpus}" placeholder="např. 0.50, 1.00 nebo prázdné pro výchozí">
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeResourceModal('{resource_modal_id}')"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Zrušit</button>
<button type="submit"><i class="fa-solid fa-check" aria-hidden="true"></i> Použít</button>
</div>
</form>
</dialog>"""
else:
resource_edit = "</div>"
# Git clone commands are an operational concern — visible to developers/admins only.
if can_manage and (http_clone or ssh_clone):
git_cell = f"""
<details class="clone-details">
<summary>Příkazy pro klonování</summary>
<label class="muted">HTTP</label>
<div class="cmd-row">
<input readonly value="{http_clone}" id="http-{app_id}">
<button type="button" onclick="return copyText(this)"><i class="fa-solid fa-copy" aria-hidden="true"></i> Kopírovat</button>
</div>
<label class="muted">SSH</label>
<div class="cmd-row">
<input readonly value="{ssh_clone}" id="ssh-{app_id}">
<button type="button" onclick="return copyText(this)"><i class="fa-solid fa-copy" aria-hidden="true"></i> Kopírovat</button>
</div>
</details>"""
else:
git_cell = '<span class="muted">—</span>'
# Deploy actions for developers/admins; delete is admin-only.
manage_icons = (
f"""
<a class="icon-action" href="/portal/deployments?app_id={app_url_id}" title="Nasazení" aria-label="Nasazení">
<i class="fa-solid fa-rocket" aria-hidden="true"></i>
</a>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {app_id}?');">
<button type="submit" class="icon-action" title="Nasadit znovu" aria-label="Nasadit znovu">
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i>
</button>
</form>"""
if can_manage
else ""
)
delete_icon = (
f"""
<form method="post" action="/portal/delete-app" onsubmit="return confirm('Smazat {app_id}? Tím se odstraní kontejner, image, workspace, záznam v katalogu a Gitea repozitář.');">
<input type="hidden" name="app_id" value="{app_id}">
<button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
</button>
</form>"""
if can_delete
else ""
)
rows += f"""
<tr class="service-row">
<td class="service-name-cell">
<div class="service-title">
{health_dot}
<div>
<strong>{app_id}</strong><br>
<span class="muted">/apps/{app_id}</span>
</div>
</div>
</td>
<td><span class="pill">{status}</span></td>
<td><a href="{docs}" target="_blank" rel="noopener">Swagger</a></td>
<td>
<div class="resource-summary">
<span>Paměť: <strong>{memory_label}</strong></span>
<span>CPU: <strong>{cpus_label}</strong></span>
{resource_edit}
</td>
<td>
{git_cell}
</td>
<td class="action-icons-cell">
<div class="action-icons">
<a class="icon-action" href="/portal/apps/{app_url_id}" title="Detail služby" aria-label="Detail služby">
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
</a>
{manage_icons}
{delete_icon}
</div>
</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Zatím nejsou nasazené žádné služby.</td></tr>'
status_options = ['<option value="">Všechny stavy</option>']
for value in status_values:
selected = " selected" if selected_status == value else ""
escaped_value = html.escape(value)
status_options.append(f'<option value="{escaped_value}"{selected}>{escaped_value}</option>')
first_item = offset + 1 if total_apps else 0
last_item = min(offset + len(apps), total_apps)
def page_url(page: int) -> str:
params = {"page": page}
if query:
params["q"] = query
if selected_status:
params["status"] = selected_status
if sort:
params["sort"] = sort
return f"/portal/apps?{urlencode(params)}"
previous_link = (
f'<a class="btn btn-secondary" href="{page_url(page_number - 1)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Předchozí</a>'
if page_number > 1
else ""
)
next_link = (
f'<a class="btn btn-secondary" href="{page_url(page_number + 1)}">Další <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>'
if page_number < total_pages
else ""
)
pagination = ""
if total_pages > 1:
pagination = f"""
<div class="pagination">
<span>Zobrazeno {first_item}-{last_item} z {total_apps}</span>
<div class="pagination-actions">
{previous_link}
<span class="page-indicator">Strana {page_number} / {total_pages}</span>
{next_link}
</div>
</div>
"""
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
# Creating services and regenerating .env files are write actions — developers/admins only.
manage_toolbar = (
"""
<p><a class="btn" href="/portal/new-app"><i class="fa-solid fa-plus" aria-hidden="true"></i> Nová služba</a></p>
<form method="post" action="/portal/apps/environment/apply-all" class="inline-form">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> Regenerovat všechny .env z databáze</button>
</form>
"""
if can_manage
else ""
)
return page(
"Služby",
f"""
<div class="card">
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Nasazené služby</h2>
{notice}
{manage_toolbar}
<form method="get" action="/portal/apps" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
<select name="status">{"".join(status_options)}</select>
<select name="sort">
<option value="">Výchozí řazení</option>
<option value="health"{" selected" if sort == "health" else ""}>Podle zdraví</option>
</select>
<input type="hidden" name="page" value="1">
<button type="submit"><i class="fa-solid fa-filter" aria-hidden="true"></i> Filtrovat</button>
<a class="btn btn-secondary" href="/portal/apps"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Reset</a>
</form>
{pagination}
<table>
<tr>
<th>Služba</th>
<th>Status</th>
<th>Dokumentace</th>
<th>Prostředky <span class="info-dot" tabindex="0" role="note" aria-label="Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro." data-tooltip="Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro."><i class="fa-solid fa-circle-info" aria-hidden="true"></i></span></th>
<th>Git</th>
<th>Akce</th>
</tr>
{rows}
</table>
{pagination}
</div>
""",
user=user,
)
@router.post("/apps/environment/apply-all")
def apply_all_app_environments_action(user=Depends(require_developer)):
try:
message = apply_all_environments_message()
except AppEnvironmentError as exc:
return RedirectResponse(url="/portal/apps?error=" + quote(f"Environment apply failed: {exc}"), status_code=303)
log_audit_event(
user,
action="app.environment.applied_all",
target_type="app",
metadata={"result": message},
)
return RedirectResponse(url="/portal/apps?message=" + quote(message), status_code=303)
@router.get("/apps/{app_id}", response_class=HTMLResponse)
def app_detail(app_id: str, request: Request, message: str = "", error: str = "", user=Depends(require_user)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
# Viewers may open the detail (services + docs are read-only here); editing the service,
# its variables or triggering a redeploy is reserved for developers/admins.
can_manage = is_developer(user)
log_audit_event(
user,
action="service.health.view",
target_type="service",
target_id=app_id,
)
escaped_app_id = html.escape(app.get("id", ""))
app_url_id = quote(app.get("id", ""), safe="")
name = html.escape(app.get("name", "") or "")
language_raw = app.get("language", "") or ""
runtime_raw = app.get("runtime", "") or ""
template_raw = app.get("template", "") or ""
language = html.escape(language_raw)
version = html.escape(app.get("version", "") or "")
status = html.escape(app.get("status", "") or "")
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 "")
runtime = html.escape(runtime_raw)
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 "")
template_value = render_template_label(templates, app.get("template", "") or "")
environment_usage_example = render_environment_usage_example(language_raw, runtime_raw, template_raw)
variables = get_app_variables(app.get("id", ""))
ip_access_rules = get_app_ip_access_rules(app.get("id", ""))
incidents = get_service_incidents(app.get("id", ""), limit=20)
current_health = get_service_health(app.get("id", ""))
health_history = get_service_health_history(app.get("id", ""), limit=50)
rows = ""
for deployment in get_app_deployments(app.get("id", ""), limit=10):
deployment_id = html.escape(str(deployment.get("id", "")))
started_at = html.escape(deployment.get("started_at", "") or "")
triggered_by = html.escape(
deployment.get("triggered_by_display_name")
or deployment.get("triggered_by_username")
or ""
)
rows += f"""
<tr>
<td><a href="/portal/deployments/{deployment_id}">#{deployment_id}</a></td>
<td>{render_status_pill(deployment.get("status"))}</td>
<td>{started_at}</td>
<td>{triggered_by}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="4">Zatím nejsou evidovaná žádná nasazení této služby.</td></tr>'
job_rows = ""
for job in get_jobs(limit=100, target=app.get("id", "")):
if job.get("target_type") != "app" or job.get("target_id") != app.get("id", ""):
continue
job_id = html.escape(str(job.get("id", "")))
job_rows += f"""
<tr>
<td><a href="/portal/jobs/{job_id}">#{job_id}</a></td>
<td>{render_status_pill(job.get("status"))}</td>
<td>{html.escape(job.get("type", "") or "")}</td>
<td>{html.escape(job.get("created_at", "") or "")}</td>
</tr>
"""
if not job_rows:
job_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné úlohy této služby.</td></tr>'
health_status = render_health_status(current_health.get("status") if current_health else None)
health_http_status = html.escape(str(current_health.get("http_status") or "")) if current_health else ""
health_response_time = html.escape(str(current_health.get("response_time_ms") or "")) if current_health else ""
health_checked_at = html.escape(current_health.get("checked_at", "") or "") if current_health else ""
health_rows = ""
for item in health_history:
error_text = item.get("error_text", "") or ""
error_preview = error_text if len(error_text) <= 140 else f"{error_text[:137]}..."
health_rows += f"""
<tr>
<td>{html.escape(item.get("checked_at", "") or "")}</td>
<td>{render_health_status(item.get("status"))}</td>
<td>{html.escape(str(item.get("http_status") or ""))}</td>
<td>{html.escape(str(item.get("response_time_ms") or ""))}</td>
<td>{html.escape(error_preview)}</td>
</tr>
"""
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="icon-action" title="Uložit" aria-label="Uložit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i></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="icon-action icon-action-danger" title="Smazat" aria-label="Smazat"><i class="fa-solid fa-trash" aria-hidden="true"></i></button>
</form>
</td>
</tr>
"""
if not variable_rows:
variable_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné proměnné.</td></tr>'
def render_method_options(selected: str) -> str:
selected_upper = (selected or "").upper()
options = list(IP_ACCESS_METHOD_OPTIONS)
if selected_upper and selected_upper not in options:
options.append(selected_upper)
out = ""
for opt in options:
is_sel = " selected" if opt == selected_upper else ""
out += f'<option value="{html.escape(opt, quote=True)}"{is_sel}>{html.escape(opt)}</option>'
return out
# IP access rules — inputs in each column are associated with the per-row update form via the
# HTML5 form="..." attribute, so the whole row is editable inline while staying valid markup.
ip_access_rule_rows = ""
for rule in ip_access_rules:
rid = html.escape(str(rule.get("id", "")))
form_id = f"iprule-{rid}"
rule_ip = html.escape(rule.get("ip_cidr", "") or "", quote=True)
rule_desc = html.escape(rule.get("description", "") or "", quote=True)
rule_enabled = bool(rule.get("is_enabled", 1))
row_class = "" if rule_enabled else " rule-disabled"
enabled_checked = bool_checked(rule_enabled)
ip_label = html.escape(rule.get("ip_cidr", "") or "")
ip_access_rule_rows += f"""
<tr class="ip-rule-row{row_class}">
<td><input name="ip_cidr" value="{rule_ip}" form="{form_id}" required></td>
<td><select name="methods" form="{form_id}">{render_method_options(rule.get("methods", ""))}</select></td>
<td><input name="description" value="{rule_desc}" form="{form_id}"></td>
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{enabled_checked}> Aktivní</label></td>
<td>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/update" id="{form_id}" class="inline-form"></form>
<button type="submit" form="{form_id}" class="btn btn-compact"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit</button>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/delete" class="inline-form" onsubmit="return confirm('Smazat pravidlo {ip_label}?');">
<button type="submit" class="btn btn-secondary btn-compact"><i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat</button>
</form>
</td>
</tr>
"""
if not ip_access_rule_rows:
ip_access_rule_rows = '<tr><td colspan="5">Zatím nejsou evidovaná žádná IP access pravidla.</td></tr>'
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
# Editable sections are developer/admin only. Viewers keep the read-only Souhrn / Zdrav\u00ed /
# Historie cards below, which already present the service metadata without write access.
redeploy_block = (
f"""
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nov\u00e9 nasazen\u00ed slu\u017eby {escaped_app_id}?');">
<button type="submit"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Nasadit znovu</button>
</form>
"""
if can_manage
else ""
)
detail_tabs = (
"""
<div class="detail-tabs" aria-label="Sekce detailu služby">
<a class="btn btn-secondary" href="#metadata"><i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata</a>
<a class="btn btn-secondary" href="#promenne"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</a>
<a class="btn btn-secondary" href="#ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</a>
<a class="btn btn-secondary" href="#historie"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Historie</a>
</div>
"""
if can_manage
else """
<div class="detail-tabs" aria-label="Sekce detailu služby">
<a class="btn btn-secondary" href="#historie"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Historie</a>
</div>
"""
)
metadata_card = (
f"""
<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"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit metadata</button>
</div>
</form>
</div>
"""
if can_manage
else ""
)
variables_card = (
f"""
<div class="card" id="promenne">
<h2><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</h2>
<form method="post" action="/portal/apps/{app_url_id}/environment/apply" class="inline-form">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Regenerovat .env z databáze</button>
</form>
{environment_usage_example}
<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"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat proměnnou</button>
</div>
</form>
</div>
"""
if can_manage
else ""
)
ip_access_card = (
f"""
<div class="card" id="ip-access">
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</h2>
<p class="muted">
Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody.
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později).
</p>
<table>
<tr>
<th>IP/CIDR</th>
<th>Methods</th>
<th>Description</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
{ip_access_rule_rows}
</table>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules" class="metadata-form add-variable-form">
<h3>Přidat pravidlo</h3>
<label>IP/CIDR</label>
<input name="ip_cidr" placeholder="např. 185.10.20.30 nebo 10.0.0.0/24" required>
<label>Methods</label>
<select name="methods">{render_method_options("WRITE")}</select>
<label>Description</label>
<input name="description" placeholder="např. Office IP">
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" checked> Aktivní</label>
<div class="form-actions">
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat pravidlo</button>
</div>
</form>
</div>
"""
if can_manage
else ""
)
return page(
"Detail slu\u017eby",
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
<p class="muted">{name}</p>
{notice}
<p>
<a class="btn" href="/portal/apps">&larr; Zpět na služby</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}"><i class="fa-solid fa-rocket" aria-hidden="true"></i> Nasazení služby</a>
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy služby</a>
</p>
{redeploy_block}
</div>
{detail_tabs}
{metadata_card}
{variables_card}
{ip_access_card}
<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>
<tr><th>Paměť</th><td>{memory}</td></tr>
<tr><th>CPU</th><td>{cpus}</td></tr>
<tr><th>Upraveno</th><td>{updated_at}</td></tr>
<tr><th>Logy</th><td><a href="/portal/jobs?target={app_url_id}">Zobrazit úlohy a logy</a></td></tr>
</table>
</div>
<div class="card">
<h2>Zdraví služby</h2>
<table>
<tr><th>Aktuální status</th><td>{health_status}</td></tr>
<tr><th>HTTP status</th><td>{health_http_status}</td></tr>
<tr><th>Odezva</th><td>{health_response_time}</td></tr>
<tr><th>Poslední kontrola</th><td>{health_checked_at}</td></tr>
</table>
</div>
<div class="card" id="historie">
<h2>Historie kontrol</h2>
<table>
<tr>
<th>Čas</th>
<th>Status</th>
<th>HTTP status</th>
<th>Odezva</th>
<th>Chyba</th>
</tr>
{health_rows}
</table>
</div>
<div class="card">
<h2>Incidenty služby</h2>
<table>
<tr>
<th>Název</th>
<th>Začátek</th>
<th>Konec</th>
<th>Trvání</th>
<th>Stav</th>
</tr>
{render_incident_history_rows(incidents, include_service=False)}
</table>
</div>
<div class="card">
<h2>Historie nasazení</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Čas</th>
<th>Spustil</th>
</tr>
{rows}
</table>
</div>
<div class="card">
<h2>Historie úloh</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Typ</th>
<th>Vytvořeno</th>
</tr>
{job_rows}
</table>
</div>
""",
user=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_developer),
):
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_developer),
):
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")
ensure_variable_key_allowed(app_id, variable_key)
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)},
)
try:
message = apply_environment_message(app_id)
except AppEnvironmentError as exc:
return redirect_app_detail(app_id, anchor="promenne", error=f"Variable saved, but environment apply failed: {exc}")
return redirect_app_detail(app_id, anchor="promenne", message=message)
@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_developer),
):
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")
ensure_variable_key_allowed(app_id, variable_key, variable_id=variable_id)
stored_value = None if existing.get("is_secret") and bool(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)},
)
try:
message = apply_environment_message(app_id)
except AppEnvironmentError as exc:
return redirect_app_detail(app_id, anchor="promenne", error=f"Variable saved, but environment apply failed: {exc}")
return redirect_app_detail(app_id, anchor="promenne", message=message)
@router.post("/apps/{app_id}/variables/{variable_id}/delete")
def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_developer)):
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"))},
)
try:
message = apply_environment_message(app_id)
except AppEnvironmentError as exc:
return redirect_app_detail(app_id, anchor="promenne", error=f"Variable deleted, but environment apply failed: {exc}")
return redirect_app_detail(app_id, anchor="promenne", message=message)
@router.post("/apps/{app_id}/environment/apply")
def apply_app_environment_action(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
try:
message = apply_environment_message(app_id)
except AppEnvironmentError as exc:
return redirect_app_detail(app_id, anchor="promenne", error=f"Environment apply failed: {exc}")
log_audit_event(
user,
action="app.environment.applied",
target_type="app",
target_id=app_id,
metadata={"result": message},
)
return redirect_app_detail(app_id, anchor="promenne", message=message)
@router.post("/apps/{app_id}/ip-access-rules")
def add_app_ip_access_rule(
app_id: str,
ip_cidr: str = Form(...),
methods: str = Form("WRITE"),
description: str = Form(""),
is_enabled: str | None = Form(None),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
rule_ip = clean_optional(ip_cidr)
rule_methods = clean_optional(methods).upper()
if not rule_ip:
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
if not rule_methods:
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
create_app_ip_access_rule(app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
log_audit_event(
user,
action="app.ip_access_rule.created",
target_type="app",
target_id=app_id,
metadata={"ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)},
)
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo přidáno.")
@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/update")
def save_app_ip_access_rule(
app_id: str,
rule_id: int,
ip_cidr: str = Form(...),
methods: str = Form("WRITE"),
description: str = Form(""),
is_enabled: str | None = Form(None),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
existing = get_app_ip_access_rule(rule_id, app_id)
if not existing:
return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.")
rule_ip = clean_optional(ip_cidr)
rule_methods = clean_optional(methods).upper()
if not rule_ip:
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
if not rule_methods:
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
log_audit_event(
user,
action="app.ip_access_rule.updated",
target_type="app",
target_id=app_id,
metadata={"rule_id": rule_id, "ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)},
)
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo upraveno.")
@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/delete")
def remove_app_ip_access_rule(app_id: str, rule_id: int, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
existing = get_app_ip_access_rule(rule_id, app_id)
if not existing:
return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.")
delete_app_ip_access_rule(rule_id, app_id)
log_audit_event(
user,
action="app.ip_access_rule.deleted",
target_type="app",
target_id=app_id,
metadata={"rule_id": rule_id, "ip_cidr": existing.get("ip_cidr"), "methods": existing.get("methods")},
)
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo smazáno.")
@router.post("/apps/{app_id}/redeploy")
def redeploy_app(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
try:
apply_environment_message(app_id)
except AppEnvironmentError as exc:
return redirect_app_detail(app_id, error=f"Environment apply failed: {exc}")
active_job = has_active_deploy_job("app", app_id)
if active_job:
return RedirectResponse(url=f"/portal/jobs/{active_job['id']}", status_code=303)
job_id = create_job(
job_type="deploy_app",
target_type="app",
target_id=app_id,
payload={},
user=user,
source="portal",
)
log_audit_event(
user,
action="app.redeploy.queued",
target_type="app",
target_id=app_id,
metadata={"job_id": job_id},
)
return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303)
@router.get("/new-app", response_class=HTMLResponse)
def new_app_form(request: Request, user=Depends(require_developer)):
template_options = render_template_options(get_app_templates(create_enabled=True), "", include_blank=False)
return page(
"Nová služba",
f"""
<div class="card">
<h2>Vytvořit novou službu</h2>
<p class="muted">Vytvoří Gitea repozitář, webhook, lokální workspace, první commit a nasadí službu.</p>
<form method="post" action="/portal/new-app" id="new-app-form" data-ajax-form="new-app">
<p>
<label>ID služby</label><br>
<input name="app_id" placeholder="gmail-service" required>
</p>
<p>
<label>Název služby</label><br>
<input name="app_name" placeholder="Gmail služba" required>
</p>
<p>
<label>Šablona</label><br>
<select name="template" required>{template_options}</select>
</p>
<div class="form-actions">
<button type="submit"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> Vytvořit službu</button>
<span class="form-loader" id="new-app-loader" hidden>
<span class="spinner" aria-hidden="true"></span>
Vytvářím službu...
</span>
</div>
<div class="async-status" id="new-app-status" aria-live="polite"></div>
</form>
<div id="new-app-result"></div>
<p><a href="/portal/apps">&larr; Zpět</a></p>
</div>
""",
user=user,
)
@router.post("/new-app", response_class=HTMLResponse)
def create_app(
app_id: str = Form(...),
app_name: str = Form(...),
template: str = Form(...),
description: str = Form(""),
owner: str = Form(""),
memory: str = Form(""),
cpus: str = Form(""),
user=Depends(require_developer),
):
selected_template = get_app_template(template, create_enabled=True)
if not selected_template:
return HTMLResponse("Nepodporovaná šablona", status_code=400)
create_script = clean_optional(selected_template.get("create_script"))
if not create_script:
return HTMLResponse("Šablona nemá nastavený create script", status_code=400)
create_result = run_command([create_script, app_id, app_name])
if create_result.returncode != 0:
log_audit_event(
user,
action="create_app",
target_type="app",
target_id=app_id,
metadata={
"app_id": app_id,
"app_name": app_name,
"template": selected_template.get("id"),
"status": "FAILED",
"create_returncode": create_result.returncode,
},
)
return render_result(
title="Vytvoření služby: FAILED",
back_url="/portal/apps",
sections=[
("Výstup vytvoření", create_result.stdout),
("Chyba vytvoření", create_result.stderr),
],
user=user,
)
gitea_url = get_gitea_public_url()
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
repository_url = f"{gitea_url}/{gitea_org}/{app_id}.git" if gitea_url else None
owner_value = clean_optional(owner) or user.get("display_name") or user.get("username") or None
try:
upsert_created_app(
app_id,
{
"name": app_name,
"template": selected_template.get("id"),
"runtime": selected_template.get("runtime"),
"language": selected_template.get("language"),
"version": "1.0.0",
"status": "created",
"memory": clean_optional(memory),
"cpus": clean_optional(cpus),
"description": clean_optional(description),
"owner": owner_value,
"repository_name": app_id,
"repository_url": repository_url,
"default_branch": "main",
"health_url": selected_template.get("default_health_path"),
"container_port": selected_template.get("default_port"),
"is_public": True,
"is_enabled": True,
},
)
except Exception as exc:
log_audit_event(
user,
action="create_app",
target_type="app",
target_id=app_id,
metadata={
"app_id": app_id,
"app_name": app_name,
"template": selected_template.get("id"),
"status": "FAILED",
"create_returncode": create_result.returncode,
"registration_error": str(exc),
},
)
return render_result(
title="Registrace služby: FAILED",
back_url="/portal/apps",
sections=[
("Výstup vytvoření", create_result.stdout),
("Chyba vytvoření", create_result.stderr),
("Chyba registrace", str(exc)),
],
user=user,
)
try:
apply_environment_message(app_id)
except AppEnvironmentError as exc:
log_audit_event(
user,
action="app.environment.apply_failed",
target_type="app",
target_id=app_id,
metadata={"error": str(exc)},
)
return render_result(
title="Generování prostředí: FAILED",
back_url="/portal/apps",
sections=[
("Výstup vytvoření", create_result.stdout),
("Chyba vytvoření", create_result.stderr),
("Chyba prostředí", str(exc)),
],
user=user,
)
job_id = create_job(
job_type="deploy_app",
target_type="app",
target_id=app_id,
payload={"template": selected_template.get("id")},
user=user,
source="portal",
)
log_audit_event(
user,
action="create_app",
target_type="app",
target_id=app_id,
metadata={
"app_id": app_id,
"app_name": app_name,
"template": selected_template.get("id"),
"status": "OK",
"create_returncode": create_result.returncode,
"job_id": job_id,
},
)
return render_result(
title="Vytvoření služby: OK",
back_url="/portal/apps",
sections=[
("Výstup vytvoření", create_result.stdout),
("Chyba vytvoření", create_result.stderr),
("Nasazení", f"Deploy job #{job_id} byl zařazen do fronty."),
],
extra_link=f"/portal/jobs/{job_id}",
extra_label="Otevřít deploy job",
user=user,
)
@router.post("/delete-app", response_class=HTMLResponse)
def delete_app(app_id: str = Form(...), user=Depends(require_admin)):
result = run_command([DELETE_APP_SCRIPT, app_id])
status = "OK" if result.returncode == 0 else "FAILED"
log_audit_event(
user,
action="delete_app",
target_type="app",
target_id=app_id,
metadata={
"app_id": app_id,
"status": status,
"returncode": result.returncode,
},
)
return render_result(
title=f"Smazání služby: {status}",
back_url="/portal/apps",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user,
)
@router.post("/update-resources", response_class=HTMLResponse)
def update_resources(
app_id: str = Form(...),
memory: str = Form(""),
cpus: str = Form(""),
user=Depends(require_developer),
):
memory = memory.strip()
cpus = cpus.strip()
update_app_resources(app_id, memory, cpus)
catalog_result = run_command(["/tools/generate-catalog.sh"])
compose_result = run_command([GENERATE_COMPOSE_SCRIPT])
deploy_result = run_command([DEPLOY_SCRIPT, app_id])
status = (
"OK"
if catalog_result.returncode == 0 and compose_result.returncode == 0 and deploy_result.returncode == 0
else "FAILED"
)
log_audit_event(
user,
action="update_resources",
target_type="app",
target_id=app_id,
metadata={
"app_id": app_id,
"memory": memory,
"cpus": cpus,
"status": status,
"catalog_returncode": catalog_result.returncode,
"compose_returncode": compose_result.returncode,
"deploy_returncode": deploy_result.returncode,
},
)
return render_result(
title=f"Úprava prostředků: {status}",
back_url="/portal/apps",
sections=[
("Výstup katalogu", catalog_result.stdout),
("Chyba katalogu", catalog_result.stderr),
("Výstup compose", compose_result.stdout),
("Chyba compose", compose_result.stderr),
("Výstup nasazení", deploy_result.stdout),
("Chyba nasazení", deploy_result.stderr),
],
user=user,
)