Files
obsidian-vault/personal/projects/personal-os/eagle-dashboard.md
T

326 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.