# 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//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= (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 ` 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/.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/` (mount `app.mount("/local-pages", StaticFiles(...))`). Метаданные парсятся из HTML: | Поле дашборда | Источник | |---|---| | `title` | `...` | | `purpose` | ``, fallback — `` | | `contains` | `` | **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