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
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
STATIC_DIR = SCRIPT_DIR
|
STATIC_DIR = SCRIPT_DIR
|
||||||
PORT = int(os.environ.get("SERVE_PORT", "18834"))
|
PORT = int(os.environ.get("SERVE_PORT", "18834"))
|
||||||
|
HOST = os.environ.get("SERVE_HOST", "127.0.0.1")
|
||||||
|
|
||||||
app = FastAPI(title="Project Directory")
|
app = FastAPI(title="Project Directory")
|
||||||
|
|
||||||
@@ -178,6 +179,111 @@ async def health():
|
|||||||
return {"status": "ok"}
|
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}")
|
@app.get("/{path:path}")
|
||||||
async def index(path: str = ""):
|
async def index(path: str = ""):
|
||||||
"""Serve static files, fallback to index.html."""
|
"""Serve static files, fallback to index.html."""
|
||||||
@@ -187,9 +293,9 @@ async def index(path: str = ""):
|
|||||||
file_path = STATIC_DIR / "index.html"
|
file_path = STATIC_DIR / "index.html"
|
||||||
if file_path.is_file():
|
if file_path.is_file():
|
||||||
return HTMLResponse(file_path.read_text())
|
return HTMLResponse(file_path.read_text())
|
||||||
return HTMLResponse("<h1>404</h1>", 404)
|
return HTMLResponse("<h1>404</h1>\n", 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
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