diff --git a/app/main.py b/app/main.py
index 7b2aa88..2fd2968 100644
--- a/app/main.py
+++ b/app/main.py
@@ -10,7 +10,7 @@ from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import JSONResponse, StreamingResponse
+from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token
@@ -184,6 +184,43 @@ async def list_models():
}
+def _html_page(title: str, icon: str, heading: str, body: str, color: str = "#3fb950") -> HTMLResponse:
+ """Render a simple mobile-friendly result page."""
+ html = f"""
+
+
+
+
+ {title} — Jon Snow
+
+
+
+
+
{icon}
+
{heading}
+
{body}
+
jon-snow · nxm.co.za
+
+
+
+"""
+ return HTMLResponse(html)
+
+
@app.get("/approve/{token}")
async def approve_action(token: str):
"""Public endpoint — called when user clicks Approve in Discord. Executes the queued action."""
@@ -191,38 +228,47 @@ async def approve_action(token: str):
try:
action_id, purpose = verify_token(token)
except ValueError as e:
- return JSONResponse(
- {"error": str(e), "message": "Approval failed — token may be expired or already used."},
- status_code=400,
- )
+ return _html_page("Error", "⚠️", "Token Invalid",
+ f"{e}. The link may have expired (15 min) or already been used.",
+ color="#f85149")
if purpose != "approve":
- return JSONResponse({"error": "wrong token purpose"}, status_code=400)
+ return _html_page("Error", "⚠️", "Wrong Link",
+ "This doesn't look like an approval link.",
+ color="#f85149")
action = pop_action(action_id)
if not action:
- return JSONResponse(
- {"error": "action not found", "message": "Action not found — it may have already been executed or expired."},
- status_code=404,
- )
+ return _html_page("Not Found", "🔍", "Action Not Found",
+ "This action has already been executed, rejected, or expired.",
+ color="#d29922")
logger.info(f"approved: {action.action_type} — {action.description}")
try:
if action.action_type == "docker_rebuild":
result = await call_qyburn_rebuild(action.params["stack_name"])
- return JSONResponse({
- "ok": True,
- "message": f"✅ Rebuilding {action.params['stack_name']}. Check container logs for progress.",
- "result": result,
- })
+ ok = result.get("ok", False)
+ if ok:
+ return _html_page("Approved", "✅", "Rebuilding",
+ f"{action.params['stack_name']} is rebuilding. "
+ f"It will be back online in ~30 seconds.")
+ else:
+ return _html_page("Failed", "❌", "Rebuild Failed",
+ f"Could not rebuild {action.params['stack_name']}: "
+ f"{result.get('stderr','unknown error')[:200]}",
+ color="#f85149")
elif action.action_type == "docker_restart":
result = await call_qyburn_restart(action.params["stack_name"])
- return JSONResponse({
- "ok": True,
- "message": f"✅ Restarting {action.params['stack_name']}.",
- "result": result,
- })
+ ok = result.get("ok", False)
+ if ok:
+ return _html_page("Approved", "✅", "Restarted",
+ f"{action.params['stack_name']} has been restarted successfully.")
+ else:
+ return _html_page("Failed", "❌", "Restart Failed",
+ f"Could not restart {action.params['stack_name']}: "
+ f"{result.get('stderr','unknown error')[:200]}",
+ color="#f85149")
elif action.action_type == "file_write":
path = action.params.get("path", "")
content = action.params.get("content", "")
@@ -230,18 +276,17 @@ async def approve_action(token: str):
target = SITES_DIR / safe_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
- return JSONResponse({
- "ok": True,
- "message": f"✅ File written: /opt/sites/{safe_path}",
- })
+ return _html_page("Approved", "✅", "File Written",
+ f"/opt/sites/{safe_path} has been updated.")
else:
- return JSONResponse(
- {"error": f"Unknown action type: {action.action_type}"},
- status_code=400,
- )
+ return _html_page("Error", "⚠️", "Unknown Action",
+ f"Action type '{action.action_type}' is not supported.",
+ color="#f85149")
except Exception as e:
logger.error(f"action execution failed: {e}")
- return JSONResponse({"error": f"Execution failed: {e}"}, status_code=500)
+ return _html_page("Error", "❌", "Execution Failed",
+ f"Something went wrong: {str(e)[:200]}",
+ color="#f85149")
@app.get("/reject/{token}")
@@ -251,26 +296,25 @@ async def reject_action(token: str):
try:
action_id, purpose = verify_token(token)
except ValueError as e:
- return JSONResponse(
- {"error": str(e), "message": "Rejection failed — token may be expired or already used."},
- status_code=400,
- )
+ return _html_page("Error", "⚠️", "Token Invalid",
+ f"{e}. The link may have expired (15 min) or already been used.",
+ color="#f85149")
if purpose != "reject":
- return JSONResponse({"error": "wrong token purpose"}, status_code=400)
+ return _html_page("Error", "⚠️", "Wrong Link",
+ "This doesn't look like a rejection link.",
+ color="#f85149")
action = pop_action(action_id)
if not action:
- return JSONResponse(
- {"error": "action not found", "message": "Action not found — it may have already been handled."},
- status_code=404,
- )
+ return _html_page("Not Found", "🔍", "Already Handled",
+ "This action has already been executed, rejected, or expired.",
+ color="#d29922")
logger.info(f"rejected: {action.action_type} — {action.description}")
- return JSONResponse({
- "ok": True,
- "message": f"❌ Action rejected: {action.description}",
- })
+ return _html_page("Rejected", "❌", "Action Cancelled",
+ f"{action.description} was rejected and will not be executed.",
+ color="#d29922")
@app.post("/internal/queue-action")