[2026-06-04] taiga-vault: personal/projects/eagle-dashboard.md
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
|
||||
**Goal:** Replace `localhost` with a visual control panel that shows health, enables management (start/stop/restart) of all 25+ Eagle LaunchAgent services, and displays network security posture per service.
|
||||
|
||||
**Architecture:** FastAPI backend reads a YAML service registry and checks health via HTTP/port/process checks every 30s. HTMX + Tailwind CDN frontend with three tabs: **Services** (health cards + control), **Pages** (local page tester — URL input + iframe), **Files** (embedded FileBrowser). Origin-aware auth middleware: `127.0.0.1` → free access; external via cloudflared `dash.qentra.top` → Bearer token required. LaunchAgent on `:3000`. FileBrowser Docker container on `:8181`.
|
||||
**Architecture:** FastAPI backend reads a YAML service registry and checks health + memory via HTTP/port/process checks every 30s. HTMX + Tailwind CDN frontend with three tabs: **Services** (health cards with memory usage + control), **Pages** (local page tester — URL input + iframe), **Files** (embedded FileBrowser). Origin-aware auth middleware: `127.0.0.1` → free access; external via cloudflared `dash.qentra.top` → Bearer token required.
|
||||
|
||||
Runs internally on `127.0.0.1:8880`. macOS `pf` anchor redirects `localhost:80` → `:8880` so it's accessible at plain `http://localhost` (replacing macOS default httpd). Cloudflared tunnel points directly to `:8880`. FileBrowser Docker container on `:8181`.
|
||||
|
||||
**Tech Stack:** Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx, python-dotenv
|
||||
|
||||
@@ -75,10 +77,10 @@ install:
|
||||
pip install -r requirements.txt
|
||||
|
||||
dev:
|
||||
uvicorn main:app --reload --host 127.0.0.1 --port 3000
|
||||
uvicorn main:app --reload --host 127.0.0.1 --port 8880
|
||||
|
||||
prod:
|
||||
uvicorn main:app --host 127.0.0.1 --port 3000 --workers 1
|
||||
uvicorn main:app --host 127.0.0.1 --port 8880 --workers 1
|
||||
|
||||
deploy:
|
||||
cp com.eagle.dashboard.plist ~/Library/LaunchAgents/
|
||||
@@ -106,6 +108,95 @@ git init && git add . && git commit -m "feat: project bootstrap"
|
||||
|
||||
---
|
||||
|
||||
## Task 1b: Port 80 → 8880 via macOS pf Redirect
|
||||
|
||||
**Goal:** Make `http://localhost` (port 80) serve the dashboard without running Python as root. Uses macOS `pf` packet filter to NAT-redirect `127.0.0.1:80` → `127.0.0.1:8880`. Replaces (disables) the macOS built-in httpd.
|
||||
|
||||
**Files:**
|
||||
- Create: `/etc/pf.anchors/eagle-localhost`
|
||||
- Modify: `/etc/pf.conf` (add anchor include)
|
||||
- Create: `/Library/LaunchDaemons/com.eagle.pf-redirect.plist`
|
||||
|
||||
- [ ] **Step 1: Disable macOS default httpd (if running)**
|
||||
|
||||
```bash
|
||||
sudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plist 2>/dev/null || true
|
||||
sudo apachectl stop 2>/dev/null || true
|
||||
```
|
||||
|
||||
Expected: no process on port 80 afterwards.
|
||||
|
||||
- [ ] **Step 2: Write pf anchor rules**
|
||||
|
||||
```bash
|
||||
sudo tee /etc/pf.anchors/eagle-localhost <<'EOF'
|
||||
rdr pass on lo0 inet proto tcp from any to 127.0.0.1 port 80 -> 127.0.0.1 port 8880
|
||||
EOF
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Load the anchor into pf.conf**
|
||||
|
||||
Check if `/etc/pf.conf` already has an anchor block. Add if missing:
|
||||
```bash
|
||||
grep -q 'eagle-localhost' /etc/pf.conf || sudo tee -a /etc/pf.conf <<'EOF'
|
||||
|
||||
# Eagle Dashboard port 80 redirect
|
||||
rdr-anchor "eagle-localhost"
|
||||
load anchor "eagle-localhost" from "/etc/pf.anchors/eagle-localhost"
|
||||
EOF
|
||||
```
|
||||
|
||||
Enable pf and load:
|
||||
```bash
|
||||
sudo pfctl -ef /etc/pf.conf
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create LaunchDaemon to reload pf at boot**
|
||||
|
||||
`/Library/LaunchDaemons/com.eagle.pf-redirect.plist`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>com.eagle.pf-redirect</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/sbin/pfctl</string>
|
||||
<string>-ef</string>
|
||||
<string>/etc/pf.conf</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key><string>/tmp/eagle-pf.log</string>
|
||||
<key>StandardErrorPath</key><string>/tmp/eagle-pf.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo launchctl load /Library/LaunchDaemons/com.eagle.pf-redirect.plist
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost/api/health # should work once eagle-dash is running
|
||||
sudo pfctl -s nat # should show the rdr rule
|
||||
```
|
||||
|
||||
Expected: redirect rule visible in pfctl output.
|
||||
|
||||
- [ ] **Step 6: Commit plist to repo**
|
||||
|
||||
```bash
|
||||
cd ~/Developer/eagle-dash
|
||||
cp /Library/LaunchDaemons/com.eagle.pf-redirect.plist .
|
||||
git add com.eagle.pf-redirect.plist
|
||||
git commit -m "feat: pf redirect port 80 → 8880 (LaunchDaemon + anchor)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Service Registry YAML
|
||||
|
||||
**Files:**
|
||||
@@ -367,14 +458,14 @@ cd ~/Developer/eagle-dash
|
||||
just dev &
|
||||
sleep 2
|
||||
# Should work (localhost):
|
||||
curl -s http://127.0.0.1:3000/api/health
|
||||
curl -s http://127.0.0.1:8880/api/health
|
||||
|
||||
# Simulate external (should 401):
|
||||
curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:3000/api/health
|
||||
curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:8880/api/health
|
||||
|
||||
# With token (should work):
|
||||
TOKEN=$(grep EAGLE_DASH_TOKEN .env | cut -d= -f2)
|
||||
curl -s -H "X-Forwarded-For: 1.2.3.4" -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/api/health
|
||||
curl -s -H "X-Forwarded-For: 1.2.3.4" -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8880/api/health
|
||||
```
|
||||
|
||||
Expected: first call `{"status":"ok"}`, second `{"error":"unauthorized"}`, third `{"status":"ok"}`.
|
||||
@@ -560,12 +651,20 @@ import httpx
|
||||
import psutil
|
||||
from registry import Service
|
||||
|
||||
@dataclass
|
||||
class ChildProc:
|
||||
pid: int
|
||||
name: str
|
||||
memory_mb: float
|
||||
|
||||
@dataclass
|
||||
class ServiceStatus:
|
||||
id: str
|
||||
healthy: bool
|
||||
detail: str = ""
|
||||
latency_ms: Optional[float] = None
|
||||
memory_mb: Optional[float] = None # RSS of main process, MB
|
||||
children: list[ChildProc] = field(default_factory=list)
|
||||
|
||||
def check_port(port: int, host: str = "127.0.0.1", timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
@@ -574,15 +673,36 @@ def check_port(port: int, host: str = "127.0.0.1", timeout: float = 1.0) -> bool
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def check_process(name: str) -> bool:
|
||||
def _rss_mb(proc: psutil.Process) -> float:
|
||||
try:
|
||||
return proc.memory_info().rss / 1024 / 1024
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return 0.0
|
||||
|
||||
def _children_info(proc: psutil.Process) -> list[ChildProc]:
|
||||
result = []
|
||||
try:
|
||||
for child in proc.children(recursive=True):
|
||||
result.append(ChildProc(
|
||||
pid=child.pid,
|
||||
name=child.name(),
|
||||
memory_mb=round(_rss_mb(child), 1),
|
||||
))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
return result
|
||||
|
||||
def check_process(name: str) -> tuple[bool, Optional[float], list[ChildProc]]:
|
||||
for proc in psutil.process_iter(["name", "cmdline"]):
|
||||
try:
|
||||
cmdline = " ".join(proc.info["cmdline"] or [])
|
||||
if name in proc.info["name"] or name in cmdline:
|
||||
return True
|
||||
mem = round(_rss_mb(proc), 1)
|
||||
children = _children_info(proc)
|
||||
return True, mem, children
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
return False
|
||||
return False, None, []
|
||||
|
||||
def check_launchctl(label: str) -> bool:
|
||||
result = subprocess.run(
|
||||
@@ -601,23 +721,45 @@ def check_http(url: str, timeout: float = 3.0) -> tuple[bool, float]:
|
||||
except Exception:
|
||||
return False, 0.0
|
||||
|
||||
def _proc_by_label(label: str) -> Optional[psutil.Process]:
|
||||
"""Find process whose cmdline contains the launchd label."""
|
||||
for proc in psutil.process_iter(["cmdline"]):
|
||||
try:
|
||||
cmdline = " ".join(proc.info["cmdline"] or [])
|
||||
if label in cmdline:
|
||||
return proc
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
return None
|
||||
|
||||
async def check_service(svc: Service) -> ServiceStatus:
|
||||
h = svc.health
|
||||
if h.type == "http":
|
||||
ok, ms = check_http(h.url)
|
||||
# also try to find process by port for memory info
|
||||
proc = _proc_by_label(svc.label) if svc.label else None
|
||||
mem = round(_rss_mb(proc), 1) if proc else None
|
||||
children = _children_info(proc) if proc else []
|
||||
return ServiceStatus(id=svc.id, healthy=ok, latency_ms=ms,
|
||||
memory_mb=mem, children=children,
|
||||
detail="HTTP ok" if ok else "HTTP failed")
|
||||
if h.type == "port":
|
||||
ok = check_port(svc.port)
|
||||
return ServiceStatus(id=svc.id, healthy=ok,
|
||||
proc = _proc_by_label(svc.label) if svc.label else None
|
||||
mem = round(_rss_mb(proc), 1) if proc else None
|
||||
children = _children_info(proc) if proc else []
|
||||
return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children,
|
||||
detail=f"port {svc.port} {'open' if ok else 'closed'}")
|
||||
if h.type == "process":
|
||||
ok = check_process(h.name)
|
||||
return ServiceStatus(id=svc.id, healthy=ok,
|
||||
ok, mem, children = check_process(h.name)
|
||||
return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children,
|
||||
detail=f"process '{h.name}' {'found' if ok else 'not found'}")
|
||||
if h.type == "launchctl":
|
||||
ok = check_launchctl(svc.label)
|
||||
return ServiceStatus(id=svc.id, healthy=ok,
|
||||
proc = _proc_by_label(svc.label) if ok else None
|
||||
mem = round(_rss_mb(proc), 1) if proc else None
|
||||
children = _children_info(proc) if proc else []
|
||||
return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children,
|
||||
detail="loaded" if ok else "not loaded")
|
||||
return ServiceStatus(id=svc.id, healthy=False, detail=f"unknown check type: {h.type}")
|
||||
```
|
||||
@@ -743,6 +885,11 @@ async def list_services():
|
||||
"healthy": status.healthy if status else None,
|
||||
"detail": status.detail if status else "pending",
|
||||
"latency_ms": status.latency_ms if status else None,
|
||||
"memory_mb": status.memory_mb if status else None,
|
||||
"children": [
|
||||
{"pid": c.pid, "name": c.name, "memory_mb": c.memory_mb}
|
||||
for c in (status.children if status else [])
|
||||
],
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -787,8 +934,8 @@ async def self_health():
|
||||
cd ~/Developer/eagle-dash
|
||||
just dev &
|
||||
sleep 2
|
||||
curl -s http://localhost:3000/api/health
|
||||
curl -s http://localhost:3000/api/services | python3 -m json.tool | head -40
|
||||
curl -s http://localhost:8880/api/health
|
||||
curl -s http://localhost:8880/api/services | python3 -m json.tool | head -40
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok","services_registered":14}` and a JSON array of services.
|
||||
@@ -885,7 +1032,19 @@ The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for intera
|
||||
</div>
|
||||
|
||||
<!-- Detail + latency -->
|
||||
<p class="text-xs text-slate-400 mb-3" x-text="svc.detail + (svc.latency_ms ? ' · ' + Math.round(svc.latency_ms) + 'ms' : '')"></p>
|
||||
<p class="text-xs text-slate-400 mb-1" x-text="svc.detail + (svc.latency_ms ? ' · ' + Math.round(svc.latency_ms) + 'ms' : '')"></p>
|
||||
|
||||
<!-- Memory usage -->
|
||||
<template x-if="svc.memory_mb">
|
||||
<div class="mb-2">
|
||||
<span class="text-xs text-slate-500" x-text="svc.memory_mb + ' MB'"></span>
|
||||
<template x-if="svc.children && svc.children.length">
|
||||
<span class="text-xs text-slate-600 ml-1"
|
||||
x-text="'+ ' + svc.children.length + ' child' + (svc.children.length > 1 ? 'ren' : '') + ' (' + svc.children.reduce((a,c) => a+c.memory_mb, 0).toFixed(1) + ' MB)'">
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Network access badge -->
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1061,7 +1220,7 @@ loadPage() {
|
||||
```bash
|
||||
cd ~/Developer/eagle-dash
|
||||
just dev
|
||||
open http://localhost:3000
|
||||
open http://localhost:8880
|
||||
```
|
||||
|
||||
Expected: Dark dashboard with tab bar. Services tab shows cards by category. Pages tab shows URL input with iframe. Files tab shows FileBrowser.
|
||||
@@ -1136,7 +1295,7 @@ And add a "logs" button to each service card (after the restart/stop buttons):
|
||||
- [ ] **Step 2: Test log panel**
|
||||
|
||||
```bash
|
||||
open http://localhost:3000
|
||||
open http://localhost:8880
|
||||
```
|
||||
|
||||
Click "logs" on the PostgreSQL card. Expected: bottom drawer opens with last 200 lines of the postgres log.
|
||||
@@ -1207,7 +1366,7 @@ Expected: `launchctl load` succeeds.
|
||||
|
||||
```bash
|
||||
launchctl list com.eagle.dashboard
|
||||
curl -s http://localhost:3000/api/health
|
||||
curl -s http://localhost:8880/api/health
|
||||
```
|
||||
|
||||
Expected: PID shown in launchctl list; `{"status":"ok"}` from curl.
|
||||
@@ -1222,7 +1381,7 @@ Add to `services.yaml` under `# ── Infrastructure`:
|
||||
port: 3000
|
||||
health:
|
||||
type: http
|
||||
url: "http://localhost:3000/api/health"
|
||||
url: "http://localhost:8880/api/health"
|
||||
access: localhost
|
||||
critical: false
|
||||
category: infra
|
||||
@@ -1233,13 +1392,13 @@ Add to `services.yaml` under `# ── Infrastructure`:
|
||||
|
||||
```bash
|
||||
cd ~/Developer/eagle-dash
|
||||
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 3000"
|
||||
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 8880"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Open and verify**
|
||||
|
||||
```bash
|
||||
open http://localhost:3000
|
||||
open http://localhost:8880
|
||||
```
|
||||
|
||||
Expected: Dashboard loads, eagle-dashboard itself shows as a green card.
|
||||
@@ -1353,7 +1512,7 @@ git commit -m "feat: FileBrowser Docker container on :8181"
|
||||
|
||||
## Task 10: Cloudflared Tunnel for dash.qentra.top
|
||||
|
||||
**Goal:** Expose `dash.qentra.top` via cloudflared → `localhost:3000`. External requests are token-gated by auth middleware (Task 2a).
|
||||
**Goal:** Expose `dash.qentra.top` via cloudflared → `localhost:8880`. External requests are token-gated by auth middleware (Task 2a).
|
||||
|
||||
- [ ] **Step 1: Add ingress rule to cloudflared config**
|
||||
|
||||
@@ -1362,11 +1521,11 @@ Check existing config:
|
||||
cat ~/.cloudflared/config.yml
|
||||
```
|
||||
|
||||
Add a new ingress entry for the dashboard:
|
||||
Add a new ingress entry for the dashboard (points directly to internal port, bypassing pf):
|
||||
```yaml
|
||||
ingress:
|
||||
- hostname: dash.qentra.top
|
||||
service: http://localhost:3000
|
||||
service: http://localhost:8880
|
||||
# ... existing rules ...
|
||||
- service: http_status:404
|
||||
```
|
||||
@@ -1456,3 +1615,7 @@ Implementation: extend `registry.py` with a `bind_check` health type that calls
|
||||
- **FileBrowser iframe:** `FB_NOAUTH=true` env var name may differ by image version — check `docker run filebrowser/filebrowser --help` if the login screen still appears. Alternatively set `--username admin --password ""`.
|
||||
- **Cookie auth and SameSite:** the cookie set by `/login` uses `samesite=strict`, which blocks it on cross-site redirects. Use `samesite=lax` instead if the login flow breaks in some browsers.
|
||||
- **Pages tab iframe:** `sandbox="allow-same-origin allow-scripts"` will block pages that set `X-Frame-Options: DENY`. This is expected — most external sites won't load; local services usually will.
|
||||
- **Memory tracking imports:** `ChildProc` uses `field(default_factory=list)` — add `from dataclasses import dataclass, field` and `from typing import Optional` to `health.py`.
|
||||
- **pf and SIP:** macOS SIP does not block `pfctl` on /etc/pf.conf edits, but some pf operations require rebooting after System Integrity Protection changes. Test with `sudo pfctl -s nat` after loading.
|
||||
- **Port 80 and launchd ordering:** Eagle Dashboard LaunchAgent must start before the pf LaunchDaemon is useful — both happen at login, but pf redirect is independent (kernel-level), so ordering doesn't matter.
|
||||
- **Memory for launchctl services:** `_proc_by_label` searches cmdline for the label string — this works for most launchd jobs whose argv[0] contains the plist label. If a service doesn't include its label in cmdline, memory will show `null`.
|
||||
|
||||
Reference in New Issue
Block a user