21 KiB
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 вручнуюlaunchctlstop/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.1direct access → no auth- Requests via cloudflared (have
X-Forwarded-For) → requireAuthorization: Bearer <EAGLE_TOKEN>oreagle_tokencookie (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>.pidimmediately 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
pfanchor: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 managedhomebrew.mxcl.postgresql@17— Homebrew managedpersonal.os.wiki-ingest— cron schedulecom.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/— Serviceshttp://dashboard.qentra.top/?tab=crons— Cronshttp://dashboard.qentra.top/?tab=pages— Pageshttp://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/ имеет public write permission, 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|servicesGET /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 и загружается через launchctlPATCH /api/crons/{source}/{job_id}— редактировать поляPOST /api/crons/{source}/{job_id}/toggle— включить/выключитьDELETE /api/crons/{source}/{job_id}— удалить
Формат Hermes cron jobs.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
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:
~not expanded in ProgramArguments. launchd does not expand~. Use full path:/Users/admin/scripts/foo.sh- Script itself exits 78. Check script logic:
exit 78isEX_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+)' statelogic:running— if PID > 0 (process alive right now)scheduled— if no PID but has StartInterval/StartCalendarIntervalidle— if no PID, no schedule, but enabled (KeepAlive daemon between ticks)stopped— if disabled (unloaded)
last_status:Nonewhen process isrunning(exit code is meaningless); integer (exit code) when process is not running;Nonewhen no LastExitStatus field (never run)- Frontend displays:
· exit <code>in green for 0, red for non-zero;· never runwhenlast_statusis 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-
everyfor 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-08-24: bilety.html added to Pages. Deployed interactive hunting-license exam page (/Library/WebServer/Documents/bilety.html, ~894 KB). 176 question cards, 532 answer buttons. All markup embedded in the HTML: each answer is a <button class="whale-a" data-correct="N" data-n="M" onclick="...">, correct-answer number hidden from display (lives only in data-correct), inline onclick colors wrong=red / correct=green. No JS-builder script — everything static. Built from ~/Downloads/origen_raw.html via ~/Downloads/gen2.py. Pitfalls learned: (1) _read_page_metadata() reads files live — no dashboard restart needed; (2) webroot is root-owned, sudo cp needed unless perms loosened; (3) relative href/src deps must sit in webroot too.
|- 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 takessourceparam. Forlaunchd: onlyevery Ns/m/h/daccepted; cron and ISO rejected with explicit message. For Eagle/Whale: all three formats. Hints, placeholders, andprettySchedule()also differ by source.Queryimport added to FastAPI. - 2026-06-25 (round 6): Tab URL routing. Each tab now persists in URL via
?tab=query-param +history.replaceState(). Frontend initializesactiveTabfrom URL. Backend accepts?tab=onGET /. AddedsetUrlTab()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 * * * *withevery 30min hint text. AddedprettySchedule(). 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.
+Cronbutton 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 vialaunchctl unload/load -w, edit viaplistlib+reload, delete via unload+rm. API:GET/POST/PATCH/DELETE /api/crons.jsonanddatetimeimports added to main.py. - 2026-06-04: Initial build.