432 lines
15 KiB
Python
432 lines
15 KiB
Python
import html
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
from ..auth import require_user
|
|
from ..config import (
|
|
DEFAULT_APPFACTORY_HOST,
|
|
DEFAULT_GITEA_ORG,
|
|
DELETE_APP_SCRIPT,
|
|
DEPLOY_SCRIPT,
|
|
GENERATE_COMPOSE_SCRIPT,
|
|
NEW_APP_SCRIPT,
|
|
read_env_value,
|
|
)
|
|
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
|
|
from ..db.audit import log_audit_event
|
|
from ..routes.deployments import render_status_pill
|
|
from ..shell import run_command, run_command_background
|
|
from ..templates.layout import page, render_result
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def index(request: Request, user=Depends(require_user)):
|
|
apps = get_apps()
|
|
|
|
gitea_url = read_env_value("GITEA_URL", "")
|
|
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
|
|
host = read_env_value("APPFACTORY_HOST", DEFAULT_APPFACTORY_HOST)
|
|
|
|
rows = ""
|
|
|
|
for item in apps:
|
|
app_id = html.escape(item.get("id", ""))
|
|
app_url_id = quote(item.get("id", ""), safe="")
|
|
status = html.escape(item.get("status", ""))
|
|
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
|
|
memory = item.get("memory", "")
|
|
cpus = item.get("cpus", "")
|
|
|
|
http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git")
|
|
ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git")
|
|
|
|
memory_options = ""
|
|
for value, label in [
|
|
("", "Výchozí"),
|
|
("256m", "256 MB - malá služba"),
|
|
("512m", "512 MB - běžná služba"),
|
|
("1g", "1 GB - větší služba"),
|
|
("2g", "2 GB - náročná služba"),
|
|
]:
|
|
selected = "selected" if memory == value else ""
|
|
memory_options += f'<option value="{value}" {selected}>{label}</option>'
|
|
|
|
cpu_options = ""
|
|
for value, label in [
|
|
("", "Výchozí"),
|
|
("0.25", "0,25 CPU - velmi malá služba"),
|
|
("0.50", "0,50 CPU - běžná služba"),
|
|
("1.00", "1 CPU - celé jádro"),
|
|
("2.00", "2 CPU - náročná služba"),
|
|
]:
|
|
selected = "selected" if cpus == value else ""
|
|
cpu_options += f'<option value="{value}" {selected}>{label}</option>'
|
|
|
|
rows += f"""
|
|
<tr>
|
|
<td>
|
|
<strong>{app_id}</strong><br>
|
|
<span class="muted">/apps/{app_id}</span>
|
|
</td>
|
|
<td><span class="pill">{status}</span></td>
|
|
<td><a href="{docs}">Swagger</a></td>
|
|
<td>
|
|
<form method="post" action="/portal/update-resources">
|
|
<input type="hidden" name="app_id" value="{app_id}">
|
|
<div class="inline-form">
|
|
<select name="memory">{memory_options}</select>
|
|
<select name="cpus">{cpu_options}</select>
|
|
<button type="submit">Použít</button>
|
|
</div>
|
|
<div class="resource-help">
|
|
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.
|
|
</div>
|
|
</form>
|
|
</td>
|
|
<td>
|
|
<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 onclick="copyValue('http-{app_id}')">Kopírovat</button>
|
|
</div>
|
|
|
|
<label class="muted">SSH</label>
|
|
<div class="cmd-row">
|
|
<input readonly value="{ssh_clone}" id="ssh-{app_id}">
|
|
<button onclick="copyValue('ssh-{app_id}')">Kopírovat</button>
|
|
</div>
|
|
</details>
|
|
</td>
|
|
<td>
|
|
<p><a class="btn" href="/portal/apps/{app_url_id}">Detail</a></p>
|
|
<p><a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení</a></p>
|
|
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {app_id}?');">
|
|
<button type="submit" class="btn-secondary">Redeploy</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="danger">Smazat</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="6">Zatím nejsou nasazené žádné aplikace.</td></tr>'
|
|
|
|
return page(
|
|
"Aplikace",
|
|
f"""
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>Aplikace</h2>
|
|
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
|
|
<a class="btn" href="/portal/new-app">+ Nová aplikace</a>
|
|
</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é aplikace</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Aplikace</th>
|
|
<th>Status</th>
|
|
<th>Dokumentace</th>
|
|
<th>Prostředky</th>
|
|
<th>Git</th>
|
|
<th>Akce</th>
|
|
</tr>
|
|
{rows}
|
|
</table>
|
|
</div>
|
|
""",
|
|
user=user,
|
|
)
|
|
|
|
|
|
@router.get("/apps/{app_id}", response_class=HTMLResponse)
|
|
def app_detail(app_id: str, request: Request, user=Depends(require_user)):
|
|
app = get_app(app_id)
|
|
if not app:
|
|
raise HTTPException(status_code=404, detail="App not found")
|
|
|
|
escaped_app_id = html.escape(app.get("id", ""))
|
|
app_url_id = quote(app.get("id", ""), safe="")
|
|
name = html.escape(app.get("name", "") or "")
|
|
language = html.escape(app.get("language", "") or "")
|
|
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 "")
|
|
|
|
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 aplikace.</td></tr>'
|
|
|
|
return page(
|
|
f"Aplikace {escaped_app_id}",
|
|
f"""
|
|
<div class="card">
|
|
<h2>{escaped_app_id}</h2>
|
|
<p class="muted">{name}</p>
|
|
<p>
|
|
<a class="btn" href="/portal">← Zpět na aplikace</a>
|
|
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení aplikace</a>
|
|
</p>
|
|
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit redeploy aplikace {escaped_app_id}?');">
|
|
<button type="submit">Redeploy</button>
|
|
</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>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>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Poslední deploymenty</h2>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Status</th>
|
|
<th>Timestamp</th>
|
|
<th>Triggered by</th>
|
|
</tr>
|
|
{rows}
|
|
</table>
|
|
</div>
|
|
""",
|
|
user=user,
|
|
)
|
|
|
|
|
|
@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")
|
|
|
|
process = run_command_background(
|
|
[DEPLOY_SCRIPT, app_id],
|
|
extra_env={
|
|
"APPFACTORY_TRIGGER_SOURCE": "portal",
|
|
"APPFACTORY_TRIGGERED_BY_USER_ID": user.get("id"),
|
|
"APPFACTORY_TRIGGERED_BY_USERNAME": user.get("username"),
|
|
"APPFACTORY_TRIGGERED_BY_DISPLAY_NAME": user.get("display_name") or user.get("username"),
|
|
},
|
|
)
|
|
log_audit_event(
|
|
user,
|
|
action="app.redeploy",
|
|
target_type="app",
|
|
target_id=app_id,
|
|
metadata={
|
|
"app_id": app_id,
|
|
"pid": process.pid,
|
|
"trigger_source": "portal",
|
|
},
|
|
)
|
|
|
|
return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}", status_code=303)
|
|
|
|
|
|
@router.get("/new-app", response_class=HTMLResponse)
|
|
def new_app_form(request: Request, user=Depends(require_user)):
|
|
return page(
|
|
"Nová aplikace",
|
|
"""
|
|
<div class="card">
|
|
<h2>Vytvořit novou aplikaci</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">
|
|
<p>
|
|
<label>ID aplikace</label><br>
|
|
<input name="app_id" placeholder="gmail-service" required>
|
|
</p>
|
|
|
|
<p>
|
|
<label>Název aplikace</label><br>
|
|
<input name="app_name" placeholder="Gmail služba" required>
|
|
</p>
|
|
|
|
<p>
|
|
<label>Šablona</label><br>
|
|
<select name="template">
|
|
<option value="python-fastapi">Python FastAPI</option>
|
|
</select>
|
|
</p>
|
|
|
|
<button type="submit">Vytvořit aplikaci</button>
|
|
</form>
|
|
|
|
<p><a href="/portal">← 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(...),
|
|
user=Depends(require_user),
|
|
):
|
|
if template != "python-fastapi":
|
|
return HTMLResponse("Nepodporovaná šablona", status_code=400)
|
|
|
|
create_result = run_command([NEW_APP_SCRIPT, app_id, app_name])
|
|
deploy_result = run_command([DEPLOY_SCRIPT, app_id])
|
|
|
|
status = "OK" if create_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED"
|
|
log_audit_event(
|
|
user,
|
|
action="create_app",
|
|
target_type="app",
|
|
target_id=app_id,
|
|
metadata={
|
|
"app_id": app_id,
|
|
"app_name": app_name,
|
|
"template": template,
|
|
"status": status,
|
|
"create_returncode": create_result.returncode,
|
|
"deploy_returncode": deploy_result.returncode,
|
|
},
|
|
)
|
|
|
|
return render_result(
|
|
title=f"Vytvoření aplikace: {status}",
|
|
back_url="/portal",
|
|
sections=[
|
|
("Výstup vytvoření", create_result.stdout),
|
|
("Chyba vytvoření", create_result.stderr),
|
|
("Výstup nasazení", deploy_result.stdout),
|
|
("Chyba nasazení", deploy_result.stderr),
|
|
],
|
|
extra_link=f"/apps/{html.escape(app_id)}/docs",
|
|
extra_label="Otevřít Swagger",
|
|
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í aplikace: {status}",
|
|
back_url="/portal",
|
|
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",
|
|
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,
|
|
)
|