[2026-06-04] taiga-vault: personal/projects/eagle-dashboard.md
This commit is contained in:
@@ -4,17 +4,17 @@
|
||||
|
||||
**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.
|
||||
**Architecture:** FastAPI backend reads a YAML service registry and checks health via HTTP/port/process checks every 30s. HTMX + Tailwind CDN frontend with three tabs: **Services** (health cards + 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. LaunchAgent on `:3000`. FileBrowser Docker container on `:8181`.
|
||||
|
||||
**Tech Stack:** Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx
|
||||
**Tech Stack:** Python 3.11, FastAPI, uvicorn, HTMX 2, Tailwind CDN, Alpine.js, PyYAML, psutil, httpx, python-dotenv
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -23,12 +23,16 @@ Two independent subsystems — build them separately:
|
||||
```
|
||||
~/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 — HTMX + Tailwind CDN + Alpine.js
|
||||
│ └── 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
|
||||
@@ -286,6 +290,105 @@ 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:3000/api/health
|
||||
|
||||
# Simulate external (should 401):
|
||||
curl -s -H "X-Forwarded-For: 1.2.3.4" http://127.0.0.1:3000/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:3000/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:**
|
||||
@@ -704,7 +807,7 @@ git add main.py control.py && git commit -m "feat: FastAPI backend with health A
|
||||
**Files:**
|
||||
- Create: `~/Developer/eagle-dash/templates/index.html`
|
||||
|
||||
The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for interactivity.
|
||||
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**
|
||||
|
||||
@@ -872,6 +975,87 @@ The UI uses Tailwind CDN (no build step), HTMX for polling, Alpine.js for intera
|
||||
</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
|
||||
@@ -880,7 +1064,7 @@ just dev
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
Expected: Dark dashboard with service cards grouped by category, green/red dots, network badges, restart/stop buttons.
|
||||
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**
|
||||
|
||||
@@ -890,7 +1074,7 @@ Expected categories: `ai`, `database`, `monitoring`, `orchestration`, `infra`
|
||||
|
||||
```bash
|
||||
cd ~/Developer/eagle-dash
|
||||
git add templates/index.html && git commit -m "feat: dashboard UI with HTMX, Tailwind, Alpine.js"
|
||||
git add templates/index.html && git commit -m "feat: 3-tab UI — Services, Pages, Files"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1062,6 +1246,191 @@ 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:3000`. 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:
|
||||
```yaml
|
||||
ingress:
|
||||
- hostname: dash.qentra.top
|
||||
service: http://localhost:3000
|
||||
# ... 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.*
|
||||
@@ -1083,3 +1452,7 @@ Implementation: extend `registry.py` with a `bind_check` health type that calls
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user