Compare commits

...

11 Commits

Author SHA1 Message Date
shawn dc932ea9dd Fix dark mode: sync dark class to html element for Tailwind 2026-05-26 21:56:11 -04:00
shawn 315a0dfbcb Fix: remove x-collision typo, use x-transition instead 2026-05-26 21:51:06 -04:00
shawn 7ecce4c176 Network topology diagram + 3-address expand on each service card (EXT/LAN/TS) 2026-05-26 21:49:59 -04:00
shawn 13b61baf65 Add AGENTS.md for AI agent context 2026-05-25 20:19:01 -04:00
shawn 8b96c3ffc9 Add live Windows Gaming PC stats with metric bars
- New API /api/windows/stats: SSHes into gamingpc, runs PowerShell for CPU/RAM/Disk/Uptime
- Live metric bars on the Windows PC card (CPU/RAM/C:) updating every 30s
- Duplicate barColor() removed
2026-05-25 01:10:15 -04:00
shawn 1e60745e47 Add Windows Gaming PC to Infrastructure section
- Full system specs: i7-8700K, RTX 2080 4GB, 32GB DDR4, 3 drives
- ComfyUI + Ollama service references
- Tailscale and LAN IPs
- Expandable card with CPU/GPU/RAM/Storage/Login details
2026-05-25 01:03:59 -04:00
shawn 1957bcf8e4 Add canteen dev/seed services and seed import project card
- Add 4 new service entries: Canteen App (Dev), Admin (Dev), Seed Import (Prod/Dev)
- Add Canteen Seed Import project card (9th project)
- Update badge count 8→9
2026-05-25 01:00:04 -04:00
shawn 96e29c3629 fix: add nostatus:true to internal services so they show N/A instead of stuck Checking... 2026-05-23 15:50:09 -04:00
shawn 5bb34e728c add Docker container stats endpoint + dashboard section 2026-05-23 15:30:06 -04:00
shawn 2f588e78ac add favicon (SVG stacked-file design) 2026-05-23 15:22:03 -04:00
shawn 488239c593 support SERVE_HOST env var for binding to 0.0.0.0 2026-05-23 15:18:45 -04:00
4 changed files with 840 additions and 397 deletions
+56
View File
@@ -0,0 +1,56 @@
# Project Directory — Agent Guide
Living index page of the Ourpad home lab. Lists self-hosted projects, public services, internal services, infrastructure details, and LXC inventory — single-page reference for the entire homelab.
## Stack
- **Python** 3.11+, **FastAPI**, **uvicorn**
- **httpx** — proxied API calls to Proxmox and network-status
- **Frontend:** static HTML/CSS with embedded JS, SVG favicon
## Project Structure
```
serve.py # ~300 lines — FastAPI server, static file + API proxy
index.html # ~41KB — self-contained HTML with all project/service data
favicon.svg # SVG favicon
README.md
```
## How to Run
```bash
python serve.py
```
Binds to `127.0.0.1` by default (port configurable via `SERVE_PORT` env var).
## Key Architecture
- Serves `index.html` as static content
- `/api/status` — proxies to internal network-status service for live status checks
- Proxmox API integration — fetches LXC/VM status from two Proxmox hosts
- All project/service data embedded directly in `index.html` (no separate data files)
## Production
- **URL:** https://projects.ourpad.casa
- **Port:** 18834 (default, configurable)
- **Binds to:** 127.0.0.1 (NPMPlus reverse proxy)
- **No systemd**
## Environment Variables
| Var | Default | Description |
|-----|---------|-------------|
| SERVE_PORT | 18834 | Server listen port |
## Security Note
`serve.py` contains Proxmox admin credentials (base64 encoded). This is internal- only behind NPMPlus proxy. Do not expose directly to the internet.
## Pitfalls
- **Single page** — `index.html` has all data hardcoded; adding a new service requires editing the HTML directly
- Proxmox credentials embedded in source — use environment-based auth if exposing more broadly
- No CI/CD — update by editing `index.html` and `serve.py` directly on the server
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#171717"/>
<rect x="20" y="15" width="60" height="20" rx="4" fill="#ebf5ff"/>
<rect x="20" y="40" width="60" height="20" rx="4" fill="#ecfdf5"/>
<rect x="20" y="65" width="60" height="20" rx="4" fill="#fef3c7"/>
<circle cx="35" cy="25" r="5" fill="#0072f5"/>
<circle cx="35" cy="50" r="5" fill="#22c55e"/>
<circle cx="35" cy="75" r="5" fill="#f59e0b"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

