rozdeleni do struktury

This commit is contained in:
JiriUhlir
2026-05-27 12:15:11 +02:00
parent 12f6323185
commit 2b20444324
14 changed files with 749 additions and 661 deletions
+1
View File
@@ -0,0 +1 @@
+25
View File
@@ -0,0 +1,25 @@
from pathlib import Path
from .config import DEFAULT_BACKUP_DIR, read_env_value
def backup_dir() -> Path:
return Path(read_env_value("BACKUP_DIR", DEFAULT_BACKUP_DIR))
def list_backups():
path = backup_dir()
if not path.exists():
return []
return sorted(
path.glob("*.tar.gz"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
def is_backup_path(path: Path) -> bool:
target = path.resolve()
root = backup_dir().resolve()
return root in target.parents and target.suffixes[-2:] == [".tar", ".gz"]
+51
View File
@@ -0,0 +1,51 @@
from pathlib import Path
from .config import CATALOG_FILE
def load_apps():
apps = []
current = {}
path = Path(CATALOG_FILE)
if not path.exists():
return apps
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if line.startswith("- id:"):
if current:
apps.append(current)
current = {"id": line.split(":", 1)[1].strip()}
elif ":" in line and current:
key, value = line.split(":", 1)
current[key.strip()] = value.strip().strip('"')
if current:
apps.append(current)
return apps
def save_apps(apps):
lines = ["apps:"]
for item in apps:
app_id = item.get("id", "")
lines.append("")
lines.append(f" - id: {app_id}")
lines.append(f" name: {item.get('name', app_id)}")
lines.append(f" language: {item.get('language', 'python')}")
lines.append(f" version: {item.get('version', '1.0.0')}")
lines.append(f" base_path: /apps/{app_id}")
lines.append(f" docs: /apps/{app_id}/docs")
lines.append(f" health: /apps/{app_id}/health")
lines.append(f" status: {item.get('status', 'deployed')}")
if item.get("memory"):
lines.append(f" memory: {item.get('memory')}")
if item.get("cpus"):
lines.append(f" cpus: \"{item.get('cpus')}\"")
Path(CATALOG_FILE).write_text("\n".join(lines) + "\n", encoding="utf-8")
+27
View File
@@ -0,0 +1,27 @@
NEW_APP_SCRIPT = "/tools/new-python-app.sh"
DEPLOY_SCRIPT = "/tools/deploy-app.sh"
DELETE_APP_SCRIPT = "/tools/delete-app.sh"
BACKUP_SCRIPT = "/tools/backup-appfactory.sh"
GENERATE_COMPOSE_SCRIPT = "/tools/generate-apps-compose.sh"
CATALOG_FILE = "/opt/appfactory/apps/catalog.yml"
APPFACTORY_ENV = "/opt/appfactory/config/appfactory.env"
DEFAULT_BACKUP_DIR = "/opt/appfactory/backups"
DEFAULT_GITEA_ORG = "appfactory"
DEFAULT_APPFACTORY_HOST = "192.168.66.130"
PORTAL_PREFIX = "/portal"
def read_env_value(key: str, default: str = "") -> str:
try:
with open(APPFACTORY_ENV, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"')
except Exception:
pass
return default
+11 -660
View File
@@ -1,671 +1,22 @@
import html
import subprocess
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Form from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles
NEW_APP_SCRIPT = "/tools/new-python-app.sh" from .routes import apps, backups, health
DEPLOY_SCRIPT = "/tools/deploy-app.sh"
DELETE_APP_SCRIPT = "/tools/delete-app.sh"
BACKUP_SCRIPT = "/tools/backup-appfactory.sh"
CATALOG_FILE = "/opt/appfactory/apps/catalog.yml"
APPFACTORY_ENV = "/opt/appfactory/config/appfactory.env"
def create_app() -> FastAPI:
app = FastAPI(title="AppFactory Portal") app = FastAPI(title="AppFactory Portal")
static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=static_dir), name="static")
def read_env_value(key: str, default: str = "") -> str: app.include_router(health.router)
try: app.include_router(apps.router)
with open(APPFACTORY_ENV, "r", encoding="utf-8") as f: app.include_router(backups.router)
for line in f:
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"')
except Exception:
pass
return default
return app
def load_apps():
apps = []
current = {}
path = Path(CATALOG_FILE) app = create_app()
if not path.exists():
return apps
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if line.startswith("- id:"):
if current:
apps.append(current)
current = {"id": line.split(":", 1)[1].strip()}
elif ":" in line and current:
key, value = line.split(":", 1)
current[key.strip()] = value.strip().strip('"')
if current:
apps.append(current)
return apps
def save_apps(apps):
lines = ["apps:"]
for item in apps:
app_id = item.get("id", "")
lines.append("")
lines.append(f" - id: {app_id}")
lines.append(f" name: {item.get('name', app_id)}")
lines.append(f" language: {item.get('language', 'python')}")
lines.append(f" version: {item.get('version', '1.0.0')}")
lines.append(f" base_path: /apps/{app_id}")
lines.append(f" docs: /apps/{app_id}/docs")
lines.append(f" health: /apps/{app_id}/health")
lines.append(f" status: {item.get('status', 'deployed')}")
if item.get("memory"):
lines.append(f" memory: {item.get('memory')}")
if item.get("cpus"):
lines.append(f" cpus: \"{item.get('cpus')}\"")
Path(CATALOG_FILE).write_text("\n".join(lines) + "\n", encoding="utf-8")
def list_backups():
backup_dir = Path(read_env_value("BACKUP_DIR", "/opt/appfactory/backups"))
if not backup_dir.exists():
return []
return sorted(
backup_dir.glob("*.tar.gz"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
def page(title: str, body: str) -> str:
return f"""
<html>
<head>
<title>{html.escape(title)}</title>
<style>
:root {{
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--danger: #dc2626;
--danger-dark: #b91c1c;
--success: #16a34a;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}}
header {{
background: #111827;
color: white;
padding: 18px 32px;
display: flex;
align-items: center;
justify-content: space-between;
}}
header h1 {{
margin: 0;
font-size: 22px;
}}
nav a {{
color: white;
text-decoration: none;
margin-left: 18px;
font-weight: 600;
}}
main {{
padding: 32px;
max-width: 1500px;
margin: 0 auto;
}}
.card {{
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 22px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
margin-bottom: 24px;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}}
table {{
border-collapse: collapse;
width: 100%;
margin-top: 12px;
}}
th, td {{
padding: 12px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}}
th {{
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
background: #f9fafb;
}}
a {{
color: var(--primary);
text-decoration: none;
font-weight: 600;
}}
a:hover {{ text-decoration: underline; }}
button, .btn {{
border: 0;
border-radius: 8px;
padding: 8px 12px;
background: var(--primary);
color: white;
font-weight: 700;
cursor: pointer;
display: inline-block;
}}
button:hover, .btn:hover {{
background: var(--primary-dark);
text-decoration: none;
}}
.danger {{
background: var(--danger);
}}
.danger:hover {{
background: var(--danger-dark);
}}
.muted {{
color: var(--muted);
font-size: 13px;
}}
.pill {{
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
background: #dcfce7;
color: #166534;
font-weight: 700;
}}
input, select {{
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
}}
input[readonly] {{
width: 100%;
font-family: monospace;
background: #f9fafb;
}}
.cmd-row {{
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
margin: 8px 0 14px;
}}
details summary {{
cursor: pointer;
font-weight: 700;
}}
pre {{
background: #111827;
color: #e5e7eb;
padding: 18px;
border-radius: 12px;
white-space: pre-wrap;
overflow: auto;
}}
.resource-help {{
font-size: 12px;
color: var(--muted);
line-height: 1.4;
margin-top: 8px;
}}
.inline-form {{
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}}
</style>
<script>
function copyValue(id) {{
const el = document.getElementById(id);
el.select();
el.setSelectionRange(0, 99999);
navigator.clipboard.writeText(el.value);
}}
</script>
</head>
<body>
<header>
<h1>AppFactory</h1>
<nav>
<a href="/portal">Apps</a>
<a href="/portal/new-app">New App</a>
<a href="/portal/backups">Backups</a>
</nav>
</header>
<main>
{body}
</main>
</body>
</html>
"""
def render_result(title, back_url, sections, extra_link=None, extra_label=None):
rendered_sections = ""
for section_title, content in sections:
rendered_sections += f"""
<h2>{html.escape(section_title)}</h2>
<pre>{html.escape(content)}</pre>
"""
extra = ""
if extra_link and extra_label:
extra = f'<p><a class="btn" href="{extra_link}">{html.escape(extra_label)}</a></p>'
return page(
title,
f"""
<div class="card">
<h2>{html.escape(title)}</h2>
{extra}
<p><a href="{back_url}">← Back</a></p>
</div>
<div class="card">
{rendered_sections}
</div>
""",
)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index():
apps = load_apps()
gitea_url = read_env_value("GITEA_URL", "")
gitea_org = read_env_value("GITEA_ORG", "appfactory")
host = read_env_value("APPFACTORY_HOST", "192.168.66.130")
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>
""",
)
@app.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>
""",
)
@app.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 = subprocess.run([NEW_APP_SCRIPT, app_id, app_name], capture_output=True, text=True)
deploy_result = subprocess.run([DEPLOY_SCRIPT, app_id], capture_output=True, text=True)
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",
)
@app.post("/delete-app", response_class=HTMLResponse)
def delete_app(app_id: str = Form(...)):
result = subprocess.run([DELETE_APP_SCRIPT, app_id], capture_output=True, text=True)
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)],
)
@app.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 = subprocess.run(["/tools/generate-apps-compose.sh"], capture_output=True, text=True)
deploy_result = subprocess.run([DEPLOY_SCRIPT, app_id], capture_output=True, text=True)
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),
],
)
@app.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>
""",
)
@app.post("/backups/create", response_class=HTMLResponse)
def create_backup():
result = subprocess.run([BACKUP_SCRIPT], capture_output=True, text=True)
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)],
)
@app.post("/backups/delete")
def delete_backup(backup_path: str = Form(...)):
backup_dir = Path(read_env_value("BACKUP_DIR", "/opt/appfactory/backups")).resolve()
target = Path(backup_path).resolve()
if backup_dir not in target.parents:
return HTMLResponse("Invalid backup path", status_code=400)
if target.exists() and target.suffixes[-2:] == [".tar", ".gz"]:
target.unlink()
return RedirectResponse(url="/portal/backups", status_code=303)
+1
View File
@@ -0,0 +1 @@
+255
View File
@@ -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">&larr; 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),
],
)
+114
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health():
return {"status": "ok"}
+5
View File
@@ -0,0 +1,5 @@
import subprocess
def run_command(args):
return subprocess.run(args, capture_output=True, text=True)
+6
View File
@@ -0,0 +1,6 @@
function copyValue(id) {
const el = document.getElementById(id);
el.select();
el.setSelectionRange(0, 99999);
navigator.clipboard.writeText(el.value);
}
+187
View File
@@ -0,0 +1,187 @@
:root {
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--danger: #dc2626;
--danger-dark: #b91c1c;
--success: #16a34a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
background: #111827;
color: white;
padding: 18px 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 {
margin: 0;
font-size: 22px;
}
nav a {
color: white;
text-decoration: none;
margin-left: 18px;
font-weight: 600;
}
main {
padding: 32px;
max-width: 1500px;
margin: 0 auto;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 22px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
margin-bottom: 24px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 12px;
}
th,
td {
padding: 12px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
background: #f9fafb;
}
a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
a:hover {
text-decoration: underline;
}
button,
.btn {
border: 0;
border-radius: 8px;
padding: 8px 12px;
background: var(--primary);
color: white;
font-weight: 700;
cursor: pointer;
display: inline-block;
}
button:hover,
.btn:hover {
background: var(--primary-dark);
text-decoration: none;
}
.danger {
background: var(--danger);
}
.danger:hover {
background: var(--danger-dark);
}
.muted {
color: var(--muted);
font-size: 13px;
}
.pill {
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
background: #dcfce7;
color: #166534;
font-weight: 700;
}
input,
select {
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: white;
}
input[readonly] {
width: 100%;
font-family: monospace;
background: #f9fafb;
}
.cmd-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
margin: 8px 0 14px;
}
details summary {
cursor: pointer;
font-weight: 700;
}
pre {
background: #111827;
color: #e5e7eb;
padding: 18px;
border-radius: 12px;
white-space: pre-wrap;
overflow: auto;
}
.resource-help {
font-size: 12px;
color: var(--muted);
line-height: 1.4;
margin-top: 8px;
}
.inline-form {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
+1
View File
@@ -0,0 +1 @@
+56
View File
@@ -0,0 +1,56 @@
import html
from ..config import PORTAL_PREFIX
def page(title: str, body: str) -> str:
return f"""
<html>
<head>
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css">
<script src="{PORTAL_PREFIX}/static/portal.js"></script>
</head>
<body>
<header>
<h1>AppFactory</h1>
<nav>
<a href="/portal">Apps</a>
<a href="/portal/new-app">New App</a>
<a href="/portal/backups">Backups</a>
</nav>
</header>
<main>
{body}
</main>
</body>
</html>
"""
def render_result(title, back_url, sections, extra_link=None, extra_label=None):
rendered_sections = ""
for section_title, content in sections:
rendered_sections += f"""
<h2>{html.escape(section_title)}</h2>
<pre>{html.escape(content)}</pre>
"""
extra = ""
if extra_link and extra_label:
extra = f'<p><a class="btn" href="{extra_link}">{html.escape(extra_label)}</a></p>'
return page(
title,
f"""
<div class="card">
<h2>{html.escape(title)}</h2>
{extra}
<p><a href="{back_url}">&larr; Back</a></p>
</div>
<div class="card">
{rendered_sections}
</div>
""",
)