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)} - - - - -
-

AppFactory

- -
-
- {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'' - - 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'' - - rows += f""" - - - {app_id}
- /apps/{app_id} - - {status} - Swagger - -
- -
- - - -
-
- Memory is RAM limit. CPU is max compute share. - Example: 0.50 = half CPU core, 1.00 = one full core. -
-
- - -
- Clone commands - -
- - -
- - -
- - -
-
- - -
- - -
- - - """ - - 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

- - - - - - - - - - {rows} -
AppStatusDocsResourcesGitActions
-
- """, - ) - - -@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 - - -
- - -
- - -
- - -
-
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

- - - - - - - - {rows} -
BackupPathRestore CommandActions
-
- """, - ) - - -@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'' + + 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'' + + rows += f""" + + + {app_id}
+ /apps/{app_id} + + {status} + Swagger + +
+ +
+ + + +
+
+ Memory is RAM limit. CPU is max compute share. + Example: 0.50 = half CPU core, 1.00 = one full core. +
+
+ + +
+ Clone commands + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+ + + """ + + 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

+ + + + + + + + + + {rows} +
AppStatusDocsResourcesGitActions
+
+ """, + ) + + +@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 + + +
+ + +
+ + +
+ + +
+
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

+ + + + + + + + {rows} +
BackupPathRestore CommandActions
+
+ """, + ) + + +@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)} + + + + +
+

AppFactory

+ +
+
+ {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} +
+ """, + )