rozdeleni do struktury
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..catalog import load_apps, save_apps
|
||||
from ..config import (
|
||||
DEFAULT_APPFACTORY_HOST,
|
||||
DEFAULT_GITEA_ORG,
|
||||
DELETE_APP_SCRIPT,
|
||||
DEPLOY_SCRIPT,
|
||||
GENERATE_COMPOSE_SCRIPT,
|
||||
NEW_APP_SCRIPT,
|
||||
read_env_value,
|
||||
)
|
||||
from ..shell import run_command
|
||||
from ..templates.layout import page, render_result
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def index():
|
||||
apps = load_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", ""))
|
||||
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 [
|
||||
("", "Default"),
|
||||
("256m", "256 MB - small service"),
|
||||
("512m", "512 MB - normal service"),
|
||||
("1g", "1 GB - larger service"),
|
||||
("2g", "2 GB - heavy service"),
|
||||
]:
|
||||
selected = "selected" if memory == value else ""
|
||||
memory_options += f'<option value="{value}" {selected}>{label}</option>'
|
||||
|
||||
cpu_options = ""
|
||||
for value, label in [
|
||||
("", "Default"),
|
||||
("0.25", "0.25 CPU - very small"),
|
||||
("0.50", "0.50 CPU - normal"),
|
||||
("1.00", "1 CPU - full core"),
|
||||
("2.00", "2 CPU - heavy"),
|
||||
]:
|
||||
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">Apply</button>
|
||||
</div>
|
||||
<div class="resource-help">
|
||||
Memory is RAM limit. CPU is max compute share.
|
||||
Example: 0.50 = half CPU core, 1.00 = one full core.
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Clone commands</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}')">Copy</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}')">Copy</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/portal/delete-app" onsubmit="return confirm('Delete {app_id}? This removes container, image, workspace, catalog entry and Gitea repo.');">
|
||||
<input type="hidden" name="app_id" value="{app_id}">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="6">No apps deployed yet.</td></tr>'
|
||||
|
||||
return page(
|
||||
"Apps",
|
||||
f"""
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Apps</h2>
|
||||
<p class="muted">Create, deploy, clone, tune resources and delete services.</p>
|
||||
<a class="btn" href="/portal/new-app">+ New App</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Backups</h2>
|
||||
<p class="muted">Create backups and copy restore commands. Restore is intentionally manual and guarded.</p>
|
||||
<a class="btn" href="/portal/backups">Manage Backups</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Deployed Apps</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th>Status</th>
|
||||
<th>Docs</th>
|
||||
<th>Resources</th>
|
||||
<th>Git</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{rows}
|
||||
</table>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/new-app", response_class=HTMLResponse)
|
||||
def new_app_form():
|
||||
return page(
|
||||
"New App",
|
||||
"""
|
||||
<div class="card">
|
||||
<h2>Create New App</h2>
|
||||
<p class="muted">This creates Gitea repo, webhook, local workspace, initial commit and deploys the service.</p>
|
||||
|
||||
<form method="post" action="/portal/new-app">
|
||||
<p>
|
||||
<label>App ID</label><br>
|
||||
<input name="app_id" placeholder="gmail-service" required>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>App Name</label><br>
|
||||
<input name="app_name" placeholder="Gmail Service" required>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>Template</label><br>
|
||||
<select name="template">
|
||||
<option value="python-fastapi">Python FastAPI</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<button type="submit">Create App</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/portal">← Back</a></p>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/new-app", response_class=HTMLResponse)
|
||||
def create_app(app_id: str = Form(...), app_name: str = Form(...), template: str = Form(...)):
|
||||
if template != "python-fastapi":
|
||||
return HTMLResponse("Unsupported template", 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"
|
||||
|
||||
return render_result(
|
||||
title=f"Create App: {status}",
|
||||
back_url="/portal",
|
||||
sections=[
|
||||
("Create Output", create_result.stdout),
|
||||
("Create Error", create_result.stderr),
|
||||
("Deploy Output", deploy_result.stdout),
|
||||
("Deploy Error", deploy_result.stderr),
|
||||
],
|
||||
extra_link=f"/apps/{html.escape(app_id)}/docs",
|
||||
extra_label="Open Swagger",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/delete-app", response_class=HTMLResponse)
|
||||
def delete_app(app_id: str = Form(...)):
|
||||
result = run_command([DELETE_APP_SCRIPT, app_id])
|
||||
status = "OK" if result.returncode == 0 else "FAILED"
|
||||
|
||||
return render_result(
|
||||
title=f"Delete App: {status}",
|
||||
back_url="/portal",
|
||||
sections=[("Output", result.stdout), ("Error", result.stderr)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/update-resources", response_class=HTMLResponse)
|
||||
def update_resources(app_id: str = Form(...), memory: str = Form(""), cpus: str = Form("")):
|
||||
apps = load_apps()
|
||||
|
||||
for item in apps:
|
||||
if item.get("id") == app_id:
|
||||
memory = memory.strip()
|
||||
cpus = cpus.strip()
|
||||
|
||||
if memory:
|
||||
item["memory"] = memory
|
||||
else:
|
||||
item.pop("memory", None)
|
||||
|
||||
if cpus:
|
||||
item["cpus"] = cpus
|
||||
else:
|
||||
item.pop("cpus", None)
|
||||
|
||||
save_apps(apps)
|
||||
|
||||
compose_result = run_command([GENERATE_COMPOSE_SCRIPT])
|
||||
deploy_result = run_command([DEPLOY_SCRIPT, app_id])
|
||||
|
||||
status = "OK" if compose_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED"
|
||||
|
||||
return render_result(
|
||||
title=f"Update Resources: {status}",
|
||||
back_url="/portal",
|
||||
sections=[
|
||||
("Compose Output", compose_result.stdout),
|
||||
("Compose Error", compose_result.stderr),
|
||||
("Deploy Output", deploy_result.stdout),
|
||||
("Deploy Error", deploy_result.stderr),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
import html
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from ..backups import is_backup_path, list_backups
|
||||
from ..config import BACKUP_SCRIPT
|
||||
from ..shell import run_command
|
||||
from ..templates.layout import page, render_result
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/backups", response_class=HTMLResponse)
|
||||
def backups_page():
|
||||
rows = ""
|
||||
|
||||
for path in list_backups():
|
||||
size_mb = path.stat().st_size / 1024 / 1024
|
||||
backup_name = html.escape(path.name)
|
||||
backup_path = html.escape(str(path))
|
||||
|
||||
restore_cmd = html.escape(
|
||||
f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}"
|
||||
)
|
||||
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{backup_name}</strong><br>
|
||||
<span class="muted">{size_mb:.2f} MB</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cmd-row">
|
||||
<input readonly value="{backup_path}" id="path-{backup_name}">
|
||||
<button onclick="copyValue('path-{backup_name}')">Copy</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cmd-row">
|
||||
<input readonly value="{restore_cmd}" id="restore-{backup_name}">
|
||||
<button onclick="copyValue('restore-{backup_name}')">Copy</button>
|
||||
</div>
|
||||
<div class="muted">Restore is dangerous and must be run manually over SSH.</div>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/portal/backups/delete" onsubmit="return confirm('Delete backup {backup_name}?');">
|
||||
<input type="hidden" name="backup_path" value="{backup_path}">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="4">No backups found.</td></tr>'
|
||||
|
||||
return page(
|
||||
"Backups",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Backup Management</h2>
|
||||
<p class="muted">
|
||||
Backups do not include the backup directory itself.
|
||||
Restoring an older backup should not delete newer .tar.gz files.
|
||||
Restore remains manual to avoid accidental overwrite.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/portal/backups/create">
|
||||
<button type="submit">Create Backup</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Available Backups</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Backup</th>
|
||||
<th>Path</th>
|
||||
<th>Restore Command</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{rows}
|
||||
</table>
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/backups/create", response_class=HTMLResponse)
|
||||
def create_backup():
|
||||
result = run_command([BACKUP_SCRIPT])
|
||||
status = "OK" if result.returncode == 0 else "FAILED"
|
||||
|
||||
return render_result(
|
||||
title=f"Create Backup: {status}",
|
||||
back_url="/portal/backups",
|
||||
sections=[("Output", result.stdout), ("Error", result.stderr)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/backups/delete")
|
||||
def delete_backup(backup_path: str = Form(...)):
|
||||
target = Path(backup_path)
|
||||
|
||||
if not is_backup_path(target):
|
||||
return HTMLResponse("Invalid backup path", status_code=400)
|
||||
|
||||
target = target.resolve()
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
|
||||
return RedirectResponse(url="/portal/backups", status_code=303)
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user