# 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 + memory via HTTP/port/process checks every 30s. HTMX + Tailwind CDN frontend with three tabs: **Services** (health cards with memory usage + 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. Runs internally on `127.0.0.1:8880`. macOS `pf` anchor redirects `localhost:80` → `:8880` so it's accessible at plain `http://localhost` (replacing macOS default httpd). Cloudflared tunnel points directly to `:8880`. 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= ├── 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** ```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 8880 prod: uvicorn main:app --host 127.0.0.1 --port 8880 --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 1b: Port 80 → 8880 via macOS pf Redirect **Goal:** Make `http://localhost` (port 80) serve the dashboard without running Python as root. Uses macOS `pf` packet filter to NAT-redirect `127.0.0.1:80` → `127.0.0.1:8880`. Replaces (disables) the macOS built-in httpd. **Files:** - Create: `/etc/pf.anchors/eagle-localhost` - Modify: `/etc/pf.conf` (add anchor include) - Create: `/Library/LaunchDaemons/com.eagle.pf-redirect.plist` - [ ] **Step 1: Disable macOS default httpd (if running)** ```bash sudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plist 2>/dev/null || true sudo apachectl stop 2>/dev/null || true ``` Expected: no process on port 80 afterwards. - [ ] **Step 2: Write pf anchor rules** ```bash sudo tee /etc/pf.anchors/eagle-localhost <<'EOF' rdr pass on lo0 inet proto tcp from any to 127.0.0.1 port 80 -> 127.0.0.1 port 8880 EOF ``` - [ ] **Step 3: Load the anchor into pf.conf** Check if `/etc/pf.conf` already has an anchor block. Add if missing: ```bash grep -q 'eagle-localhost' /etc/pf.conf || sudo tee -a /etc/pf.conf <<'EOF' # Eagle Dashboard port 80 redirect rdr-anchor "eagle-localhost" load anchor "eagle-localhost" from "/etc/pf.anchors/eagle-localhost" EOF ``` Enable pf and load: ```bash sudo pfctl -ef /etc/pf.conf ``` - [ ] **Step 4: Create LaunchDaemon to reload pf at boot** `/Library/LaunchDaemons/com.eagle.pf-redirect.plist`: ```xml Labelcom.eagle.pf-redirect ProgramArguments /sbin/pfctl -ef /etc/pf.conf RunAtLoad StandardOutPath/tmp/eagle-pf.log StandardErrorPath/tmp/eagle-pf.log ``` ```bash sudo launchctl load /Library/LaunchDaemons/com.eagle.pf-redirect.plist ``` - [ ] **Step 5: Verify** ```bash curl -s http://localhost/api/health # should work once eagle-dash is running sudo pfctl -s nat # should show the rdr rule ``` Expected: redirect rule visible in pfctl output. - [ ] **Step 6: Commit plist to repo** ```bash cd ~/Developer/eagle-dash cp /Library/LaunchDaemons/com.eagle.pf-redirect.plist . git add com.eagle.pf-redirect.plist git commit -m "feat: pf redirect port 80 → 8880 (LaunchDaemon + anchor)" ``` --- ## 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 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** ```bash 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`: ```bash echo ".env" >> .gitignore ``` - [ ] **Step 2: Write auth.py** `~/Developer/eagle-dash/auth.py`: ```python 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): ```python from auth import OriginAuthMiddleware # after: app = FastAPI(...) app.add_middleware(OriginAuthMiddleware) ``` Also add `python-dotenv` to `requirements.txt`. - [ ] **Step 4: Test locally** ```bash cd ~/Developer/eagle-dash just dev & sleep 2 # Should work (localhost): curl -s http://127.0.0.1:8880/api/health # Simulate external (should 401): curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:8880/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:8880/api/health ``` Expected: first call `{"status":"ok"}`, second `{"error":"unauthorized"}`, third `{"status":"ok"}`. - [ ] **Step 5: Commit** ```bash 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`: ```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 ChildProc: pid: int name: str memory_mb: float @dataclass class ServiceStatus: id: str healthy: bool detail: str = "" latency_ms: Optional[float] = None memory_mb: Optional[float] = None # RSS of main process, MB children: list[ChildProc] = field(default_factory=list) 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 _rss_mb(proc: psutil.Process) -> float: try: return proc.memory_info().rss / 1024 / 1024 except (psutil.NoSuchProcess, psutil.AccessDenied): return 0.0 def _children_info(proc: psutil.Process) -> list[ChildProc]: result = [] try: for child in proc.children(recursive=True): result.append(ChildProc( pid=child.pid, name=child.name(), memory_mb=round(_rss_mb(child), 1), )) except (psutil.NoSuchProcess, psutil.AccessDenied): pass return result def check_process(name: str) -> tuple[bool, Optional[float], list[ChildProc]]: 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: mem = round(_rss_mb(proc), 1) children = _children_info(proc) return True, mem, children except (psutil.NoSuchProcess, psutil.AccessDenied): pass return False, None, [] 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 def _proc_by_label(label: str) -> Optional[psutil.Process]: """Find process whose cmdline contains the launchd label.""" for proc in psutil.process_iter(["cmdline"]): try: cmdline = " ".join(proc.info["cmdline"] or []) if label in cmdline: return proc except (psutil.NoSuchProcess, psutil.AccessDenied): pass return None async def check_service(svc: Service) -> ServiceStatus: h = svc.health if h.type == "http": ok, ms = check_http(h.url) # also try to find process by port for memory info proc = _proc_by_label(svc.label) if svc.label else None mem = round(_rss_mb(proc), 1) if proc else None children = _children_info(proc) if proc else [] return ServiceStatus(id=svc.id, healthy=ok, latency_ms=ms, memory_mb=mem, children=children, detail="HTTP ok" if ok else "HTTP failed") if h.type == "port": ok = check_port(svc.port) proc = _proc_by_label(svc.label) if svc.label else None mem = round(_rss_mb(proc), 1) if proc else None children = _children_info(proc) if proc else [] return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children, detail=f"port {svc.port} {'open' if ok else 'closed'}") if h.type == "process": ok, mem, children = check_process(h.name) return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children, detail=f"process '{h.name}' {'found' if ok else 'not found'}") if h.type == "launchctl": ok = check_launchctl(svc.label) proc = _proc_by_label(svc.label) if ok else None mem = round(_rss_mb(proc), 1) if proc else None children = _children_info(proc) if proc else [] return ServiceStatus(id=svc.id, healthy=ok, memory_mb=mem, children=children, 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, "memory_mb": status.memory_mb if status else None, "children": [ {"pid": c.pid, "name": c.name, "memory_mb": c.memory_mb} for c in (status.children if status else []) ], }) 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:8880/api/health curl -s http://localhost:8880/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. Three tabs: **Services**, **Pages**, **Files**. - [ ] **Step 1: Write index.html** `~/Developer/eagle-dash/templates/index.html`: ```html Eagle Dashboard

Eagle Dashboard

services · healthy · unhealthy

auto-refresh 30s

Last updated:

``` The `` element uses `x-data="dashboard()"` and tab state `activeTab: 'services'`. Add a tab bar at the top: ```html
``` Wrap the existing category grid in `
`. Add a **Pages tab** section: ```html
Enter a URL above to load a page
``` Add a **Files tab** section: ```html
``` Add to the `dashboard()` Alpine object: ```javascript 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** ```bash cd ~/Developer/eagle-dash just dev open http://localhost:8880 ``` 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** ```bash 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 ``, after the main grid and before the `