326 lines
20 KiB
Markdown
326 lines
20 KiB
Markdown
# Eagle Dashboard
|
||
|
||
Process supervisor and control panel for all Eagle local services.
|
||
|
||
**URL:** `http://localhost:8880` (internal) · `https://dashboard.qentra.top` (external, token-required)
|
||
**Source:** `~/Developer/eagle-dash/`
|
||
**LaunchAgent:** `com.eagle.dashboard` (single Login Item, KeepAlive=true)
|
||
|
||
---
|
||
|
||
## Management Policy
|
||
|
||
**Hermes gateway (Eagle и Whale) стартуются, стопаются и перезапускаются ТОЛЬКО через Eagle Dashboard или его API (`POST /api/services/<id>/start|stop|restart`).**
|
||
|
||
Нельзя:
|
||
- `kill` процесс Hermes вручную
|
||
- `launchctl` stop/start Hermes
|
||
- supervisorctl
|
||
- любой другой прямой способ
|
||
|
||
Dashboard supervisor управляет процессами через PID-файлы, отслеживает состояние, не рестартует после ручного стопа (`stop` выставляет флаг, autorestart игнорируется до явного `start`). Health check верится по supervisor PID (точное совпадение), а не по подстроке в cmdline.
|
||
|
||
Для дашборда самого (`com.eagle.dashboard`, type: self) — кнопки управления скрыты, так как supervisor не может restart/stop/start свой процесс.
|
||
|
||
## Architecture
|
||
|
||
FastAPI + HTMX + Tailwind CDN + Alpine.js. No build step, CDN-only frontend.
|
||
|
||
```
|
||
~/Developer/eagle-dash/
|
||
├── main.py # FastAPI app, lifespan, SSE
|
||
├── auth.py # Origin-aware auth: 127.0.0.1 → free; cloudflared → token
|
||
├── supervisor.py # PID-file-based process supervisor (asyncio, setsid)
|
||
├── registry.py # Service registry + YAML loader
|
||
├── health.py # Health checks: http / port / process / launchctl
|
||
├── control.py # /restart /stop /start endpoints
|
||
├── memory.py # RSS tracking (main process + children)
|
||
├── loader.py # services.yaml loader
|
||
├── services.yaml # Service definitions (source of truth)
|
||
├── .env # EAGLE_TOKEN=<secret> (not committed)
|
||
├── templates/
|
||
│ └── index.html # Dashboard UI — 4 tabs: Services, Crons, Pages, Files
|
||
└── com.eagle.dashboard.plist
|
||
```
|
||
|
||
### Auth
|
||
|
||
- `127.0.0.1` direct access → no auth
|
||
- Requests via cloudflared (have `X-Forwarded-For`) → require `Authorization: Bearer <EAGLE_TOKEN>`
|
||
or `eagle_token` cookie (set on first login, 30-day expiry)
|
||
- Token stored in `.env`
|
||
|
||
### Supervisor (supervisor.py)
|
||
|
||
PID-file-based — survives Dashboard restarts without crashing.
|
||
|
||
- Services launched with `start_new_session=True` (setsid) → run in own session
|
||
- PID written to `~/.eagle-dash/pids/<id>.pid` immediately on launch
|
||
- On Dashboard restart: reads PID file → if process alive → adopts without re-launching
|
||
- If process dead → launches fresh
|
||
- Auto-restart with exponential backoff (2s → 60s max)
|
||
|
||
### macOS integration
|
||
|
||
- `pf` anchor: `localhost:80` → `127.0.0.1:8880` (setup-pf-redirect.sh)
|
||
- cloudflared tunnel: `dashboard.qentra.top` → `localhost:8880`
|
||
- FileBrowser: `http://127.0.0.1:8181` (FB_NOAUTH=true, auth delegated to Dashboard)
|
||
|
||
---
|
||
|
||
## Services
|
||
|
||
24 services in `services.yaml`, 10 with `autostart: true`. Секция **Docker** — сервисы, запускающиеся через docker-compose — отображается первой в дашборде.
|
||
|
||
### Docker
|
||
|
||
| ID | Name | Access | Type |
|
||
|----|------|--------|------|
|
||
| zulip | Zulip Connector | localhost | container |
|
||
| filebrowser | FileBrowser | localhost | container |
|
||
|
||
### AI
|
||
|
||
| ID | Name | Access | Type |
|
||
|----|------|--------|------|
|
||
| hermes | Hermes Eagle | localhost | daemon |
|
||
| hermes_whale | Hermes Whale | localhost | daemon |
|
||
| claude_proxy_node | Claude Proxy (node) | localhost | daemon |
|
||
| claude_proxy_python | Claude Proxy (python) | localhost | daemon |
|
||
| asana_mcp | Asana MCP | localhost | daemon |
|
||
| virfield | Virfield | localhost | daemon |
|
||
|
||
### ML
|
||
|
||
| ID | Name | Access | Type |
|
||
|----|------|--------|------|
|
||
| mlx_lm | MLX LM Server | lan (0.0.0.0:8080) | on-demand |
|
||
| ollama | Ollama | lan (0.0.0.0:11434) | on-demand |
|
||
|
||
### Reflect
|
||
|
||
| ID | Name | Access | Type |
|
||
|----|------|--------|------|
|
||
| reflect_proxy | Reflect Proxy | lan | daemon |
|
||
| reflect_fdroid | Reflect F-Droid Server | lan | daemon |
|
||
|
||
### Monitoring
|
||
|
||
| ID | Name | Access | Type |
|
||
|----|------|--------|------|
|
||
| aw_ddg | AW Watcher DDG | localhost | daemon |
|
||
| aw_xcode | AW Watcher Xcode | localhost | daemon |
|
||
|
||
### Orchestration
|
||
|
||
| ID | Name | Access | Type |
|
||
|----|------|--------|------|
|
||
| wiki_ingest | Wiki Ingest | localhost | cron |
|
||
|
||
**Remaining LaunchAgents (not under Dashboard):**
|
||
- `com.colima.start` — Homebrew managed
|
||
- `homebrew.mxcl.postgresql@17` — Homebrew managed
|
||
- `personal.os.wiki-ingest` — cron schedule
|
||
- `com.personalos.heartbeat` — cron schedule
|
||
|
||
---
|
||
|
||
## Network Access Badges
|
||
|
||
- 🟢 `localhost` — bound to 127.0.0.1, safe
|
||
- 🟡 `lan` — bound to 0.0.0.0, accessible on home network (mlx_lm, ollama)
|
||
- 🔵 `tunnel` — cloudflared-managed, token-protected
|
||
|
||
---
|
||
|
||
## Tabs — заглушка урл
|
||
|
||
Каждый таб имеет свой URL через `?tab=` query-параметр. Переключение через Alpine.js `setUrlTab()`, который использует `history.replaceState()`. При загрузке `activeTab` инициализируется из `URLSearchParams` (с fallback на `'services'`). При клике на таб URL обновляется без перезагрузки страницы. Бэкенд также принимает `?tab=` в `GET /` и передаёт его как `initial_tab` в шаблон (пока не используется).
|
||
|
||
**Урлы:**
|
||
- `http://dashboard.qentra.top/` — Services
|
||
- `http://dashboard.qentra.top/?tab=crons` — Crons
|
||
- `http://dashboard.qentra.top/?tab=pages` — Pages
|
||
- `http://dashboard.qentra.top/?tab=files` — Files
|
||
|
||
## Tabs
|
||
|
||
**Services** — health cards with: status badge, PID, uptime, memory (RSS + children), start/stop/restart controls, log tail drawer (last 200 lines).
|
||
|
||
**Pages** — URL input + metadata table for `/Library/WebServer/Documents/*.html` test pages. Each row shows filename, title, purpose, contents, modified date, and opens the page in a new browser tab.
|
||
|
||
### Как добавить страницу в Pages (2026-08-24, проверено)
|
||
|
||
Механика — просто копия `*.html` файла в webroot `/Library/WebServer/Documents/`. Дашборд читает каталог **живьём** через `GET /api/pages` → `_read_page_metadata()` — рестарт дашборда и `launchctl kickstart` **не нужны**. Страница отдаётся по `/local-pages/<filename>` (mount `app.mount("/local-pages", StaticFiles(...))`).
|
||
|
||
Метаданные парсятся из HTML:
|
||
| Поле дашборда | Источник |
|
||
|---|---|
|
||
| `title` | `<title>...</title>` |
|
||
| `purpose` | `<meta name="eagle-test-purpose" content="...">`, fallback — `<meta name="description">` |
|
||
| `contains` | `<meta name="eagle-test-contains" content="...">` |
|
||
|
||
**Pitfall — относительные `href`/`src`:** если страница ссылается на свои `.js`/`.css` относительными путями (как `bilety.html` → `bilety.js`, `bilety.css`), эти файлы тоже обязаны лежать рядом в `/Library/WebServer/Documents/`, иначе запрос уйдёт на `/local-pages/bilety.js` и не найдётся.
|
||
|
||
**Pitfall — права (блокер):** `/Library/WebServer/Documents/` принадлежит `root:wheel`, права `drwxr-xr-x`. `admin` писать туда **не может** без sudo. NOPASSWD-правил для этого пути в sudoers **нет** (`admin` имеет только `(ALL) ALL` с паролем + спец. pmset/launchctl NOPASSWD). Из неинтерактивного шелла Hermes `sudo cp` встаёт на запрос пароля → **требуется пароль пользователя или ручная команда**: `sudo cp ~/Downloads/bilety.* /Library/WebServer/Documents/`.
|
||
|
||
**Files** — FileBrowser iframe at `http://localhost:8181`.
|
||
|
||
**Crons** (2-я вкладка) — управление cron jobs из 3 источников:
|
||
- **Hermes Eagle** (22 jobs) — read/write напрямую в `~/.hermes/cron/jobs.json`
|
||
- **Hermes Whale** (0 jobs) — read/write в `~/.hermes/hermes-whale/cron/jobs.json`
|
||
- **Launchd (User)** — launchd plist-ы из `~/Library/LaunchAgents/` с `StartInterval` или `StartCalendarInterval` (редактируемые через launchctl + plistlib)
|
||
|
||
Для каждого крона на карточке: name, schedule, enabled/disabled, last run, prompt preview, chips (skills, toolsets, model, workdir). Кнопки:
|
||
- **+ Cron** — создать новый крон (выбор source: Hermes Eagle / Hermes Whale / Launchd (User)). При выборе Launchd поля Deliver, Prompt, Model/Provider, Skills/Toolsets динамически скрываются через `x-show="cronModal.source !== 'launchd'"`.
|
||
- ✎ — открыть модал редактирования всех полей (name, schedule, deliver, prompt, script, model, provider, skills, toolsets, workdir)
|
||
- ⏸/▶ — pause/resume (toggle enabled)
|
||
- 🗑 — удалить крон
|
||
|
||
||**Launchd create** — `POST /api/crons` с `source=launchd` генерирует plist. Принимает: `name` (→ Label, префикс `com.` если не указан), `schedule` (только `every Ns/m/h/d` → StartInterval в секундах), `script` (→ ProgramArguments, разбивка по пробелам), `workdir` (→ WorkingDirectory), `run_at_load` (→ RunAtLoad, boolean, default false). Cron-выражения и ISO-даты для launchd **не поддерживаются** — валидатор на фронтенде их отклоняет с сообщением "Launchd: используйте every 30m / every 2h / every 1d". Поля prompt, deliver, skills, toolsets, model, provider игнорируются. Backend парсит `every N` + суффикс s/m/h/d → множитель 1/60/3600/86400.
|
||
|
||
**RunAtLoad** — чекбокс \"Run on load\" в модалке создания/редактирования launchd крона. Сквозь `x-show="cronModal.source === 'launchd'"` показывается только для launchd. Описание: \"запускать при логине / загрузке системы\". При открытии редактирования — читается из `c.run_at_load` (backend отдаёт `plist.get("RunAtLoad", False)`). Сохраняется как `body.run_at_load → pd["RunAtLoad"]`. По умолчанию `false`.
|
||
|
||
**`_list_launchd_crons()`** теперь показывает **все** plist-ы из `~/Library/LaunchAgents/`, не только с StartInterval/StartCalendarInterval. Для plist без schedule — `schedule: "none (manual)"`. Это фикс: раньше plist без schedule (например созданный с cron-выражением) не отображался в дашборде.
|
||
|
||
**Schedule input — формат подсказки и валидация зависят от source:**
|
||
|
||
- Для **launchd**: подсказка только `every 30s / 30m / 2h / 1d — интервал`. Плейсхолдер `every 30m / every 2h`. Валидатор reject cron и ISO.
|
||
- Для **Eagle/Whale**: полная cron-схема (5 полей), пояснения `*`, `*/N`, `N-M`, `A,B`, `N`, плюс `every 30s / 30m / 2h / 1d` и ISO-дата.
|
||
|
||
**Schedule hint** — сворачиваемый multiline блок над полем ввода, открывается по кнопке `? подсказка`. Финальный рабочий текст:
|
||
|
||
```
|
||
┌── минута (0-59)
|
||
│ ┌── час (0-23)
|
||
│ │ ┌── день месяца (1-31)
|
||
│ │ │ ┌── месяц (1-12)
|
||
│ │ │ │ ┌── день недели (0-6, вс=0)
|
||
│ │ │ │ │
|
||
* * * * *
|
||
|
||
* — любое значение
|
||
*/N — с шагом N (*/30 = каждые 30)
|
||
N-M — диапазон (1-5 = пн-пт)
|
||
A,B — список (0,6 = вс,сб)
|
||
N — точное число (0 = в 0 мин)
|
||
|
||
every 30s / 30m / 2h / 1d — человеческий формат
|
||
2026-07-01T09:00 — ISO, одноразово (только Eagle/Whale)
|
||
```
|
||
|
||
- `scheduleHintOpen` — boolean в `cronModal`, по умолчанию `false`
|
||
- При открытии модала сбрасывается в `false`
|
||
- ISO-строка скрыта для launchd через `x-if="cronModal.source !== 'launchd'"`
|
||
|
||
**API эндпоинты:**
|
||
- `GET /` — главная страница, опционально `?tab=crons|pages|files|services`
|
||
- `GET /api/crons` — список всех кронов
|
||
- `POST /api/crons` — создать новый крон (body: source, name, schedule, prompt, script, deliver, skills, enabled_toolsets, model, provider, workdir). Для source=launchd: только name, schedule, script, workdir используются — генерируется plist и загружается через launchctl
|
||
- `PATCH /api/crons/{source}/{job_id}` — редактировать поля
|
||
- `POST /api/crons/{source}/{job_id}/toggle` — включить/выключить
|
||
- `DELETE /api/crons/{source}/{job_id}` — удалить
|
||
|
||
**Формат Hermes cron jobs.json:**
|
||
```json
|
||
{
|
||
"jobs": [
|
||
{
|
||
"id": "531b242c30ad",
|
||
"name": "data-pipeline",
|
||
"prompt": "[SILENT] bash ...",
|
||
"skills": [],
|
||
"skill": null,
|
||
"model": null,
|
||
"provider": null,
|
||
"script": null,
|
||
"schedule": { "kind": "cron", "expr": "*/30 7-21 * * 1-5", "display": "*/30 7-21 * * 1-5" },
|
||
"schedule_display": "*/30 7-21 * * 1-5",
|
||
"enabled": false,
|
||
"state": "paused",
|
||
"deliver": "local",
|
||
"enabled_toolsets": null,
|
||
"workdir": null
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Deployment
|
||
|
||
```bash
|
||
cd ~/Developer/eagle-dash
|
||
|
||
# Install deps
|
||
uv sync
|
||
|
||
# Generate token
|
||
python3 -c "import secrets; print('EAGLE_TOKEN='+secrets.token_hex(32))" > .env
|
||
|
||
# Setup pf redirect (localhost:80 → :8880)
|
||
sudo bash setup-pf-redirect.sh
|
||
|
||
# Load LaunchAgent
|
||
launchctl load ~/Library/LaunchAgents/com.eagle.dashboard.plist
|
||
```
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
**Services show `running: null`** — check `supervisor.supervisor_status` field in `/api/services` (not top-level `running`). The supervisor status is in the nested `supervisor` object.
|
||
|
||
**PID files stale** — `ls ~/.eagle-dash/pids/` and compare with `ps`. Supervisor adopts automatically on next poll.
|
||
|
||
**Ghost launchctl entries** — after removing plists, use `launchctl remove <label>` (not `bootout`) to clear bootstrap session entries.
|
||
|
||
**`_launchd_toggle()` known bug** — `_launchd_toggle()` in `main.py` uses `launchctl list <label>` exit code (0 = loaded) to determine "enabled". But `launchctl list` returns 0 even when process is **not running**, as long as plist is registered. After creating a launchd cron via API (`launchctl load -w`), plist is loaded → `enabled=true` → UI shows Pause. Clicking calls unload → `enabled=false` → "✓ paused". Functionally correct but button state appears inverted if user expected Resume. Fix: rewrite `_launchd_toggle()` to check `Disabled` key in plist after unload rather than `launchctl list` exit code.
|
||
|
||
## Troubleshooting — launchd cron doesn't run (EX_CONFIG)
|
||
|
||
When a launchd cron is created and `launchctl print gui/<uid>/<label>` shows `last exit code = 78: EX_CONFIG`:
|
||
|
||
1. **`~` not expanded in ProgramArguments.** launchd does not expand `~`. Use full path: `/Users/admin/scripts/foo.sh`
|
||
2. **Script itself exits 78.** Check script logic: `exit 78` is `EX_CONFIG` (configuration error). Fix the script.
|
||
|
||
## Launchd status display — PID & exit code parsing
|
||
|
||
`_list_launchd_crons()` parses `launchctl list <label>` output to determine process state:
|
||
|
||
- **next-format plist** — macOS Sequoia returns `{ "PID" = 1234; "LastExitStatus" = 0; ... }` (not JSON, not tab-separated)
|
||
- **PID** — parsed via regex `r'"PID"\s*=\s*(\d+)'`
|
||
- **LastExitStatus** — parsed via regex `r'"LastExitStatus"\s*=\s*(-?\d+)'`
|
||
- `state` logic:
|
||
- `running` — if PID > 0 (process alive right now)
|
||
- `scheduled` — if no PID but has StartInterval/StartCalendarInterval
|
||
- `idle` — if no PID, no schedule, but enabled (KeepAlive daemon between ticks)
|
||
- `stopped` — if disabled (unloaded)
|
||
- `last_status`: `None` when process is `running` (exit code is meaningless); integer (exit code) when process is not running; `None` when no LastExitStatus field (never run)
|
||
- **Frontend** displays: `· exit <code>` in green for 0, red for non-zero; `· never run` when `last_status` is None (grey text)
|
||
|
||
**Files changed:** `main.py` — `_list_launchd_crons()`, `templates/index.html` — cron card status line
|
||
|
||
## Troubleshooting — sync-vault appeared as "none (manual)"
|
||
|
||
**Root cause:** Launchd create accepted cron expression `*/5 * * * *` (pre-fix), couldn't parse it into StartInterval, wrote plist without schedule → `_list_launchd_crons()` (pre-fix) filtered it out. Fixed in two ways:
|
||
- Backend create now rejects non-`every` for launchd (frontend validator also blocks)
|
||
- `_list_launchd_crons()` now shows ALL plists (schedule: "none (manual)" if no StartInterval/StartCalendarInterval)
|
||
|
||
**Virfield port conflict** — if previously a LaunchAgent (plist deleted but still loaded), stop it: `launchctl stop com.virfield.server && launchctl remove com.virfield.server`.
|
||
|
||
---
|
||
|
||
## History
|
||
|
||
|- 2026-06-25 (round 10): **Launchd status — PID/exit code from launchctl list.** Added PID parsing (next-format "PID" = N) and LastExitStatus parsing in `_list_launchd_crons()`. `last_status` is None for running processes, integer exit code otherwise. Frontend shows `· exit <code>` or `· never run`. Legacy tab-separated format no longer supported. macOS launchctl returns next-format plist (not JSON) — regex parsing, not json.loads.
|
||
|- 2026-06-25 (round 9): **RunAtLoad checkbox for launchd crons.** Added `RunAtLoad` field to launchd plist creation. Backend: `main.py` line 636 changed from `pd["RunAtLoad"] = False` to `pd["RunAtLoad"] = body.get("run_at_load", False)`. Frontend: checkbox "Run on load" in cron modal, shown only for source=launchd via `x-show`; `openAddCron()`, `openCronEdit()`, `saveCronModal()` all wired. Backend `_list_launchd_crons()` returns `run_at_load` for edit modal. **Also fixed:** `_msg` auto-dismiss bug — stale object reference after `fetchCrons()` replaced with `find` in fresh array (both crons and services).
|
||
- 2026-06-25 (round 7): **Launchd schedule — source-dependent validation.** `validateSchedule()` now takes `source` param. For `launchd`: only `every Ns/m/h/d` accepted; cron and ISO rejected with explicit message. For Eagle/Whale: all three formats. Hints, placeholders, and `prettySchedule()` also differ by source. `Query` import added to FastAPI.
|
||
- 2026-06-25 (round 6): **Tab URL routing.** Each tab now persists in URL via `?tab=` query-param + `history.replaceState()`. Frontend initializes `activeTab` from URL. Backend accepts `?tab=` on `GET /`. Added `setUrlTab()` to Alpine.js data.
|
||
- 2026-06-25 (round 4): **Schedule hint final format applied.** Multiline cron reference (no `#` prefix), field labels (m/h/d/m/w), value notation (`*/N`, `N-M`, `A,B`, `N`), human format (`every 30s / 30m / 2h / 1d`), ISO date. Collapsible via `? подсказка` button. ISO line hidden for launchd. Validator also blocks Save on invalid input.
|
||
- 2026-06-25 (round 3): **Schedule hint/validator polish.** Replaced `*/30 * * * *` with `every 30m` in hint text. Added `prettySchedule()`. Removed all prefatory labels from hint.
|
||
- 2026-06-25 (round 1): Added **launchd create** support — +Cron source selector now includes "⏰ Launchd (User)". Backend generates plist. Fields for Hermes-only dynamically hidden for launchd.
|
||
- 2026-06-24: Added **Crons** tab — management of Hermes Eagle/Whale cron jobs + Launchd (User) agents. Tab moved to 2nd position. `+Cron` button with source selector (Eagle/Whale). Full edit modal: name, schedule, prompt, script, deliver, skills, toolsets, model, provider, workdir. Toggle pause/resume, delete. All sections editable — launchd crons toggle via `launchctl unload/load -w`, edit via `plistlib`+reload, delete via unload+rm. API: `GET/POST/PATCH/DELETE /api/crons`. `json` and `datetime` imports added to main.py.
|
||
- 2026-06-04: Initial build.
|