+655 -383
View File
File diff suppressed because it is too large Load Diff
+108 -2
View File
@@ -11,6 +11,7 @@ import httpx
SCRIPT_DIR = Path(__file__).resolve().parent
STATIC_DIR = SCRIPT_DIR
PORT = int(os.environ.get("SERVE_PORT", "18834"))
HOST = os.environ.get("SERVE_HOST", "127.0.0.1")
app = FastAPI(title="Project Directory")
@@ -178,6 +179,111 @@ async def health():
return {"status": "ok"}
@app.get("/api/docker/stats")
async def docker_stats():
"""Get Docker container stats from CT 100 via Proxmox SSH."""
import subprocess
try:
# Run docker stats on CT 100 via proxmox (use raw output to avoid Go template escaping)
result = subprocess.run(
["sshpass", "-p", _PVE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
"root@192.168.0.144", "pct", "exec", "100", "--",
"docker", "stats", "--no-stream"],
capture_output=True, text=True, timeout=15
)
if result.returncode != 0:
return {"error": result.stderr.strip(), "containers": []}
containers = []
for line in result.stdout.strip().split("\n"):
if not line.strip() or line.startswith("CONTAINER"):
continue
# Parse default docker stats output:
# CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
parts = line.split()
if len(parts) < 8:
continue
# Account for container ID being first column
cid = parts[0]
name = parts[1]
cpu = parts[2].replace("%", "")
mem_usage = parts[3] # e.g., "311.2MiB"
mem_limit = parts[5] # e.g., "2GiB" (parts[4] is "/")
mem_pct = parts[6].replace("%", "")
# NET I/O: parts[7] and parts[9] with "/" at parts[8]
# BLOCK I/O: parts[10] and parts[12] with "/" at parts[11]
# PIDS: last element (could be at different positions depending on name length)
# More reliable: find from the right
pids = parts[-1]
# Block I/O is tricky because of spaces in column names;
# use a simpler approach: rest of line after NET I/O position
# Actually, docker stats columns are fixed-width, let's just grab what we can
net_in = parts[7] if len(parts) > 7 else "0"
net_out = parts[9] if len(parts) > 9 else "0"
blk_in = parts[10] if len(parts) > 10 else "0"
blk_out = parts[12] if len(parts) > 12 else "0"
containers.append({
"name": name,
"cpu": cpu,
"mem_pct": mem_pct,
"mem_used": mem_usage,
"mem_limit": mem_limit,
"net_in": net_in,
"net_out": net_out,
"blk_in": blk_in,
"blk_out": blk_out,
"pids": pids,
})
return {"containers": containers, "host": "CT 100 (docker)", "count": len(containers)}
except Exception as e:
return {"error": str(e)[:200], "containers": []}
@app.get("/api/windows/stats")
async def windows_stats():
"""Get live stats from Windows Gaming PC via PowerShell over SSH."""
import subprocess, base64, json as _json
# PowerShell script: backslash counter paths need single \, Python raw string preserves them
ps_script = r'''
$cpu=(Get-Counter '\Processor(_Total)\% Processor Time').CounterSamples[0].CookedValue
$os=Get-CimInstance Win32_OperatingSystem
$disk=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$uptime=(Get-Date)-$os.LastBootUpTime
@{
'cpu'=[math]::Round($cpu,1)
'mem_total_gb'=[math]::Round($os.TotalVisibleMemorySize/1MB,1)
'mem_free_gb'=[math]::Round($os.FreePhysicalMemory/1MB,1)
'disk_c_total_gb'=[math]::Round($disk.Size/1GB,1)
'disk_c_free_gb'=[math]::Round($disk.FreeSpace/1GB,1)
'uptime_days'=[math]::Round($uptime.TotalDays,1)
} | ConvertTo-Json
'''.strip()
try:
encoded = base64.b64encode(ps_script.encode('utf-16le')).decode()
result = subprocess.run(
["ssh", "gamingpc",
"powershell", "-NoProfile", "-EncodedCommand", encoded],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return {"error": result.stderr.strip()[:300], "online": False}
# Filter CLIXML noise from stdout
clean = "\n".join(l for l in result.stdout.split("\n")
if l.strip() and not l.startswith("#<") and not l.startswith("<"))
data = _json.loads(clean)
data["online"] = True
data["mem_used_gb"] = round(data["mem_total_gb"] - data["mem_free_gb"], 1)
data["disk_c_used_gb"] = round(data["disk_c_total_gb"] - data["disk_c_free_gb"], 1)
return data
except Exception as e:
return {"error": str(e)[:300], "online": False}
@app.get("/{path:path}")
async def index(path: str = ""):
"""Serve static files, fallback to index.html."""
@@ -187,9 +293,9 @@ async def index(path: str = ""):
file_path = STATIC_DIR / "index.html"
if file_path.is_file():
return HTMLResponse(file_path.read_text())
return HTMLResponse("<h1>404</h1>", 404)
return HTMLResponse("<h1>404</h1>\n", 404)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=PORT)
uvicorn.run(app, host=HOST, port=PORT)