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

18 KiB
Raw Blame History

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:80127.0.0.1:8880 (setup-pf-redirect.sh)
  • cloudflared tunnel: dashboard.qentra.toplocalhost: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.

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 createPOST /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:

{
  "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 stalels ~/.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.