1622 lines
50 KiB
Markdown
1622 lines
50 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 + 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=<secret>
|
|
├── templates/
|
|
│ └── index.html # Dashboard — 3-tab HTMX + Tailwind CDN + Alpine.js
|
|
├── docker/
|
|
│ └── filebrowser.yml # FileBrowser Docker Compose
|
|
├── requirements.txt
|
|
├── justfile
|
|
└── com.eagle.dashboard.plist # LaunchAgent plist
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Project Bootstrap
|
|
|
|
**Files:**
|
|
- Create: `~/Developer/eagle-dash/requirements.txt`
|
|
- Create: `~/Developer/eagle-dash/justfile`
|
|
|
|
- [ ] **Step 1: Create project directory and requirements**
|
|
|
|
```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
|
|
<?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.pf-redirect</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>/sbin/pfctl</string>
|
|
<string>-ef</string>
|
|
<string>/etc/pf.conf</string>
|
|
</array>
|
|
<key>RunAtLoad</key><true/>
|
|
<key>StandardOutPath</key><string>/tmp/eagle-pf.log</string>
|
|
<key>StandardErrorPath</key><string>/tmp/eagle-pf.log</string>
|
|
</dict>
|
|
</plist>
|
|
```
|
|
|
|
```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
|
|
<!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-1" x-text="svc.detail + (svc.latency_ms ? ' · ' + Math.round(svc.latency_ms) + 'ms' : '')"></p>
|
|
|
|
<!-- Memory usage -->
|
|
<template x-if="svc.memory_mb">
|
|
<div class="mb-2">
|
|
<span class="text-xs text-slate-500" x-text="svc.memory_mb + ' MB'"></span>
|
|
<template x-if="svc.children && svc.children.length">
|
|
<span class="text-xs text-slate-600 ml-1"
|
|
x-text="'+ ' + svc.children.length + ' child' + (svc.children.length > 1 ? 'ren' : '') + ' (' + svc.children.reduce((a,c) => a+c.memory_mb, 0).toFixed(1) + ' MB)'">
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Network access badge -->
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs px-2 py-0.5 rounded-full font-mono"
|
|
:class="'badge-' + svc.access"
|
|
x-text="svc.access">
|
|
</span>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-1.5" x-show="svc.healthy !== null">
|
|
<button @click="action(svc.id, 'restart')"
|
|
class="text-xs px-2 py-0.5 bg-slate-700 hover:bg-slate-600 rounded text-slate-300">
|
|
restart
|
|
</button>
|
|
<button @click="action(svc.id, svc.healthy ? 'stop' : 'start')"
|
|
class="text-xs px-2 py-0.5 rounded text-slate-300"
|
|
:class="svc.healthy ? 'bg-red-900 hover:bg-red-800' : 'bg-green-900 hover:bg-green-800'">
|
|
<span x-text="svc.healthy ? 'stop' : 'start'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action feedback -->
|
|
<p x-show="svc._msg" x-text="svc._msg"
|
|
class="mt-2 text-xs text-slate-400 italic"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<p class="text-xs text-slate-600 mt-8">
|
|
Last updated: <span x-text="lastUpdate"></span>
|
|
</p>
|
|
|
|
<script>
|
|
function dashboard() {
|
|
return {
|
|
services: [],
|
|
lastUpdate: '',
|
|
intervalId: null,
|
|
|
|
get categories() {
|
|
return [...new Set(this.services.map(s => s.category))];
|
|
},
|
|
get healthy() {
|
|
return this.services.filter(s => s.healthy === true).length;
|
|
},
|
|
get unhealthy() {
|
|
return this.services.filter(s => s.healthy === false).length;
|
|
},
|
|
|
|
byCategory(cat) {
|
|
return this.services.filter(s => s.category === cat);
|
|
},
|
|
|
|
async refresh() {
|
|
const res = await fetch('/api/services');
|
|
const data = await res.json();
|
|
// merge _msg field from existing services
|
|
this.services = data.map(svc => ({
|
|
...svc,
|
|
_msg: (this.services.find(s => s.id === svc.id) || {})._msg || ''
|
|
}));
|
|
this.lastUpdate = new Date().toLocaleTimeString();
|
|
},
|
|
|
|
async action(id, verb) {
|
|
const res = await fetch(`/api/services/${id}/${verb}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
const svc = this.services.find(s => s.id === id);
|
|
if (svc) {
|
|
svc._msg = data.ok ? '✓ ' + verb + ' sent' : '✗ ' + data.message;
|
|
setTimeout(() => { svc._msg = ''; }, 4000);
|
|
}
|
|
setTimeout(() => this.refresh(), 1500);
|
|
},
|
|
|
|
init() {
|
|
this.refresh();
|
|
this.intervalId = setInterval(() => this.refresh(), 30000);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
The `<body>` element uses `x-data="dashboard()"` and tab state `activeTab: 'services'`. Add a tab bar at the top:
|
|
|
|
```html
|
|
<!-- Tab Bar -->
|
|
<div class="flex gap-1 mb-6 border-b border-slate-700 pb-2">
|
|
<button @click="activeTab='services'"
|
|
:class="activeTab==='services' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
|
|
class="px-4 py-1.5 text-sm font-medium -mb-2">Services</button>
|
|
<button @click="activeTab='pages'"
|
|
:class="activeTab==='pages' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
|
|
class="px-4 py-1.5 text-sm font-medium -mb-2">Pages</button>
|
|
<button @click="activeTab='files'"
|
|
:class="activeTab==='files' ? 'text-white border-b-2 border-indigo-500' : 'text-slate-500 hover:text-slate-300'"
|
|
class="px-4 py-1.5 text-sm font-medium -mb-2">Files</button>
|
|
</div>
|
|
```
|
|
|
|
Wrap the existing category grid in `<div x-show="activeTab==='services'">`.
|
|
|
|
Add a **Pages tab** section:
|
|
```html
|
|
<!-- Pages Tab -->
|
|
<div x-show="activeTab==='pages'" class="flex flex-col gap-4 h-[80vh]">
|
|
<div class="flex gap-2">
|
|
<input x-model="pageUrl" @keyup.enter="loadPage()"
|
|
placeholder="http://localhost:8080/..."
|
|
class="flex-1 bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-sm text-white
|
|
font-mono placeholder-slate-500 focus:outline-none focus:border-indigo-500" />
|
|
<button @click="loadPage()"
|
|
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 rounded text-sm text-white">Load</button>
|
|
<button @click="pageUrl=''; pageLoaded=false"
|
|
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 rounded text-sm text-slate-300">Clear</button>
|
|
</div>
|
|
<!-- Quick links to common local services -->
|
|
<div class="flex gap-2 flex-wrap">
|
|
<template x-for="link in quickLinks" :key="link.url">
|
|
<button @click="pageUrl=link.url; loadPage()"
|
|
class="text-xs px-2 py-1 bg-slate-800 hover:bg-slate-700 border border-slate-600
|
|
rounded text-slate-400 hover:text-white">
|
|
<span x-text="link.label"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
<iframe x-show="pageLoaded" :src="pageUrl" class="flex-1 rounded border border-slate-700 bg-white"
|
|
sandbox="allow-same-origin allow-scripts allow-forms"></iframe>
|
|
<div x-show="!pageLoaded" class="flex-1 rounded border border-slate-700 flex items-center
|
|
justify-center text-slate-600 text-sm">
|
|
Enter a URL above to load a page
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
Add a **Files tab** section:
|
|
```html
|
|
<!-- Files Tab -->
|
|
<div x-show="activeTab==='files'" class="h-[80vh]">
|
|
<iframe src="http://localhost:8181"
|
|
class="w-full h-full rounded border border-slate-700"
|
|
allow="fullscreen"></iframe>
|
|
</div>
|
|
```
|
|
|
|
Add to the `dashboard()` Alpine object:
|
|
```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 `<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:8880
|
|
```
|
|
|
|
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:8880/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:8880/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 8880"
|
|
```
|
|
|
|
- [ ] **Step 6: Open and verify**
|
|
|
|
```bash
|
|
open http://localhost:8880
|
|
```
|
|
|
|
Expected: Dashboard loads, eagle-dashboard itself shows as a green card.
|
|
|
|
---
|
|
|
|
## Task 9: FileBrowser Docker Container
|
|
|
|
**Files:**
|
|
- Create: `~/Developer/eagle-dash/docker/filebrowser.yml`
|
|
|
|
FileBrowser is a web UI for browsing and managing files — same image used on TrueNAS. Runs via Colima (Docker on Mac).
|
|
|
|
- [ ] **Step 1: Write docker-compose file**
|
|
|
|
`~/Developer/eagle-dash/docker/filebrowser.yml`:
|
|
```yaml
|
|
services:
|
|
filebrowser:
|
|
image: filebrowser/filebrowser:latest
|
|
container_name: eagle-filebrowser
|
|
ports:
|
|
- "127.0.0.1:8181:80"
|
|
volumes:
|
|
- /Users/admin:/srv
|
|
- filebrowser_db:/database
|
|
environment:
|
|
- FB_NOAUTH=true # auth handled by Eagle Dashboard token gate
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
filebrowser_db:
|
|
```
|
|
|
|
Note: `FB_NOAUTH=true` disables FileBrowser's own auth since it's served behind Eagle Dashboard's token middleware (external requests already authenticated before they reach the iframe).
|
|
|
|
- [ ] **Step 2: Start it**
|
|
|
|
```bash
|
|
docker compose -f ~/Developer/eagle-dash/docker/filebrowser.yml up -d
|
|
curl -s http://localhost:8181
|
|
```
|
|
|
|
Expected: FileBrowser HTML response.
|
|
|
|
- [ ] **Step 3: Add to services.yaml**
|
|
|
|
Under `# ── Infrastructure`:
|
|
```yaml
|
|
filebrowser:
|
|
name: "FileBrowser"
|
|
label: "eagle-filebrowser"
|
|
port: 8181
|
|
health:
|
|
type: http
|
|
url: "http://localhost:8181"
|
|
access: localhost
|
|
critical: false
|
|
category: infra
|
|
log: ~
|
|
```
|
|
|
|
- [ ] **Step 4: Add docker start to justfile**
|
|
|
|
```just
|
|
filebrowser:
|
|
docker compose -f docker/filebrowser.yml up -d
|
|
|
|
filebrowser-stop:
|
|
docker compose -f docker/filebrowser.yml down
|
|
```
|
|
|
|
- [ ] **Step 5: Make it start at login via LaunchAgent**
|
|
|
|
Create `~/Library/LaunchAgents/com.eagle.filebrowser.plist`:
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key><string>com.eagle.filebrowser</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>/usr/local/bin/docker</string>
|
|
<string>compose</string>
|
|
<string>-f</string>
|
|
<string>/Users/admin/Developer/eagle-dash/docker/filebrowser.yml</string>
|
|
<string>up</string>
|
|
<string>-d</string>
|
|
</array>
|
|
<key>RunAtLoad</key><true/>
|
|
<key>StandardOutPath</key><string>/tmp/eagle-filebrowser.log</string>
|
|
<key>StandardErrorPath</key><string>/tmp/eagle-filebrowser.log</string>
|
|
</dict>
|
|
</plist>
|
|
```
|
|
|
|
```bash
|
|
launchctl load ~/Library/LaunchAgents/com.eagle.filebrowser.plist
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd ~/Developer/eagle-dash
|
|
git add docker/ services.yaml justfile
|
|
git commit -m "feat: FileBrowser Docker container on :8181"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Cloudflared Tunnel for dash.qentra.top
|
|
|
|
**Goal:** Expose `dash.qentra.top` via cloudflared → `localhost:8880`. External requests are token-gated by auth middleware (Task 2a).
|
|
|
|
- [ ] **Step 1: Add ingress rule to cloudflared config**
|
|
|
|
Check existing config:
|
|
```bash
|
|
cat ~/.cloudflared/config.yml
|
|
```
|
|
|
|
Add a new ingress entry for the dashboard (points directly to internal port, bypassing pf):
|
|
```yaml
|
|
ingress:
|
|
- hostname: dash.qentra.top
|
|
service: http://localhost:8880
|
|
# ... existing rules ...
|
|
- service: http_status:404
|
|
```
|
|
|
|
If a `tunnel:` + `credentials-file:` block already exists, just add the ingress line above any catch-all rule.
|
|
|
|
- [ ] **Step 2: Restart cloudflared**
|
|
|
|
```bash
|
|
launchctl kickstart -k gui/$(id -u)/com.cloudflare.cloudflared
|
|
```
|
|
|
|
Or if it's a brew service:
|
|
```bash
|
|
brew services restart cloudflare/cloudflare/cloudflared
|
|
```
|
|
|
|
- [ ] **Step 3: Create DNS CNAME in Cloudflare dashboard**
|
|
|
|
In Cloudflare DNS for `qentra.top`:
|
|
- Type: `CNAME`
|
|
- Name: `dash`
|
|
- Target: `<your-tunnel-id>.cfargotunnel.com`
|
|
- Proxy: enabled (orange cloud)
|
|
|
|
- [ ] **Step 4: Test external access**
|
|
|
|
```bash
|
|
TOKEN=$(grep EAGLE_DASH_TOKEN ~/Developer/eagle-dash/.env | cut -d= -f2)
|
|
curl -s https://dash.qentra.top/api/health # should 401
|
|
curl -s -H "Authorization: Bearer $TOKEN" https://dash.qentra.top/api/health # should 200
|
|
```
|
|
|
|
- [ ] **Step 5: Add bookmarklet for browser access**
|
|
|
|
For convenient external access from a browser, create a bookmarklet that injects the token header. Or simpler — add a `/login` page to FastAPI that sets a cookie:
|
|
|
|
`~/Developer/eagle-dash/auth.py` extension: check `eagle_token` cookie in addition to `Authorization` header. The `/login?token=<TOKEN>` route sets the cookie and redirects to `/`. This way the browser can store the token in a cookie after one authenticated visit.
|
|
|
|
```python
|
|
from fastapi import Cookie, Response
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
# In main.py, add this route:
|
|
@app.get("/login")
|
|
async def login(token: str, response: Response):
|
|
if token != TOKEN:
|
|
raise HTTPException(status_code=401, detail="invalid token")
|
|
resp = RedirectResponse(url="/")
|
|
resp.set_cookie("eagle_token", token, httponly=True, samesite="strict", max_age=86400*30)
|
|
return resp
|
|
```
|
|
|
|
Update `OriginAuthMiddleware.dispatch` to also check `request.cookies.get("eagle_token")`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd ~/Developer/eagle-dash
|
|
git add . && git commit -m "feat: cloudflared tunnel + cookie-based browser auth"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2 Outline: Network Security Panel
|
|
|
|
*Not in this plan — build after Phase 1 ships.*
|
|
|
|
Goals:
|
|
- Detect which services bind to `0.0.0.0` vs `127.0.0.1` (currently: MLX LM and Ollama are on LAN)
|
|
- Show effective firewall status per service (via `pf` rules or macOS firewall API)
|
|
- Cloudflared tunnel panel: list active tunnels from `~/.cloudflared/config.yml`, show which services are exposed externally, enable/disable ingress rules per service
|
|
- Alert when a service marked `access: localhost` is actually bound to `0.0.0.0`
|
|
|
|
Implementation: extend `registry.py` with a `bind_check` health type that calls `lsof -i :<port>` to verify the actual bind address at runtime.
|
|
|
|
---
|
|
|
|
## Known Pitfalls
|
|
|
|
- `launchctl start` only works if the service is already loaded (plist in LaunchAgents). Use `kickstart` for force-restart.
|
|
- Python uvicorn must be on PATH when launchd launches it — use full path `/opt/homebrew/bin/uvicorn` if `just dev` works but the LaunchAgent doesn't start.
|
|
- Some services (Ollama, MLX LM) bind to `0.0.0.0` and are accessible from LAN — displayed as `⚠️ lan` badge.
|
|
- The heartbeat.sh log path uses `~` — expand it before opening: `Path(svc.log).expanduser()` — already handled in `main.py`.
|
|
- launchctl `stop` doesn't prevent KeepAlive services from restarting — `unload` is needed for a permanent stop (not exposed in UI on purpose, to prevent accidents).
|
|
- **Auth / cloudflared:** cloudflared sets `X-Forwarded-For` but `request.client.host` is still `127.0.0.1` (cloudflared runs locally). Detect external origin by checking `X-Forwarded-For` header presence, not just `client.host`.
|
|
- **FileBrowser iframe:** `FB_NOAUTH=true` env var name may differ by image version — check `docker run filebrowser/filebrowser --help` if the login screen still appears. Alternatively set `--username admin --password ""`.
|
|
- **Cookie auth and SameSite:** the cookie set by `/login` uses `samesite=strict`, which blocks it on cross-site redirects. Use `samesite=lax` instead if the login flow breaks in some browsers.
|
|
- **Pages tab iframe:** `sandbox="allow-same-origin allow-scripts"` will block pages that set `X-Frame-Options: DENY`. This is expected — most external sites won't load; local services usually will.
|
|
- **Memory tracking imports:** `ChildProc` uses `field(default_factory=list)` — add `from dataclasses import dataclass, field` and `from typing import Optional` to `health.py`.
|
|
- **pf and SIP:** macOS SIP does not block `pfctl` on /etc/pf.conf edits, but some pf operations require rebooting after System Integrity Protection changes. Test with `sudo pfctl -s nat` after loading.
|
|
- **Port 80 and launchd ordering:** Eagle Dashboard LaunchAgent must start before the pf LaunchDaemon is useful — both happen at login, but pf redirect is independent (kernel-level), so ordering doesn't matter.
|
|
- **Memory for launchctl services:** `_proc_by_label` searches cmdline for the label string — this works for most launchd jobs whose argv[0] contains the plist label. If a service doesn't include its label in cmdline, memory will show `null`.
|