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

1086 lines
31 KiB
Markdown

# Eagle Dashboard — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `localhost` with a visual control panel that shows health, enables management (start/stop/restart) of all 25+ Eagle LaunchAgent services, and displays network security posture per service.
**Architecture:** FastAPI backend reads a YAML service registry and checks health 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**
```bash
mkdir -p ~/Developer/eagle-dash/templates
```
`~/Developer/eagle-dash/requirements.txt`:
```
fastapi==0.115.0
uvicorn[standard]==0.30.6
pyyaml==6.0.2
httpx==0.27.2
psutil==6.0.0
jinja2==3.1.4
```
- [ ] **Step 2: Create justfile**
`~/Developer/eagle-dash/justfile`:
```just
# vim: set ft=just ts=2
[private]
default:
@just --list
install:
pip install -r requirements.txt
dev:
uvicorn main:app --reload --host 127.0.0.1 --port 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**
```bash
cd ~/Developer/eagle-dash
pip install -r requirements.txt
```
Expected: all packages installed without error.
- [ ] **Step 4: Commit**
```bash
cd ~/Developer/eagle-dash
git init && git add . && git commit -m "feat: project bootstrap"
```
---
## Task 2: Service Registry YAML
**Files:**
- Create: `~/Developer/eagle-dash/services.yaml`
This is the source of truth. Every service gets: name, launchd label, health check config, network access level, category, log path.
- [ ] **Step 1: Write services.yaml**
```yaml
# Network access levels:
# localhost — binds to 127.0.0.1 only ✅
# lan — binds to 0.0.0.0, home-network-only ⚠️
# tunnel — exposed via cloudflared 🌐
# public — directly internet-facing 🔴
services:
# ── AI / ML ──────────────────────────────────────────────
claude_proxy_python:
name: "Claude Proxy (Python)"
label: "com.openclaw.claude-proxy"
port: 8090
health:
type: http
url: "http://localhost:8090/v1/models"
access: localhost
critical: true
category: ai
log: "/tmp/claude-proxy.log"
claude_proxy_node:
name: "Claude Proxy (Node.js)"
label: "ai.claude-proxy"
port: 3456
health:
type: port
access: localhost
critical: false
category: ai
log: "/tmp/claude-proxy-node.log"
mlx_lm:
name: "MLX LM Server"
label: "com.mlx-lm-server"
port: 8080
health:
type: http
url: "http://localhost:8080/v1/models"
access: lan
critical: false
category: ai
log: "/tmp/mlx-lm.log"
ollama:
name: "Ollama"
label: "com.ollama.server"
port: 11434
health:
type: http
url: "http://localhost:11434/api/tags"
access: lan
critical: false
category: ai
log: "/tmp/ollama.log"
hermes:
name: "Hermes Gateway"
label: "ai.hermes.gateway"
health:
type: process
name: "hermes"
access: localhost
critical: true
category: ai
log: "/tmp/hermes.log"
hermes_whale:
name: "Hermes Whale"
label: "ai.hermes.whale-gateway"
health:
type: process
name: "hermes-whale"
access: localhost
critical: false
category: ai
log: "/tmp/hermes-whale.log"
asana_mcp:
name: "Asana MCP Proxy"
label: "com.asana-mcp-proxy"
health:
type: launchctl
access: localhost
critical: false
category: ai
log: "/tmp/asana-mcp.log"
virfield:
name: "Virfield VM MCP"
label: "com.virfield.server"
health:
type: launchctl
access: localhost
critical: false
category: ai
# ── Database ─────────────────────────────────────────────
postgres:
name: "PostgreSQL 17"
label: "homebrew.mxcl.postgresql@17"
port: 5432
health:
type: port
access: localhost
critical: true
category: database
log: "/opt/homebrew/var/log/postgresql@17.log"
# ── Monitoring ───────────────────────────────────────────
aw_ddg:
name: "AW Watcher DDG"
label: "com.personalos.aw-watcher-ddg"
health:
type: process
name: "aw-watcher-ddg"
access: localhost
critical: false
category: monitoring
log: "/tmp/aw-watcher-ddg.log"
aw_xcode:
name: "AW Watcher Xcode"
label: "com.personalos.aw-watcher-xcode"
health:
type: process
name: "aw-watcher-xcode"
access: localhost
critical: false
category: monitoring
log: "/tmp/aw-watcher-xcode.log"
# ── Orchestration ────────────────────────────────────────
heartbeat:
name: "Personal OS Heartbeat"
label: "com.personalos.heartbeat"
health:
type: launchctl
access: localhost
critical: true
category: orchestration
log: "~/Library/Logs/personal-os/heartbeat.log"
# ── Infrastructure ───────────────────────────────────────
colima:
name: "Colima (Docker)"
label: "com.colima.start"
health:
type: process
name: "colima"
access: localhost
critical: false
category: infra
zulip:
name: "Zulip"
label: "top.qentra.zulip"
port: 8080
health:
type: launchctl
access: localhost
critical: false
category: infra
```
- [ ] **Step 2: Commit**
```bash
cd ~/Developer/eagle-dash
git add services.yaml && git commit -m "feat: service registry YAML"
```
---
## Task 3: Registry Loader
**Files:**
- Create: `~/Developer/eagle-dash/registry.py`
- Create: `~/Developer/eagle-dash/tests/test_registry.py`
- [ ] **Step 1: Write the failing test**
`~/Developer/eagle-dash/tests/test_registry.py`:
```python
import pytest
from registry import load_registry, Service
def test_load_registry_returns_services():
services = load_registry("services.yaml")
assert len(services) > 0
def test_critical_services_present():
services = load_registry("services.yaml")
names = {s.id for s in services}
assert "postgres" in names
assert "hermes" in names
assert "claude_proxy_python" in names
def test_service_has_required_fields():
services = load_registry("services.yaml")
svc = next(s for s in services if s.id == "postgres")
assert svc.name == "PostgreSQL 17"
assert svc.label == "homebrew.mxcl.postgresql@17"
assert svc.access == "localhost"
assert svc.critical is True
assert svc.category == "database"
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_registry.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'registry'`
- [ ] **Step 3: Write registry.py**
`~/Developer/eagle-dash/registry.py`:
```python
from dataclasses import dataclass, field
from typing import Optional
import yaml
@dataclass
class HealthConfig:
type: str # http | port | process | launchctl
url: Optional[str] = None
name: Optional[str] = None # process name
@dataclass
class Service:
id: str
name: str
label: str
health: HealthConfig
access: str # localhost | lan | tunnel | public
critical: bool
category: str
port: Optional[int] = None
log: Optional[str] = None
def load_registry(path: str = "services.yaml") -> list[Service]:
with open(path) as f:
raw = yaml.safe_load(f)
services = []
for svc_id, cfg in raw["services"].items():
hcfg = cfg["health"]
health = HealthConfig(
type=hcfg["type"],
url=hcfg.get("url"),
name=hcfg.get("name"),
)
svc = Service(
id=svc_id,
name=cfg["name"],
label=cfg["label"],
health=health,
access=cfg.get("access", "localhost"),
critical=cfg.get("critical", False),
category=cfg.get("category", "other"),
port=cfg.get("port"),
log=cfg.get("log"),
)
services.append(svc)
return services
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_registry.py -v
```
Expected: PASS all 3 tests.
- [ ] **Step 5: Commit**
```bash
cd ~/Developer/eagle-dash
git add registry.py tests/ && git commit -m "feat: service registry loader"
```
---
## Task 4: Health Check Engine
**Files:**
- Create: `~/Developer/eagle-dash/health.py`
- Create: `~/Developer/eagle-dash/tests/test_health.py`
- [ ] **Step 1: Write the failing test**
`~/Developer/eagle-dash/tests/test_health.py`:
```python
import pytest
from unittest.mock import patch, MagicMock
from health import check_port, check_process, ServiceStatus
def test_port_check_open():
# Port 5432 (postgres) should be open on this machine
result = check_port(5432)
assert result is True
def test_port_check_closed():
result = check_port(19999) # nothing runs here
assert result is False
def test_process_check_existing():
result = check_process("python3")
assert result is True
def test_process_check_missing():
result = check_process("definitely-not-running-xyz")
assert result is False
def test_service_status_dataclass():
s = ServiceStatus(id="test", healthy=True, detail="ok")
assert s.id == "test"
assert s.healthy is True
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_health.py -v
```
Expected: FAIL with `ModuleNotFoundError: No module named 'health'`
- [ ] **Step 3: Write health.py**
`~/Developer/eagle-dash/health.py`:
```python
import socket
import subprocess
from dataclasses import dataclass
from typing import Optional
import httpx
import psutil
from registry import Service
@dataclass
class 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**
```bash
cd ~/Developer/eagle-dash
python -m pytest tests/test_health.py -v
```
Expected: PASS all 5 tests. (Port 5432 must be open — postgres must be running.)
- [ ] **Step 5: Commit**
```bash
cd ~/Developer/eagle-dash
git add health.py tests/test_health.py && git commit -m "feat: health check engine"
```
---
## Task 5: FastAPI Backend
**Files:**
- Create: `~/Developer/eagle-dash/main.py`
- Create: `~/Developer/eagle-dash/control.py`
- [ ] **Step 1: Write control.py (launchctl wrappers)**
`~/Developer/eagle-dash/control.py`:
```python
import subprocess
import os
def _launchctl(action: str, label_or_plist: str) -> tuple[bool, str]:
cmd = ["launchctl", action, label_or_plist]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0, result.stderr.strip() or result.stdout.strip()
def stop_service(label: str) -> tuple[bool, str]:
return _launchctl("stop", label)
def start_service(label: str) -> tuple[bool, str]:
return _launchctl("start", label)
def kickstart_service(label: str) -> tuple[bool, str]:
domain = f"gui/{os.getuid()}/{label}"
result = subprocess.run(
["launchctl", "kickstart", "-k", domain],
capture_output=True, text=True
)
return result.returncode == 0, result.stderr.strip() or result.stdout.strip()
```
- [ ] **Step 2: Write main.py**
`~/Developer/eagle-dash/main.py`:
```python
import asyncio
import os
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from fastapi import Request
from registry import load_registry, Service
from health import check_service, ServiceStatus
from control import stop_service, start_service, kickstart_service
REGISTRY_PATH = Path(__file__).parent / "services.yaml"
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
_services: list[Service] = []
_status_cache: dict[str, ServiceStatus] = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
global _services
_services = load_registry(str(REGISTRY_PATH))
asyncio.create_task(_poll_loop())
yield
app = FastAPI(title="Eagle Dashboard", lifespan=lifespan)
async def _poll_loop():
while True:
results = await asyncio.gather(
*[check_service(s) for s in _services],
return_exceptions=True
)
for svc, result in zip(_services, results):
if isinstance(result, ServiceStatus):
_status_cache[svc.id] = result
await asyncio.sleep(30)
def _svc_by_id(svc_id: str) -> Service:
for s in _services:
if s.id == svc_id:
return s
raise HTTPException(status_code=404, detail=f"Service '{svc_id}' not in registry")
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
return TEMPLATES.TemplateResponse("index.html", {"request": request})
@app.get("/api/services")
async def list_services():
result = []
for svc in _services:
status = _status_cache.get(svc.id)
result.append({
"id": svc.id,
"name": svc.name,
"label": svc.label,
"category": svc.category,
"access": svc.access,
"critical": svc.critical,
"port": svc.port,
"healthy": status.healthy if status else None,
"detail": status.detail if status else "pending",
"latency_ms": status.latency_ms if status else None,
})
return result
@app.post("/api/services/{svc_id}/restart")
async def restart_service(svc_id: str):
svc = _svc_by_id(svc_id)
ok, msg = kickstart_service(svc.label)
return {"ok": ok, "message": msg}
@app.post("/api/services/{svc_id}/stop")
async def stop(svc_id: str):
svc = _svc_by_id(svc_id)
ok, msg = stop_service(svc.label)
return {"ok": ok, "message": msg}
@app.post("/api/services/{svc_id}/start")
async def start(svc_id: str):
svc = _svc_by_id(svc_id)
ok, msg = start_service(svc.label)
return {"ok": ok, "message": msg}
@app.get("/api/services/{svc_id}/logs")
async def tail_log(svc_id: str, lines: int = 100):
svc = _svc_by_id(svc_id)
if not svc.log:
raise HTTPException(status_code=404, detail="No log configured for this service")
log_path = Path(svc.log).expanduser()
if not log_path.exists():
return {"lines": [], "error": "log file not found"}
with open(log_path, "r", errors="replace") as f:
all_lines = f.readlines()
return {"lines": all_lines[-lines:]}
@app.get("/api/health")
async def self_health():
return {"status": "ok", "services_registered": len(_services)}
```
- [ ] **Step 3: Smoke-test the API manually**
```bash
cd ~/Developer/eagle-dash
just dev &
sleep 2
curl -s http://localhost: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**
```bash
cd ~/Developer/eagle-dash
git add main.py control.py && git commit -m "feat: FastAPI backend with health API and control endpoints"
```
---
## Task 6: Dashboard HTML Template
**Files:**
- Create: `~/Developer/eagle-dash/templates/index.html`
The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for interactivity.
- [ ] **Step 1: Write index.html**
`~/Developer/eagle-dash/templates/index.html`:
```html
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eagle Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {} }
}
</script>
<script src="https://unpkg.com/htmx.org@2.0.2"></script>
<script src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js" defer></script>
<style>
body { background: #0f1117; color: #e2e8f0; font-family: 'SF Pro Display', system-ui, sans-serif; }
.card { background: #1a1d27; border: 1px solid #2d3047; }
.healthy { background: #1a2e1a; border-color: #2d5a2d; }
.unhealthy { background: #2e1a1a; border-color: #5a2d2d; }
.pending { background: #1a1d27; border-color: #3d3d5a; }
.badge-localhost { background: #1a3a1a; color: #4ade80; }
.badge-lan { background: #3a2e0a; color: #fbbf24; }
.badge-tunnel { background: #1a2a3a; color: #60a5fa; }
.badge-public { background: #3a0a0a; color: #f87171; }
</style>
</head>
<body class="min-h-screen p-6" x-data="dashboard()">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white">Eagle Dashboard</h1>
<p class="text-slate-400 text-sm mt-1">
<span x-text="services.length"></span> services ·
<span class="text-green-400" x-text="healthy"></span> healthy ·
<span class="text-red-400" x-text="unhealthy"></span> unhealthy
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">auto-refresh 30s</span>
<button @click="refresh()" class="px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 rounded-md text-white">↻ Refresh</button>
</div>
</div>
<!-- Category Sections -->
<template x-for="cat in categories" :key="cat">
<div class="mb-8">
<h2 class="text-xs uppercase tracking-widest text-slate-500 mb-3" x-text="cat"></h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
<template x-for="svc in byCategory(cat)" :key="svc.id">
<div class="card rounded-lg p-4 transition-colors duration-300"
:class="svc.healthy === true ? 'healthy' : svc.healthy === false ? 'unhealthy' : 'pending'">
<!-- Service header -->
<div class="flex items-start justify-between mb-2">
<div>
<span class="font-medium text-white text-sm" x-text="svc.name"></span>
<template x-if="svc.critical">
<span class="ml-1 text-xs text-amber-400"></span>
</template>
<template x-if="svc.port">
<span class="ml-1 text-xs text-slate-500" x-text="':' + svc.port"></span>
</template>
</div>
<!-- Status dot -->
<span class="mt-0.5 w-2.5 h-2.5 rounded-full flex-shrink-0"
:class="svc.healthy === true ? 'bg-green-400' : svc.healthy === false ? 'bg-red-400' : 'bg-slate-500'">
</span>
</div>
<!-- Detail + latency -->
<p class="text-xs text-slate-400 mb-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**
```bash
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**
```bash
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:
```html
<!-- Log Drawer -->
<div x-show="logOpen" x-transition class="fixed bottom-0 left-0 right-0 h-64 bg-gray-950 border-t border-gray-700 overflow-hidden flex flex-col">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800">
<span class="text-sm text-slate-300 font-mono" x-text="'Logs: ' + (logService || '')"></span>
<button @click="logOpen = false" class="text-slate-500 hover:text-white text-sm">✕ close</button>
</div>
<div class="flex-1 overflow-y-auto p-3 font-mono text-xs text-slate-400 leading-5" id="log-panel">
<template x-for="line in logLines" :key="line">
<div x-text="line"></div>
</template>
<div x-show="logLines.length === 0" class="text-slate-600">No log lines available.</div>
</div>
</div>
```
Also extend the Alpine `dashboard()` function with log state and a showLogs method:
```javascript
// Add to the returned object in dashboard():
logOpen: false,
logService: '',
logLines: [],
async showLogs(id) {
this.logService = id;
this.logOpen = true;
const res = await fetch(`/api/services/${id}/logs?lines=200`);
const data = await res.json();
this.logLines = data.lines || [];
await this.$nextTick();
const panel = document.getElementById('log-panel');
if (panel) panel.scrollTop = panel.scrollHeight;
},
```
And add a "logs" button to each service card (after the restart/stop buttons):
```html
<button @click="showLogs(svc.id)" x-show="svc.log"
class="text-xs px-2 py-0.5 bg-slate-700 hover:bg-slate-600 rounded text-slate-300">
logs
</button>
```
- [ ] **Step 2: Test log panel**
```bash
open http://localhost:3000
```
Click "logs" on the PostgreSQL card. Expected: bottom drawer opens with last 200 lines of the postgres log.
- [ ] **Step 3: Commit**
```bash
cd ~/Developer/eagle-dash
git add templates/index.html && git commit -m "feat: expandable log tail drawer"
```
---
## Task 8: Deploy as LaunchAgent
**Files:**
- Create: `~/Developer/eagle-dash/com.eagle.dashboard.plist`
- [ ] **Step 1: Write the plist**
`~/Developer/eagle-dash/com.eagle.dashboard.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.eagle.dashboard</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>uvicorn</string>
<string>main:app</string>
<string>--host</string>
<string>127.0.0.1</string>
<string>--port</string>
<string>3000</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/admin/Developer/eagle-dash</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/eagle-dashboard.log</string>
<key>StandardErrorPath</key>
<string>/tmp/eagle-dashboard.log</string>
</dict>
</plist>
```
- [ ] **Step 2: Deploy**
```bash
cd ~/Developer/eagle-dash
just deploy
```
Expected: `launchctl load` succeeds.
- [ ] **Step 3: Verify it's running**
```bash
launchctl list com.eagle.dashboard
curl -s http://localhost: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`:
```yaml
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**
```bash
cd ~/Developer/eagle-dash
git add . && git commit -m "feat: LaunchAgent deploy — eagle-dashboard is live on port 3000"
```
- [ ] **Step 6: Open and verify**
```bash
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).