diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1 @@
+
diff --git a/app/backups.py b/app/backups.py
new file mode 100644
index 0000000..b89c35c
--- /dev/null
+++ b/app/backups.py
@@ -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"]
diff --git a/app/catalog.py b/app/catalog.py
new file mode 100644
index 0000000..d85078e
--- /dev/null
+++ b/app/catalog.py
@@ -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")
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..bc38128
--- /dev/null
+++ b/app/config.py
@@ -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
diff --git a/app/main.py b/app/main.py
index a5ca7d5..1d8a169 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,671 +1,22 @@
-import html
-import subprocess
from pathlib import Path
-from fastapi import FastAPI, Form
-from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
-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"
+from .routes import apps, backups, health
-CATALOG_FILE = "/opt/appfactory/apps/catalog.yml"
-APPFACTORY_ENV = "/opt/appfactory/config/appfactory.env"
-app = FastAPI(title="AppFactory Portal")
+def create_app() -> FastAPI:
+ 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:
- 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
+ app.include_router(health.router)
+ app.include_router(apps.router)
+ app.include_router(backups.router)
+ return app
-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")
-
-
-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.escape(title)}
-
-
-
-
-
-
- {body}
-
-
-
- """
-
-
-def render_result(title, back_url, sections, extra_link=None, extra_label=None):
- rendered_sections = ""
-
- for section_title, content in sections:
- rendered_sections += f"""
- {html.escape(section_title)}
- {html.escape(content)}
- """
-
- extra = ""
- if extra_link and extra_label:
- extra = f'{html.escape(extra_label)}
'
-
- return page(
- title,
- f"""
-
-
{html.escape(title)}
- {extra}
-
← Back
-
-
- {rendered_sections}
-
- """,
- )
-
-
-@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'{label} '
-
- 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'{label} '
-
- rows += f"""
-
-
- {app_id}
- /apps/{app_id}
-
- {status}
- Swagger
-
-
-
-
-
- Clone commands
- HTTP
-
-
- Copy
-
-
- SSH
-
-
- Copy
-
-
-
-
-
-
-
- """
-
- if not rows:
- rows = 'No apps deployed yet. '
-
- return page(
- "Apps",
- f"""
-
-
-
Apps
-
Create, deploy, clone, tune resources and delete services.
-
+ New App
-
-
-
Backups
-
Create backups and copy restore commands. Restore is intentionally manual and guarded.
-
Manage Backups
-
-
-
-
-
Deployed Apps
-
-
- App
- Status
- Docs
- Resources
- Git
- Actions
-
- {rows}
-
-
- """,
- )
-
-
-@app.get("/new-app", response_class=HTMLResponse)
-def new_app_form():
- return page(
- "New App",
- """
-
-
Create New App
-
This creates Gitea repo, webhook, local workspace, initial commit and deploys the service.
-
-
-
-
← Back
-
- """,
- )
-
-
-@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"""
-
-
- {backup_name}
- {size_mb:.2f} MB
-
-
-
-
- Copy
-
-
-
-
-
- Copy
-
- Restore is dangerous and must be run manually over SSH.
-
-
-
-
-
- """
-
- if not rows:
- rows = 'No backups found. '
-
- return page(
- "Backups",
- f"""
-
-
Backup Management
-
- 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.
-
-
-
-
-
-
-
Available Backups
-
-
- Backup
- Path
- Restore Command
- Actions
-
- {rows}
-
-
- """,
- )
-
-
-@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)
+app = create_app()
diff --git a/app/routes/__init__.py b/app/routes/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/routes/__init__.py
@@ -0,0 +1 @@
+
diff --git a/app/routes/apps.py b/app/routes/apps.py
new file mode 100644
index 0000000..0598933
--- /dev/null
+++ b/app/routes/apps.py
@@ -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'{label} '
+
+ 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'{label} '
+
+ rows += f"""
+
+
+ {app_id}
+ /apps/{app_id}
+
+ {status}
+ Swagger
+
+
+
+
+
+ Clone commands
+ HTTP
+
+
+ Copy
+
+
+ SSH
+
+
+ Copy
+
+
+
+
+
+
+
+ """
+
+ if not rows:
+ rows = 'No apps deployed yet. '
+
+ return page(
+ "Apps",
+ f"""
+
+
+
Apps
+
Create, deploy, clone, tune resources and delete services.
+
+ New App
+
+
+
Backups
+
Create backups and copy restore commands. Restore is intentionally manual and guarded.
+
Manage Backups
+
+
+
+
+
Deployed Apps
+
+
+ App
+ Status
+ Docs
+ Resources
+ Git
+ Actions
+
+ {rows}
+
+
+ """,
+ )
+
+
+@router.get("/new-app", response_class=HTMLResponse)
+def new_app_form():
+ return page(
+ "New App",
+ """
+
+
Create New App
+
This creates Gitea repo, webhook, local workspace, initial commit and deploys the service.
+
+
+
+
← Back
+
+ """,
+ )
+
+
+@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),
+ ],
+ )
diff --git a/app/routes/backups.py b/app/routes/backups.py
new file mode 100644
index 0000000..1687f2b
--- /dev/null
+++ b/app/routes/backups.py
@@ -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"""
+
+
+ {backup_name}
+ {size_mb:.2f} MB
+
+
+
+
+ Copy
+
+
+
+
+
+ Copy
+
+ Restore is dangerous and must be run manually over SSH.
+
+
+
+
+
+ """
+
+ if not rows:
+ rows = 'No backups found. '
+
+ return page(
+ "Backups",
+ f"""
+
+
Backup Management
+
+ 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.
+
+
+
+
+
+
+
Available Backups
+
+
+ Backup
+ Path
+ Restore Command
+ Actions
+
+ {rows}
+
+
+ """,
+ )
+
+
+@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)
diff --git a/app/routes/health.py b/app/routes/health.py
new file mode 100644
index 0000000..b4cb63b
--- /dev/null
+++ b/app/routes/health.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter()
+
+
+@router.get("/health")
+def health():
+ return {"status": "ok"}
diff --git a/app/shell.py b/app/shell.py
new file mode 100644
index 0000000..3862714
--- /dev/null
+++ b/app/shell.py
@@ -0,0 +1,5 @@
+import subprocess
+
+
+def run_command(args):
+ return subprocess.run(args, capture_output=True, text=True)
diff --git a/app/static/portal.js b/app/static/portal.js
new file mode 100644
index 0000000..bbc0671
--- /dev/null
+++ b/app/static/portal.js
@@ -0,0 +1,6 @@
+function copyValue(id) {
+ const el = document.getElementById(id);
+ el.select();
+ el.setSelectionRange(0, 99999);
+ navigator.clipboard.writeText(el.value);
+}
diff --git a/app/static/styles.css b/app/static/styles.css
new file mode 100644
index 0000000..d6c2790
--- /dev/null
+++ b/app/static/styles.css
@@ -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;
+}
diff --git a/app/templates/__init__.py b/app/templates/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/templates/__init__.py
@@ -0,0 +1 @@
+
diff --git a/app/templates/layout.py b/app/templates/layout.py
new file mode 100644
index 0000000..d5cb3e8
--- /dev/null
+++ b/app/templates/layout.py
@@ -0,0 +1,56 @@
+import html
+
+from ..config import PORTAL_PREFIX
+
+
+def page(title: str, body: str) -> str:
+ return f"""
+
+
+ {html.escape(title)}
+
+
+
+
+
+
+ {body}
+
+
+
+ """
+
+
+def render_result(title, back_url, sections, extra_link=None, extra_label=None):
+ rendered_sections = ""
+
+ for section_title, content in sections:
+ rendered_sections += f"""
+ {html.escape(section_title)}
+ {html.escape(content)}
+ """
+
+ extra = ""
+ if extra_link and extra_label:
+ extra = f'{html.escape(extra_label)}
'
+
+ return page(
+ title,
+ f"""
+
+
{html.escape(title)}
+ {extra}
+
← Back
+
+
+ {rendered_sections}
+
+ """,
+ )