From 8d65e4291b333b3b19cee692e5416b1476315b0a Mon Sep 17 00:00:00 2001 From: Shawn Date: Sat, 23 May 2026 15:15:41 -0400 Subject: [PATCH] Full dashboard: Proxmox hosts, LXC tables, dark mode, Hermes URLs, status dots --- __pycache__/serve.cpython-311.pyc | Bin 0 -> 14084 bytes index.html | 436 ++++++++++++++++++++++++++++++ serve.py | 195 +++++++++++++ 3 files changed, 631 insertions(+) create mode 100644 __pycache__/serve.cpython-311.pyc create mode 100644 index.html create mode 100755 serve.py diff --git a/__pycache__/serve.cpython-311.pyc b/__pycache__/serve.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50cec563c426b00b99ca9e7e4f94e9e11ecf4997 GIT binary patch literal 14084 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 V1+rZv+aaOcX)Q)p0lv%t{}(j|P*4B> literal 0 HcmV?d00001 diff --git a/index.html b/index.html new file mode 100644 index 0000000..aad0e2f --- /dev/null +++ b/index.html @@ -0,0 +1,436 @@ + + + + + + Ourpad โ€” Dashboard + + + + + + + +
+

Ourpad Dashboard

+

Live service health, Proxmox hosts, resource usage, and project links.

+
+ + +
+

๐Ÿ–ฅ๏ธ Proxmox Hosts

2
+
+
Loading host stats...
+
+
+ + +
+
+

๐ŸŒ Public Services

22
+

All behind NPMplus + Cloudflare โ€” click to expand for URLs

+
+
+
+ + +
+
+

๐Ÿ”’ Internal Services

0
+

Tailscale / internal network only

+
+
+
+ + + + + +
+
+

๐Ÿ—๏ธ Infrastructure

+
+ + + + + +
+
+
+ + + + + + diff --git a/serve.py b/serve.py new file mode 100755 index 0000000..bb30d33 --- /dev/null +++ b/serve.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Serve project-directory static HTML with a /api/status proxy.""" +import os +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, Response +from fastapi.staticfiles import StaticFiles +import httpx + +SCRIPT_DIR = Path(__file__).resolve().parent +STATIC_DIR = SCRIPT_DIR +PORT = int(os.environ.get("SERVE_PORT", "18834")) + +app = FastAPI(title="Project Directory") + + +@app.get("/api/status") +async def status_proxy(): + """Proxy status from the internal network-status service.""" + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(10.0), + ) as client: + r = await client.get("http://127.0.0.1:18898/api/status") + return Response(content=r.text, media_type="application/json", status_code=r.status_code) + except Exception: + return {"error": "status service unreachable"} + + +import base64 as _base64 +import time as _time +import asyncio as _asyncio + +_SYSDB_AUTH = "Basic " + _base64.b64encode(b"shawn:Brett85!@").decode() + +# Proxmox host configs +_PVE_HOSTS = [ + {"id": "primary", "name": "Primary Proxmox", "url": "https://192.168.0.144:8006", "node": "pve"}, + {"id": "secondary", "name": "Secondary Proxmox", "url": "https://admin.ourpad.casa", "node": "ourpad"}, +] +_PVE_USER = "root@pam" +_PVE_PASS = "Brett85!@" +_pve_cache = {"data": None, "ts": 0} +_PVE_CACHE_TTL = 30.0 + + +async def _pve_ticket(client: httpx.AsyncClient, base_url: str) -> str: + """Get a Proxmox API ticket.""" + r = await client.post(f"{base_url}/api2/json/access/ticket", + data={"username": _PVE_USER, "password": _PVE_PASS}) + return r.json()["data"]["ticket"] + + +async def _pve_node_status(client: httpx.AsyncClient, base_url: str, node: str, ticket: str) -> dict: + r = await client.get(f"{base_url}/api2/json/nodes/{node}/status", + headers={"Cookie": f"PVEAuthCookie={ticket}"}) + d = r.json()["data"] + mem = d.get("memory", {}) + rootfs = d.get("rootfs", {}) + swap = d.get("swap", {}) + return { + "node": node, + "status": d.get("status", "unknown"), + "cpu": round(d.get("cpu", 0) * 100, 1), + "cpu_model": (d.get("cpuinfo", {}).get("model", "")[:60] if d.get("cpuinfo") else ""), + "memory": { + "total_mb": round(mem.get("total", 0) / 1024 / 1024, 0), + "used_mb": round(mem.get("used", 0) / 1024 / 1024, 0), + "free_mb": round(mem.get("free", 0) / 1024 / 1024, 0), + "pct": round(mem.get("used", 0) / max(mem.get("total", 1), 1) * 100, 1) if mem else 0, + }, + "disk": { + "total_gb": round(rootfs.get("total", 0) / 1024 / 1024 / 1024, 1), + "used_gb": round(rootfs.get("used", 0) / 1024 / 1024 / 1024, 1), + "free_gb": round(rootfs.get("free", 0) / 1024 / 1024 / 1024, 1), + "pct": round(rootfs.get("used", 0) / max(rootfs.get("total", 1), 1) * 100, 1) if rootfs else 0, + }, + "swap": { + "total_mb": round(swap.get("total", 0) / 1024 / 1024, 0), + "used_mb": round(swap.get("used", 0) / 1024 / 1024, 0), + }, + "uptime_hrs": round(d.get("uptime", 0) / 3600, 1), + "pveversion": d.get("pveversion", ""), + } + + +async def _pve_lxc_list(client: httpx.AsyncClient, base_url: str, node: str, ticket: str) -> list: + r = await client.get(f"{base_url}/api2/json/nodes/{node}/lxc", + headers={"Cookie": f"PVEAuthCookie={ticket}"}) + results = [] + for ct in r.json()["data"]: + disk_bytes = ct.get("disk", 0) + maxdisk_bytes = ct.get("maxdisk", 0) + mem_bytes = ct.get("mem", 0) + maxmem_bytes = ct.get("maxmem", 0) + results.append({ + "vmid": ct["vmid"], + "name": ct.get("name", "?"), + "status": ct.get("status"), + "cpu_pct": round(ct.get("cpu", 0) * 100, 1), + "mem_mb": round(mem_bytes / 1024 / 1024, 0), + "mem_max_mb": round(maxmem_bytes / 1024 / 1024, 0), + "mem_pct": round(mem_bytes / max(maxmem_bytes, 1) * 100, 1), + "disk_gb": round(disk_bytes / 1024 / 1024 / 1024, 1), + "disk_max_gb": round(maxdisk_bytes / 1024 / 1024 / 1024, 1), + "disk_pct": round(disk_bytes / max(maxdisk_bytes, 1) * 100, 1), + }) + return results + + +@app.get("/api/proxmox/stats") +async def proxmox_stats(): + """Aggregate stats from both Proxmox hosts (cached 30s).""" + global _pve_cache + now = _time.time() + if _pve_cache["data"] is not None and (now - _pve_cache["ts"]) < _PVE_CACHE_TTL: + return _pve_cache["data"] + + hosts_data = [] + async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(15.0)) as client: + for host in _PVE_HOSTS: + try: + ticket = await _pve_ticket(client, host["url"]) + node_data, lxcs = await _asyncio.gather( + _pve_node_status(client, host["url"], host["node"], ticket), + _pve_lxc_list(client, host["url"], host["node"], ticket), + ) + hosts_data.append({ + "id": host["id"], + "name": host["name"], + "node": node_data, + "lxcs": lxcs, + }) + except Exception as e: + hosts_data.append({ + "id": host["id"], + "name": host["name"], + "error": str(e)[:120], + }) + + data = {"hosts": hosts_data, "timestamp": now} + _pve_cache["data"] = data + _pve_cache["ts"] = now + return data + + +@app.get("/api/system-stats") +async def system_stats_proxy(): + """Proxy system stats from the internal system-dashboard service.""" + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(10.0), + ) as client: + r = await client.get("http://127.0.0.1:8899/api/stats", + headers={"Authorization": _SYSDB_AUTH}) + return Response(content=r.text, media_type="application/json", status_code=r.status_code) + except Exception: + return {"error": "system dashboard unreachable"} + + +@app.get("/api/system-history") +async def system_history_proxy(): + """Proxy history from the internal system-dashboard service.""" + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(10.0), + ) as client: + r = await client.get("http://127.0.0.1:8899/api/history", + headers={"Authorization": _SYSDB_AUTH}) + return Response(content=r.text, media_type="application/json", status_code=r.status_code) + except Exception: + return {"error": "system dashboard unreachable"} + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/{path:path}") +async def index(path: str = ""): + """Serve static files, fallback to index.html.""" + file_path = STATIC_DIR / (path or "index.html") + if file_path.is_file(): + return HTMLResponse(file_path.read_text()) + file_path = STATIC_DIR / "index.html" + if file_path.is_file(): + return HTMLResponse(file_path.read_text()) + return HTMLResponse("

404

", 404) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=PORT)