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

31 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 renders live cards with no build step. launchctl integration enables lifecycle control. Runs as its own LaunchAgent on port 3000.

Tech Stack: Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx


Scope note

Two independent subsystems — build them separately:

  • Plan A (this doc): Service registry + health API + dashboard UI + LaunchAgent control
  • Plan B (future): Network security panel — cloudflared tunnel management, pf rules, per-service firewall toggling

File Structure

~/Developer/eagle-dash/
├── main.py               # FastAPI app — routes, startup, SSE
├── 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)
├── templates/
│   └── index.html        # Dashboard — HTMX + Tailwind CDN + Alpine.js
├── 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 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.

  • 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>
  • Step 2: Open the dashboard in browser
cd ~/Developer/eagle-dash
just dev
open http://localhost:3000

Expected: Dark dashboard with service cards grouped by category, green/red dots, network badges, restart/stop buttons.

  • 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: dashboard UI with HTMX, Tailwind, Alpine.js"

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.


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