44 KiB
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 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.
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
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:
# 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 3000
prod:
uvicorn main:app --host 127.0.0.1 --port 3000 --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
cd ~/Developer/eagle-dash
pip install -r requirements.txt
Expected: all packages installed without error.
- Step 4: Commit
cd ~/Developer/eagle-dash
git init && git add . && git commit -m "feat: project bootstrap"
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
# 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
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
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:
echo ".env" >> .gitignore
- Step 2: Write auth.py
~/Developer/eagle-dash/auth.py:
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):
from auth import OriginAuthMiddleware
# after: app = FastAPI(...)
app.add_middleware(OriginAuthMiddleware)
Also add python-dotenv to requirements.txt.
- Step 4: Test locally
cd ~/Developer/eagle-dash
just dev &
sleep 2
# Should work (localhost):
curl -s http://127.0.0.1:3000/api/health
# Simulate external (should 401):
curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:3000/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
Expected: first call {"status":"ok"}, second {"error":"unauthorized"}, third {"status":"ok"}.
- Step 5: Commit
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:
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
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:
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
cd ~/Developer/eagle-dash
python -m pytest tests/test_registry.py -v
Expected: PASS all 3 tests.
- Step 5: Commit
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:
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
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:
import socket
import subprocess
from dataclasses import dataclass
from typing import Optional
import httpx
import psutil
from registry import Service
@dataclass
class ServiceStatus:
id: str
healthy: bool
detail: str = ""
latency_ms: Optional[float] = None
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 check_process(name: str) -> bool:
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
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return False
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
async def check_service(svc: Service) -> ServiceStatus:
h = svc.health
if h.type == "http":
ok, ms = check_http(h.url)
return ServiceStatus(id=svc.id, healthy=ok, latency_ms=ms,
detail="HTTP ok" if ok else "HTTP failed")
if h.type == "port":
ok = check_port(svc.port)
return ServiceStatus(id=svc.id, healthy=ok,
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,
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,
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
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
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:
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:
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,
})
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
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
Expected: {"status":"ok","services_registered":14} and a JSON array of services.
- Step 4: Commit
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:
<!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-3" x-text="svc.detail + (svc.latency_ms ? ' · ' + Math.round(svc.latency_ms) + 'ms' : '')"></p>
<!-- 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:
<!-- 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:
<!-- 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:
<!-- 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:
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
cd ~/Developer/eagle-dash
just dev
open http://localhost:3000
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
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:
<!-- 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:
// 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):
<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
open http://localhost:3000
Click "logs" on the PostgreSQL card. Expected: bottom drawer opens with last 200 lines of the postgres log.
- Step 3: Commit
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 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
cd ~/Developer/eagle-dash
just deploy
Expected: launchctl load succeeds.
- Step 3: Verify it's running
launchctl list com.eagle.dashboard
curl -s http://localhost:3000/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:
eagle_dashboard:
name: "Eagle Dashboard"
label: "com.eagle.dashboard"
port: 3000
health:
type: http
url: "http://localhost:3000/api/health"
access: localhost
critical: false
category: infra
log: "/tmp/eagle-dashboard.log"
- Step 5: Add to git + commit final state
cd ~/Developer/eagle-dash
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 3000"
- Step 6: Open and verify
open http://localhost:3000
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:
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
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:
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
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 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>
launchctl load ~/Library/LaunchAgents/com.eagle.filebrowser.plist
- Step 6: Commit
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:3000. External requests are token-gated by auth middleware (Task 2a).
- Step 1: Add ingress rule to cloudflared config
Check existing config:
cat ~/.cloudflared/config.yml
Add a new ingress entry for the dashboard:
ingress:
- hostname: dash.qentra.top
service: http://localhost:3000
# ... 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
launchctl kickstart -k gui/$(id -u)/com.cloudflare.cloudflared
Or if it's a brew service:
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
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.
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
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.0vs127.0.0.1(currently: MLX LM and Ollama are on LAN) - Show effective firewall status per service (via
pfrules 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: localhostis actually bound to0.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 startonly works if the service is already loaded (plist in LaunchAgents). Usekickstartfor force-restart.- Python uvicorn must be on PATH when launchd launches it — use full path
/opt/homebrew/bin/uvicornifjust devworks but the LaunchAgent doesn't start. - Some services (Ollama, MLX LM) bind to
0.0.0.0and are accessible from LAN — displayed as⚠️ lanbadge. - The heartbeat.sh log path uses
~— expand it before opening:Path(svc.log).expanduser()— already handled inmain.py. - launchctl
stopdoesn't prevent KeepAlive services from restarting —unloadis needed for a permanent stop (not exposed in UI on purpose, to prevent accidents). - Auth / cloudflared: cloudflared sets
X-Forwarded-Forbutrequest.client.hostis still127.0.0.1(cloudflared runs locally). Detect external origin by checkingX-Forwarded-Forheader presence, not justclient.host. - FileBrowser iframe:
FB_NOAUTH=trueenv var name may differ by image version — checkdocker run filebrowser/filebrowser --helpif the login screen still appears. Alternatively set--username admin --password "". - Cookie auth and SameSite: the cookie set by
/loginusessamesite=strict, which blocks it on cross-site redirects. Usesamesite=laxinstead if the login flow breaks in some browsers. - Pages tab iframe:
sandbox="allow-same-origin allow-scripts"will block pages that setX-Frame-Options: DENY. This is expected — most external sites won't load; local services usually will.