1086 lines
31 KiB
Markdown
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).
|