Compare commits
11 Commits
934ad40c56
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc932ea9dd | |||
| 315a0dfbcb | |||
| 7ecce4c176 | |||
| 13b61baf65 | |||
| 8b96c3ffc9 | |||
| 1e60745e47 | |||
| 1957bcf8e4 | |||
| 96e29c3629 | |||
| 5bb34e728c | |||
| 2f588e78ac | |||
| 488239c593 |
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user