Files
obsidian-vault/personal/projects/eagle-dashboard.md
T

1622 lines
50 KiB
Markdown

# Eagle Dashboard — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**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 + 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
---
## Scope note
Three independent subsystems:
- **Plan A (this doc):** Service registry + health API + dashboard UI + LaunchAgent control + auth + FileBrowser + Pages tab + cloudflared tunnel
- **Plan B (future):** Network security panel — pf rules, per-service firewall toggling, bind-address runtime verification
---
## File Structure
```
~/Developer/eagle-dash/
├── main.py # FastAPI app — routes, startup, SSE
├── auth.py # Origin-aware auth middleware
├── registry.py # Service registry loader + YAML parser
├── health.py # Health check engine (http/port/process/launchctl)
├── control.py # launchctl start/stop/restart wrappers
├── services.yaml # Service definitions (source of truth)
├── .env # EAGLE_DASH_TOKEN=<secret>
├── templates/
│ └── index.html # Dashboard — 3-tab HTMX + Tailwind CDN + Alpine.js
├── docker/
│ └── filebrowser.yml # FileBrowser Docker Compose
├── requirements.txt
├── justfile
└── com.eagle.dashboard.plist # LaunchAgent plist
```
---
## Task 1: Project Bootstrap
**Files:**
- Create: `~/Developer/eagle-dash/requirements.txt`
- Create: `~/Developer/eagle-dash/justfile`
- [ ] **Step 1: Create project directory and requirements**
```bash
mkdir -p ~/Developer/eagle-dash/templates
```
`~/Developer/eagle-dash/requirements.txt`:
```
fastapi==0.115.0
uvicorn[standard]==0.30.6
pyyaml==6.0.2
httpx==0.27.2
psutil==6.0.0
jinja2==3.1.4
```
- [ ] **Step 2: Create justfile**
`~/Developer/eagle-dash/justfile`:
```just
# vim: set ft=just ts=2
[private]
default:
@just --list
install:
pip install -r requirements.txt
dev:
uvicorn main:app --reload --host 127.0.0.1 --port 8880
prod:
uvicorn main:app --host 127.0.0.1 --port 8880 --workers 1
deploy:
cp com.eagle.dashboard.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.eagle.dashboard.plist
undeploy:
launchctl unload ~/Library/LaunchAgents/com.eagle.dashboard.plist
```
- [ ] **Step 3: Install deps**
```bash
cd ~/Developer/eagle-dash
pip install -r requirements.txt
```
Expected: all packages installed without error.
- [ ] **Step 4: Commit**
```bash
cd ~/Developer/eagle-dash
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:**
- Create: `~/Developer/eagle-dash/services.yaml`
This is the source of truth. Every service gets: name, launchd label, health check config, network access level, category, log path.
- [ ] **Step 1: Write services.yaml**
```yaml
# Network access levels:
# localhost — binds to 127.0.0.1 only ✅
# lan — binds to 0.0.0.0, home-network-only ⚠️
# tunnel — exposed via cloudflared 🌐
# public — directly internet-facing 🔴
services:
# ── AI / ML ──────────────────────────────────────────────
claude_proxy_python:
name: "Claude Proxy (Python)"
label: "com.openclaw.claude-proxy"
port: 8090
health:
type: http
url: "http://localhost:8090/v1/models"
access: localhost
critical: true
category: ai
log: "/tmp/claude-proxy.log"
claude_proxy_node:
name: "Claude Proxy (Node.js)"
label: "ai.claude-proxy"
port: 3456
health:
type: port
access: localhost
critical: false
category: ai
log: "/tmp/claude-proxy-node.log"
mlx_lm:
name: "MLX LM Server"
label: "com.mlx-lm-server"
port: 8080
health:
type: http
url: "http://localhost:8080/v1/models"
access: lan
critical: false
category: ai
log: "/tmp/mlx-lm.log"
ollama:
name: "Ollama"
label: "com.ollama.server"
port: 11434
health:
type: http
url: "http://localhost:11434/api/tags"
access: lan
critical: false
category: ai
log: "/tmp/ollama.log"
hermes:
name: "Hermes Gateway"
label: "ai.hermes.gateway"
health:
type: process
name: "hermes"
access: localhost
critical: true
category: ai
log: "/tmp/hermes.log"
hermes_whale:
name: "Hermes Whale"
label: "ai.hermes.whale-gateway"
health:
type: process
name: "hermes-whale"
access: localhost
critical: false
category: ai
log: "/tmp/hermes-whale.log"
asana_mcp:
name: "Asana MCP Proxy"
label: "com.asana-mcp-proxy"
health:
type: launchctl
access: localhost
critical: false
category: ai
log: "/tmp/asana-mcp.log"
virfield:
name: "Virfield VM MCP"
label: "com.virfield.server"
health:
type: launchctl
access: localhost
critical: false
category: ai
# ── Database ─────────────────────────────────────────────
postgres:
name: "PostgreSQL 17"
label: "homebrew.mxcl.postgresql@17"
port: 5432
health:
type: port
access: localhost
critical: true
category: database
log: "/opt/homebrew/var/log/postgresql@17.log"
# ── Monitoring ───────────────────────────────────────────
aw_ddg:
name: "AW Watcher DDG"
label: "com.personalos.aw-watcher-ddg"
health:
type: process
name: "aw-watcher-ddg"
access: localhost
critical: false
category: monitoring
log: "/tmp/aw-watcher-ddg.log"
aw_xcode:
name: "AW Watcher Xcode"
label: "com.personalos.aw-watcher-xcode"
health:
type: process
name: "aw-watcher-xcode"
access: localhost
critical: false
category: monitoring
log: "/tmp/aw-watcher-xcode.log"
# ── Orchestration ────────────────────────────────────────
heartbeat:
name: "Personal OS Heartbeat"
label: "com.personalos.heartbeat"
health:
type: launchctl
access: localhost
critical: true
category: orchestration
log: "~/Library/Logs/personal-os/heartbeat.log"
# ── Infrastructure ───────────────────────────────────────
colima:
name: "Colima (Docker)"
label: "com.colima.start"
health:
type: process
name: "colima"
access: localhost
critical: false
category: infra
zulip:
name: "Zulip"
label: "top.qentra.zulip"
port: 8080
health:
type: launchctl
access: localhost
critical: false
category: infra
```
- [ ] **Step 2: Commit**
```bash
cd ~/Developer/eagle-dash
git add services.yaml && git commit -m "feat: service registry YAML"
```
---
## Task 2a: Auth Middleware
**Files:**
- Create: `~/Developer/eagle-dash/auth.py`
- Create: `~/Developer/eagle-dash/.env`
Origin-aware authentication: requests from `127.0.0.1` (localhost browser) bypass all auth. Requests arriving via cloudflared (any other origin or forwarded header) must present a Bearer token matching `EAGLE_DASH_TOKEN` env var.
- [ ] **Step 1: Generate a token and write .env**
```bash
cd ~/Developer/eagle-dash
python3 -c "import secrets; print('EAGLE_DASH_TOKEN=' + secrets.token_urlsafe(32))" > .env
cat .env
```
Expected: `EAGLE_DASH_TOKEN=<48-char random string>`
Add `.env` to `.gitignore`:
```bash
echo ".env" >> .gitignore
```
- [ ] **Step 2: Write auth.py**
`~/Developer/eagle-dash/auth.py`:
```python
import os
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.getenv("EAGLE_DASH_TOKEN", "")
LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost"}
class OriginAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
client_host = (request.client.host if request.client else "")
forwarded_for = request.headers.get("X-Forwarded-For", "")
is_local = (
client_host in LOCALHOST_HOSTS
and not forwarded_for
)
if is_local:
return await call_next(request)
# External request — require Bearer token
auth = request.headers.get("Authorization", "")
if not TOKEN:
return JSONResponse({"error": "token not configured"}, status_code=503)
if auth != f"Bearer {TOKEN}":
return JSONResponse({"error": "unauthorized"}, status_code=401)
return await call_next(request)
```
- [ ] **Step 3: Wire into main.py**
Add to `main.py` imports and app setup (before the first route):
```python
from auth import OriginAuthMiddleware
# after: app = FastAPI(...)
app.add_middleware(OriginAuthMiddleware)
```
Also add `python-dotenv` to `requirements.txt`.
- [ ] **Step 4: Test locally**
```bash
cd ~/Developer/eagle-dash
just dev &
sleep 2
# Should work (localhost):
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: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:8880/api/health
```
Expected: first call `{"status":"ok"}`, second `{"error":"unauthorized"}`, third `{"status":"ok"}`.
- [ ] **Step 5: Commit**
```bash
cd ~/Developer/eagle-dash
git add auth.py .gitignore requirements.txt main.py
git commit -m "feat: origin-aware auth middleware — localhost free, external token-gated"
```
---
## Task 3: Registry Loader
**Files:**
- Create: `~/Developer/eagle-dash/registry.py`
- Create: `~/Developer/eagle-dash/tests/test_registry.py`
- [ ] **Step 1: Write the failing test**
`~/Developer/eagle-dash/tests/test_registry.py`:
```python
import pytest
from registry import load_registry, Service
def test_load_registry_returns_services():
services = load_registry("services.yaml")
assert len(services) > 0
def test_critical_services_present():
services = load_registry("services.yaml")
names = {s.id for s in services}
assert "postgres" in names
assert "hermes" in names
assert "claude_proxy_python" in names
def test_service_has_required_fields():
services = load_registry("services.yaml")
svc = next(s for s in services if s.id == "postgres")
assert svc.name == "PostgreSQL 17"
assert svc.label == "homebrew.mxcl.postgresql@17"
assert svc.access == "localhost"
assert svc.critical is True
assert svc.category == "database"
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_registry.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'registry'`
- [ ] **Step 3: Write registry.py**
`~/Developer/eagle-dash/registry.py`:
```python
from dataclasses import dataclass, field
from typing import Optional
import yaml
@dataclass
class HealthConfig:
type: str # http | port | process | launchctl
url: Optional[str] = None
name: Optional[str] = None # process name
@dataclass
class Service:
id: str
name: str
label: str
health: HealthConfig
access: str # localhost | lan | tunnel | public
critical: bool
category: str
port: Optional[int] = None
log: Optional[str] = None
def load_registry(path: str = "services.yaml") -> list[Service]:
with open(path) as f:
raw = yaml.safe_load(f)
services = []
for svc_id, cfg in raw["services"].items():
hcfg = cfg["health"]
health = HealthConfig(
type=hcfg["type"],
url=hcfg.get("url"),
name=hcfg.get("name"),
)
svc = Service(
id=svc_id,
name=cfg["name"],
label=cfg["label"],
health=health,
access=cfg.get("access", "localhost"),
critical=cfg.get("critical", False),
category=cfg.get("category", "other"),
port=cfg.get("port"),
log=cfg.get("log"),
)
services.append(svc)
return services
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_registry.py -v
```
Expected: PASS all 3 tests.
- [ ] **Step 5: Commit**
```bash
cd ~/Developer/eagle-dash
git add registry.py tests/ && git commit -m "feat: service registry loader"
```
---
## Task 4: Health Check Engine
**Files:**
- Create: `~/Developer/eagle-dash/health.py`
- Create: `~/Developer/eagle-dash/tests/test_health.py`
- [ ] **Step 1: Write the failing test**
`~/Developer/eagle-dash/tests/test_health.py`:
```python
import pytest
from unittest.mock import patch, MagicMock
from health import check_port, check_process, ServiceStatus
def test_port_check_open():
# Port 5432 (postgres) should be open on this machine
result = check_port(5432)
assert result is True
def test_port_check_closed():
result = check_port(19999) # nothing runs here
assert result is False
def test_process_check_existing():
result = check_process("python3")
assert result is True
def test_process_check_missing():
result = check_process("definitely-not-running-xyz")
assert result is False
def test_service_status_dataclass():
s = ServiceStatus(id="test", healthy=True, detail="ok")
assert s.id == "test"
assert s.healthy is True
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_health.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'health'`
- [ ] **Step 3: Write health.py**
`~/Developer/eagle-dash/health.py`:
```python
import socket
import subprocess
from dataclasses import dataclass
from typing import Optional
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:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
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:
mem = round(_rss_mb(proc), 1)
children = _children_info(proc)
return True, mem, children
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return False, None, []
def check_launchctl(label: str) -> bool:
result = subprocess.run(
["launchctl", "list", label],
capture_output=True, text=True
)
return result.returncode == 0
def check_http(url: str, timeout: float = 3.0) -> tuple[bool, float]:
import time
try:
t0 = time.monotonic()
resp = httpx.get(url, timeout=timeout)
latency = (time.monotonic() - t0) * 1000
return resp.status_code < 500, latency
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)
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, 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)
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}")
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_health.py -v
```
Expected: PASS all 5 tests. (Port 5432 must be open — postgres must be running.)
- [ ] **Step 5: Commit**
```bash
cd ~/Developer/eagle-dash
git add health.py tests/test_health.py && git commit -m "feat: health check engine"
```
---
## Task 5: FastAPI Backend
**Files:**
- Create: `~/Developer/eagle-dash/main.py`
- Create: `~/Developer/eagle-dash/control.py`
- [ ] **Step 1: Write control.py (launchctl wrappers)**
`~/Developer/eagle-dash/control.py`:
```python
import subprocess
import os
def _launchctl(action: str, label_or_plist: str) -> tuple[bool, str]:
cmd = ["launchctl", action, label_or_plist]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0, result.stderr.strip() or result.stdout.strip()
def stop_service(label: str) -> tuple[bool, str]:
return _launchctl("stop", label)
def start_service(label: str) -> tuple[bool, str]:
return _launchctl("start", label)
def kickstart_service(label: str) -> tuple[bool, str]:
domain = f"gui/{os.getuid()}/{label}"
result = subprocess.run(
["launchctl", "kickstart", "-k", domain],
capture_output=True, text=True
)
return result.returncode == 0, result.stderr.strip() or result.stdout.strip()
```
- [ ] **Step 2: Write main.py**
`~/Developer/eagle-dash/main.py`:
```python
import asyncio
import os
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from fastapi import Request
from registry import load_registry, Service
from health import check_service, ServiceStatus
from control import stop_service, start_service, kickstart_service
REGISTRY_PATH = Path(__file__).parent / "services.yaml"
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
_services: list[Service] = []
_status_cache: dict[str, ServiceStatus] = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
global _services
_services = load_registry(str(REGISTRY_PATH))
asyncio.create_task(_poll_loop())
yield
app = FastAPI(title="Eagle Dashboard", lifespan=lifespan)
async def _poll_loop():
while True:
results = await asyncio.gather(
*[check_service(s) for s in _services],
return_exceptions=True
)
for svc, result in zip(_services, results):
if isinstance(result, ServiceStatus):
_status_cache[svc.id] = result
await asyncio.sleep(30)
def _svc_by_id(svc_id: str) -> Service:
for s in _services:
if s.id == svc_id:
return s
raise HTTPException(status_code=404, detail=f"Service '{svc_id}' not in registry")
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
return TEMPLATES.TemplateResponse("index.html", {"request": request})
@app.get("/api/services")
async def list_services():
result = []
for svc in _services:
status = _status_cache.get(svc.id)
result.append({
"id": svc.id,
"name": svc.name,
"label": svc.label,
"category": svc.category,
"access": svc.access,
"critical": svc.critical,
"port": svc.port,
"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
@app.post("/api/services/{svc_id}/restart")
async def restart_service(svc_id: str):
svc = _svc_by_id(svc_id)
ok, msg = kickstart_service(svc.label)
return {"ok": ok, "message": msg}
@app.post("/api/services/{svc_id}/stop")
async def stop(svc_id: str):
svc = _svc_by_id(svc_id)
ok, msg = stop_service(svc.label)
return {"ok": ok, "message": msg}
@app.post("/api/services/{svc_id}/start")
async def start(svc_id: str):
svc = _svc_by_id(svc_id)
ok, msg = start_service(svc.label)
return {"ok": ok, "message": msg}
@app.get("/api/services/{svc_id}/logs")
async def tail_log(svc_id: str, lines: int = 100):
svc = _svc_by_id(svc_id)
if not svc.log:
raise HTTPException(status_code=404, detail="No log configured for this service")
log_path = Path(svc.log).expanduser()
if not log_path.exists():
return {"lines": [], "error": "log file not found"}
with open(log_path, "r", errors="replace") as f:
all_lines = f.readlines()
return {"lines": all_lines[-lines:]}
@app.get("/api/health")
async def self_health():
return {"status": "ok", "services_registered": len(_services)}
```
- [ ] **Step 3: Smoke-test the API manually**
```bash
cd ~/Developer/eagle-dash
just dev &
sleep 2
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.
- [ ] **Step 4: Commit**
```bash
cd ~/Developer/eagle-dash
git add main.py control.py && git commit -m "feat: FastAPI backend with health API and control endpoints"
```
---
## Task 6: Dashboard HTML Template
**Files:**
- Create: `~/Developer/eagle-dash/templates/index.html`
The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for interactivity. Three tabs: **Services**, **Pages**, **Files**.
- [ ] **Step 1: Write index.html**
`~/Developer/eagle-dash/templates/index.html`:
```html
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eagle Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {} }
}
</script>
<script src="https://unpkg.com/htmx.org@2.0.2"></script>
<script src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js" defer></script>
<style>
body { background: #0f1117; color: #e2e8f0; font-family: 'SF Pro Display', system-ui, sans-serif; }
.card { background: #1a1d27; border: 1px solid #2d3047; }
.healthy { background: #1a2e1a; border-color: #2d5a2d; }
.unhealthy { background: #2e1a1a; border-color: #5a2d2d; }
.pending { background: #1a1d27; border-color: #3d3d5a; }
.badge-localhost { background: #1a3a1a; color: #4ade80; }
.badge-lan { background: #3a2e0a; color: #fbbf24; }
.badge-tunnel { background: #1a2a3a; color: #60a5fa; }
.badge-public { background: #3a0a0a; color: #f87171; }
</style>
</head>
<body class="min-h-screen p-6" x-data="dashboard()">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white">Eagle Dashboard</h1>
<p class="text-slate-400 text-sm mt-1">
<span x-text="services.length"></span> services ·
<span class="text-green-400" x-text="healthy"></span> healthy ·
<span class="text-red-400" x-text="unhealthy"></span> unhealthy
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">auto-refresh 30s</span>
<button @click="refresh()" class="px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 rounded-md text-white">↻ Refresh</button>
</div>
</div>
<!-- Category Sections -->
<template x-for="cat in categories" :key="cat">
<div class="mb-8">
<h2 class="text-xs uppercase tracking-widest text-slate-500 mb-3" x-text="cat"></h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<template x-for="svc in byCategory(cat)" :key="svc.id">
<div class="card rounded-lg p-4 transition-colors duration-300"
:class="svc.healthy === true ? 'healthy' : svc.healthy === false ? 'unhealthy' : 'pending'">
<!-- Service header -->
<div class="flex items-start justify-between mb-2">
<div>
<span class="font-medium text-white text-sm" x-text="svc.name"></span>
<template x-if="svc.critical">
<span class="ml-1 text-xs text-amber-400"></span>
</template>
<template x-if="svc.port">
<span class="ml-1 text-xs text-slate-500" x-text="':' + svc.port"></span>
</template>
</div>
<!-- Status dot -->
<span class="mt-0.5 w-2.5 h-2.5 rounded-full flex-shrink-0"
:class="svc.healthy === true ? 'bg-green-400' : svc.healthy === false ? 'bg-red-400' : 'bg-slate-500'">
</span>
</div>
<!-- Detail + latency -->
<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">
<span class="text-xs px-2 py-0.5 rounded-full font-mono"
:class="'badge-' + svc.access"
x-text="svc.access">
</span>
<!-- Actions -->
<div class="flex gap-1.5" x-show="svc.healthy !== null">
<button @click="action(svc.id, 'restart')"
class="text-xs px-2 py-0.5 bg-slate-700 hover:bg-slate-600 rounded text-slate-300">
restart
</button>
<button @click="action(svc.id, svc.healthy ? 'stop' : 'start')"
class="text-xs px-2 py-0.5 rounded text-slate-300"
:class="svc.healthy ? 'bg-red-900 hover:bg-red-800' : 'bg-green-900 hover:bg-green-800'">
<span x-text="svc.healthy ? 'stop' : 'start'"></span>
</button>
</div>
</div>
<!-- Action feedback -->
<p x-show="svc._msg" x-text="svc._msg"
class="mt-2 text-xs text-slate-400 italic"></p>
</div>
</template>
</div>
</div>
</template>
<p class="text-xs text-slate-600 mt-8">
Last updated: <span x-text="lastUpdate"></span>
</p>
<script>
function dashboard() {
return {
services: [],
lastUpdate: '',
intervalId: null,
get categories() {
return [...new Set(this.services.map(s => s.category))];
},
get healthy() {
return this.services.filter(s => s.healthy === true).length;
},
get unhealthy() {
return this.services.filter(s => s.healthy === false).length;
},
byCategory(cat) {
return this.services.filter(s => s.category === cat);
},
async refresh() {
const res = await fetch('/api/services');
const data = await res.json();
// merge _msg field from existing services
this.services = data.map(svc => ({
...svc,
_msg: (this.services.find(s => s.id === svc.id) || {})._msg || ''
}));
this.lastUpdate = new Date().toLocaleTimeString();
},
async action(id, verb) {
const res = await fetch(`/api/services/${id}/${verb}`, { method: 'POST' });
const data = await res.json();
const svc = this.services.find(s => s.id === id);
if (svc) {
svc._msg = data.ok ? '✓ ' + verb + ' sent' : '✗ ' + data.message;
setTimeout(() => { svc._msg = ''; }, 4000);
}
setTimeout(() => this.refresh(), 1500);
},
init() {
this.refresh();
this.intervalId = setInterval(() => this.refresh(), 30000);
}
}
}
</script>
</body>
</html>
```
The `<body>` element uses `x-data="dashboard()"` and tab state `activeTab: 'services'`. Add a tab bar at the top:
```html
<!-- Tab Bar -->
<div class="flex gap-1 mb-6 border-b border-slate-700 pb-2">
<button @click="activeTab='services'"
:class="activeTab==='services' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
class="px-4 py-1.5 text-sm font-medium -mb-2">Services</button>
<button @click="activeTab='pages'"
:class="activeTab==='pages' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
class="px-4 py-1.5 text-sm font-medium -mb-2">Pages</button>
<button @click="activeTab='files'"
:class="activeTab==='files' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
class="px-4 py-1.5 text-sm font-medium -mb-2">Files</button>
</div>
```
Wrap the existing category grid in `<div x-show="activeTab==='services'">`.
Add a **Pages tab** section:
```html
<!-- Pages Tab -->
<div x-show="activeTab==='pages'" class="flex flex-col gap-4 h-[80vh]">
<div class="flex gap-2">
<input x-model="pageUrl" @keyup.enter="loadPage()"
placeholder="http://localhost:8080/..."
class="flex-1 bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-sm text-white
font-mono placeholder-slate-500 focus:outline-none focus:border-indigo-500" />
<button @click="loadPage()"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 rounded text-sm text-white">Load</button>
<button @click="pageUrl=''; pageLoaded=false"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded text-sm text-slate-300">Clear</button>
</div>
<!-- Quick links to common local services -->
<div class="flex gap-2 flex-wrap">
<template x-for="link in quickLinks" :key="link.url">
<button @click="pageUrl=link.url; loadPage()"
class="text-xs px-2 py-1 bg-slate-800 hover:bg-slate-700 border border-slate-600
rounded text-slate-400 hover:text-white">
<span x-text="link.label"></span>
</button>
</template>
</div>
<iframe x-show="pageLoaded" :src="pageUrl" class="flex-1 rounded border border-slate-700 bg-white"
sandbox="allow-same-origin allow-scripts allow-forms"></iframe>
<div x-show="!pageLoaded" class="flex-1 rounded border border-slate-700 flex items-center
justify-center text-slate-600 text-sm">
Enter a URL above to load a page
</div>
</div>
```
Add a **Files tab** section:
```html
<!-- Files Tab -->
<div x-show="activeTab==='files'" class="h-[80vh]">
<iframe src="http://localhost:8181"
class="w-full h-full rounded border border-slate-700"
allow="fullscreen"></iframe>
</div>
```
Add to the `dashboard()` Alpine object:
```javascript
activeTab: 'services',
pageUrl: '',
pageLoaded: false,
quickLinks: [
{ label: 'Hermes :8000', url: 'http://localhost:8000' },
{ label: 'Postgres :5432', url: 'http://localhost:5432' },
{ label: 'MLX LM :8080', url: 'http://localhost:8080/v1/models' },
{ label: 'Ollama :11434', url: 'http://localhost:11434' },
{ label: 'Zulip :8080', url: 'http://localhost:8080' },
],
loadPage() {
if (!this.pageUrl) return;
this.pageLoaded = false;
this.$nextTick(() => { this.pageLoaded = true; });
},
```
- [ ] **Step 2: Open the dashboard in browser**
```bash
cd ~/Developer/eagle-dash
just dev
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.
- [ ] **Step 3: Verify all categories appear**
Expected categories: `ai`, `database`, `monitoring`, `orchestration`, `infra`
- [ ] **Step 4: Commit**
```bash
cd ~/Developer/eagle-dash
git add templates/index.html && git commit -m "feat: 3-tab UI — Services, Pages, Files"
```
---
## Task 7: Log Tail UI Panel
**Files:**
- Modify: `~/Developer/eagle-dash/templates/index.html` (add log drawer)
- [ ] **Step 1: Add log panel to index.html**
Add this section inside `<body>`, after the main grid and before the `<script>` tag:
```html
<!-- Log Drawer -->
<div x-show="logOpen" x-transition class="fixed bottom-0 left-0 right-0 h-64 bg-gray-950 border-t border-gray-700 overflow-hidden flex flex-col">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800">
<span class="text-sm text-slate-300 font-mono" x-text="'Logs: ' + (logService || '')"></span>
<button @click="logOpen = false" class="text-slate-500 hover:text-white text-sm">✕ close</button>
</div>
<div class="flex-1 overflow-y-auto p-3 font-mono text-xs text-slate-400 leading-5" id="log-panel">
<template x-for="line in logLines" :key="line">
<div x-text="line"></div>
</template>
<div x-show="logLines.length === 0" class="text-slate-600">No log lines available.</div>
</div>
</div>
```
Also extend the Alpine `dashboard()` function with log state and a showLogs method:
```javascript
// Add to the returned object in dashboard():
logOpen: false,
logService: '',
logLines: [],
async showLogs(id) {
this.logService = id;
this.logOpen = true;
const res = await fetch(`/api/services/${id}/logs?lines=200`);
const data = await res.json();
this.logLines = data.lines || [];
await this.$nextTick();
const panel = document.getElementById('log-panel');
if (panel) panel.scrollTop = panel.scrollHeight;
},
```
And add a "logs" button to each service card (after the restart/stop buttons):
```html
<button @click="showLogs(svc.id)" x-show="svc.log"
class="text-xs px-2 py-0.5 bg-slate-700 hover:bg-slate-600 rounded text-slate-300">
logs
</button>
```
- [ ] **Step 2: Test log panel**
```bash
open http://localhost:8880
```
Click "logs" on the PostgreSQL card. Expected: bottom drawer opens with last 200 lines of the postgres log.
- [ ] **Step 3: Commit**
```bash
cd ~/Developer/eagle-dash
git add templates/index.html && git commit -m "feat: expandable log tail drawer"
```
---
## Task 8: Deploy as LaunchAgent
**Files:**
- Create: `~/Developer/eagle-dash/com.eagle.dashboard.plist`
- [ ] **Step 1: Write the plist**
`~/Developer/eagle-dash/com.eagle.dashboard.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.dashboard</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>uvicorn</string>
<string>main:app</string>
<string>--host</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>3000</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/admin/Developer/eagle-dash</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/eagle-dashboard.log</string>
<key>StandardErrorPath</key>
<string>/tmp/eagle-dashboard.log</string>
</dict>
</plist>
```
- [ ] **Step 2: Deploy**
```bash
cd ~/Developer/eagle-dash
just deploy
```
Expected: `launchctl load` succeeds.
- [ ] **Step 3: Verify it's running**
```bash
launchctl list com.eagle.dashboard
curl -s http://localhost:8880/api/health
```
Expected: PID shown in launchctl list; `{"status":"ok"}` from curl.
- [ ] **Step 4: Add eagle-dashboard entry to services.yaml**
Add to `services.yaml` under `# ── Infrastructure`:
```yaml
eagle_dashboard:
name: "Eagle Dashboard"
label: "com.eagle.dashboard"
port: 3000
health:
type: http
url: "http://localhost:8880/api/health"
access: localhost
critical: false
category: infra
log: "/tmp/eagle-dashboard.log"
```
- [ ] **Step 5: Add to git + commit final state**
```bash
cd ~/Developer/eagle-dash
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 8880"
```
- [ ] **Step 6: Open and verify**
```bash
open http://localhost:8880
```
Expected: Dashboard loads, eagle-dashboard itself shows as a green card.
---
## Task 9: FileBrowser Docker Container
**Files:**
- Create: `~/Developer/eagle-dash/docker/filebrowser.yml`
FileBrowser is a web UI for browsing and managing files — same image used on TrueNAS. Runs via Colima (Docker on Mac).
- [ ] **Step 1: Write docker-compose file**
`~/Developer/eagle-dash/docker/filebrowser.yml`:
```yaml
services:
filebrowser:
image: filebrowser/filebrowser:latest
container_name: eagle-filebrowser
ports:
- "127.0.0.1:8181:80"
volumes:
- /Users/admin:/srv
- filebrowser_db:/database
environment:
- FB_NOAUTH=true # auth handled by Eagle Dashboard token gate
restart: unless-stopped
volumes:
filebrowser_db:
```
Note: `FB_NOAUTH=true` disables FileBrowser's own auth since it's served behind Eagle Dashboard's token middleware (external requests already authenticated before they reach the iframe).
- [ ] **Step 2: Start it**
```bash
docker compose -f ~/Developer/eagle-dash/docker/filebrowser.yml up -d
curl -s http://localhost:8181
```
Expected: FileBrowser HTML response.
- [ ] **Step 3: Add to services.yaml**
Under `# ── Infrastructure`:
```yaml
filebrowser:
name: "FileBrowser"
label: "eagle-filebrowser"
port: 8181
health:
type: http
url: "http://localhost:8181"
access: localhost
critical: false
category: infra
log: ~
```
- [ ] **Step 4: Add docker start to justfile**
```just
filebrowser:
docker compose -f docker/filebrowser.yml up -d
filebrowser-stop:
docker compose -f docker/filebrowser.yml down
```
- [ ] **Step 5: Make it start at login via LaunchAgent**
Create `~/Library/LaunchAgents/com.eagle.filebrowser.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.filebrowser</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/docker</string>
<string>compose</string>
<string>-f</string>
<string>/Users/admin/Developer/eagle-dash/docker/filebrowser.yml</string>
<string>up</string>
<string>-d</string>
</array>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/tmp/eagle-filebrowser.log</string>
<key>StandardErrorPath</key><string>/tmp/eagle-filebrowser.log</string>
</dict>
</plist>
```
```bash
launchctl load ~/Library/LaunchAgents/com.eagle.filebrowser.plist
```
- [ ] **Step 6: Commit**
```bash
cd ~/Developer/eagle-dash
git add docker/ services.yaml justfile
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:8880`. External requests are token-gated by auth middleware (Task 2a).
- [ ] **Step 1: Add ingress rule to cloudflared config**
Check existing config:
```bash
cat ~/.cloudflared/config.yml
```
Add a new ingress entry for the dashboard (points directly to internal port, bypassing pf):
```yaml
ingress:
- hostname: dash.qentra.top
service: http://localhost:8880
# ... existing rules ...
- service: http_status:404
```
If a `tunnel:` + `credentials-file:` block already exists, just add the ingress line above any catch-all rule.
- [ ] **Step 2: Restart cloudflared**
```bash
launchctl kickstart -k gui/$(id -u)/com.cloudflare.cloudflared
```
Or if it's a brew service:
```bash
brew services restart cloudflare/cloudflare/cloudflared
```
- [ ] **Step 3: Create DNS CNAME in Cloudflare dashboard**
In Cloudflare DNS for `qentra.top`:
- Type: `CNAME`
- Name: `dash`
- Target: `<your-tunnel-id>.cfargotunnel.com`
- Proxy: enabled (orange cloud)
- [ ] **Step 4: Test external access**
```bash
TOKEN=$(grep EAGLE_DASH_TOKEN ~/Developer/eagle-dash/.env | cut -d= -f2)
curl -s https://dash.qentra.top/api/health # should 401
curl -s -H "Authorization: Bearer $TOKEN" https://dash.qentra.top/api/health # should 200
```
- [ ] **Step 5: Add bookmarklet for browser access**
For convenient external access from a browser, create a bookmarklet that injects the token header. Or simpler — add a `/login` page to FastAPI that sets a cookie:
`~/Developer/eagle-dash/auth.py` extension: check `eagle_token` cookie in addition to `Authorization` header. The `/login?token=<TOKEN>` route sets the cookie and redirects to `/`. This way the browser can store the token in a cookie after one authenticated visit.
```python
from fastapi import Cookie, Response
from fastapi.responses import RedirectResponse
# In main.py, add this route:
@app.get("/login")
async def login(token: str, response: Response):
if token != TOKEN:
raise HTTPException(status_code=401, detail="invalid token")
resp = RedirectResponse(url="/")
resp.set_cookie("eagle_token", token, httponly=True, samesite="strict", max_age=86400*30)
return resp
```
Update `OriginAuthMiddleware.dispatch` to also check `request.cookies.get("eagle_token")`.
- [ ] **Step 6: Commit**
```bash
cd ~/Developer/eagle-dash
git add . && git commit -m "feat: cloudflared tunnel + cookie-based browser auth"
```
---
## Phase 2 Outline: Network Security Panel
*Not in this plan — build after Phase 1 ships.*
Goals:
- Detect which services bind to `0.0.0.0` vs `127.0.0.1` (currently: MLX LM and Ollama are on LAN)
- Show effective firewall status per service (via `pf` rules or macOS firewall API)
- Cloudflared tunnel panel: list active tunnels from `~/.cloudflared/config.yml`, show which services are exposed externally, enable/disable ingress rules per service
- Alert when a service marked `access: localhost` is actually bound to `0.0.0.0`
Implementation: extend `registry.py` with a `bind_check` health type that calls `lsof -i :<port>` to verify the actual bind address at runtime.
---
## Known Pitfalls
- `launchctl start` only works if the service is already loaded (plist in LaunchAgents). Use `kickstart` for force-restart.
- Python uvicorn must be on PATH when launchd launches it — use full path `/opt/homebrew/bin/uvicorn` if `just dev` works but the LaunchAgent doesn't start.
- Some services (Ollama, MLX LM) bind to `0.0.0.0` and are accessible from LAN — displayed as `⚠️ lan` badge.
- The heartbeat.sh log path uses `~` — expand it before opening: `Path(svc.log).expanduser()` — already handled in `main.py`.
- launchctl `stop` doesn't prevent KeepAlive services from restarting — `unload` is needed for a permanent stop (not exposed in UI on purpose, to prevent accidents).
- **Auth / cloudflared:** cloudflared sets `X-Forwarded-For` but `request.client.host` is still `127.0.0.1` (cloudflared runs locally). Detect external origin by checking `X-Forwarded-For` header presence, not just `client.host`.
- **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`.