1336 lines
52 KiB
Python
1336 lines
52 KiB
Python
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 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_variable,
|
||
delete_app_variable,
|
||
get_app,
|
||
get_app_template,
|
||
get_app_deployments,
|
||
get_app_templates,
|
||
get_app_variables,
|
||
get_apps,
|
||
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
|
||
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 "")
|
||
|
||
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 ""
|
||
|
||
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>
|
||
<button type="button" class="btn btn-compact btn-secondary" onclick="openResourceModal('{resource_modal_id}')">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}')">Zrušit</button>
|
||
<button type="submit">Použít</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
</td>
|
||
<td>
|
||
<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)">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)">Kopírovat</button>
|
||
</div>
|
||
</details>
|
||
</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>
|
||
<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>
|
||
<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>
|
||
</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)}">Předchozí</a>'
|
||
if page_number > 1
|
||
else ""
|
||
)
|
||
next_link = (
|
||
f'<a class="btn btn-secondary" href="{page_url(page_number + 1)}">Další</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>'
|
||
|
||
return page(
|
||
"Služby",
|
||
f"""
|
||
<div class="grid">
|
||
<div class="card">
|
||
<h2>Služby</h2>
|
||
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Zálohy</h2>
|
||
<p class="muted">Vytváření záloh a kopírování příkazů pro obnovu. Obnova je záměrně ruční a chráněná.</p>
|
||
<a class="btn" href="/portal/backups">Spravovat zálohy</a>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Nasazení</h2>
|
||
<p class="muted">Historie posledních běhů nasazení, stavů a výstupů z deploy procesu.</p>
|
||
<a class="btn" href="/portal/deployments">Zobrazit nasazení</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Nasazené služby</h2>
|
||
{notice}
|
||
<p><a class="btn" href="/portal/new-app">+ Nová služba</a></p>
|
||
<form method="post" action="/portal/apps/environment/apply-all" class="inline-form">
|
||
<button type="submit" class="btn-secondary">Regenerovat všechny .env z databáze</button>
|
||
</form>
|
||
<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">Filtrovat</button>
|
||
<a class="btn btn-secondary" href="/portal/apps">Reset</a>
|
||
</form>
|
||
{pagination}
|
||
<table>
|
||
<tr>
|
||
<th>Služba</th>
|
||
<th>Status</th>
|
||
<th>Dokumentace</th>
|
||
<th>Prostředky <span class="info-dot" title="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</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_user)):
|
||
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")
|
||
|
||
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", ""))
|
||
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="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>'
|
||
|
||
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>'
|
||
|
||
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">← Zpět na služby</a>
|
||
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení služby</a>
|
||
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}">Úlohy služby</a>
|
||
</p>
|
||
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {escaped_app_id}?');">
|
||
<button type="submit">Nasadit znovu</button>
|
||
</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>
|
||
<form method="post" action="/portal/apps/{app_url_id}/environment/apply" class="inline-form">
|
||
<button type="submit" class="btn-secondary">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">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>
|
||
<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_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")
|
||
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_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")
|
||
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_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"))},
|
||
)
|
||
|
||
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_user)):
|
||
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}/redeploy")
|
||
def redeploy_app(app_id: str, user=Depends(require_user)):
|
||
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_user)):
|
||
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">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">← 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_user),
|
||
):
|
||
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_user)):
|
||
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_user),
|
||
):
|
||
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,
|
||
)
|