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

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.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.