Compare commits
35 Commits
master
...
122ea392d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 122ea392d1 | |||
| f2374506b4 | |||
| beb727528a | |||
| 9a81138cc9 | |||
| 84021278f9 | |||
| a0bbf801f1 | |||
| 8825cc3d4a | |||
| 9dff3c2d95 | |||
| f9a024ce10 | |||
| af2b560686 | |||
| 89eddfc134 | |||
| d7ca70db32 | |||
| a220e7903b | |||
| 986f219135 | |||
| bfb9b03a26 | |||
| 8a051939ed | |||
| 66c65d1b4e | |||
| 45573cf9fc | |||
| d35af1ddad | |||
| ed5f330d2c | |||
| 5d7c8ea030 | |||
| 81a7464dd8 | |||
| 0e4b2f7d1a | |||
| f0e2918bd1 | |||
| f48c7a40cd | |||
| 27f6d95bf6 | |||
| 47ac49a039 | |||
| 971fe8dda8 | |||
| 266ea11447 | |||
| dddc6734d0 | |||
| 835a8d6644 | |||
| 41ee6452fc | |||
| a5df6d4d3c | |||
| 0c3ce454bd | |||
| 7218a11f20 |
@@ -142,6 +142,7 @@ When creating documentation:
|
||||
2. Full content in markdown format
|
||||
3. Mermaid diagrams embedded within the documentation
|
||||
4. Summary of what was documented and any follow-up actions needed
|
||||
5. Update the change logs
|
||||
|
||||
When providing information to other agents:
|
||||
1. The specific information requested
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"Bash(docker version:*)",
|
||||
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"servers\": []\n}\nEOF)",
|
||||
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {}\n}\nEOF)",
|
||||
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)"
|
||||
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)",
|
||||
"Bash(sleep 20 && sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.100 \"docker ps --filter name=n8n --format 'table {{.Names}}\\\\t{{.Status}}' && echo '---' && docker logs n8n --tail 15 2>&1\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,10 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Tool scratch output (not infrastructure)
|
||||
.playwright-mcp/
|
||||
.superpowers/
|
||||
|
||||
# Has its own repo: git.hobbs.farm/jeremy/friday-voice.git
|
||||
/friday-voice/
|
||||
|
||||
@@ -23,45 +23,55 @@ This repository manages a **homelab infrastructure** with self-hosted services r
|
||||
hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, hardware repaired)
|
||||
├── [COMPOSE] Frigate NVR (:5000) -- Coral USB TPU (edgetpu), ~11ms inference, 13 cameras active
|
||||
├── [COMPOSE] Frigate-Notify v2 (:5199) -- MQTT->AI->Matrix notifications
|
||||
├── [COMPOSE] clip-service (:5200) -- CLIP embeddings (ViT-L-14, CPU)
|
||||
├── [COMPOSE] clip-service (:5200) -- **RUNNING, and actively used.** CORRECTED 2026-09-21: the 2026-07-13 decommission note below was wrong for the current deployment — it was redeployed and the *live* `frigate-notify/docker-compose.yml` sets `CLIP_SERVICE_URL=http://localhost:5200`, with live `app/recognition.py` calling `/embed`. ~1.2GB RSS. **Do not remove without first rewiring frigate-notify's recognition pipeline.**
|
||||
├── [COMPOSE] Home Assistant (:8123)
|
||||
├── [COMPOSE] ESPHome (:6052)
|
||||
├── [COMPOSE] ESPHome (:6052) -- manages **Pool Value Controls** (ESP8266 at static **192.168.86.6**, drives pool pumps/valves; only ESPHome device registered in HA). Also polelightpond (.14) and living_room_lights (.153) configs, NOT in HA. See Known Issue #39.
|
||||
├── [COMPOSE] Mosquitto (:1883, :8883)
|
||||
├── [COMPOSE] Synapse/Matrix (:8008) + synapse-db (postgres) + wellknown (:8010)
|
||||
├── [COMPOSE] Synapse/Matrix (:8008) + wellknown (:8010) + synapse-db (**postgres:16-alpine, LOCAL /var/lib/synapse-db, C collation**) -- Synapse data on LOCAL /var/lib/synapse. Migrated SQLite->PostgreSQL 2026-08-20 (required by syn2mas). **Auth is delegated to MAS** -- Synapse no longer serves /login.
|
||||
├── [COMPOSE] **MAS / Matrix Authentication Service (:8020 web, :8021 health) + mas-db (postgres:16-alpine, LOCAL /var/lib/mas-db)** -- OAuth2/OIDC provider for Matrix. Public: auth.hobbs.farm. Deployed 2026-08-20 to enable QR sign-in (MSC4108). QR sign-in **verified working end-to-end 2026-08-20**. **Every Matrix login now depends on this service.**
|
||||
├── [COMPOSE] Guacamole (:8199) + guacd + guacamole-db (mysql, LOCAL /var/lib/guacamole-db)
|
||||
├── [COMPOSE] AI Agents (margaret:18790, spirit:18795 [infra ops])
|
||||
├── [COMPOSE] Traefik (reverse proxy, ports 80/443/8090)
|
||||
├── [COMPOSE] AdGuard Home (:3000, :53)
|
||||
├── [COMPOSE] AI Agents (margaret:18790, spirit:18795 [infra ops]) -- **OpenClaw 2026.7.1-2, images PINNED via Dockerfile ARG** (`compose/hf-pdocker-01/ai-agents/{margaret,spirit}/Dockerfile`). Both primary `anthropic/claude-sonnet-4-6`, fallback `google/gemini-2.5-flash`.
|
||||
├── [COMPOSE] Traefik (reverse proxy, ports 80/443/8090) -- `traefik:v3.7` (3.7.16, bumped from v3.6 2026-10-26; 3.7 migration notes are k8s-only for us)
|
||||
├── [REMOVED] AdGuard Home -- decommissioned 2026-07-13 (conf dir had been empty since 2026-05-17, DNS engine never ran, LAN uses router DNS). Ports 53/3000 freed on .192.
|
||||
├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094)
|
||||
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
|
||||
├── [COMPOSE] Farm Agent (:8889)
|
||||
├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm.
|
||||
├── [COMPOSE] LinkedStorm (:8300 pipeline, :8310 api, cron no-port) -- whole-home audio assistant. VAD+Whisper STT+speaker-ID. Deployed 2026-05-28. Public: linkedstorm.hobbs.farm -> :8310.
|
||||
├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04
|
||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
|
||||
├── [COMPOSE] **Onyx (RAG/search) -- 10 containers, `/mnt/docker-data/compose/hf-pdocker-01/onyx`.** NOT decommissioned (the bart entry below refers to an older, separate deployment). Public: **onyx.hobbs.farm** -> nginx :3005. Custom build: `gitea_patch.py` + `unstructured_patch.py` index Gitea (`git.hobbs.farm/api/v1`) and call unstructured-api on .100:8000. LLM -> `192.168.86.150:18020/v1` (`qwen3.8-27b`) — **dead while .150 is down.** `onyx-opensearch-1` **stopped 2026-09-21** (`ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false`, held no Onyx data, nothing `depends_on` it; freed ~1.1GB). **2026-10-10: it came back on its own after .192's crash/reboot (1.5GB) — now stopped, `restart=no`, and gated behind compose `profiles: ["opensearch"]` so neither reboots nor `compose up` revive it.** Restart with `docker compose --profile opensearch up -d opensearch` if the flag is ever flipped.
|
||||
├── [COMPOSE] hermes -- **moved here from .150** (host network, `/mnt/docker-data/compose/hf-pdocker-01/hermes`, pinned `v2026.8.18`). Points at `http://192.168.86.150:18020/v1` model `qwen3.8-27b`; that endpoint is **dead**, so every call burns 200s x3 retries and fails. Note this is a different host:port than the `.150:8081` llamacpp setup documented in the GPU Machine section.
|
||||
├── [COMPOSE] litellm (:4000?) + litellm-db -- LLM proxy (see Known Issue #15)
|
||||
├── [COMPOSE] synapse-admin -- Synapse admin UI
|
||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
||||
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
||||
├── [CRON] sync-frigate-to-nas (daily 2am), cleanup-frigate-media (weekly Sun 3am), restart-frigate (daily 4am)
|
||||
├── [CRON] sync-frigate-to-nas (daily 2am), cleanup-frigate-media (weekly Sun 3am), restart-frigate (4x daily 4am/10am/4pm/10pm, log ~/frigate-restart.log)
|
||||
├── 5TB USB disk at /mnt/frigate-recordings (Frigate recordings storage)
|
||||
|
||||
bart (15GB RAM, i5-4570T, HDD)
|
||||
├── [COMPOSE] Media Stack (Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, Jellyseerr)
|
||||
├── [COMPOSE] Onyx (:3005) -- redeployed 2026-05-20, all services :latest, Watchtower disabled on all 10
|
||||
├── [COMPOSE] OpenVAS (vulnerability scanner)
|
||||
├── [COMPOSE] Media Stack (Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, **Seerr** -- replaced Jellyseerr 2026-10-17, `seerr/seerr:v3.5.1`, config LOCAL /var/lib/seerr) -- Sonarr config on LOCAL /var/lib/sonarr, NZBHydra config on LOCAL /var/lib/nzbhydra (2026-07-13), **Jellyfin config on LOCAL /var/lib/jellyfin (2026-07-19)** -- all moved off NFS after SQLite/lock crashes. **Jellyfin on official STABLE 12.x, pinned to the dated tag `jellyfin/jellyfin:12.3.20261023-013714` (12.3.0, 2026-10-26).** History: ran the 2026060106 unstable build from 2026-07-14 because the DB had been migrated to the v12 schema (10.11.x cannot read it); stable 12.0 shipped 2026-09-08, moved to 12.2 on 2026-10-17, 12.3 on 2026-10-26 (DB backup `/var/lib/jellyfin-backup-20261026-pre123`). **Never go below 12.**
|
||||
├── [REMOVED] Onyx -- this *bart* deployment was decommissioned 2026-07-13. **A different, newer Onyx now runs on hf-pdocker-01 (see above) — 'Onyx is gone' is false.**
|
||||
├── [REMOVED] OpenVAS -- decommissioned 2026-07-19 per user (containers, volumes incl. PG DB, image removed; ~39GB freed with other cleanup). Compose dir kept for reference. History: pg_upgrade crash-loop (Known Issue #21), DB wiped fresh 2026-07-15.
|
||||
├── [COMPOSE] Farm Agent (:8889)
|
||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
|
||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
||||
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
||||
├── [COMPOSE] Watchtower
|
||||
├── [COMPOSE] Watchtower -- `nickfedor/watchtower` (maintained fork; containrrr is archived), runs **twice daily 04:00 + 16:00** (`WATCHTOWER_SCHEDULE=0 0 4,16 * * *`)
|
||||
|
||||
hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
|
||||
├── [COMPOSE] Unifi + unifi-db (MongoDB)
|
||||
├── [COMPOSE] Gitea + gitea-db (PostgreSQL)
|
||||
├── [COMPOSE] Semaphore + semaphore-db (MySQL)
|
||||
├── [REMOVED] UniFi OS Server -- the `unifi-os-server` container (ghcr.io/lemker/unifi-os-server, v5.1.15) was decommissioned 2026-10-05. It was a **duplicate/idle** controller -- the real UniFi controller is the **Podman UniFi OS Server on the Windows box (.150)**, which manages all UniFi devices (e.g. `downstairsswitch` US-24-250W at 192.168.86.159, inform `http://192.168.86.150:8080/inform`). Container + image + network removed; ports 11443/8080/3478/etc freed on .100. **Data preserved** at `/var/lib/unifi-os/*` (971M local) + `/mnt/docker-data/configs/unifi-os/persistent` (NAS); compose dir kept for reference. **Only .150 manages UniFi, by design.** **2026-10-26: it was found RUNNING again** (the 10-05 removal never stuck -- journal shows it up again later that same day, then recreated from its compose dir 2026-10-17 21:06Z). Removed again (container, `unifi_default` network, `ghcr.io/lemker/unifi-os-server` + stale `linuxserver/unifi-network-application` images) and the compose file renamed to `docker-compose.yml.decommissioned-20261026` + `DECOMMISSIONED.txt` so a stray `compose up` cannot revive it. Data still preserved.
|
||||
├── [COMPOSE] Gitea + gitea-db (PostgreSQL) -- LevelDB queues on LOCAL /var/lib/gitea-queues (2026-07-13, stale NFS lock caused 7,972-restart loop)
|
||||
├── [COMPOSE] Semaphore + semaphore-db (MySQL, LOCAL /var/lib/semaphore-db since 2026-07-13)
|
||||
├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL)
|
||||
├── [COMPOSE] Vaultwarden, Actual Budget, MCPO, Trivy
|
||||
├── [COMPOSE] Vaultwarden, MCPO, Trivy
|
||||
├── [COMPOSE] SearXNG (`searxng/searxng:latest`) -- found running 2026-10-26, previously undocumented here
|
||||
├── [COMPOSE] unstructured-api (:8000, `downloads.unstructured.io/unstructured-io/unstructured-api:latest`) -- used by Onyx's `unstructured_patch.py` on .192
|
||||
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
|
||||
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
|
||||
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
|
||||
├── [COMPOSE] Farm Agent (:8889)
|
||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
|
||||
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
|
||||
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
|
||||
├── [COMPOSE] Watchtower
|
||||
├── [COMPOSE] Watchtower -- `nickfedor/watchtower` (maintained fork; containrrr is archived), runs **twice daily 04:00 + 16:00** (`WATCHTOWER_SCHEDULE=0 0 4,16 * * *`)
|
||||
|
||||
```
|
||||
|
||||
@@ -140,22 +150,24 @@ compose/ # Standalone Compose definitions (deployed per-node)
|
||||
- Home Assistant: `yoda.hobbs.farm`
|
||||
- Vaultwarden: `vault.hobbs.farm`
|
||||
- Gitea: `git.hobbs.farm`
|
||||
- VoiceCoach: `coach.hobbs.farm` (LiveKit WebRTC: `voice.hobbs.farm`)
|
||||
- FRIDAY Voice: `coach.hobbs.farm` — **DECOMMISSIONED 2026-08-26; `coach.hobbs.farm` returns 404, which is EXPECTED, not an outage.** CORRECTED 2026-09-21: the "UP as of 2026-08-20" text previously here was overtaken by events. The live Prometheus config records the `voicecoach` teardown (2026-08-24) and the FRIDAY Voice + speaches teardown (2026-08-26) — routing and monitoring sides done, with "container/volume teardown on .150 still pending manual action next Windows boot". The 6-container `voicecoach` project is indeed gone from .192 (verified 2026-09-21). Its `.150` images/volumes may still exist. See memory `friday-voice`.
|
||||
- MAS (Matrix auth): `auth.hobbs.farm` -- OAuth2/OIDC issuer. **If this is down, nobody can log into Matrix** (existing tokens keep working).
|
||||
- Onyx (RAG/search): `onyx.hobbs.farm` -- **self-registration is LAN-only** (Traefik `onyx-register-lockdown` router). See Known Issue #34.
|
||||
- Rongorongo: `rongorongo.hobbs.farm`
|
||||
- LinkedStorm: `linkedstorm.hobbs.farm`
|
||||
|
||||
### Internal Services by Node
|
||||
|
||||
**hf-pdocker-01 (192.168.86.192):**
|
||||
- Frigate: :5000
|
||||
- Frigate Alerts (v2): :5199 (web UI + REST API)
|
||||
- clip-service: :5200 (CLIP embeddings, ViT-L-14, CPU)
|
||||
- Home Assistant: :8123
|
||||
- ESPHome: :6052
|
||||
- Mosquitto: :1883
|
||||
- Matrix/Synapse: :8008
|
||||
- MAS: :8020 (web/oauth/compat), :8021 (health only -- /health is NOT on :8020)
|
||||
- Well-known (Matrix federation): :8010
|
||||
- Guacamole: :8199
|
||||
- AdGuard Home: :3000 (DNS: :53)
|
||||
- Traefik Dashboard: :8090 (HTTP: :80, HTTPS: :443)
|
||||
- Grafana: :3001
|
||||
- Prometheus: :9095
|
||||
@@ -163,22 +175,25 @@ compose/ # Standalone Compose definitions (deployed per-node)
|
||||
- InfluxDB: :8086
|
||||
- Blackbox Exporter: :9115
|
||||
- matrix-alertmanager-receiver: :9094
|
||||
- AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0)
|
||||
- AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0). OpenClaw **2026.7.1-2** (upgraded 2026-08-20 from 2026.5.27). Matrix: `@margaret:hobbs.farm` / `@spirit:hobbs.farm`, token auth against `http://192.168.86.192:8008`, encryption off, `requireMention: false`. Rollback images kept as `margaret-openclaw:rollback-2026.5.27` / `spirit-ops:rollback-2026.5.27`.
|
||||
- Onyx: :3005 (onyx-nginx). Public onyx.hobbs.farm. `onyx-opensearch-1` deliberately stopped 2026-09-21.
|
||||
- hermes: no published port (host network) -- see service tree; its LLM endpoint is currently dead.
|
||||
- Farm Manager: :8888 (dashboard + API)
|
||||
- Farm Agent: :8889
|
||||
- Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden).
|
||||
- ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04
|
||||
- LinkedStorm: :8300 (pipeline/VAD+STT+speaker-ID), :8310 (API/dashboard), cron (no port). Public: linkedstorm.hobbs.farm -> :8310. Source: /opt/linkedstorm (rsync from WSL). Data: /mnt/docker-data/configs/linkedstorm/. STT via Speaches GPU machine :8000, LLM via Ollama :11434 (qwen2.5:14b).
|
||||
- ~~VoiceCoach~~ -- replaced by FRIDAY Voice on .150 (see GPU Machine section + memory `friday-voice`). **CORRECTION 2026-08-20: the teardown claimed below never actually happened.** All 6 containers of compose project `voicecoach` (`voicecoach-web` :8085, `-pipecat`, `-speaches`, `-speechbrain`, `-openclaw` :3100, `-livekit` :7880-7881 + 50000-50100/udp) are **running on .192, up since ~2026-08-06**. Earlier text said both projects were `docker compose down`'d on 2026-08-05; that is false for `voicecoach` (`voicecoach-v2` is genuinely gone). ~1GB RAM. `voicecoach-openclaw` was upgraded to OpenClaw 2026.7.1-2 on 2026-08-20 via `docker compose up -d --no-deps voicecoach-openclaw` (single service only — does not resurrect anything, the rest were already up). Its model is `ollama-local/qwen2.5:14b` at the **dead** `192.168.86.27:11434` with **no fallbacks**, so it cannot serve a turn. **Decide: fix its model chain or actually tear the stack down.**
|
||||
|
||||
**hf-pdocker-02 (192.168.86.100):**
|
||||
- Farm Agent: :8889
|
||||
- Unifi: :8443
|
||||
- ~~UniFi OS Server: :11443~~ -- **REMOVED 2026-10-05** (duplicate controller; see service tree). The UniFi controller lives on **.150 (Podman), UI at https://192.168.86.150:11443, inform :8080**.
|
||||
- Gitea: :3003 (web), :2222 (SSH)
|
||||
- Semaphore: :3004
|
||||
- Wiki.js: :3002
|
||||
- Vaultwarden: :26226
|
||||
- Actual Budget: :5006
|
||||
- MCPO: :8001
|
||||
- Trivy: :4954
|
||||
- CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box.
|
||||
|
||||
**bart (192.168.86.167):**
|
||||
- Farm Agent: :8889
|
||||
@@ -188,25 +203,26 @@ compose/ # Standalone Compose definitions (deployed per-node)
|
||||
- SABnzbd: :8082
|
||||
- NZBHydra2: :5076
|
||||
- Jellyseerr: :5055
|
||||
- OpenVAS: :9392
|
||||
|
||||
**GPU Machine (192.168.86.150, RTX 3090, 15GB RAM):**
|
||||
- Ollama: :11434 (llama3.1:8b for VoiceCoach LLM, qwen2.5:14b, others)
|
||||
- [COMPOSE] VoiceCoach (all services co-located):
|
||||
- voicecoach-livekit: :7880/:7881, UDP 50000-50100 (public at voice.hobbs.farm)
|
||||
- voicecoach-speaches-gpu: :8000 (GPU Whisper STT + Kokoro TTS, CUDA float16)
|
||||
- voicecoach-speechbrain: :8001 (emotion analysis)
|
||||
- voicecoach-token-server: :8080 (token + settings API)
|
||||
- voicecoach-pipecat: pipeline agent (LLM via local Ollama llama3.1:8b)
|
||||
- voicecoach-web: :8085 (public at coach.hobbs.farm)
|
||||
- voicecoach-openclaw: :3100 (idle, kept for future Claude API routing)
|
||||
- Compose: ~/voicecoach-speaches/docker-compose.yml
|
||||
**GPU Machine (192.168.86.150, RTX 3090):**
|
||||
- **NOTE (2026-07-05):** .150 is actually the user's **Windows workstation** (runs the WSL/Claude Code session), not a headless Linux server. It runs **Docker Desktop** (WSL2 backend, RTX 3090 GPU passthrough working) alongside **UniFi OS Server** (Podman) — **this is the ONE real UniFi controller** (the duplicate container on .100 was removed 2026-10-05). UI https://192.168.86.150:11443, inform http://192.168.86.150:8080. Manages all UniFi devices incl. `downstairsswitch` (US-24-250W, 192.168.86.159; SSH `admin`/node-password). Docker Desktop panics if UniFi/Podman claims `\\.\pipe\docker_engine` first — start Docker Desktop before UniFi OS Server. See memory `docker-desktop-unifi-podman-conflict`.
|
||||
- **FRIDAY Voice replaced VoiceCoach (2026-07-05).** VoiceCoach fully decommissioned (v1+v2 removed from hf-pdocker-01; voice.hobbs.farm route removed). See memory `friday-voice`.
|
||||
- [COMPOSE] **FRIDAY Voice stack (all on .150 Docker Desktop, via `docker.exe -c desktop-linux`):**
|
||||
- **voicecoach-speaches-gpu**: :8000 — GPU Whisper large-v3-turbo (STT) + Kokoro-82M af_nova (TTS), CUDA float16. Compose: `compose/gpu-machine/voicecoach-speaches/`. **REMOVED 2026-08-21 (see the speaches note at the end of this section) — rebuild when voice I/O is wanted again.**
|
||||
- **hermes**: **PINNED `nousresearch/hermes-agent:v2026.8.18`** (upgraded 2026-08-20 from v2026.7.1 / v0.18.0 → v0.20.4). FRIDAY's reasoning agent. Settings in named volume `hermes-data`. Compose: `compose/gpu-machine/hermes/`. Was on `:latest` **with Watchtower disabled**, so nothing ever re-pulled it — it sat 7 weeks / 9 releases behind. Note `:latest` on this repo is the same digest as `main` (rolling dev build), so pin a `vYYYY.M.D` tag. Rollback image `nousresearch/hermes-agent:rollback-2026.7.1`; volume backup in volume `hermes-data-backup-20260820` (`hermes-data-20260820.tgz`, 29MB). **Matrix pairing:** hermes denies unknown senders by default — approve with `docker exec hermes hermes pairing approve matrix <CODE>` (`@jeremy:hobbs.farm` approved 2026-08-20; state persists in the volume). **RESOLVED 2026-08-21 by moving off ChatGPT entirely.** Its Codex refresh token had expired (`Could not validate your refresh token`) and, with **no API key set for any provider**, that was the only auth path — both Matrix and `/api/ask` failed. Verified NOT an upgrade regression (the rollback image failed identically on the same volume). Rather than re-auth interactively, hermes was pointed at the **local llamacpp** endpoint: `model.provider=llamacpp`, `model.default=qwen3.8-64k`, `model.base_url=http://192.168.86.150:8081/v1`. No tokens, no TTY, no subscription. Config backups: `/opt/data/config.yaml.bak-20260820-ollama` and `.bak-20260821-llamacpp`. **`hermes model` is interactive-only — use `hermes config set` for non-interactive changes.** hermes also **hard-refuses any model advertising <64K context** ("Choose a model with at least 64K context"), which is why the served model is a 64k build, not 32k.
|
||||
- **llamacpp**: :8081 — **the inference backend for hermes as of 2026-08-21.** `ghcr.io/ggml-org/llama.cpp` **pinned by digest** `sha256:995fe9f2...`. Serves `qwen3.8-64k` (alias) from ollama's GGUF blob, mounted read-only. Compose: `compose/gpu-machine/llamacpp/`. Replaced ollama as the server after a same-model/same-context benchmark: **ollama 15.8 tok/s vs llama.cpp 39.7 tok/s (2.4x)**, because `--cache-type-k/v q8_0` halves the KV cache (23.2GB -> 18.6GB) so the model fits the 3090 instead of spilling layers to CPU. Ollama has no KV-quantization knob. The image's own HEALTHCHECK probes :8080 and can never pass here, so it is `disable: true`d — liveness is an external GET of `:8081/health`.
|
||||
- **friday-voice**: :8320 — push-to-talk console. Calls speaches (`host.docker.internal:8000`) + hermes (`docker exec hermes hermes -Q` via wrapper). Public: **coach.hobbs.farm** → :8320. Compose: `compose/gpu-machine/friday-voice/`.
|
||||
- Ollama: **TWO separate instances on this box, easy to confuse** —
|
||||
- **Native Windows** `ollama.exe` v0.32.15, listens on `::11434` -> reachable as **`192.168.86.150:11434`**. Holds `qwen3.8`, `qwen3.8-64k`, `gemma4`. This is the LAN-visible one and the source of the GGUF that llamacpp serves.
|
||||
- **Inside WSL** (`/usr/local/bin/ollama serve`, systemd unit, **enabled+autostart**) v0.15.5, relayed to Windows `127.0.0.1:11434` by `wslrelay.exe`. Holds `qwen2.5:14b`, `hf.co/NousResearch/Hermes-4.3-36B-GGUF:Q4_K_M`, `qwen3.6`, `glm-4.7-flash`, `qwen3-coder:30b`, `gpt-oss:20b`, `llama3.1`. **`host.docker.internal:11434` from a Docker Desktop container resolves to THIS one**, not the native one — that cost real debugging time.
|
||||
- Both share the one physical 3090. Native ollama env (User scope): `OLLAMA_KEEP_ALIVE=30s`, `OLLAMA_MAX_LOADED_MODELS=1`, `OLLAMA_NUM_PARALLEL=1` — keep-alive was deliberately dropped from `-1` back to `30s` once llamacpp became the server, so ollama stops pinning ~22GB of VRAM. **These are read from the registry only by processes launched fresh (e.g. at login); a process started from an existing shell inherits that shell's env instead.**
|
||||
- **speaches (voicecoach-speaches-gpu) REMOVED 2026-08-21** to free ~3.3GB VRAM for the llama.cpp benchmark, per user ("they can be rebuilt later"). **Image and the `voicecoach-speaches_speaches-models` volume were kept**, so rebuild is just `docker compose -f 'C:\Users\Jeremy\projects\docker\compose\gpu-machine\voicecoach-speaches\docker-compose.yml' up -d` with no model re-download. **FRIDAY's voice in/out (STT+TTS) is down until then; the text `/api/ask` path works.** Its compose sets `WHISPER__TTL=-1`, which is why it pinned VRAM permanently — consider a finite TTL on rebuild.
|
||||
|
||||
### Port Conflict Notes
|
||||
|
||||
Port conflicts already resolved:
|
||||
- Port 8000: Freed (MCPO moved to 8001, Portainer moved elsewhere)
|
||||
- Port 3000: AdGuard Home (Gitea moved to 3003)
|
||||
- Port 3000: freed 2026-07-13 (was AdGuard Home, decommissioned; Gitea remains on 3003)
|
||||
- Port 3001: Grafana (Semaphore moved to 3004)
|
||||
- Port 5000: Frigate uses host networking (registry container stopped)
|
||||
- AI Agent ports re-spaced (2026-03-15): openclaw 2026.3.13 uses gateway+2 and gateway+3 for browser/chrome-relay. Agents spaced 5 apart: margaret=18790, spirit=18795
|
||||
@@ -230,6 +246,7 @@ After ANY infrastructure change (deploy, remove, reconfigure, add service, chang
|
||||
2. **MEMORY.md** — Add/update relevant memory entries
|
||||
3. **Prometheus probes** — Add/remove blackbox targets, hot-reload Prometheus
|
||||
4. **Wiki** — Launch documentation-keeper agent to update wiki pages (run in background)
|
||||
5. **Farm Manager links page** (last audited **2026-10-26**: every link probed; removed both FRIDAY Voice links, added Onyx (public + :3005), Guacamole :8199, Synapse Admin :8080, SearXNG :8888, n8n :5678, CouchDB Fauxton; MCPO -> `/docs`, Trivy -> `/healthz` (API only, no UI); Jellyseerr -> Seerr. Groups live in `/mnt/docker-data/configs/farm-manager/groups.json` -- **update them too when containers are added/renamed**. Previous audit 2026-08-20: dropped dead Clippy/Bob/Aaron links, added FRIDAY Voice + LinkedStorm + Rongorongo and a new `GPU Machine (192.168.86.150)` category; `hermes` intentionally absent — it publishes no ports. Served path is `/app.js`, not `/static/app.js`.) — If a service was added/removed or its URL/port changed: edit the `serverLinks` array in `/opt/farm-manager/server/static/app.js` on hf-pdocker-01, then rebuild + redeploy: `docker build -f /opt/farm-manager/Dockerfile.server -t farm-manager:latest /opt/farm-manager && cd /mnt/docker-data/compose/hf-pdocker-01/farm-manager && docker compose up -d` (links are baked into the image, not mounted)
|
||||
|
||||
Do NOT wait for the user to ask. Do NOT skip "because the change was small." Every change gets documented.
|
||||
|
||||
@@ -290,10 +307,49 @@ All persistent data on NAS at `/mnt/docker-data/`:
|
||||
2. **Telegraf Docker Input**: Disabled due to Docker socket permission issues across nodes
|
||||
3. **Watchtower API Version**: Requires `DOCKER_API_VERSION=1.44`
|
||||
4. **InfluxDB Connection**: Telegraf may show timeout errors if InfluxDB is not running
|
||||
5. **Frigate cameras**: Cameras on 192.168.44.x subnet may show RTSP timeouts (check camera network)
|
||||
5. **Frigate cameras**: 192.168.44.0/24 is the **"Camera" VLAN 200** on the Firewalla (gateway 192.168.86.1 / 44.1). Cameras with 44.x IPs MUST sit on Camera-native switch ports. **2026-10-08:** house_north/.51, house_east/.58, house_south/.53, house_west/.52 are wired to downstairsswitch (.159) ports 3/4/18/19 (verified from the switch MAC table 2026-10-11), which are **VLAN 1**, so they can't be reached by routing. They only half-worked because .192's Wi-Fi NIC held a wrong-VLAN 44.x lease. The fix is to make those ports Camera-native (see memory `unifi-estate`). The nodes' `wlp2s0` Wi-Fi NICs must hold **86.x** leases; a 44.x lease there is stale and breaks routing to the real Camera VLAN. **2026-10-10: with the Wi-Fi workaround gone (86.x lease since 10-08), those 4 cameras are now fully DOWN (0 fps, no ping) until the ports are re-profiled.**
|
||||
6. **Registry container**: Stopped on hf-pdocker-01 to free port 5000 for Frigate
|
||||
7. **systemd-resolved DNSStubListener**: Must be disabled on any node running AdGuard Home (port 53 conflict). Fix: `/etc/systemd/resolved.conf.d/nostub.conf` with `DNSStubListener=no`, then `systemctl restart systemd-resolved`. Applied on hf-pdocker-01 and hf-pdocker-02.
|
||||
7. **systemd-resolved DNSStubListener**: disabled on hf-pdocker-01 and hf-pdocker-02 via `/etc/systemd/resolved.conf.d/nostub.conf` (`DNSStubListener=no`) — originally for AdGuard Home's port 53. AdGuard was decommissioned 2026-07-13; the stub-listener change is harmless and left in place (re-enable only if something needs 127.0.0.53).
|
||||
8. **NFS boot-mount race (hf-pdocker-01)**: NFS mounts failed at boot (network not ready in time). Fixed 2026-05-20 by adding `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` to both fstab entries. If mounts are missing after reboot: `sudo mount /mnt/docker-data && sudo mount /mnt/infrastructure`.
|
||||
9. **Guacamole schema**: App image pinned to `guacamole/guacamole:1.6.0` + `guacd:1.6.0` with Watchtower disabled. DB schema was reset to fresh 1.6.0 on 2026-05-20 (old schema was non-standard). Recreate connections after any DB reset.
|
||||
10. **Gitea image**: Pinned to `gitea/gitea:1.26.1` with Watchtower disabled (2026-05-20). Watchtower previously pulled broken `:latest` images causing missing git binary errors.
|
||||
11. **Home Assistant reverse proxy**: Config at `/mnt/docker-data/configs/homeassistant/configuration.yaml` (no hyphen). Must have `http: use_x_forwarded_for: true` + `trusted_proxies: [172.16.0.0/12, 127.0.0.1]` or proxied requests return 400.
|
||||
12. **hf-pdocker-01 memory pressure (2026-07-05, retrimmed 2026-07-11)**: Node runs hot (~15-18/23GB, chronic swap). Mitigations applied: `vm.swappiness` lowered 60→10 (persisted in `/etc/sysctl.d/99-swappiness.conf`), and VoiceCoach (v1+v2, ~10 containers) removed (replaced by FRIDAY Voice on .150) — **but see correction: the VoiceCoach compose stacks on .192 were only *stopped* in July, not `down`'d, and reappeared (~3GB) on the 2026-08-05 reboot; properly torn down 2026-08-05 (`docker compose down` on `voicecoach` + `voicecoach-v2`), node mem 9.0→6.7GB used.** — **this is WRONG, see 2026-08-20: only `voicecoach-v2` is gone; the 6-container `voicecoach` project is still running on .192.** Frigate is the main consumer. **2026-07-11:** Frigate's built-in AI features (`semantic_search: large`, `face_recognition`, `lpr`, `classification.bird`) were found *enabled* (contradicting the intended baseline) and re-disabled — dropped the embeddings CLIP model ~1.2GB→168MB and cut CPU inference; host mem 18→~9GB, swap 3.9→1.3GB right after. Config `/mnt/docker-data/configs/frigate/config/config.yml` (backup `.bak.20260711-aitrim`). Recognition is instead handled externally by frigate-notify v2 (clip-service was removed 2026-07-13 — frigate-notify's config never referenced it). **2026-07-12 — WHOLE-NODE CRASH:** .192 froze hard (~14:15, sshd/HA/DNS all dead, required hard reboot). Cause: Frigate `memswap_limit` had drifted to **16G** vs `mem_limit` 12G, letting it spill ~4GB into host swap; with the ffmpeg leak pinning it at 12G all day (cgroup OOM every ~45min), the host swap-thrashed to death. **Fix: node compose `memswap_limit: 12G` (= mem_limit) so Frigate can never use host swap** — a hit now trims its own ffmpeg (contained cgroup OOM), not the host. Repo already had 12G; node had drifted (never redeployed). NOTE: recording ffmpeg still leaks ~0.8GB/h atop ~6GB baseline (fills 12G in ~7.5h); twice-daily 4am+4pm restart-frigate cron mitigates but doesn't fully beat it — consider every-6h restart + fixing broken cameras (gate_camera, garage_2). Always keep `memswap_limit == mem_limit` on this node.
|
||||
13. **Docker Desktop vs UniFi OS Server pipe conflict (Windows box .150, 2026-07-05)**: Both fight over `\\.\pipe\docker_engine`; Docker Desktop's backend nil-panics ("backend exited before becoming ready") if Podman wins. Fix: let Docker Desktop (auto-starts at login) claim the pipe first, launch UniFi OS Server manually after. Full detail in memory `docker-desktop-unifi-podman-conflict`.
|
||||
14. **MySQL InnoDB corruption on NFS**: `semaphore-db` (mysql:8.0 on NFS) hit system-tablespace page corruption and crash-looped 3789× (2026-07-11), same class as the Guacamole-on-NFS issue (#9). Recovered in-place via `--innodb-force-recovery=1` → mysqldump → fresh volume → reimport (DB was empty, no data lost); admin recreated with `semaphore user add`. Any MySQL DB on NFS is at risk — prefer LOCAL storage like guacamole-db. See memory `semaphore-db-nfs-corruption`.
|
||||
15. **litellm hangs after DB blip**: litellm does NOT self-recover from a transient `litellm-db` disconnect — it loops on "Prisma DB reconnect failed" forever; needs manual `docker restart litellm`. Its healthcheck was fixed from `curl` (absent from image, permanent false-unhealthy) to a python urllib check (2026-07-11).
|
||||
16. **Stale NFS locks after unclean reboot/power loss (2026-07-12 power event)**: NFS file locks held by pre-reboot client identities survive on the Synology and block relocking — gitea crash-looped 7,972× on its LevelDB queue lock, semaphore-db 975× on `ibdata1`, and nzbhydra2 sat unhealthy on its H2 database lock. Fix applied 2026-07-13: lock-prone data moved to LOCAL disk (gitea queues → `/var/lib/gitea-queues`, semaphore-db → `/var/lib/semaphore-db` on .100; sonarr config → `/var/lib/sonarr`, nzbhydra config → `/var/lib/nzbhydra` on bart; jellyfin config → `/var/lib/jellyfin` on bart 2026-07-19 after 60-100s "database is locked" stalls on jellyfin.db caused playback lockups; **synapse data → `/var/lib/synapse` on .192 2026-08-20**, pre-emptively — its 279MB SQLite homeserver.db was on NFS). **Rule: anything holding long-lived file locks (MySQL, SQLite, LevelDB) goes on local disk, never NFS.**
|
||||
17. **Synapse rate-limited Alertmanager notifications**: default `rc_message` (0.2 msg/s) made every matrix-alertmanager-receiver send fail with M_LIMIT_EXCEEDED 429 — **no alerts reached Matrix for weeks** while outages accumulated (Onyx down 5 weeks, AdGuard dead 2 months, unnoticed). Fixed 2026-07-13: `rc_message: {per_second: 5, burst_count: 50}` in homeserver.yaml (backup `.bak-20260713-ratelimit`).
|
||||
18. **AdGuard Home decommissioned 2026-07-13**: its conf dir had been empty since 2026-05-17 (container ran the first-launch wizard, served no DNS, no backup existed) and the LAN had been using router DNS ever since. Container, data, compose dir, and Prometheus probe all removed; ports 53/3000 freed on .192. LAN DNS = the router.
|
||||
19. **Duplicate failover stacks removed from .100 and bart (2026-07-13)**: .100 ran a second monitoring stack (prometheus/grafana/alertmanager/influxdb/blackbox), an idle traefik (shared acme.json with .192 — ACME conflict risk), and a stray guacamole; bart ran a failover guacamole. All removed (~2GB RAM reclaimed). Compose dirs kept for reference; data volumes untouched.
|
||||
20. **UniFi OS Server probe port** (historical): the `unifi-os-server` container on .100 served HTTPS on host port **11443** (container 443), not 8443. **That container was removed 2026-10-05** (duplicate controller) — the sole UniFi controller is now the Podman instance on **.150** (UI https://192.168.86.150:11443, inform :8080). The live Prometheus config **did** carry a blackbox probe for `https://192.168.86.100:11443` (line 132) — it was **removed 2026-10-06** (backup `prometheus.yml.bak-20261006-unifi-probe-remove`) and Prometheus hot-reloaded, since nothing listens there now. **No probe added for the .150 controller** because that box is a workstation that is sometimes off (it would false-alarm); the switch itself (192.168.86.159) could be ICMP-probed instead if device-down alerting is wanted. (Note: the `UnifiDeviceDown` alert in `alerts.yml` keys on `unifi_device_up`, which needs a `unifi-exporter` that is NOT deployed — the metric doesn't exist, so that rule is inert, not firing.)
|
||||
21. **OpenVAS pg_upgrade loop saturated bart's HDD (2026-07-15)**: `immauss/openvas:latest` looped on the PG 13→15 auto-upgrade for ~21h (158 restarts) — each cycle re-copied the 21GB DB, pinning sda at ~90% util/40% iowait and making Jellyfin (same disk) hang. The upgrade itself succeeded every time but the final `mv /data/database /data/database_13_old` failed with "Device or resource busy" because the openvas-db volume is mounted **at** `/data/database` (can't rename a mountpoint). Fixed by wiping the DB (`docker volume rm openvas_openvas-db`) and letting it initdb fresh on PG15 — scan history lost, feeds re-synced. **On the next major PG bump this will recur** unless the upgrade is done out-of-band (content-level swap) or the volume is remounted at `/data`. General rule: a service "hanging" on bart usually means another container is eating the single HDD — check `iostat -x`/`pidstat -d` first. **OpenVAS fully decommissioned 2026-07-19** (moot unless redeployed).
|
||||
22. **Jellyfin SQLite-on-NFS lockups (fixed 2026-07-19)**: jellyfin.db (WAL mode) on `/mnt/docker-data/configs/jellyfin` hit escalating `SQLite Error 5: 'database is locked'` (22→33→125/day), DB commands blocking 60-102s → healthcheck flaps + playback stalls ("Unexpected end of request content" on /Sessions/Playing/Progress). Config moved to LOCAL `/var/lib/jellyfin` (old copy left on NFS as backup). Same class as Known Issue #16.
|
||||
|
||||
|
||||
23. **Matrix registration was wide open to the internet (fixed 2026-08-20)**: `mess.hobbs.farm` is publicly reachable (Traefik, port 443, federation + client API on :8008) and `homeserver.yaml` had `enable_registration: true` + `enable_registration_without_verification: true` — the registration flow was a bare `m.login.dummy`, i.e. **anyone on the internet could create an account on hobbs.farm with no email, captcha, or token**. No abuse had occurred (all 18 accounts are known/expected; newest legit account was @fry 2026-07-05). Fixed by setting both to `false` and `restrict_public_rooms_to_local_users: true` (was `false`, which published the public room directory to any remote server). Backup: `/var/lib/synapse/homeserver.yaml.bak-20260820-registration`. New accounts are still creatable on the box via `docker exec synapse register_new_matrix_user -k <registration_shared_secret>`.
|
||||
24. **Synapse database history (SQLite -> local disk -> PostgreSQL, all 2026-08-20)**: Synapse ran on **SQLite on NFS** while CLAUDE.md claimed it used a `synapse-db` postgres that in fact had **0 tables**. First the data was moved to LOCAL `/var/lib/synapse` and the empty postgres deleted; then, because `syn2mas` **only reads PostgreSQL** ("The migration tool only supports reading from PostgreSQL for the Synapse database as well"), a real `synapse-db` was created and the DB ported with `synapse_port_db` (20 users / 93,888 events / 65 devices, verified identical; 2m35s downtime). The PG database MUST have `LC_COLLATE=C` -- verify with `select datcollate from pg_database where datname='synapse'` (PG16 removed the `SHOW lc_collate` GUC). The old SQLite file is still at `/var/lib/synapse/homeserver.db`, now inert; NFS copy at `/mnt/docker-data/configs/matrix/data` is an older backup -- **never remount either**.
|
||||
25. **Synapse upgraded 1.148.0 -> 1.159.0 and moved to the element-hq registry (2026-08-20)**: it had been on `matrixdotorg/synapse:latest`, an **abandoned Docker Hub repo** whose `:latest` froze at 1.148.0 (image built 2026-02-24) — a moving tag that silently stopped moving on a publicly-exposed homeserver. Now pinned to `ghcr.io/element-hq/synapse:v1.159.0` (note the **`v` prefix** — `:1.159.0` without it 404s). Upgrade was clean: schema 93 -> 94, all background updates finished in ~5s, 0 errors, 19 users / 93,884 events intact, memory 112MB/1G. None of the v1.149-v1.159 breaking changes applied to us (MSC3861 removal — unused; `quarantined_media_changes` stream writer — workers only; the rest are Debian/Ubuntu packaging). Pre-upgrade backup: `/var/lib/synapse.bak-20260820-pre1159` (646MB) — delete once you're satisfied. **Pinned on purpose: bump it manually and deliberately, since the last moving tag rotted for 6 months unnoticed.** **2026-10-26: bumped v1.159.0 -> v1.163.2** (schema 94 deltas applied in <1s; config contains none of the options affected by the 1.160-1.163 upgrade notes). pg_dump backups taken first: `/var/lib/upgrade-backups-20261026/{synapse-db,mas-db}.dump` on .192. MAS bumped 1.23.0 -> **1.27.1** at the same time.
|
||||
26. **`SYNAPSE_SERVER_NAME` in the matrix compose disagrees with homeserver.yaml**: compose passes `SYNAPSE_SERVER_NAME=mess.hobbs.farm` but the real `server_name` is **`hobbs.farm`** (user IDs are `@user:hobbs.farm`). The env var is only read when the image *generates* a config, so it's inert today — but if `homeserver.yaml` ever went missing, the container would generate a fresh config with the wrong `server_name` AND a new signing key, which would break federation and every existing user ID. Left as-is (changing it has no effect while the config exists); just never let the container auto-generate..
|
||||
|
||||
27. **Matrix auth is delegated to MAS -- Synapse does NOT serve /login any more (2026-08-20)**: with `matrix_authentication_service.enabled: true`, Synapse answers `M_UNRECOGNIZED` on `/_matrix/client/*/login`. The **reverse proxy must route the three compat paths to MAS or nobody can sign in**: `login`, `logout`, `refresh` (verified: MAS returns 200/405 on those and 404 on `register`/`whoami`, which stay on Synapse). Implemented as the `mess-auth-compat` router in `/mnt/docker-data/traefik/dynamic/temp-routes.yml` with explicit `priority: 1000` so it beats the catch-all `mess` router. **Anything that talks to `192.168.86.192:8008` directly bypasses Traefik and therefore cannot log in with a password** -- token-based bots (spirit, frigate, alertbot) are unaffected because they never call /login, but `@clippyauto` (n8n) and `@pluto` historically re-login with a password (17 and 15 accumulated devices). They currently run on migrated tokens; if either ever needs a fresh password login, point it at `https://mess.hobbs.farm` instead of `:8008`.
|
||||
28. **MAS image tags are NOT `v`-prefixed** -- `ghcr.io/element-hq/matrix-authentication-service:1.23.0` is correct, `:v1.23.0` does not exist. This is the **opposite** of Synapse (`ghcr.io/element-hq/synapse:v1.159.0`, where `:1.159.0` 404s). The MAS image is also **distroless** -- no shell, so a container healthcheck is impossible; liveness comes from the blackbox probe of `http://192.168.86.192:8021/health`. Note `/health` lives on the internal listener only: `https://auth.hobbs.farm/health` returns 404 by design, so the public probe targets `/.well-known/openid-configuration`.
|
||||
29. **MAS config holds unrecoverable signing keys**: `/mnt/docker-data/configs/mas/config.yaml` (mode 640) contains the RSA/EC signing keys and the `matrix.secret` shared with Synapse. Regenerating them invalidates every session. It is **not yet backed up to Vaultwarden** because the vault master password is currently unknown (see memory `vault-master-password-stale`) -- resolve that and store it. Also note `http.issuer`/`public_base` (`https://auth.hobbs.farm/`) are baked into session state; changing the hostname later invalidates sessions.
|
||||
|
||||
30. **OpenClaw agent images were built from `openclaw@latest` and rotted for months (fixed 2026-08-20)**: margaret and spirit sat on **2026.5.27** while upstream was on **2026.7.1-2** — same failure mode as the abandoned `matrixdotorg/synapse:latest` (Known Issue #25). Worse, **the Dockerfiles existed nowhere** — not in the repo, not on any node — and had to be reconstructed from `docker history` layer output. They now live at `compose/hf-pdocker-01/ai-agents/{margaret,spirit}/Dockerfile` (staged on the NAS at `/mnt/docker-data/compose/hf-pdocker-01/ai-agents/`) with `ARG OPENCLAW_VERSION` **pinned**; bump it deliberately. Rebuild: `cd /mnt/docker-data/compose/hf-pdocker-01/ai-agents && docker build -t margaret-openclaw:<ver> -t margaret-openclaw:latest ./margaret` (same for `./spirit` → `spirit-ops`). npm dist-tags to check: `latest`, `extended-stable`, `beta`. **Upgrading migrates the state dir one-way** (JSON sidecars → shared SQLite; originals archived as `*.migrated` under `/mnt/infrastructure/{margaret,spirit}/`), so a rollback to `*:rollback-2026.5.27` also needs those `.migrated` files restored. Config backups: `/mnt/infrastructure/{margaret,spirit}/openclaw.json.bak-20260820-preupgrade`.
|
||||
31. **`192.168.86.27` is dead and several configs still point at it (2026-08-20)**: spirit's *primary* model was `vllm-local/qwen` at `192.168.86.27:8001`, so **every** message burned ~12s timing out before failing over to Anthropic (2,913 consecutive primary-model failures). margaret had the same host as a `vllm-local/hermes` provider. Both `vllm-local` provider blocks were deleted; both agents are now `anthropic/claude-sonnet-4-6` primary with `google/gemini-2.5-flash` fallback. `voicecoach-openclaw` **still** points at `192.168.86.27:11434` with no fallbacks. **There is no host at .27** — grep any new agent config for it. Related: margaret listed `google/gemini-2.5-flash` as a fallback while defining **no** google provider or auth profile, so it could never have fired; a google provider block (api `google-generative-ai`, key verified against the live models list) was added to match spirit's working shape.
|
||||
32. **CORRECTED 2026-08-21 — there are TWO ollama instances on .150, which is why the model lists disagreed**: a **native Windows** `ollama.exe` (v0.32.15) on `192.168.86.150:11434` holding only `gemma4` + the new `qwen3.8*`, and one **inside WSL** (v0.15.5, systemd, autostart) relayed to `127.0.0.1:11434`, holding `qwen2.5:14b`, `Hermes-4.3-36B-GGUF`, `qwen3.6`, `qwen3-coder:30b`, `gpt-oss:20b`, `llama3.1`. So the older docs were **right** — those models exist, just in the WSL instance. An earlier note here claiming "only gemma4" was measuring the native one alone. **`host.docker.internal:11434` from a Docker Desktop container hits the WSL instance; the LAN IP hits the native one.** Both share the single 3090, so either can starve the other. spirit's `ollama` provider still advertises a nonexistent `qwen3.5` (harmless, not in its chain). **LinkedStorm on .192 points at `192.168.86.150:11434` for `qwen2.5:14b` — that model is NOT on the native instance, so LinkedStorm's LLM calls are probably 404ing. Still unverified.**
|
||||
|
||||
33. **Docker Desktop on .150 died twice while loading a large model (2026-08-21)**: the WSL2 VM had **no `.wslconfig`**, so it defaulted to 50% of host RAM = **15GB** shared by the Ubuntu distro, the docker-desktop VM and podman-uosserver. Loading llama.cpp's 16.8GB GGUF alongside the running containers **OOM-killed hermes and friday-voice (exit 137)**, and on the first attempt took the whole Docker Desktop backend down — it went `unable to start` -> HTTP 500 on every API call -> engine pipe missing entirely. **Recovery that worked:** kill all `Docker Desktop`/`com.docker` processes, `wsl --terminate docker-desktop`, then start Docker Desktop **before** UniFi OS Server (the pipe-conflict rule in issue #13 still applies — UniFi went down in the crash and had to be restarted after). Mitigation applied: `C:\Users\Jeremy\.wslconfig` now sets `memory=22GB`, `swap=8GB` — **this needs `wsl --shutdown` or a reboot to take effect, and `wsl --shutdown` kills the Claude Code session too**, so it has NOT been applied yet. Until it is, expect OOM churn when a big model loads.
|
||||
|
||||
34. **Onyx self-registration was open to the internet (fixed 2026-09-21)**: `onyx.hobbs.farm` is publicly reachable and Onyx runs `AUTH_TYPE=basic` with **no** `REQUIRE_EMAIL_VERIFICATION` and **no** `VALID_EMAIL_DOMAINS` — so `POST /api/auth/register` let anyone on the internet create an account. External scanners were already probing the host (a DigitalOcean IP hit `/` on 2026-09-21). No abuse had occurred: only 2 accounts existed (`glyptic@gmail.com` + Onyx's internal `anonymous@onyx.app`). **Fix: a Traefik router `onyx-register-lockdown` (`priority: 1000`, beats the catch-all `onyx` router) matching `PathPrefix(/api/auth/register)` behind an `ipAllowList` middleware limited to `192.168.86.0/24` + `127.0.0.1/32`.** Verified by temporarily narrowing the range: register returned **403** from a non-allowlisted source while `/api/auth/login` (405) and `/` (200) were unaffected; range then restored. Config `/mnt/docker-data/traefik/dynamic/onyx.yml` (backup `.bak-20260920-195851`). **To add a user: register from the LAN, or widen the `sourceRange`.** Note `VALID_EMAIL_DOMAINS` was rejected as the control because it is domain-level only — `gmail.com` would admit any Gmail user. Same class as Known Issue #23 (open Matrix registration).
|
||||
35. **The live Prometheus config is `/mnt/docker-data/configs/prometheus/prometheus.yml` — the copy at `compose/hf-pdocker-01/monitoring/prometheus.yml` is STALE and misleading (2026-09-21)**: the compose-dir copy still lists decommissioned targets, including Onyx at the wrong node (`192.168.86.167:3005`, bart) and OpenVAS (`:9392`). Neither is in the live config, so neither was ever probed — grepping the compose copy to answer "is X monitored?" gives the wrong answer. Onyx had **no probe at all** until one was added to the live file on 2026-09-21 (`http://192.168.86.192:3005`, now `probe_success=1`). Backup `prometheus.yml.bak-20260920-*`. The live file is also the better changelog: it records the voicecoach (2026-08-24) and FRIDAY Voice / speaches (2026-08-26) teardowns that CLAUDE.md still described as live.
|
||||
36. **SSH key auth to hf-pdocker-01 is broken — password only (2026-09-21)**: all of `~/.ssh/id_docker_server`, `id_bart`, `id_nas` are rejected by `192.168.86.192` (`Permission denied (publickey,password)`), while `id_docker_server` still works for `.100` and `id_bart` for bart. Connect with `sshpass -p '&Rip8a115' ssh -o PubkeyAuthentication=no glyptic@192.168.86.192`. **Any key-based automation targeting .192 is silently failing** — check `~/.ssh/authorized_keys` on that node (likely lost when the host was rebuilt/restored).
|
||||
37. **hf-pdocker-01 hard-hung for ~27h (2026-10-09 13:55 → 2026-10-10 17:08 CDT)**: journal simply stops at 13:55 with no OOM, kernel error, or shutdown record; `sar` at 13:50 showed a healthy box (62% mem, 23% swap, load ~4), and .100/bart stayed up (9d uptime), so it was neither memory exhaustion nor a house-wide power event. Came back on kernel 7.0.0-38 (was -34) after a power cycle. Cause unknown — if it recurs, suspect hardware (this box was repaired 2026-03) and consider enabling persistent `kdump`/`netconsole`. Side effects found on 2026-10-10: `onyx-opensearch-1` revived itself (fixed, see Onyx entry). Also fixed then: `ServiceHighMemory` used `container_memory_usage_bytes`, which counts reclaimable page cache, so clip-service false-alarmed at 94% while really at 64% — rule now uses `container_memory_working_set_bytes` (backup `alerts.yml.bak-20261010-workingset`).
|
||||
38. **bart shows ~20 nameless `Dead` containers in `docker ps -a`** — orphaned `/var/lib/docker/containers/<id>` dirs for containers whose images Watchtower already deleted. `docker rm` says "No such container". Harmless clutter; clearing them requires stopping dockerd (moving the dirs aside) which restarts the whole media stack, so it was left for a maintenance window.
|
||||
39. **[RESOLVED 2026-10-11] Static-IP ESPHome boards were invisible behind UniFi Proxy ARP (found 2026-10-10)**: the `ASUS_F0_2G` WLAN has **Proxy ARP on** (`proxy_arp=1` in every AP's `/etc/hostapd/wifi0ap0.cfg`; it also has `ap_isolate=1` and `mcast.enhance=1`). With Proxy ARP, the AP answers ARP for wireless clients **from a table it learns by snooping DHCP** and stops passing ARP broadcasts through to them. Boards with ESPHome `manual_ip` never DHCP, so after any reconnect nobody answers ARP for them: they associate fine and even announce themselves over mDNS, but are unreachable. **Pool Value Controls (.6, MAC `a4:cf:12:f1:6e:c8`) went dark 2026-10-09 11:04** at a Wi-Fi reconnect on AP .187 and then looped every 15 min on ESPHome's API `reboot_timeout`. Diagnosed by capture on .187: ARP who-has .6 reached the radio and got no reply, while a static neighbor entry made ping and API work at once. **Workaround (live):** systemd unit `static-arp-esphome.service` on .192 pins `.6 -> a4:cf:12:f1:6e:c8 nud permanent` on `enp0s31f6` (HA and ESPHome are bridge-networked on .192, so they use the host's neighbor table). **Real fix (pending, needs controller access on .150):** turn Proxy ARP off on the `ASUS_F0_2G` WLAN, OR switch the boards to DHCP + Firewalla reservations. Once Proxy ARP is off, `systemctl disable --now static-arp-esphome` on .192. A `power_save_mode: none` change was also OTA'd to the board (backup `pool-value-controls.yaml.bak-20261010-powersave`). It did NOT fix this, but it's harmless and was left in. **Any other static-IP Wi-Fi device on that SSID has the same problem.** **RESOLVED 2026-10-11:** Proxy ARP (and `ap_isolate`) were turned off on `ASUS_F0_2G` in the controller (pushed to all APs 11:09–11:18; the `proxy_arp`/`ap_isolate` lines are gone from every AP's hostapd config). `static-arp-esphome.service` was then disabled and deleted on .192. Verified afterwards: .6 resolves by normal ARP (neighbor `REACHABLE`, not `PERMANENT`), ping OK, HA API session established. If a static-IP Wi-Fi device goes dark again, check that Proxy ARP hasn't been switched back on. polelightpond (.14) and living_room_lights (.153) were still unreachable after the fix, so they have some other problem (unpowered or offline).
|
||||
|
||||
40. **Container version audit + upgrade round (2026-10-26)**. Upgraded: Synapse v1.163.2, MAS 1.27.1, Traefik v3.7 (3.7.16), node-exporter v1.12.1 (all 3 nodes), cadvisor v0.60.6, matrix-alertmanager-receiver 2026.9.23, Gitea 1.26.4 (patch only; pg_dump at `~/gitea-db-20261026-pre1264.dump` on .100), Jellyfin 12.3, Seerr v3.5.1, plus re-pulls of every moving tag on .192 (HA 2026.10.4, ESPHome, Mosquitto, Prometheus, Blackbox, Telegraf, nginx, litellm, and the postgres/mysql/redis patch images). Every compose file touched has a `.bak-20261026` next to it on the NAS. **Deliberately NOT done, each needs its own decision:** Onyx `:latest` now = **v5.0.0 (major, released 2026-10-24)**, still on v4.9.2 (no Watchtower on .192, so it only moves on a manual pull); Gitea 1.27.x / **28.x** (major jumps); OpenClaw 2026.7.1-2 -> 2026.10.7 (one-way state migration, see #30); hermes v2026.8.18 -> v0.22.5 (upstream switched to semver tags, and its LLM endpoint is dead anyway); **MySQL 8.0 (guacamole-db, semaphore-db) went EOL April 2026 -> needs an 8.4 LTS migration with a dump/restore**; InfluxDB 1.8 (EOL); Onyx's `postgres:15.2-alpine` (2023 patch level, managed by Onyx's compose); `awesometechnologies/synapse-admin` (stale, maintained fork is `etkecc/synapse-admin`). How to audit: compare each container's `RepoDigests` against registry HEAD digests -- for Docker Hub use the hub.docker.com web API (`/v2/namespaces/<ns>/repositories/<repo>/tags/<tag>` -> `digest`), which does not count against pull limits. `docker buildx imagetools inspect --format` is ignored by the nodes' buildx v0.31.1.
|
||||
41. **Docker Hub anonymous pull limit is shared by the whole house** (public IP 98.160.103.126, 100 pulls/hour): on 2026-10-26 every node AND WSL got 429 `toomanyrequests`, meaning Watchtower updates of Docker Hub images were silently failing. Mitigation applied: both Watchtowers moved from a 24h poll interval to a **twice-daily cron schedule** (04:00/16:00), and the archived `containrrr/watchtower` swapped for the maintained drop-in fork `nickfedor/watchtower` (**image name is `nickfedor/`, not `nicholas-fedor/`** -- the latter is the GitHub org and 404s on Hub). Same env vars and still honors `com.centurylinklabs.watchtower.enable`. A `docker login` on the nodes would raise the limit further. **cadvisor no longer publishes to gcr.io** after v0.55 -- the image is now `ghcr.io/google/cadvisor`.
|
||||
42. **node-exporter had been launched from `/tmp` (compose project `tmp`) on all 3 nodes**, so `docker compose` in the real dirs didn't own it. Fixed 2026-10-26: removed and redeployed from `/mnt/docker-data/compose/<node>/node-exporter/`. Separately, the **monitoring** compose on .192 still defines a legacy `node-exporter:latest` service on :9100 that a bare `docker compose up -d` would try to start; it is now gated behind `profiles: ["legacy-node-exporter"]`. Also: the matrix-alertmanager-receiver container is now named `monitoring-matrix-alertmanager-receiver-1` (compose default) -- Alertmanager reaches it by service name `matrix-alertmanager-receiver`, which still resolves.
|
||||
43. **Farm Manager showed ZERO containers for hf-pdocker-01 (fixed 2026-10-26)**: the server fetches each agent's `/containers` with a timeout and on any error silently returns `[]` for that node. .192's agent took ~12s (47 containers, per-container `stats(stream=False)` = Docker samples twice ~1s apart, only 10 workers) vs a 10s timeout, so the main host's containers simply vanished from the dashboard while `/api/nodes` still said "healthy, 47". Fix: agent uses `stats(..., one_shot=True)` + 20 workers (now ~2s), server timeout 30s. Agent rebuilt on .192 and shipped to .100/bart via `docker save | docker load` (only .192 has the `/opt/farm-manager` source); rollback tags `farm-agent:rollback-20261026` / `farm-manager:rollback-20261026`. Same day: removed a dead `gpu-workstation` node (192.168.86.27) from the live `config.json`, made `/health` ignore Dead containers (bart reported 30 instead of 10), and rebuilt `groups.json` -- every group still used Swarm-era / decommissioned names (`ai-agents_margaret`, `jellyseerr`, `adguardhome`, bart Onyx, `openvas`, `unifi`...), so all group actions were no-ops. Now 14 groups / 63 containers; per-node utilities (farm-agent, node-exporter, telegraf, watchtower) are deliberately ungrouped so a group "stop" can't kill the agents. **4 tests in `tests/test_agent_docker_ops.py` fail -- pre-existing** (they mock `containers.list` but the code uses low-level `api.containers`); not caused by this change.
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# RONGORONGO_DEPLOY.md
|
||||
# Rongorongo Decipherment Engine — Claude Code Deployment Guide
|
||||
# Target: hobbs.farm Docker Swarm infrastructure
|
||||
# Subdomain: rongorongo.hobbs.farm
|
||||
|
||||
---
|
||||
|
||||
## CONTEXT
|
||||
|
||||
This document instructs Claude Code to build, push, and deploy the Rongorongo
|
||||
Decipherment Engine as a containerized static web app to the hobbs.farm Swarm.
|
||||
|
||||
The app is a single-file HTML/JS application served by nginx. It calls the
|
||||
Anthropic API directly from the browser. No backend, no database. All session
|
||||
state is stored in browser localStorage.
|
||||
|
||||
Infrastructure assumptions (from existing hobbs.farm stack):
|
||||
- Docker Swarm: nodes hf-pdocker-01, hf-pdocker-02, bart
|
||||
- Traefik reverse proxy with wildcard *.hobbs.farm TLS via Cloudflare DNS
|
||||
- External overlay network: traefik-public
|
||||
- Registry: registry.hobbs.farm (if local) OR use Docker Hub as fallback
|
||||
- Portainer CE available for stack management
|
||||
- DNS: AdGuard Home with hairpin NAT for *.hobbs.farm
|
||||
|
||||
---
|
||||
|
||||
## REPOSITORY STRUCTURE
|
||||
|
||||
After cloning or creating the project, verify this structure exists:
|
||||
|
||||
```
|
||||
rongorongo/
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── stack.yml
|
||||
├── frontend/
|
||||
│ └── index.html
|
||||
└── RONGORONGO_DEPLOY.md (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 1 — VERIFY PREREQUISITES
|
||||
|
||||
Run these checks before proceeding. Stop and report any failures.
|
||||
|
||||
```bash
|
||||
# Confirm Docker and Swarm context
|
||||
docker info | grep -E "Swarm:|Manager:|Nodes:"
|
||||
|
||||
# Confirm traefik-public network exists
|
||||
docker network ls | grep traefik-public
|
||||
|
||||
# Confirm Traefik is running
|
||||
docker service ls | grep traefik
|
||||
|
||||
# Test registry connectivity (if using local registry)
|
||||
curl -s https://registry.hobbs.farm/v2/_catalog || echo "Registry unreachable — will use build-and-load method"
|
||||
|
||||
# Confirm you are on a Swarm manager node
|
||||
docker node ls
|
||||
```
|
||||
|
||||
If registry.hobbs.farm is unreachable, use the ALTERNATIVE DEPLOYMENT method
|
||||
at the bottom of this document.
|
||||
|
||||
---
|
||||
|
||||
## STEP 2 — BUILD THE IMAGE
|
||||
|
||||
```bash
|
||||
cd rongorongo/
|
||||
|
||||
# Build image
|
||||
docker build -t registry.hobbs.farm/rongorongo:latest .
|
||||
|
||||
# Verify image was created
|
||||
docker images | grep rongorongo
|
||||
```
|
||||
|
||||
Expected output: image tagged `registry.hobbs.farm/rongorongo:latest`, size
|
||||
should be ~25–40MB (nginx:alpine base + small HTML file).
|
||||
|
||||
---
|
||||
|
||||
## STEP 3 — PUSH TO REGISTRY
|
||||
|
||||
```bash
|
||||
# If registry requires auth:
|
||||
docker login registry.hobbs.farm
|
||||
|
||||
# Push
|
||||
docker push registry.hobbs.farm/rongorongo:latest
|
||||
```
|
||||
|
||||
If the push fails due to registry issues, fall through to ALTERNATIVE DEPLOYMENT.
|
||||
|
||||
---
|
||||
|
||||
## STEP 4 — DEPLOY STACK
|
||||
|
||||
```bash
|
||||
# Deploy or update the stack
|
||||
docker stack deploy -c stack.yml rongorongo --with-registry-auth
|
||||
|
||||
# Watch rollout
|
||||
docker service ps rongorongo_rongorongo --no-trunc
|
||||
```
|
||||
|
||||
Wait for the service to show `Running` status. This typically takes 15–30 seconds.
|
||||
|
||||
```bash
|
||||
# Confirm healthy
|
||||
docker service ls | grep rongorongo
|
||||
curl -s http://localhost/health # or via container exec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 5 — DNS VERIFICATION
|
||||
|
||||
The stack.yml assumes `rongorongo.hobbs.farm` resolves to the Traefik entrypoint.
|
||||
|
||||
```bash
|
||||
# Check AdGuard Home has the record (or wildcard covers it)
|
||||
nslookup rongorongo.hobbs.farm
|
||||
|
||||
# Test internal access
|
||||
curl -sk https://rongorongo.hobbs.farm/health
|
||||
```
|
||||
|
||||
If using AdGuard Home for internal DNS and the wildcard *.hobbs.farm is already
|
||||
configured for hairpin NAT, no additional DNS entry is needed.
|
||||
|
||||
---
|
||||
|
||||
## STEP 6 — SMOKE TEST
|
||||
|
||||
Open https://rongorongo.hobbs.farm in a browser and verify:
|
||||
1. App loads with "Rongorongo · Decipherment Engine" title
|
||||
2. Sidebar navigation works (Image Corpus, Glyph Catalog, etc.)
|
||||
3. Enter an Anthropic API key in the sidebar footer input
|
||||
4. Upload a test image to the corpus
|
||||
5. Navigate to Pattern Analyzer and run an analysis
|
||||
6. Confirm AI response appears in the result box
|
||||
|
||||
---
|
||||
|
||||
## UPDATING THE APP
|
||||
|
||||
When index.html is modified:
|
||||
|
||||
```bash
|
||||
cd rongorongo/
|
||||
|
||||
# Rebuild
|
||||
docker build -t registry.hobbs.farm/rongorongo:latest .
|
||||
|
||||
# Push
|
||||
docker push registry.hobbs.farm/rongorongo:latest
|
||||
|
||||
# Force service update (pull new image)
|
||||
docker service update --image registry.hobbs.farm/rongorongo:latest --force rongorongo_rongorongo
|
||||
|
||||
# Watch update
|
||||
docker service ps rongorongo_rongorongo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ROLLBACK
|
||||
|
||||
If something breaks after an update:
|
||||
|
||||
```bash
|
||||
docker service rollback rongorongo_rongorongo
|
||||
docker service ps rongorongo_rongorongo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TEARDOWN
|
||||
|
||||
To remove the service without destroying data (there is no persistent volume):
|
||||
|
||||
```bash
|
||||
docker stack rm rongorongo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ALTERNATIVE DEPLOYMENT (no registry)
|
||||
|
||||
Use this if registry.hobbs.farm is unavailable. This method builds the image
|
||||
directly on the Swarm manager and uses `docker service create` instead of
|
||||
`stack deploy`.
|
||||
|
||||
```bash
|
||||
# On the Swarm manager node:
|
||||
cd rongorongo/
|
||||
|
||||
# Build locally
|
||||
docker build -t rongorongo:latest .
|
||||
|
||||
# Create service manually (adjust Traefik labels as needed)
|
||||
docker service create \
|
||||
--name rongorongo_rongorongo \
|
||||
--network traefik-public \
|
||||
--label "traefik.enable=true" \
|
||||
--label "traefik.docker.network=traefik-public" \
|
||||
--label "traefik.http.routers.rongorongo.rule=Host(\`rongorongo.hobbs.farm\`)" \
|
||||
--label "traefik.http.routers.rongorongo.entrypoints=websecure" \
|
||||
--label "traefik.http.routers.rongorongo.tls=true" \
|
||||
--label "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" \
|
||||
--label "traefik.http.services.rongorongo.loadbalancer.server.port=80" \
|
||||
--replicas 1 \
|
||||
--restart-condition on-failure \
|
||||
rongorongo:latest
|
||||
|
||||
# Monitor
|
||||
docker service ps rongorongo_rongorongo
|
||||
```
|
||||
|
||||
NOTE: With this method, to update you must rebuild locally and run:
|
||||
```bash
|
||||
docker service update --image rongorongo:latest --force rongorongo_rongorongo
|
||||
```
|
||||
|
||||
This only works if your current node is the one running the replica. If you
|
||||
have a multi-node swarm, you should set up the registry to avoid placement issues.
|
||||
|
||||
---
|
||||
|
||||
## PORTAINER DEPLOYMENT (optional GUI method)
|
||||
|
||||
If you prefer Portainer CE:
|
||||
|
||||
1. Navigate to https://portainer.hobbs.farm (or your Portainer URL)
|
||||
2. Select the Swarm endpoint
|
||||
3. Go to Stacks → Add Stack
|
||||
4. Name: `rongorongo`
|
||||
5. Paste the contents of stack.yml into the web editor
|
||||
6. Set `registry.hobbs.farm/rongorongo:latest` as the image
|
||||
(you still need to build and push first)
|
||||
7. Click Deploy the stack
|
||||
|
||||
---
|
||||
|
||||
## ENVIRONMENT NOTES
|
||||
|
||||
The app does NOT use environment variables or Docker secrets. The Anthropic API
|
||||
key is entered by the user at runtime and stored in their browser's localStorage.
|
||||
This is intentional — the app is designed to be used by a single user (Jeremy)
|
||||
and no server-side key storage is needed or appropriate.
|
||||
|
||||
If you want to pre-populate the API key for convenience (only if the deployment
|
||||
is strictly private/internal), you can inject it at build time by modifying
|
||||
frontend/index.html before building:
|
||||
|
||||
Find this line:
|
||||
```javascript
|
||||
function getApiKey() {
|
||||
return document.getElementById('apiKeyInput').value.trim()
|
||||
```
|
||||
|
||||
And prepend a hardcoded fallback (NOT recommended for any externally accessible URL).
|
||||
|
||||
---
|
||||
|
||||
## TRAEFIK ENTRYPOINT NAMES
|
||||
|
||||
The stack.yml assumes:
|
||||
- HTTP entrypoint name: `web`
|
||||
- HTTPS entrypoint name: `websecure`
|
||||
- Cert resolver name: `cloudflare`
|
||||
|
||||
These match the standard hobbs.farm Traefik configuration. If your Traefik uses
|
||||
different names, edit the labels in stack.yml before deploying.
|
||||
|
||||
---
|
||||
|
||||
## TROUBLESHOOTING
|
||||
|
||||
**Service won't start:**
|
||||
```bash
|
||||
docker service ps rongorongo_rongorongo --no-trunc
|
||||
docker inspect $(docker ps -q --filter name=rongorongo) 2>/dev/null | grep -A5 '"State"'
|
||||
```
|
||||
|
||||
**Traefik not routing:**
|
||||
```bash
|
||||
# Check Traefik logs for rongorongo
|
||||
docker service logs traefik_traefik 2>&1 | grep -i rongorongo | tail -20
|
||||
# Confirm labels are applied
|
||||
docker service inspect rongorongo_rongorongo | grep -A2 Labels
|
||||
```
|
||||
|
||||
**TLS cert not issuing:**
|
||||
```bash
|
||||
# Wildcard cert should already cover rongorongo.hobbs.farm
|
||||
# If not, check Cloudflare DNS API token has Zone:Read and DNS:Edit
|
||||
docker service logs traefik_traefik 2>&1 | grep -i "acme\|certificate\|cloudflare" | tail -20
|
||||
```
|
||||
|
||||
**API calls blocked (CORS/CSP):**
|
||||
The nginx.conf includes a Content-Security-Policy header that allows
|
||||
`connect-src https://api.anthropic.com`. If you see CORS errors in the browser
|
||||
console, verify the CSP header is present:
|
||||
```bash
|
||||
curl -I https://rongorongo.hobbs.farm/ | grep -i content-security
|
||||
```
|
||||
|
||||
**App loads but AI calls fail:**
|
||||
- Check API key is entered correctly in the sidebar
|
||||
- Anthropic API key must start with `sk-ant-`
|
||||
- Confirm outbound HTTPS to api.anthropic.com is not blocked by firewall rules
|
||||
- The app sends `anthropic-dangerous-direct-browser-access: true` header which
|
||||
is required for direct browser API access
|
||||
|
||||
---
|
||||
|
||||
## END OF DEPLOYMENT GUIDE
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# Remove default nginx static content
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy app
|
||||
COPY frontend/index.html /usr/share/nginx/html/index.html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost/health || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,322 @@
|
||||
# RONGORONGO_DEPLOY.md
|
||||
# Rongorongo Decipherment Engine — Claude Code Deployment Guide
|
||||
# Target: hobbs.farm Docker Swarm infrastructure
|
||||
# Subdomain: rongorongo.hobbs.farm
|
||||
|
||||
---
|
||||
|
||||
## CONTEXT
|
||||
|
||||
This document instructs Claude Code to build, push, and deploy the Rongorongo
|
||||
Decipherment Engine as a containerized static web app to the hobbs.farm Swarm.
|
||||
|
||||
The app is a single-file HTML/JS application served by nginx. It calls the
|
||||
Anthropic API directly from the browser. No backend, no database. All session
|
||||
state is stored in browser localStorage.
|
||||
|
||||
Infrastructure assumptions (from existing hobbs.farm stack):
|
||||
- Docker Swarm: nodes hf-pdocker-01, hf-pdocker-02, bart
|
||||
- Traefik reverse proxy with wildcard *.hobbs.farm TLS via Cloudflare DNS
|
||||
- External overlay network: traefik-public
|
||||
- Registry: registry.hobbs.farm (if local) OR use Docker Hub as fallback
|
||||
- Portainer CE available for stack management
|
||||
- DNS: AdGuard Home with hairpin NAT for *.hobbs.farm
|
||||
|
||||
---
|
||||
|
||||
## REPOSITORY STRUCTURE
|
||||
|
||||
After cloning or creating the project, verify this structure exists:
|
||||
|
||||
```
|
||||
rongorongo/
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── stack.yml
|
||||
├── frontend/
|
||||
│ └── index.html
|
||||
└── RONGORONGO_DEPLOY.md (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 1 — VERIFY PREREQUISITES
|
||||
|
||||
Run these checks before proceeding. Stop and report any failures.
|
||||
|
||||
```bash
|
||||
# Confirm Docker and Swarm context
|
||||
docker info | grep -E "Swarm:|Manager:|Nodes:"
|
||||
|
||||
# Confirm traefik-public network exists
|
||||
docker network ls | grep traefik-public
|
||||
|
||||
# Confirm Traefik is running
|
||||
docker service ls | grep traefik
|
||||
|
||||
# Test registry connectivity (if using local registry)
|
||||
curl -s https://registry.hobbs.farm/v2/_catalog || echo "Registry unreachable — will use build-and-load method"
|
||||
|
||||
# Confirm you are on a Swarm manager node
|
||||
docker node ls
|
||||
```
|
||||
|
||||
If registry.hobbs.farm is unreachable, use the ALTERNATIVE DEPLOYMENT method
|
||||
at the bottom of this document.
|
||||
|
||||
---
|
||||
|
||||
## STEP 2 — BUILD THE IMAGE
|
||||
|
||||
```bash
|
||||
cd rongorongo/
|
||||
|
||||
# Build image
|
||||
docker build -t registry.hobbs.farm/rongorongo:latest .
|
||||
|
||||
# Verify image was created
|
||||
docker images | grep rongorongo
|
||||
```
|
||||
|
||||
Expected output: image tagged `registry.hobbs.farm/rongorongo:latest`, size
|
||||
should be ~25–40MB (nginx:alpine base + small HTML file).
|
||||
|
||||
---
|
||||
|
||||
## STEP 3 — PUSH TO REGISTRY
|
||||
|
||||
```bash
|
||||
# If registry requires auth:
|
||||
docker login registry.hobbs.farm
|
||||
|
||||
# Push
|
||||
docker push registry.hobbs.farm/rongorongo:latest
|
||||
```
|
||||
|
||||
If the push fails due to registry issues, fall through to ALTERNATIVE DEPLOYMENT.
|
||||
|
||||
---
|
||||
|
||||
## STEP 4 — DEPLOY STACK
|
||||
|
||||
```bash
|
||||
# Deploy or update the stack
|
||||
docker stack deploy -c stack.yml rongorongo --with-registry-auth
|
||||
|
||||
# Watch rollout
|
||||
docker service ps rongorongo_rongorongo --no-trunc
|
||||
```
|
||||
|
||||
Wait for the service to show `Running` status. This typically takes 15–30 seconds.
|
||||
|
||||
```bash
|
||||
# Confirm healthy
|
||||
docker service ls | grep rongorongo
|
||||
curl -s http://localhost/health # or via container exec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 5 — DNS VERIFICATION
|
||||
|
||||
The stack.yml assumes `rongorongo.hobbs.farm` resolves to the Traefik entrypoint.
|
||||
|
||||
```bash
|
||||
# Check AdGuard Home has the record (or wildcard covers it)
|
||||
nslookup rongorongo.hobbs.farm
|
||||
|
||||
# Test internal access
|
||||
curl -sk https://rongorongo.hobbs.farm/health
|
||||
```
|
||||
|
||||
If using AdGuard Home for internal DNS and the wildcard *.hobbs.farm is already
|
||||
configured for hairpin NAT, no additional DNS entry is needed.
|
||||
|
||||
---
|
||||
|
||||
## STEP 6 — SMOKE TEST
|
||||
|
||||
Open https://rongorongo.hobbs.farm in a browser and verify:
|
||||
1. App loads with "Rongorongo · Decipherment Engine" title
|
||||
2. Sidebar navigation works (Image Corpus, Glyph Catalog, etc.)
|
||||
3. Enter an Anthropic API key in the sidebar footer input
|
||||
4. Upload a test image to the corpus
|
||||
5. Navigate to Pattern Analyzer and run an analysis
|
||||
6. Confirm AI response appears in the result box
|
||||
|
||||
---
|
||||
|
||||
## UPDATING THE APP
|
||||
|
||||
When index.html is modified:
|
||||
|
||||
```bash
|
||||
cd rongorongo/
|
||||
|
||||
# Rebuild
|
||||
docker build -t registry.hobbs.farm/rongorongo:latest .
|
||||
|
||||
# Push
|
||||
docker push registry.hobbs.farm/rongorongo:latest
|
||||
|
||||
# Force service update (pull new image)
|
||||
docker service update --image registry.hobbs.farm/rongorongo:latest --force rongorongo_rongorongo
|
||||
|
||||
# Watch update
|
||||
docker service ps rongorongo_rongorongo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ROLLBACK
|
||||
|
||||
If something breaks after an update:
|
||||
|
||||
```bash
|
||||
docker service rollback rongorongo_rongorongo
|
||||
docker service ps rongorongo_rongorongo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TEARDOWN
|
||||
|
||||
To remove the service without destroying data (there is no persistent volume):
|
||||
|
||||
```bash
|
||||
docker stack rm rongorongo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ALTERNATIVE DEPLOYMENT (no registry)
|
||||
|
||||
Use this if registry.hobbs.farm is unavailable. This method builds the image
|
||||
directly on the Swarm manager and uses `docker service create` instead of
|
||||
`stack deploy`.
|
||||
|
||||
```bash
|
||||
# On the Swarm manager node:
|
||||
cd rongorongo/
|
||||
|
||||
# Build locally
|
||||
docker build -t rongorongo:latest .
|
||||
|
||||
# Create service manually (adjust Traefik labels as needed)
|
||||
docker service create \
|
||||
--name rongorongo_rongorongo \
|
||||
--network traefik-public \
|
||||
--label "traefik.enable=true" \
|
||||
--label "traefik.docker.network=traefik-public" \
|
||||
--label "traefik.http.routers.rongorongo.rule=Host(\`rongorongo.hobbs.farm\`)" \
|
||||
--label "traefik.http.routers.rongorongo.entrypoints=websecure" \
|
||||
--label "traefik.http.routers.rongorongo.tls=true" \
|
||||
--label "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" \
|
||||
--label "traefik.http.services.rongorongo.loadbalancer.server.port=80" \
|
||||
--replicas 1 \
|
||||
--restart-condition on-failure \
|
||||
rongorongo:latest
|
||||
|
||||
# Monitor
|
||||
docker service ps rongorongo_rongorongo
|
||||
```
|
||||
|
||||
NOTE: With this method, to update you must rebuild locally and run:
|
||||
```bash
|
||||
docker service update --image rongorongo:latest --force rongorongo_rongorongo
|
||||
```
|
||||
|
||||
This only works if your current node is the one running the replica. If you
|
||||
have a multi-node swarm, you should set up the registry to avoid placement issues.
|
||||
|
||||
---
|
||||
|
||||
## PORTAINER DEPLOYMENT (optional GUI method)
|
||||
|
||||
If you prefer Portainer CE:
|
||||
|
||||
1. Navigate to https://portainer.hobbs.farm (or your Portainer URL)
|
||||
2. Select the Swarm endpoint
|
||||
3. Go to Stacks → Add Stack
|
||||
4. Name: `rongorongo`
|
||||
5. Paste the contents of stack.yml into the web editor
|
||||
6. Set `registry.hobbs.farm/rongorongo:latest` as the image
|
||||
(you still need to build and push first)
|
||||
7. Click Deploy the stack
|
||||
|
||||
---
|
||||
|
||||
## ENVIRONMENT NOTES
|
||||
|
||||
The app does NOT use environment variables or Docker secrets. The Anthropic API
|
||||
key is entered by the user at runtime and stored in their browser's localStorage.
|
||||
This is intentional — the app is designed to be used by a single user (Jeremy)
|
||||
and no server-side key storage is needed or appropriate.
|
||||
|
||||
If you want to pre-populate the API key for convenience (only if the deployment
|
||||
is strictly private/internal), you can inject it at build time by modifying
|
||||
frontend/index.html before building:
|
||||
|
||||
Find this line:
|
||||
```javascript
|
||||
function getApiKey() {
|
||||
return document.getElementById('apiKeyInput').value.trim()
|
||||
```
|
||||
|
||||
And prepend a hardcoded fallback (NOT recommended for any externally accessible URL).
|
||||
|
||||
---
|
||||
|
||||
## TRAEFIK ENTRYPOINT NAMES
|
||||
|
||||
The stack.yml assumes:
|
||||
- HTTP entrypoint name: `web`
|
||||
- HTTPS entrypoint name: `websecure`
|
||||
- Cert resolver name: `cloudflare`
|
||||
|
||||
These match the standard hobbs.farm Traefik configuration. If your Traefik uses
|
||||
different names, edit the labels in stack.yml before deploying.
|
||||
|
||||
---
|
||||
|
||||
## TROUBLESHOOTING
|
||||
|
||||
**Service won't start:**
|
||||
```bash
|
||||
docker service ps rongorongo_rongorongo --no-trunc
|
||||
docker inspect $(docker ps -q --filter name=rongorongo) 2>/dev/null | grep -A5 '"State"'
|
||||
```
|
||||
|
||||
**Traefik not routing:**
|
||||
```bash
|
||||
# Check Traefik logs for rongorongo
|
||||
docker service logs traefik_traefik 2>&1 | grep -i rongorongo | tail -20
|
||||
# Confirm labels are applied
|
||||
docker service inspect rongorongo_rongorongo | grep -A2 Labels
|
||||
```
|
||||
|
||||
**TLS cert not issuing:**
|
||||
```bash
|
||||
# Wildcard cert should already cover rongorongo.hobbs.farm
|
||||
# If not, check Cloudflare DNS API token has Zone:Read and DNS:Edit
|
||||
docker service logs traefik_traefik 2>&1 | grep -i "acme\|certificate\|cloudflare" | tail -20
|
||||
```
|
||||
|
||||
**API calls blocked (CORS/CSP):**
|
||||
The nginx.conf includes a Content-Security-Policy header that allows
|
||||
`connect-src https://api.anthropic.com`. If you see CORS errors in the browser
|
||||
console, verify the CSP header is present:
|
||||
```bash
|
||||
curl -I https://rongorongo.hobbs.farm/ | grep -i content-security
|
||||
```
|
||||
|
||||
**App loads but AI calls fail:**
|
||||
- Check API key is entered correctly in the sidebar
|
||||
- Anthropic API key must start with `sk-ant-`
|
||||
- Confirm outbound HTTPS to api.anthropic.com is not blocked by firewall rules
|
||||
- The app sends `anthropic-dangerous-direct-browser-access: true` header which
|
||||
is required for direct browser API access
|
||||
|
||||
---
|
||||
|
||||
## END OF DEPLOYMENT GUIDE
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# CSP — allows Anthropic API calls and Google Fonts
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/html text/css application/javascript;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Health endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Cache static assets aggressively if you add any
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
rongorongo:
|
||||
image: registry.hobbs.farm/rongorongo:latest
|
||||
networks:
|
||||
- traefik-public
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 10s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
failure_action: rollback
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
# HTTP → HTTPS redirect
|
||||
- "traefik.http.routers.rongorongo-http.rule=Host(`rongorongo.hobbs.farm`)"
|
||||
- "traefik.http.routers.rongorongo-http.entrypoints=web"
|
||||
- "traefik.http.routers.rongorongo-http.middlewares=redirect-to-https"
|
||||
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.rongorongo.rule=Host(`rongorongo.hobbs.farm`)"
|
||||
- "traefik.http.routers.rongorongo.entrypoints=websecure"
|
||||
- "traefik.http.routers.rongorongo.tls=true"
|
||||
- "traefik.http.routers.rongorongo.tls.certresolver=cloudflare"
|
||||
|
||||
# Service
|
||||
- "traefik.http.services.rongorongo.loadbalancer.server.port=80"
|
||||
|
||||
# Middlewares
|
||||
- "traefik.http.routers.rongorongo.middlewares=rongorongo-headers"
|
||||
- "traefik.http.middlewares.rongorongo-headers.headers.customResponseHeaders.X-Robots-Tag=noindex,nofollow"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,29 @@
|
||||
services:
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
# Pinned to official stable 12.2. History: ran the 2026060106 unstable
|
||||
# build from 2026-07-14 because the DB had been migrated to the v12 schema
|
||||
# and 10.11.x could not read it. Stable 12 shipped 2026-09; moved to
|
||||
# 12.2 on 2026-10-17, 12.3 on 2026-10-26. Never go back below 12.
|
||||
image: jellyfin/jellyfin:12.3.20261023-013714
|
||||
container_name: jellyfin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- LIBVA_DRIVER_NAME=i965 # Haswell HD 4600 uses legacy i965 (iHD is Broadwell+)
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/jellyfin:/config
|
||||
# LOCAL disk, not NFS: jellyfin.db (SQLite WAL) on NFS caused 60-100s
|
||||
# "database is locked" stalls -> server lockups during playback
|
||||
# (2026-07-19). Old copy left at /mnt/docker-data/configs/jellyfin.
|
||||
- /var/lib/jellyfin:/config
|
||||
- /mnt/nas-media:/media
|
||||
ports:
|
||||
- "8096:8096"
|
||||
devices:
|
||||
- /dev/dri:/dev/dri # Intel iGPU for VAAPI hardware transcoding
|
||||
group_add:
|
||||
- "993" # render
|
||||
- "44" # video
|
||||
mem_limit: 2G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8096/health || exit 1"]
|
||||
@@ -33,7 +46,9 @@ services:
|
||||
- PGID=1000
|
||||
- TZ=America/Chicago
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/sonarr:/config
|
||||
# LOCAL disk, not NFS: SQLite-on-NFS locking crashed Sonarr's DB migration
|
||||
# after the 2026-07-12 power event ("waiting for user intervention")
|
||||
- /var/lib/sonarr:/config
|
||||
- /mnt/nas-media:/media
|
||||
ports:
|
||||
- "8989:8989"
|
||||
@@ -111,10 +126,12 @@ services:
|
||||
- PGID=1000
|
||||
- TZ=America/Chicago
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/nzbhydra:/config
|
||||
# LOCAL disk, not NFS: stale NFS lock on the H2 database after the
|
||||
# 2026-07-12 power event kept hydra unhealthy in a DB-retry loop
|
||||
- /var/lib/nzbhydra:/config
|
||||
ports:
|
||||
- "5076:5076"
|
||||
mem_limit: 512M
|
||||
mem_limit: 1G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"]
|
||||
interval: 30s
|
||||
@@ -127,14 +144,19 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
jellyseerr:
|
||||
image: fallenbagel/jellyseerr:latest
|
||||
container_name: jellyseerr
|
||||
seerr:
|
||||
# Seerr = merged successor of Jellyseerr/Overseerr (fallenbagel/jellyseerr
|
||||
# is deprecated). Migrated 2026-10-17; DB auto-migrated on first start.
|
||||
# Runs as node (UID 1000), needs init. LOCAL disk (SQLite). Old config left
|
||||
# at /mnt/docker-data/configs/jellyseerr for rollback.
|
||||
image: seerr/seerr:v3.5.1
|
||||
init: true
|
||||
container_name: seerr
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=America/Chicago
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/jellyseerr:/app/config
|
||||
- /var/lib/seerr:/app/config
|
||||
ports:
|
||||
- "5055:5055"
|
||||
mem_limit: 512M
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.12.1
|
||||
container_name: node-exporter
|
||||
hostname: bart
|
||||
network_mode: host
|
||||
pid: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- "--path.procfs=/host/proc"
|
||||
- "--path.rootfs=/rootfs"
|
||||
- "--path.sysfs=/host/sys"
|
||||
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 50m
|
||||
max-file: "3"
|
||||
labels:
|
||||
- com.centurylinklabs.watchtower.enable=false
|
||||
@@ -1,231 +0,0 @@
|
||||
services:
|
||||
api_server:
|
||||
image: onyxdotapp/onyx-backend:latest
|
||||
command: >
|
||||
/bin/sh -c "alembic upgrade head &&
|
||||
echo Starting Onyx API Server &&
|
||||
uvicorn onyx.main:app --host 0.0.0.0 --port 8080"
|
||||
depends_on:
|
||||
relational_db:
|
||||
condition: service_healthy
|
||||
index:
|
||||
condition: service_healthy
|
||||
cache:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- AUTH_TYPE=basic
|
||||
- DISABLE_TELEMETRY=true
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=inference_model_server
|
||||
- INDEXING_MODEL_SERVER_HOST=indexing_model_server
|
||||
- OPENSEARCH_HOST=opensearch
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false
|
||||
- FILE_STORE_BACKEND=postgres
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- onyx_file_connector:/home/file_connector_storage
|
||||
networks:
|
||||
- onyx
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
background:
|
||||
image: onyxdotapp/onyx-backend:latest
|
||||
command: >
|
||||
/bin/sh -c "/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf"
|
||||
depends_on:
|
||||
relational_db:
|
||||
condition: service_healthy
|
||||
index:
|
||||
condition: service_healthy
|
||||
cache:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
mem_limit: 3G
|
||||
memswap_limit: 3G
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- AUTH_TYPE=basic
|
||||
- DISABLE_TELEMETRY=true
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=inference_model_server
|
||||
- INDEXING_MODEL_SERVER_HOST=indexing_model_server
|
||||
- OPENSEARCH_HOST=opensearch
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false
|
||||
- FILE_STORE_BACKEND=postgres
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- onyx_file_connector:/home/file_connector_storage
|
||||
networks:
|
||||
- onyx
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
web_server:
|
||||
image: onyxdotapp/onyx-web-server:latest
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: "no"
|
||||
mem_limit: 512M
|
||||
memswap_limit: 512M
|
||||
environment:
|
||||
- INTERNAL_URL=http://api_server:8080
|
||||
- WEB_DOMAIN=https://onyx.hobbs.farm
|
||||
- NEXT_PUBLIC_DISABLE_STREAMING=false
|
||||
- AUTH_TYPE=basic
|
||||
networks:
|
||||
- onyx
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
inference_model_server:
|
||||
image: onyxdotapp/onyx-model-server:latest
|
||||
command: >
|
||||
/bin/sh -c "exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000"
|
||||
restart: "no"
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
environment:
|
||||
- MIN_THREADS_ML_MODELS=1
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
- onyx_model_cache_huggingface:/root/.cache/huggingface/
|
||||
networks:
|
||||
- onyx
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
indexing_model_server:
|
||||
image: onyxdotapp/onyx-model-server:latest
|
||||
command: >
|
||||
/bin/sh -c "exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000"
|
||||
restart: "no"
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
environment:
|
||||
- MIN_THREADS_ML_MODELS=1
|
||||
- INDEXING_ONLY=true
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
- onyx_model_cache_huggingface:/root/.cache/huggingface/
|
||||
networks:
|
||||
- onyx
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
relational_db:
|
||||
image: postgres:15.2-alpine
|
||||
restart: "no"
|
||||
mem_limit: 512M
|
||||
memswap_limit: 512M
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=onyx_postgres_pw
|
||||
volumes:
|
||||
- onyx_db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- onyx
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
index:
|
||||
image: vespaengine/vespa:8.609.39
|
||||
restart: "no"
|
||||
mem_limit: 4G
|
||||
memswap_limit: 4G
|
||||
volumes:
|
||||
- onyx_vespa:/opt/vespa/var
|
||||
networks:
|
||||
- onyx
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:19071/state/v1/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 120s
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.11.1
|
||||
restart: "no"
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- plugins.security.disabled=true
|
||||
- OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
|
||||
- DISABLE_INSTALL_DEMO_CONFIG=true
|
||||
volumes:
|
||||
- onyx_opensearch:/usr/share/opensearch/data
|
||||
networks:
|
||||
- onyx
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 120s
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
cache:
|
||||
image: redis:7.4-alpine
|
||||
restart: "no"
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
|
||||
networks:
|
||||
- onyx
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25.5-alpine
|
||||
restart: "no"
|
||||
mem_limit: 128M
|
||||
memswap_limit: 128M
|
||||
depends_on:
|
||||
- api_server
|
||||
- web_server
|
||||
ports:
|
||||
- "3005:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
- onyx
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "false"
|
||||
|
||||
volumes:
|
||||
onyx_file_connector:
|
||||
onyx_model_cache_huggingface:
|
||||
onyx_db:
|
||||
onyx_vespa:
|
||||
onyx_opensearch:
|
||||
|
||||
networks:
|
||||
onyx:
|
||||
driver: bridge
|
||||
@@ -1,36 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Use Docker's internal DNS with a short TTL so container IP changes
|
||||
# (e.g. after docker compose up -d for a single service) are picked up
|
||||
# without requiring an nginx reload.
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
location /api/ {
|
||||
set $api_upstream http://api_server:8080;
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass $api_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location / {
|
||||
set $web_upstream http://web_server:3000;
|
||||
proxy_pass $web_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ services:
|
||||
image: immauss/openvas:latest
|
||||
container_name: openvas
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
# Watchtower pulled a new major that forced a pg_upgrade crash-loop
|
||||
# (212 restarts, 2026-07-13); update OpenVAS deliberately instead
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
environment:
|
||||
- PASSWORD=${OPENVAS_PASSWORD}
|
||||
- USERNAME=${OPENVAS_USERNAME}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
telegraf:
|
||||
image: telegraf:latest
|
||||
container_name: telegraf
|
||||
hostname: bart
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HOST_PROC=/host/proc
|
||||
- HOST_SYS=/host/sys
|
||||
- HOST_ETC=/host/etc
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/telegraf/telegraf.conf:/etc/telegraf/telegraf.conf:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /proc:/host/proc:ro
|
||||
- /etc:/host/etc:ro
|
||||
mem_limit: 512M
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
image: nickfedor/watchtower:latest # containrrr/watchtower is archived upstream (2026-10-26)
|
||||
container_name: watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
environment:
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
- WATCHTOWER_INCLUDE_RESTARTING=true
|
||||
- WATCHTOWER_POLL_INTERVAL=86400
|
||||
- WATCHTOWER_SCHEDULE=0 0 4,16 * * * # twice daily, 04:00 + 16:00 local
|
||||
- WATCHTOWER_MONITOR_ONLY=false
|
||||
- TZ=America/Chicago
|
||||
- DOCKER_API_VERSION=1.44
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Overlay on the friday-voice app image that adds the docker CLI + a wrapper so
|
||||
# the container can reach the hermes agent via `docker exec hermes hermes ...`.
|
||||
# Runs as root to access the mounted /var/run/docker.sock.
|
||||
FROM friday-voice:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Static docker *client* only (~small), for `docker exec` into the hermes container.
|
||||
RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin docker/docker \
|
||||
&& chmod +x /usr/local/bin/docker \
|
||||
&& docker --version
|
||||
|
||||
COPY hermes-wrap /usr/local/bin/hermes-wrap
|
||||
RUN chmod +x /usr/local/bin/hermes-wrap
|
||||
|
||||
# Keep the base image's CMD (uvicorn friday_voice.app:app :8320).
|
||||
@@ -0,0 +1,49 @@
|
||||
# FRIDAY Voice on .150 (Docker Desktop) — replaces VoiceCoach.
|
||||
# STT/TTS via local GPU speaches (:8000). Reasoning via the hermes container
|
||||
# (invoked through the docker-exec wrapper). Runs as root for socket access.
|
||||
services:
|
||||
friday-voice:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.hermes
|
||||
image: friday-voice-hermes:latest
|
||||
container_name: friday-voice
|
||||
restart: unless-stopped
|
||||
user: "0:0"
|
||||
ports:
|
||||
- "8320:8320"
|
||||
volumes:
|
||||
- friday-voice-data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- SPEACHES_URL=http://host.docker.internal:8000
|
||||
- HERMES_COMMAND=/usr/local/bin/hermes-wrap
|
||||
- HERMES_SOURCE=friday-voice
|
||||
- WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||
- TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
- TTS_VOICE=af_nova
|
||||
- SPEAKER_CONFIRMED_THRESHOLD=0.85
|
||||
- SPEAKER_PROBABLE_THRESHOLD=0.70
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8320/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
friday-voice-data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: friday-voice
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# friday-voice invokes: hermes-wrap chat -q "<text>" --source friday-voice
|
||||
# We forward into the hermes container and add -Q (quiet) so only the final
|
||||
# response text is returned (no banner/spinner/box art) for clean TTS.
|
||||
exec docker exec hermes hermes "$@" -Q
|
||||
@@ -0,0 +1,34 @@
|
||||
# Hermes Agent on .150 (Docker Desktop) — FRIDAY's reasoning agent.
|
||||
# Data/settings live in the `hermes-data` named volume, seeded from the user's
|
||||
# WSL ~/.hermes (settings preserved; SQLite state.db kept on the VM ext4, not a
|
||||
# bridged bind mount). friday-voice calls this via `docker exec hermes hermes ...`.
|
||||
services:
|
||||
gateway:
|
||||
# PINNED 2026-08-20. Was `:latest`, which on this repo is the same digest as
|
||||
# `main` (a rolling dev build), and with Watchtower disabled nothing ever
|
||||
# re-pulled it — the container sat on v2026.7.1 for ~7 weeks / 9 releases.
|
||||
# Bump deliberately; rollback image: nousresearch/hermes-agent:rollback-2026.7.1
|
||||
image: nousresearch/hermes-agent:v2026.8.18
|
||||
container_name: hermes
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- hermes-data:/opt/data
|
||||
environment:
|
||||
- HERMES_UID=10000
|
||||
- HERMES_GID=10000
|
||||
command: ["gateway", "run"]
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# dashboard: disabled — crash-loops under s6 on Docker Desktop and not needed
|
||||
# by friday-voice (gateway + `docker exec hermes` is all that's required).
|
||||
# Re-enable + debug later if the localhost:9119 web UI is wanted.
|
||||
|
||||
volumes:
|
||||
hermes-data:
|
||||
external: true
|
||||
@@ -0,0 +1,62 @@
|
||||
# llama.cpp server on .150 (Docker Desktop) — inference backend for hermes/FRIDAY.
|
||||
#
|
||||
# Replaced ollama as the *serving* engine on 2026-08-21 after a head-to-head
|
||||
# benchmark on the same GGUF, same 64k context, same GPU:
|
||||
# ollama (f16 KV): 15.8 tok/s aggregate, 16.5 sustained, 23207 MiB VRAM
|
||||
# llama.cpp (q8_0 KV): 39.7 tok/s aggregate, 39.9 sustained, 19545 MiB VRAM
|
||||
# The difference is the quantized KV cache: it drops the footprint from 23.2GB
|
||||
# to 18.6GB, so the whole model fits on the 3090 instead of spilling layers to
|
||||
# CPU. Ollama exposes no knob for KV quantization.
|
||||
#
|
||||
# Image is pinned BY DIGEST on purpose — `:server-cuda` is a moving tag, and
|
||||
# this homelab has been bitten by tag-rot three times (synapse, gitea, openclaw).
|
||||
services:
|
||||
llamacpp:
|
||||
image: ghcr.io/ggml-org/llama.cpp@sha256:995fe9f237c622ac257905948c3cdde447ab343db22e0686322445b5ec6d3131
|
||||
container_name: llamacpp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
# Read-only mount of ollama's blob store. NOTE the coupling: this is the
|
||||
# GGUF that `qwen3.8` resolves to. C: only had 26GB free so a dedicated
|
||||
# copy wasn't practical. If you ever `ollama rm qwen3.8`, this breaks —
|
||||
# copy the blob out first.
|
||||
- "C:/Users/Jeremy/.ollama/models/blobs:/models:ro"
|
||||
command:
|
||||
- "-m"
|
||||
- "/models/sha256-f5f1dd8920d417aac2718b0bda3403da274301efdd6760b4f0f4b864ff2ad57d"
|
||||
- "--alias"
|
||||
- "qwen3.8-64k"
|
||||
- "-ngl"
|
||||
- "99" # all 65 layers to GPU
|
||||
- "-c"
|
||||
- "65536" # hermes hard-refuses anything under 64K
|
||||
- "--cache-type-k"
|
||||
- "q8_0" # the whole point — halves KV cache
|
||||
- "--cache-type-v"
|
||||
- "q8_0"
|
||||
- "--host"
|
||||
- "0.0.0.0"
|
||||
- "--port"
|
||||
- "8081"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
# The image ships its own HEALTHCHECK that cannot run here, so the
|
||||
# container reports permanently "unhealthy" while serving fine (cf. litellm,
|
||||
# CLAUDE.md known issue #15). Disable it; liveness is the blackbox probe of
|
||||
# GET http://192.168.86.150:8081/health from outside.
|
||||
healthcheck:
|
||||
disable: true
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
speaches:
|
||||
image: ghcr.io/speaches-ai/speaches:latest-cuda
|
||||
container_name: voicecoach-speaches-gpu
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- WHISPER__INFERENCE_DEVICE=cuda
|
||||
- WHISPER__COMPUTE_TYPE=float16
|
||||
- WHISPER__TTL=-1
|
||||
volumes:
|
||||
- speaches-models:/home/ubuntu/.cache
|
||||
ports:
|
||||
- "8000:8000"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 180s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
speaches-models:
|
||||
@@ -1,26 +0,0 @@
|
||||
services:
|
||||
adguardhome:
|
||||
image: adguard/adguardhome:latest
|
||||
container_name: adguardhome
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/adguardhome/work:/opt/adguardhome/work
|
||||
- /mnt/docker-data/configs/adguardhome/conf:/opt/adguardhome/conf
|
||||
ports:
|
||||
- "53:53/tcp"
|
||||
- "53:53/udp"
|
||||
- "3000:3000/tcp"
|
||||
- "853:853/tcp"
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
- HOME=/home/glyptic
|
||||
- USER=glyptic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
- WIKIJS_API_KEY=${WIKIJS_API_KEY}
|
||||
- OPENCLAW_STATE_DIR=/home/glyptic/.spirit
|
||||
- OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json
|
||||
@@ -43,6 +44,8 @@ services:
|
||||
- /mnt/infrastructure/spirit:/home/glyptic/.spirit
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /mnt/infrastructure/spirit/ssh:/root/.ssh:ro
|
||||
- /mnt/docker-data/configs/prometheus:/prometheus-config
|
||||
- /mnt/docker-data/configs/alertmanager:/alertmanager-config
|
||||
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
||||
mem_limit: 3G
|
||||
memswap_limit: 3G
|
||||
@@ -52,74 +55,3 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
bob:
|
||||
image: margaret-openclaw:latest
|
||||
container_name: bob
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: "0:0"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOME=/home/glyptic
|
||||
- USER=glyptic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- OPENCLAW_STATE_DIR=/home/glyptic/.bob
|
||||
- OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json
|
||||
volumes:
|
||||
- /mnt/infrastructure/bob:/home/glyptic/.bob
|
||||
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
clippy:
|
||||
image: margaret-openclaw:latest
|
||||
container_name: clippy
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: "0:0"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOME=/home/glyptic
|
||||
- USER=glyptic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- OPENCLAW_STATE_DIR=/home/glyptic/.clippy
|
||||
- OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json
|
||||
volumes:
|
||||
- /mnt/infrastructure/clippy:/home/glyptic/.clippy
|
||||
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
travis:
|
||||
image: travis-nemoclaw:latest
|
||||
container_name: travis
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: "0:0"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOME=/home/glyptic
|
||||
- USER=glyptic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- OPENCLAW_STATE_DIR=/home/glyptic/.travis
|
||||
- OPENCLAW_CONFIG_PATH=/home/glyptic/.travis/openclaw.json
|
||||
volumes:
|
||||
- /mnt/infrastructure/travis:/home/glyptic/.travis
|
||||
command: ["sh", "-c", "mkdir -p /home/glyptic && openclaw plugins install /opt/nemoclaw 2>/dev/null; node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Margaret — OpenClaw gateway agent (hf-pdocker-01, :18790)
|
||||
#
|
||||
# Reconstructed 2026-08-20 from the layer history of margaret-openclaw:latest;
|
||||
# the original Dockerfile was never committed anywhere.
|
||||
#
|
||||
# OPENCLAW_VERSION is pinned on purpose. This image was previously built with
|
||||
# `openclaw@latest`, which is how it sat on 2026.5.27 for months without anyone
|
||||
# noticing (same failure mode as the matrixdotorg/synapse :latest rot).
|
||||
# Bump this ARG deliberately.
|
||||
FROM node:22.22.3-bookworm
|
||||
|
||||
ARG OPENCLAW_VERSION=2026.7.1-2
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g openclaw@${OPENCLAW_VERSION} 2>&1 | tail -5
|
||||
|
||||
# The bundled matrix extension ships without its runtime deps installed.
|
||||
RUN if [ -d /usr/local/lib/node_modules/openclaw/dist/extensions/matrix ]; then \
|
||||
cd /usr/local/lib/node_modules/openclaw/dist/extensions/matrix && \
|
||||
npm install --no-fund --no-audit 2>&1 | tail -5; \
|
||||
fi
|
||||
|
||||
# Shim for a plugin-sdk export path some builds omit.
|
||||
RUN if [ ! -f /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js ]; then \
|
||||
printf 'export { KeyedAsyncQueue, enqueueKeyedTask } from "./index.js";\n' \
|
||||
> /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js; \
|
||||
fi
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
EXPOSE 18790
|
||||
CMD ["openclaw", "gateway", "start", "--foreground"]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Spirit — OpenClaw infra-ops agent (hf-pdocker-01, :18795)
|
||||
#
|
||||
# Reconstructed 2026-08-20 from the layer history of spirit-ops:latest;
|
||||
# the original Dockerfile was never committed anywhere.
|
||||
#
|
||||
# OPENCLAW_VERSION is pinned on purpose. This image was previously built with
|
||||
# `openclaw@latest`, which is how it sat on 2026.5.27 for months without anyone
|
||||
# noticing (same failure mode as the matrixdotorg/synapse :latest rot).
|
||||
# Bump this ARG deliberately.
|
||||
FROM node:22.22.3-bookworm
|
||||
|
||||
ARG OPENCLAW_VERSION=2026.7.1-2
|
||||
|
||||
# spirit does infra ops: needs ssh out to the nodes plus basic diagnostics.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git ca-certificates openssh-client curl jq procps net-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g openclaw@${OPENCLAW_VERSION} 2>&1 | tail -5
|
||||
|
||||
RUN npm install -g @google/gemini-cli 2>&1 | tail -3
|
||||
|
||||
# The bundled matrix extension ships without its runtime deps installed.
|
||||
RUN if [ -d /usr/local/lib/node_modules/openclaw/dist/extensions/matrix ]; then \
|
||||
cd /usr/local/lib/node_modules/openclaw/dist/extensions/matrix && \
|
||||
npm install --no-fund --no-audit 2>&1 | tail -5; \
|
||||
fi
|
||||
|
||||
# Shim for a plugin-sdk export path some builds omit.
|
||||
RUN if [ ! -f /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js ]; then \
|
||||
printf 'export { KeyedAsyncQueue, enqueueKeyedTask } from "./index.js";\n' \
|
||||
> /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js; \
|
||||
fi
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
EXPOSE 18795
|
||||
CMD ["openclaw", "gateway", "start", "--foreground"]
|
||||
@@ -0,0 +1,8 @@
|
||||
SPEACHES_URL=http://192.168.86.150:8000
|
||||
HERMES_COMMAND=hermes
|
||||
HERMES_SOURCE=friday-voice
|
||||
WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
TTS_VOICE=af_nova
|
||||
SPEAKER_CONFIRMED_THRESHOLD=0.85
|
||||
SPEAKER_PROBABLE_THRESHOLD=0.70
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
friday-voice:
|
||||
build:
|
||||
context: /opt/friday-voice
|
||||
dockerfile: Dockerfile
|
||||
image: friday-voice:latest
|
||||
container_name: friday-voice
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8320:8320"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/friday-voice:/data
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- SPEACHES_URL=${SPEACHES_URL:-http://192.168.86.150:8000}
|
||||
- HERMES_COMMAND=${HERMES_COMMAND:-hermes}
|
||||
- HERMES_SOURCE=${HERMES_SOURCE:-friday-voice}
|
||||
- WHISPER_MODEL=${WHISPER_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
|
||||
- TTS_MODEL=${TTS_MODEL:-speaches-ai/Kokoro-82M-v1.0-ONNX}
|
||||
- TTS_VOICE=${TTS_VOICE:-af_nova}
|
||||
- SPEAKER_CONFIRMED_THRESHOLD=${SPEAKER_CONFIRMED_THRESHOLD:-0.85}
|
||||
- SPEAKER_PROBABLE_THRESHOLD=${SPEAKER_PROBABLE_THRESHOLD:-0.70}
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8320/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: friday-voice
|
||||
@@ -9,6 +9,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ .
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 5199
|
||||
|
||||
CMD ["python", "-u", "main.py"]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Automatic event cleanup based on retention settings."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def cleanup_old_events():
|
||||
"""Delete events older than the configured retention period. Runs hourly."""
|
||||
while True:
|
||||
try:
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key='event_retention_days'"
|
||||
).fetchone()
|
||||
days = int(row["value"]) if row else 90
|
||||
|
||||
result = conn.execute(
|
||||
"DELETE FROM events WHERE created_at < datetime('now', ? || ' days')",
|
||||
(f"-{days}",),
|
||||
)
|
||||
conn.commit()
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
"Cleaned up %d events older than %d days",
|
||||
result.rowcount,
|
||||
days,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Cleanup failed")
|
||||
|
||||
await asyncio.sleep(3600)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Centralized configuration for frigate-notify v2."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
"""Application configuration read from environment variables."""
|
||||
|
||||
# MQTT
|
||||
MQTT_HOST: str = os.environ.get("MQTT_HOST", "192.168.86.192")
|
||||
MQTT_PORT: int = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
|
||||
# Frigate
|
||||
FRIGATE_URL: str = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000")
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER: str = os.environ.get(
|
||||
"MATRIX_HOMESERVER", "http://192.168.86.192:8008"
|
||||
)
|
||||
MATRIX_ACCESS_TOKEN: str = os.environ.get("MATRIX_ACCESS_TOKEN", "")
|
||||
MATRIX_ROOM_ID: str = os.environ.get("MATRIX_ROOM_ID", "")
|
||||
|
||||
# Vision - CLIP service
|
||||
CLIP_SERVICE_URL: str = os.environ.get(
|
||||
"CLIP_SERVICE_URL", "http://192.168.86.150:5200"
|
||||
)
|
||||
|
||||
# Vision - Ollama
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://192.168.86.150:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llava:13b")
|
||||
|
||||
# Recognition thresholds
|
||||
RECOGNITION_THRESHOLD_HIGH: float = float(
|
||||
os.environ.get("RECOGNITION_THRESHOLD_HIGH", "0.85")
|
||||
)
|
||||
RECOGNITION_THRESHOLD_LOW: float = float(
|
||||
os.environ.get("RECOGNITION_THRESHOLD_LOW", "0.60")
|
||||
)
|
||||
|
||||
# Filtering
|
||||
ALLOWED_LABELS: set = set(
|
||||
os.environ.get("ALLOWED_LABELS", "person,car,dog,cat,bird").split(",")
|
||||
)
|
||||
MIN_SCORE: float = float(os.environ.get("MIN_SCORE", "0.7"))
|
||||
RATE_LIMIT_SECONDS: int = int(os.environ.get("RATE_LIMIT_SECONDS", "60"))
|
||||
DEDUP_WINDOW_SECONDS: int = int(os.environ.get("DEDUP_WINDOW_SECONDS", "30"))
|
||||
|
||||
# Server
|
||||
SERVER_HOST: str = os.environ.get("SERVER_HOST", "0.0.0.0")
|
||||
SERVER_PORT: int = int(os.environ.get("SERVER_PORT", "5199"))
|
||||
|
||||
# Database
|
||||
DB_PATH: str = os.environ.get("DB_PATH", "/data/frigate-notify.db")
|
||||
|
||||
|
||||
config = Config()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""SQLite database schema and connection management for frigate-notify v2."""
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thread-local storage for database connections
|
||||
_local = threading.local()
|
||||
|
||||
_db_path: str = "/data/frigate-notify.db"
|
||||
|
||||
|
||||
def set_db_path(path: str) -> None:
|
||||
"""Set the database path. Closes any existing connection first."""
|
||||
global _db_path
|
||||
close_connection()
|
||||
_db_path = path
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Get a thread-local database connection with WAL mode and foreign keys enabled."""
|
||||
if not hasattr(_local, "connection") or _local.connection is None:
|
||||
conn = sqlite3.connect(_db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
_local.connection = conn
|
||||
return _local.connection
|
||||
|
||||
|
||||
def close_connection() -> None:
|
||||
"""Close the thread-local database connection if one exists."""
|
||||
if hasattr(_local, "connection") and _local.connection is not None:
|
||||
_local.connection.close()
|
||||
_local.connection = None
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create all tables and default settings."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS known_objects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('person', 'vehicle', 'pet')),
|
||||
action TEXT NOT NULL DEFAULT 'quiet' CHECK(action IN ('quiet', 'suppress', 'alert')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS embeddings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
known_object_id INTEGER NOT NULL,
|
||||
embedding BLOB NOT NULL,
|
||||
snapshot_url TEXT,
|
||||
thumbnail BLOB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (known_object_id) REFERENCES known_objects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
frigate_event_id TEXT UNIQUE NOT NULL,
|
||||
camera TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
snapshot BLOB,
|
||||
embedding BLOB,
|
||||
match_status TEXT NOT NULL DEFAULT 'unreviewed'
|
||||
CHECK(match_status IN ('known', 'unknown', 'ambiguous', 'unreviewed')),
|
||||
matched_object_id INTEGER,
|
||||
match_confidence REAL,
|
||||
ai_description TEXT,
|
||||
notified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (matched_object_id) REFERENCES known_objects(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_match_status ON events(match_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_camera ON events(camera);
|
||||
CREATE INDEX IF NOT EXISTS idx_embeddings_known_object_id ON embeddings(known_object_id);
|
||||
"""
|
||||
)
|
||||
|
||||
# Insert default settings if they don't exist
|
||||
defaults = {
|
||||
"vision_backend": "haiku",
|
||||
"recognition_enabled": "false",
|
||||
"event_retention_days": "90",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized at %s", _db_path)
|
||||
@@ -1,3 +1,5 @@
|
||||
"""frigate-notify v2 — FastAPI application with MQTT listener."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
@@ -6,29 +8,28 @@ import signal
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
|
||||
import aiohttp
|
||||
import anthropic
|
||||
import paho.mqtt.client as mqtt
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from vision import analyze_snapshot
|
||||
from config import config
|
||||
from database import close_connection, get_connection, init_db, set_db_path
|
||||
from matrix_client import MatrixClient
|
||||
|
||||
# --- Configuration ---
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.86.192")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000")
|
||||
MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008")
|
||||
MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"]
|
||||
MATRIX_ROOM_ID = os.environ["MATRIX_ROOM_ID"]
|
||||
HEALTH_PORT = int(os.environ.get("HEALTH_PORT", "5199"))
|
||||
|
||||
ALLOWED_LABELS = {"person", "car", "dog", "cat", "bird"}
|
||||
MIN_SCORE = 0.7
|
||||
RATE_LIMIT_SECONDS = 60
|
||||
DEDUP_WINDOW_SECONDS = 30
|
||||
from routes.events import router as events_router
|
||||
from routes.known_objects import router as known_objects_router
|
||||
from routes.settings import router as settings_router
|
||||
from ollama_client import OllamaClient
|
||||
from recognition import recognize_event, get_clip_embedding, embedding_to_bytes
|
||||
from vision import get_description
|
||||
from cleanup import cleanup_old_events
|
||||
|
||||
# --- Logging ---
|
||||
logging.basicConfig(
|
||||
@@ -44,46 +45,12 @@ last_notify_per_camera: defaultdict[str, float] = defaultdict(float)
|
||||
events_processed = 0
|
||||
last_event_time: str | None = None
|
||||
mqtt_connected = False
|
||||
|
||||
|
||||
# --- Health endpoint ---
|
||||
class HealthHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
body = json.dumps(
|
||||
{
|
||||
"status": "ok",
|
||||
"mqtt_connected": mqtt_connected,
|
||||
"events_processed": events_processed,
|
||||
"last_event": last_event_time,
|
||||
}
|
||||
)
|
||||
encoded = body.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # suppress access logs
|
||||
|
||||
|
||||
def start_health_server():
|
||||
server = HTTPServer(("0.0.0.0", HEALTH_PORT), HealthHandler)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
logger.info("Health server started on port %d", HEALTH_PORT)
|
||||
mqtt_client_instance: mqtt.Client | None = None
|
||||
|
||||
|
||||
# --- Snapshot fetching ---
|
||||
async def fetch_snapshot(event_id: str) -> bytes | None:
|
||||
url = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg"
|
||||
url = f"{config.FRIGATE_URL}/api/events/{event_id}/snapshot.jpg"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -103,7 +70,8 @@ async def fetch_snapshot(event_id: str) -> bytes | None:
|
||||
|
||||
# --- Event processing ---
|
||||
async def process_event(
|
||||
event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient
|
||||
event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient,
|
||||
ollama_client: OllamaClient = None,
|
||||
):
|
||||
global events_processed, last_event_time
|
||||
|
||||
@@ -119,27 +87,30 @@ async def process_event(
|
||||
has_snapshot = after.get("has_snapshot", False)
|
||||
|
||||
# --- Filtering ---
|
||||
if label not in ALLOWED_LABELS:
|
||||
if label not in config.ALLOWED_LABELS:
|
||||
return
|
||||
if score < MIN_SCORE:
|
||||
if score < config.MIN_SCORE:
|
||||
return
|
||||
if not has_snapshot:
|
||||
return
|
||||
|
||||
# Dedup
|
||||
now = time.time()
|
||||
if event_id in recent_events and now - recent_events[event_id] < DEDUP_WINDOW_SECONDS:
|
||||
if (
|
||||
event_id in recent_events
|
||||
and now - recent_events[event_id] < config.DEDUP_WINDOW_SECONDS
|
||||
):
|
||||
return
|
||||
recent_events[event_id] = now
|
||||
|
||||
# Rate limit per camera
|
||||
if now - last_notify_per_camera[camera] < RATE_LIMIT_SECONDS:
|
||||
if now - last_notify_per_camera[camera] < config.RATE_LIMIT_SECONDS:
|
||||
logger.info("Rate limited: %s on %s", label, camera)
|
||||
return
|
||||
last_notify_per_camera[camera] = now
|
||||
|
||||
# Cleanup old dedup entries
|
||||
cutoff = now - DEDUP_WINDOW_SECONDS * 2
|
||||
cutoff = now - config.DEDUP_WINDOW_SECONDS * 2
|
||||
for eid in [k for k, v in recent_events.items() if v < cutoff]:
|
||||
del recent_events[eid]
|
||||
|
||||
@@ -150,18 +121,84 @@ async def process_event(
|
||||
# --- Fetch snapshot ---
|
||||
snapshot = await fetch_snapshot(event_id)
|
||||
|
||||
# --- AI analysis ---
|
||||
# --- Recognition ---
|
||||
match_status = "unreviewed"
|
||||
matched_id = None
|
||||
match_conf = 0.0
|
||||
matched_name = None
|
||||
|
||||
# Check if recognition is enabled
|
||||
recognition_enabled = False
|
||||
try:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='recognition_enabled'").fetchone()
|
||||
if row:
|
||||
recognition_enabled = row["value"].lower() == "true"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if recognition_enabled and snapshot:
|
||||
match_status, matched_id, match_conf, matched_name = await recognize_event(
|
||||
snapshot, ollama_client
|
||||
)
|
||||
# Store embedding with event
|
||||
embedding = await get_clip_embedding(snapshot)
|
||||
if embedding:
|
||||
embedding_blob = embedding_to_bytes(embedding)
|
||||
try:
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"UPDATE events SET embedding=?, match_status=?, matched_object_id=?, match_confidence=? WHERE frigate_event_id=?",
|
||||
(embedding_blob, match_status, matched_id, match_conf, event_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update event recognition")
|
||||
|
||||
# If known, check action - skip notification for quiet/suppress
|
||||
if match_status == "known" and matched_id:
|
||||
conn = get_connection()
|
||||
obj = conn.execute("SELECT action FROM known_objects WHERE id=?", (matched_id,)).fetchone()
|
||||
if obj and obj["action"] in ("quiet", "suppress"):
|
||||
logger.info("Known object '%s' (%s), skipping notification", matched_name, obj["action"])
|
||||
events_processed += 1
|
||||
last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
return
|
||||
|
||||
# --- AI description ---
|
||||
vision_backend = "haiku"
|
||||
try:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='vision_backend'").fetchone()
|
||||
if row:
|
||||
vision_backend = row["value"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
description = None
|
||||
if snapshot:
|
||||
description = analyze_snapshot(claude, snapshot)
|
||||
description = await get_description(snapshot, vision_backend, claude, ollama_client)
|
||||
if not description:
|
||||
description = f"{label.capitalize()} detected on {camera}."
|
||||
|
||||
# --- Store event in database ---
|
||||
try:
|
||||
conn = get_connection()
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO events
|
||||
(frigate_event_id, camera, label, score, snapshot, match_status, ai_description, notified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)""",
|
||||
(event_id, camera, label, score, snapshot, match_status, description),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to store event %s in database", event_id)
|
||||
|
||||
# --- Format message ---
|
||||
score_pct = int(score * 100)
|
||||
ts = time.strftime("%I:%M:%S %p", time.localtime())
|
||||
date_str = time.strftime("%b %d, %Y", time.localtime())
|
||||
frigate_link = f"{FRIGATE_URL}/review?id={event_id}"
|
||||
frigate_link = f"{config.FRIGATE_URL}/review?id={event_id}"
|
||||
|
||||
plain = (
|
||||
f"{camera} - {label.capitalize()} - {score_pct}%\n"
|
||||
@@ -195,7 +232,8 @@ def create_mqtt_client(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
claude: anthropic.Anthropic,
|
||||
matrix: MatrixClient,
|
||||
):
|
||||
ollama_client: OllamaClient = None,
|
||||
) -> mqtt.Client:
|
||||
global mqtt_connected
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties):
|
||||
@@ -213,7 +251,7 @@ def create_mqtt_client(
|
||||
try:
|
||||
payload = json.loads(msg.payload)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
process_event(payload, claude, matrix), loop
|
||||
process_event(payload, claude, matrix, ollama_client), loop
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid JSON on %s", msg.topic)
|
||||
@@ -227,33 +265,99 @@ def create_mqtt_client(
|
||||
return client
|
||||
|
||||
|
||||
# --- Main ---
|
||||
def main():
|
||||
logger.info("Starting frigate-notify")
|
||||
def start_mqtt(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Start MQTT client in a background thread."""
|
||||
global mqtt_client_instance
|
||||
|
||||
claude = anthropic.Anthropic()
|
||||
matrix = MatrixClient(MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID)
|
||||
matrix = MatrixClient(
|
||||
config.MATRIX_HOMESERVER,
|
||||
config.MATRIX_ACCESS_TOKEN,
|
||||
config.MATRIX_ROOM_ID,
|
||||
)
|
||||
ollama_client = OllamaClient(config.OLLAMA_URL, config.OLLAMA_MODEL)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
loop_thread = Thread(target=loop.run_forever, daemon=True)
|
||||
loop_thread.start()
|
||||
mqtt_client_instance = create_mqtt_client(loop, claude, matrix, ollama_client)
|
||||
mqtt_client_instance.connect(config.MQTT_HOST, config.MQTT_PORT)
|
||||
|
||||
start_health_server()
|
||||
|
||||
mqtt_client = create_mqtt_client(loop, claude, matrix)
|
||||
mqtt_client.connect(MQTT_HOST, MQTT_PORT)
|
||||
|
||||
def shutdown(signum, frame):
|
||||
logger.info("Shutting down...")
|
||||
mqtt_client.disconnect()
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
|
||||
mqtt_client.loop_forever()
|
||||
thread = Thread(target=mqtt_client_instance.loop_forever, daemon=True)
|
||||
thread.start()
|
||||
logger.info("MQTT client started in background thread")
|
||||
|
||||
|
||||
# --- FastAPI lifespan ---
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown logic for the FastAPI application."""
|
||||
logger.info("Starting frigate-notify v2")
|
||||
|
||||
# Initialize database
|
||||
set_db_path(config.DB_PATH)
|
||||
init_db()
|
||||
|
||||
# Start MQTT listener in background
|
||||
loop = asyncio.get_event_loop()
|
||||
start_mqtt(loop)
|
||||
|
||||
# Start periodic event cleanup
|
||||
asyncio.create_task(cleanup_old_events())
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down frigate-notify v2")
|
||||
if mqtt_client_instance:
|
||||
mqtt_client_instance.disconnect()
|
||||
close_connection()
|
||||
|
||||
|
||||
# --- FastAPI app ---
|
||||
app = FastAPI(
|
||||
title="frigate-notify",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Include API routers
|
||||
app.include_router(events_router)
|
||||
app.include_router(known_objects_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
# Mount static files directory
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
static_dir.mkdir(exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "2.0.0",
|
||||
"mqtt_connected": mqtt_connected,
|
||||
"events_processed": events_processed,
|
||||
"last_event": last_event_time,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
"""Serve the web UI index page."""
|
||||
index_path = static_dir / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(str(index_path))
|
||||
return JSONResponse(
|
||||
{"message": "frigate-notify v2", "docs": "/docs"},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
# --- Entrypoint ---
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=config.SERVER_HOST,
|
||||
port=config.SERVER_PORT,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pydantic models for frigate-notify v2 API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --- Known Objects ---
|
||||
|
||||
|
||||
class KnownObjectCreate(BaseModel):
|
||||
"""Request model for creating a known object."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
type: str = Field(..., pattern="^(person|vehicle|pet)$")
|
||||
action: str = Field(default="quiet", pattern="^(quiet|suppress|alert)$")
|
||||
|
||||
|
||||
class KnownObjectUpdate(BaseModel):
|
||||
"""Request model for updating a known object."""
|
||||
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
type: Optional[str] = Field(default=None, pattern="^(person|vehicle|pet)$")
|
||||
action: Optional[str] = Field(default=None, pattern="^(quiet|suppress|alert)$")
|
||||
|
||||
|
||||
class KnownObjectResponse(BaseModel):
|
||||
"""Response model for a known object."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
action: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
# --- Events ---
|
||||
|
||||
|
||||
class TagEventRequest(BaseModel):
|
||||
"""Request model for tagging an event with a known object.
|
||||
|
||||
Either provide known_object_id to link to an existing object,
|
||||
or provide name + type to create a new known object.
|
||||
"""
|
||||
|
||||
known_object_id: Optional[int] = None
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
type: Optional[str] = Field(default=None, pattern="^(person|vehicle|pet)$")
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
"""Response model for an event."""
|
||||
|
||||
id: int
|
||||
frigate_event_id: str
|
||||
camera: str
|
||||
label: str
|
||||
score: float
|
||||
has_snapshot: bool = False
|
||||
snapshot_base64: Optional[str] = None
|
||||
match_status: str
|
||||
matched_object_id: Optional[int] = None
|
||||
matched_object_name: Optional[str] = None
|
||||
match_confidence: Optional[float] = None
|
||||
ai_description: Optional[str] = None
|
||||
notified: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
# --- Settings ---
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
"""Response model for application settings."""
|
||||
|
||||
vision_backend: str
|
||||
recognition_enabled: bool
|
||||
event_retention_days: int
|
||||
|
||||
|
||||
# --- Stats ---
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
"""Response model for application statistics."""
|
||||
|
||||
total_events: int = 0
|
||||
known_matches: int = 0
|
||||
unknown_events: int = 0
|
||||
unreviewed: int = 0
|
||||
known_objects_count: int = 0
|
||||
events_today: int = 0
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Ollama vision LLM client for frigate-notify v2."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""Client for Ollama vision models (e.g., llava)."""
|
||||
|
||||
def __init__(self, base_url: str, model: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
|
||||
async def analyze_image(self, image_bytes: bytes, prompt: str) -> str | None:
|
||||
"""Send an image to Ollama for analysis.
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image bytes (JPEG).
|
||||
prompt: Text prompt for the vision model.
|
||||
|
||||
Returns:
|
||||
Response text or None on failure.
|
||||
"""
|
||||
b64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"images": [b64],
|
||||
"stream": False,
|
||||
"options": {"num_predict": 200},
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
text = data.get("response", "").strip()
|
||||
logger.info(
|
||||
"Ollama analysis complete: %d chars", len(text)
|
||||
)
|
||||
return text if text else None
|
||||
body = await resp.text()
|
||||
logger.error(
|
||||
"Ollama analyze_image failed %s: %s", resp.status, body
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Ollama analyze_image request failed")
|
||||
return None
|
||||
|
||||
async def compare_images(
|
||||
self,
|
||||
image_bytes: bytes,
|
||||
reference_bytes: bytes,
|
||||
object_name: str,
|
||||
) -> bool | None:
|
||||
"""Compare two images to determine if they show the same object/person.
|
||||
|
||||
Args:
|
||||
image_bytes: The new event image bytes.
|
||||
reference_bytes: The reference (known) image bytes.
|
||||
object_name: Name of the known object for context.
|
||||
|
||||
Returns:
|
||||
True if same, False if different, None on failure/inconclusive.
|
||||
"""
|
||||
b64_new = base64.b64encode(image_bytes).decode("utf-8")
|
||||
b64_ref = base64.b64encode(reference_bytes).decode("utf-8")
|
||||
prompt = (
|
||||
f"Look at these two images. The first image is from a security camera. "
|
||||
f"The second image is a reference photo of '{object_name}'. "
|
||||
f"Are these the same person/vehicle/animal? "
|
||||
f"Answer with ONLY 'yes' or 'no'."
|
||||
)
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"images": [b64_new, b64_ref],
|
||||
"stream": False,
|
||||
"options": {"num_predict": 200},
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
text = data.get("response", "").strip().lower()
|
||||
logger.info("Ollama compare result: %s", text)
|
||||
if "yes" in text.split()[:3]:
|
||||
return True
|
||||
if "no" in text.split()[:3]:
|
||||
return False
|
||||
logger.warning(
|
||||
"Ollama compare inconclusive: %s", text
|
||||
)
|
||||
return None
|
||||
body = await resp.text()
|
||||
logger.error(
|
||||
"Ollama compare_images failed %s: %s",
|
||||
resp.status,
|
||||
body,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Ollama compare_images request failed")
|
||||
return None
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if Ollama is reachable and responding.
|
||||
|
||||
Returns:
|
||||
True if Ollama responds with HTTP 200, False otherwise.
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{self.base_url}/api/tags",
|
||||
timeout=aiohttp.ClientTimeout(total=3),
|
||||
) as resp:
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Recognition pipeline using CLIP embeddings with Ollama vision fallback."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
|
||||
from config import config
|
||||
from database import get_connection
|
||||
from ollama_client import OllamaClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def embedding_to_bytes(embedding: list[float]) -> bytes:
|
||||
"""Pack a float32 embedding list into bytes for SQLite storage.
|
||||
|
||||
Args:
|
||||
embedding: List of float values (typically 768-dimensional).
|
||||
|
||||
Returns:
|
||||
Packed bytes (len(embedding) * 4 bytes).
|
||||
"""
|
||||
return struct.pack(f"{len(embedding)}f", *embedding)
|
||||
|
||||
|
||||
def bytes_to_embedding(data: bytes) -> np.ndarray:
|
||||
"""Unpack bytes back into a float32 numpy array.
|
||||
|
||||
Args:
|
||||
data: Packed bytes from embedding_to_bytes().
|
||||
|
||||
Returns:
|
||||
Numpy array of float32 values.
|
||||
"""
|
||||
count = len(data) // 4
|
||||
values = struct.unpack(f"{count}f", data)
|
||||
return np.array(values, dtype=np.float32)
|
||||
|
||||
|
||||
async def get_clip_embedding(image_bytes: bytes) -> list[float] | None:
|
||||
"""Get CLIP embedding for an image from the clip-service sidecar.
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image bytes (JPEG).
|
||||
|
||||
Returns:
|
||||
List of float embedding values, or None on failure.
|
||||
"""
|
||||
b64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{config.CLIP_SERVICE_URL}/embed",
|
||||
json={"image": b64},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
embedding = data.get("embedding")
|
||||
if embedding:
|
||||
logger.info(
|
||||
"CLIP embedding received: %d dimensions",
|
||||
len(embedding),
|
||||
)
|
||||
return embedding
|
||||
logger.warning("CLIP response missing embedding field")
|
||||
else:
|
||||
body = await resp.text()
|
||||
logger.error(
|
||||
"CLIP embed failed %s: %s", resp.status, body
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("CLIP embedding request failed")
|
||||
return None
|
||||
|
||||
|
||||
async def find_best_match(
|
||||
query_embedding: list[float],
|
||||
) -> tuple[int | None, float, str | None]:
|
||||
"""Find the best matching known object for a query embedding.
|
||||
|
||||
Loads all embeddings from the database, computes cosine similarity,
|
||||
groups by known_object_id and takes the max score per object.
|
||||
|
||||
Args:
|
||||
query_embedding: The query embedding to match against.
|
||||
|
||||
Returns:
|
||||
Tuple of (best_object_id, best_score, best_name).
|
||||
Returns (None, 0.0, None) if no embeddings exist.
|
||||
"""
|
||||
conn = get_connection()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT e.id, e.known_object_id, e.embedding, ko.name
|
||||
FROM embeddings e
|
||||
JOIN known_objects ko ON e.known_object_id = ko.id
|
||||
"""
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return (None, 0.0, None)
|
||||
|
||||
query_vec = np.array(query_embedding, dtype=np.float32)
|
||||
query_norm = np.linalg.norm(query_vec)
|
||||
if query_norm == 0:
|
||||
return (None, 0.0, None)
|
||||
query_vec = query_vec / query_norm
|
||||
|
||||
# Group scores by known_object_id, keeping max per object
|
||||
best_per_object: dict[int, tuple[float, str]] = {}
|
||||
|
||||
for row in rows:
|
||||
stored_vec = bytes_to_embedding(row["embedding"])
|
||||
stored_norm = np.linalg.norm(stored_vec)
|
||||
if stored_norm == 0:
|
||||
continue
|
||||
stored_vec = stored_vec / stored_norm
|
||||
|
||||
similarity = float(np.dot(query_vec, stored_vec))
|
||||
obj_id = row["known_object_id"]
|
||||
obj_name = row["name"]
|
||||
|
||||
if obj_id not in best_per_object or similarity > best_per_object[obj_id][0]:
|
||||
best_per_object[obj_id] = (similarity, obj_name)
|
||||
|
||||
if not best_per_object:
|
||||
return (None, 0.0, None)
|
||||
|
||||
best_id = max(best_per_object, key=lambda k: best_per_object[k][0])
|
||||
best_score, best_name = best_per_object[best_id]
|
||||
|
||||
return (best_id, best_score, best_name)
|
||||
|
||||
|
||||
async def recognize_event(
|
||||
image_bytes: bytes,
|
||||
ollama_client: Optional[OllamaClient] = None,
|
||||
) -> tuple[str, int | None, float, str | None]:
|
||||
"""Full recognition pipeline for a Frigate event image.
|
||||
|
||||
Steps:
|
||||
1. Get CLIP embedding for the image.
|
||||
2. Find the best matching known object.
|
||||
3. High confidence (>= threshold_high): return "known".
|
||||
4. Low confidence (< threshold_low): return "unknown".
|
||||
5. Ambiguous range: use Ollama vision LLM to confirm/deny.
|
||||
6. If LLM unavailable or inconclusive: return "ambiguous".
|
||||
7. If no embedding could be obtained: return "unreviewed".
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image bytes (JPEG).
|
||||
ollama_client: Optional OllamaClient for ambiguous case fallback.
|
||||
|
||||
Returns:
|
||||
Tuple of (match_status, object_id, score, object_name).
|
||||
match_status is one of: "known", "unknown", "ambiguous", "unreviewed".
|
||||
"""
|
||||
# Step 1: Get CLIP embedding
|
||||
embedding = await get_clip_embedding(image_bytes)
|
||||
if embedding is None:
|
||||
logger.warning("Could not get CLIP embedding, returning unreviewed")
|
||||
return ("unreviewed", None, 0.0, None)
|
||||
|
||||
# Step 2: Find best match
|
||||
best_id, best_score, best_name = await find_best_match(embedding)
|
||||
|
||||
# No embeddings in DB at all
|
||||
if best_id is None:
|
||||
return ("unknown", None, 0.0, None)
|
||||
|
||||
high_threshold = config.RECOGNITION_THRESHOLD_HIGH
|
||||
low_threshold = config.RECOGNITION_THRESHOLD_LOW
|
||||
|
||||
# Step 3: High confidence match
|
||||
if best_score >= high_threshold:
|
||||
logger.info(
|
||||
"High confidence match: %s (%.3f)", best_name, best_score
|
||||
)
|
||||
return ("known", best_id, best_score, best_name)
|
||||
|
||||
# Step 4: Low confidence - clearly not a match
|
||||
if best_score < low_threshold:
|
||||
logger.info("Low confidence, no match (%.3f)", best_score)
|
||||
return ("unknown", None, best_score, None)
|
||||
|
||||
# Step 5: Ambiguous range - try Ollama vision LLM
|
||||
if ollama_client is not None:
|
||||
logger.info(
|
||||
"Ambiguous score %.3f for %s, trying Ollama comparison",
|
||||
best_score,
|
||||
best_name,
|
||||
)
|
||||
# Get a reference thumbnail from the embeddings table
|
||||
conn = get_connection()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT thumbnail FROM embeddings
|
||||
WHERE known_object_id = ? AND thumbnail IS NOT NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(best_id,),
|
||||
)
|
||||
ref_row = cursor.fetchone()
|
||||
if ref_row and ref_row["thumbnail"] is not None:
|
||||
result = await ollama_client.compare_images(
|
||||
image_bytes, ref_row["thumbnail"], best_name
|
||||
)
|
||||
if result is True:
|
||||
logger.info(
|
||||
"Ollama confirmed match: %s (%.3f)",
|
||||
best_name,
|
||||
best_score,
|
||||
)
|
||||
return ("known", best_id, best_score, best_name)
|
||||
elif result is False:
|
||||
logger.info(
|
||||
"Ollama denied match: %s (%.3f)", best_name, best_score
|
||||
)
|
||||
return ("unknown", None, best_score, None)
|
||||
# result is None: inconclusive, fall through to ambiguous
|
||||
|
||||
# Step 6: LLM unavailable or inconclusive
|
||||
logger.info(
|
||||
"Ambiguous match: %s (%.3f)", best_name, best_score
|
||||
)
|
||||
return ("ambiguous", best_id, best_score, best_name)
|
||||
@@ -1,3 +1,7 @@
|
||||
paho-mqtt==2.1.0
|
||||
anthropic>=0.40.0
|
||||
aiohttp>=3.9.0
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
python-multipart>=0.0.18
|
||||
numpy>=1.26.0
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Event API endpoints for frigate-notify v2."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from database import get_connection
|
||||
from models import EventResponse, TagEventRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[EventResponse])
|
||||
async def list_events(
|
||||
camera: Optional[str] = Query(default=None),
|
||||
label: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""List events with optional filters and pagination.
|
||||
|
||||
Returns events without snapshot/embedding BLOBs but with has_snapshot bool.
|
||||
Joins to known_objects for matched_object_name.
|
||||
"""
|
||||
conn = get_connection()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
e.id, e.frigate_event_id, e.camera, e.label, e.score,
|
||||
CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot,
|
||||
e.match_status, e.matched_object_id,
|
||||
ko.name AS matched_object_name,
|
||||
e.match_confidence, e.ai_description, e.notified, e.created_at
|
||||
FROM events e
|
||||
LEFT JOIN known_objects ko ON e.matched_object_id = ko.id
|
||||
"""
|
||||
conditions = []
|
||||
params: list = []
|
||||
|
||||
if camera:
|
||||
conditions.append("e.camera = ?")
|
||||
params.append(camera)
|
||||
if label:
|
||||
conditions.append("e.label = ?")
|
||||
params.append(label)
|
||||
if status:
|
||||
conditions.append("e.match_status = ?")
|
||||
params.append(status)
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " ORDER BY e.created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor = conn.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
EventResponse(
|
||||
id=row["id"],
|
||||
frigate_event_id=row["frigate_event_id"],
|
||||
camera=row["camera"],
|
||||
label=row["label"],
|
||||
score=row["score"],
|
||||
has_snapshot=bool(row["has_snapshot"]),
|
||||
match_status=row["match_status"],
|
||||
matched_object_id=row["matched_object_id"],
|
||||
matched_object_name=row["matched_object_name"],
|
||||
match_confidence=row["match_confidence"],
|
||||
ai_description=row["ai_description"],
|
||||
notified=bool(row["notified"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=EventResponse)
|
||||
async def get_event(event_id: int):
|
||||
"""Get a single event detail. Includes base64-encoded snapshot if exists."""
|
||||
conn = get_connection()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
e.id, e.frigate_event_id, e.camera, e.label, e.score,
|
||||
e.snapshot,
|
||||
CASE WHEN e.snapshot IS NOT NULL THEN 1 ELSE 0 END AS has_snapshot,
|
||||
e.match_status, e.matched_object_id,
|
||||
ko.name AS matched_object_name,
|
||||
e.match_confidence, e.ai_description, e.notified, e.created_at
|
||||
FROM events e
|
||||
LEFT JOIN known_objects ko ON e.matched_object_id = ko.id
|
||||
WHERE e.id = ?
|
||||
""",
|
||||
(event_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
snapshot_b64 = None
|
||||
if row["snapshot"] is not None:
|
||||
snapshot_b64 = base64.b64encode(row["snapshot"]).decode("ascii")
|
||||
|
||||
return EventResponse(
|
||||
id=row["id"],
|
||||
frigate_event_id=row["frigate_event_id"],
|
||||
camera=row["camera"],
|
||||
label=row["label"],
|
||||
score=row["score"],
|
||||
has_snapshot=bool(row["has_snapshot"]),
|
||||
snapshot_base64=snapshot_b64,
|
||||
match_status=row["match_status"],
|
||||
matched_object_id=row["matched_object_id"],
|
||||
matched_object_name=row["matched_object_name"],
|
||||
match_confidence=row["match_confidence"],
|
||||
ai_description=row["ai_description"],
|
||||
notified=bool(row["notified"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{event_id}/snapshot")
|
||||
async def get_event_snapshot(event_id: int):
|
||||
"""Return raw JPEG snapshot for an event. 404 if no snapshot."""
|
||||
conn = get_connection()
|
||||
cursor = conn.execute(
|
||||
"SELECT snapshot FROM events WHERE id = ?",
|
||||
(event_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
if row["snapshot"] is None:
|
||||
raise HTTPException(status_code=404, detail="No snapshot available")
|
||||
|
||||
return Response(content=row["snapshot"], media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.post("/{event_id}/tag")
|
||||
async def tag_event(event_id: int, body: TagEventRequest):
|
||||
"""Tag an event as a known object.
|
||||
|
||||
Either provide known_object_id to link to an existing object,
|
||||
or provide name + type to create a new known object.
|
||||
If the event has an embedding, store it in the embeddings table.
|
||||
"""
|
||||
conn = get_connection()
|
||||
|
||||
# Verify event exists
|
||||
cursor = conn.execute(
|
||||
"SELECT id, embedding FROM events WHERE id = ?",
|
||||
(event_id,),
|
||||
)
|
||||
event_row = cursor.fetchone()
|
||||
if not event_row:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
known_object_id = body.known_object_id
|
||||
|
||||
if known_object_id is not None:
|
||||
# Verify the known object exists
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM known_objects WHERE id = ?",
|
||||
(known_object_id,),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Known object not found")
|
||||
elif body.name and body.type:
|
||||
# Create a new known object
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO known_objects (name, type) VALUES (?, ?)",
|
||||
(body.name, body.type),
|
||||
)
|
||||
known_object_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Must provide either known_object_id or both name and type",
|
||||
)
|
||||
|
||||
# If event has an embedding, store it in the embeddings table
|
||||
if event_row["embedding"] is not None:
|
||||
conn.execute(
|
||||
"INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)",
|
||||
(known_object_id, event_row["embedding"]),
|
||||
)
|
||||
|
||||
# Update event
|
||||
conn.execute(
|
||||
"""UPDATE events
|
||||
SET match_status = 'known',
|
||||
matched_object_id = ?,
|
||||
match_confidence = 1.0
|
||||
WHERE id = ?""",
|
||||
(known_object_id, event_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok", "matched_object_id": known_object_id}
|
||||
|
||||
|
||||
@router.post("/{event_id}/dismiss")
|
||||
async def dismiss_event(event_id: int):
|
||||
"""Set event match_status to 'unknown'."""
|
||||
conn = get_connection()
|
||||
|
||||
cursor = conn.execute("SELECT id FROM events WHERE id = ?", (event_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE events SET match_status = 'unknown' WHERE id = ?",
|
||||
(event_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Known objects API endpoints for frigate-notify v2."""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from database import get_connection
|
||||
from models import KnownObjectResponse, KnownObjectUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/known-objects", tags=["known-objects"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_known_objects(
|
||||
type: Optional[str] = Query(default=None, pattern="^(person|vehicle|pet)$"),
|
||||
):
|
||||
"""List all known objects with embedding_count. Optional type filter."""
|
||||
conn = get_connection()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
ko.id, ko.name, ko.type, ko.action,
|
||||
ko.created_at, ko.updated_at,
|
||||
COUNT(e.id) AS embedding_count
|
||||
FROM known_objects ko
|
||||
LEFT JOIN embeddings e ON ko.id = e.known_object_id
|
||||
"""
|
||||
params: list = []
|
||||
|
||||
if type:
|
||||
query += " WHERE ko.type = ?"
|
||||
params.append(type)
|
||||
|
||||
query += " GROUP BY ko.id ORDER BY ko.name"
|
||||
|
||||
cursor = conn.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"action": row["action"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"embedding_count": row["embedding_count"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{object_id}")
|
||||
async def get_known_object(object_id: int):
|
||||
"""Get a single known object with list of its embeddings (id, created_at only)."""
|
||||
conn = get_connection()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?",
|
||||
(object_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Known object not found")
|
||||
|
||||
# Get embeddings list
|
||||
emb_cursor = conn.execute(
|
||||
"SELECT id, created_at FROM embeddings WHERE known_object_id = ? ORDER BY created_at",
|
||||
(object_id,),
|
||||
)
|
||||
embeddings = [
|
||||
{"id": e["id"], "created_at": e["created_at"]} for e in emb_cursor.fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"action": row["action"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"embeddings": embeddings,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{object_id}/thumbnails/{embedding_id}")
|
||||
async def get_embedding_thumbnail(object_id: int, embedding_id: int):
|
||||
"""Return raw JPEG thumbnail from embeddings table."""
|
||||
conn = get_connection()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT thumbnail FROM embeddings WHERE id = ? AND known_object_id = ?",
|
||||
(embedding_id, object_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Embedding not found")
|
||||
if row["thumbnail"] is None:
|
||||
raise HTTPException(status_code=404, detail="No thumbnail available")
|
||||
|
||||
return Response(content=row["thumbnail"], media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.put("/{object_id}")
|
||||
async def update_known_object(object_id: int, body: KnownObjectUpdate):
|
||||
"""Update name/type/action for a known object. Sets updated_at."""
|
||||
conn = get_connection()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM known_objects WHERE id = ?",
|
||||
(object_id,),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Known object not found")
|
||||
|
||||
updates = []
|
||||
params: list = []
|
||||
|
||||
if body.name is not None:
|
||||
updates.append("name = ?")
|
||||
params.append(body.name)
|
||||
if body.type is not None:
|
||||
updates.append("type = ?")
|
||||
params.append(body.type)
|
||||
if body.action is not None:
|
||||
updates.append("action = ?")
|
||||
params.append(body.action)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(object_id)
|
||||
|
||||
conn.execute(
|
||||
f"UPDATE known_objects SET {', '.join(updates)} WHERE id = ?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Return the updated object
|
||||
cursor = conn.execute(
|
||||
"SELECT id, name, type, action, created_at, updated_at FROM known_objects WHERE id = ?",
|
||||
(object_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
return KnownObjectResponse(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
action=row["action"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{object_id}")
|
||||
async def delete_known_object(object_id: int):
|
||||
"""Delete a known object. Embeddings cascade delete automatically."""
|
||||
conn = get_connection()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM known_objects WHERE id = ?",
|
||||
(object_id,),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Known object not found")
|
||||
|
||||
conn.execute("DELETE FROM known_objects WHERE id = ?", (object_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Settings and stats API endpoints for frigate-notify v2."""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from config import config
|
||||
from database import get_connection
|
||||
from models import StatsResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings():
|
||||
"""Return all settings from DB plus runtime config values."""
|
||||
conn = get_connection()
|
||||
cursor = conn.execute("SELECT key, value FROM settings")
|
||||
db_settings = {row["key"]: row["value"] for row in cursor.fetchall()}
|
||||
|
||||
# Merge with runtime config values
|
||||
return {
|
||||
**db_settings,
|
||||
"recognition_threshold_high": config.RECOGNITION_THRESHOLD_HIGH,
|
||||
"recognition_threshold_low": config.RECOGNITION_THRESHOLD_LOW,
|
||||
"rate_limit_seconds": config.RATE_LIMIT_SECONDS,
|
||||
"allowed_labels": list(config.ALLOWED_LABELS),
|
||||
"min_score": config.MIN_SCORE,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(body: dict):
|
||||
"""Accept dict body, upsert each key/value into settings table."""
|
||||
conn = get_connection()
|
||||
for key, value in body.items():
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, str(value)),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse)
|
||||
async def get_stats():
|
||||
"""Return event and object counts."""
|
||||
conn = get_connection()
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
known = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE match_status = 'known'"
|
||||
).fetchone()[0]
|
||||
unknown = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE match_status = 'unknown'"
|
||||
).fetchone()[0]
|
||||
unreviewed = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE match_status = 'unreviewed'"
|
||||
).fetchone()[0]
|
||||
objects_count = conn.execute("SELECT COUNT(*) FROM known_objects").fetchone()[0]
|
||||
today = date.today().isoformat()
|
||||
events_today = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE DATE(created_at) = ?",
|
||||
(today,),
|
||||
).fetchone()[0]
|
||||
|
||||
return StatsResponse(
|
||||
total_events=total,
|
||||
known_matches=known,
|
||||
unknown_events=unknown,
|
||||
unreviewed=unreviewed,
|
||||
known_objects_count=objects_count,
|
||||
events_today=events_today,
|
||||
)
|
||||
@@ -0,0 +1,578 @@
|
||||
// Frigate Alerts - Web UI
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let currentView = 'dashboard';
|
||||
let currentTagEventId = null;
|
||||
let refreshTimer = null;
|
||||
let knownObjectsCache = [];
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupNavigation();
|
||||
setupTagModal();
|
||||
loadView('dashboard');
|
||||
loadStats();
|
||||
});
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
function setupNavigation() {
|
||||
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = item.dataset.view;
|
||||
loadView(view);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveNav(view) {
|
||||
document.querySelectorAll('.sidebar-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.view === view);
|
||||
});
|
||||
}
|
||||
|
||||
// --- View Router ---
|
||||
|
||||
function loadView(view) {
|
||||
currentView = view;
|
||||
setActiveNav(view);
|
||||
clearAutoRefresh();
|
||||
|
||||
switch (view) {
|
||||
case 'dashboard':
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
break;
|
||||
case 'review':
|
||||
loadReview();
|
||||
startAutoRefresh();
|
||||
break;
|
||||
case 'known':
|
||||
loadKnown();
|
||||
break;
|
||||
case 'settings':
|
||||
loadSettings();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto Refresh ---
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (currentView === 'dashboard') {
|
||||
loadDashboard();
|
||||
} else if (currentView === 'review') {
|
||||
loadReview();
|
||||
}
|
||||
loadStats();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function clearAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dashboard View ---
|
||||
|
||||
function loadDashboard() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading events</div>';
|
||||
|
||||
fetch('/api/events?limit=50')
|
||||
.then(r => r.json())
|
||||
.then(events => {
|
||||
if (!events.length) {
|
||||
content.innerHTML = `
|
||||
<h1>Dashboard</h1>
|
||||
<div class="empty-state">
|
||||
<p>No events recorded yet.</p>
|
||||
<p>Events will appear here when Frigate detects objects.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select id="filter-camera" onchange="window._applyFilters()">
|
||||
<option value="">All Cameras</option>
|
||||
</select>
|
||||
<select id="filter-label" onchange="window._applyFilters()">
|
||||
<option value="">All Labels</option>
|
||||
</select>
|
||||
<select id="filter-status" onchange="window._applyFilters()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="known">Known</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="ambiguous">Ambiguous</option>
|
||||
<option value="unreviewed">Unreviewed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="events-list"></div>`;
|
||||
|
||||
populateFilterOptions(events);
|
||||
renderEvents(events);
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Dashboard</h1><div class="empty-state"><p>Error loading events: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function populateFilterOptions(events) {
|
||||
const cameras = [...new Set(events.map(e => e.camera).filter(Boolean))].sort();
|
||||
const labels = [...new Set(events.map(e => e.label).filter(Boolean))].sort();
|
||||
|
||||
const cameraSelect = document.getElementById('filter-camera');
|
||||
const labelSelect = document.getElementById('filter-label');
|
||||
|
||||
if (cameraSelect) {
|
||||
cameras.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
opt.textContent = c;
|
||||
cameraSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
if (labelSelect) {
|
||||
labels.forEach(l => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = l;
|
||||
opt.textContent = l;
|
||||
labelSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Expose filter function globally for inline onchange handlers
|
||||
window._applyFilters = function () {
|
||||
const camera = document.getElementById('filter-camera')?.value || '';
|
||||
const label = document.getElementById('filter-label')?.value || '';
|
||||
const status = document.getElementById('filter-status')?.value || '';
|
||||
|
||||
let url = '/api/events?limit=50';
|
||||
if (camera) url += `&camera=${encodeURIComponent(camera)}`;
|
||||
if (label) url += `&label=${encodeURIComponent(label)}`;
|
||||
if (status) url += `&status=${encodeURIComponent(status)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(events => renderEvents(events))
|
||||
.catch(err => console.error('Filter error:', err));
|
||||
};
|
||||
|
||||
function renderEvents(events) {
|
||||
const container = document.getElementById('events-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!events.length) {
|
||||
container.innerHTML = '<div class="empty-state"><p>No events match the current filters.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = events.map(event => `
|
||||
<div class="event-card" data-event-id="${event.id}">
|
||||
<img src="/api/events/${event.id}/snapshot"
|
||||
onerror="this.style.display='none'"
|
||||
alt="${event.label || 'snapshot'}">
|
||||
<div class="event-info">
|
||||
<div class="event-header">
|
||||
<span>
|
||||
<span class="event-camera">${escapeHtml(event.camera || 'unknown')}</span>
|
||||
<span class="event-label"> — ${escapeHtml(event.label || 'unknown')}</span>
|
||||
<span class="event-score">(${(event.score * 100).toFixed(0)}%)</span>
|
||||
</span>
|
||||
<span class="badge badge-${event.match_status || 'unreviewed'}">${event.match_status || 'unreviewed'}</span>
|
||||
</div>
|
||||
<div class="event-time">${formatTime(event.created_at)}</div>
|
||||
${event.ai_description ? `<div class="event-description">${escapeHtml(event.ai_description)}</div>` : ''}
|
||||
${event.matched_object_name ? `<div class="event-label">Identified as: <strong>${escapeHtml(event.matched_object_name)}</strong></div>` : ''}
|
||||
<div class="event-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="window._tagEvent('${event.id}')">Tag</button>
|
||||
<button class="btn btn-sm" onclick="window._dismissEvent('${event.id}')">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// --- Review View ---
|
||||
|
||||
function loadReview() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading review queue</div>';
|
||||
|
||||
fetch('/api/events?limit=50&status=unreviewed')
|
||||
.then(r => r.json())
|
||||
.then(events => {
|
||||
if (!events.length) {
|
||||
content.innerHTML = `
|
||||
<h1>Review Queue</h1>
|
||||
<div class="empty-state">
|
||||
<p>No events to review.</p>
|
||||
<p>All events have been reviewed or dismissed.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Review Queue</h1>
|
||||
<span>${events.length} event${events.length !== 1 ? 's' : ''} to review</span>
|
||||
</div>
|
||||
<div id="events-list"></div>`;
|
||||
renderEvents(events);
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Review Queue</h1><div class="empty-state"><p>Error loading events: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Known Objects View ---
|
||||
|
||||
function loadKnown() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading known objects</div>';
|
||||
|
||||
fetch('/api/known-objects')
|
||||
.then(r => r.json())
|
||||
.then(objects => {
|
||||
knownObjectsCache = objects;
|
||||
|
||||
if (!objects.length) {
|
||||
content.innerHTML = `
|
||||
<h1>Known Objects</h1>
|
||||
<div class="empty-state">
|
||||
<p>No known objects registered.</p>
|
||||
<p>Tag events from the Dashboard to add known objects.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Known Objects</h1>
|
||||
<span>${objects.length} object${objects.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="known-grid">
|
||||
${objects.map(obj => `
|
||||
<div class="known-card" data-object-id="${obj.id}">
|
||||
<h4>${escapeHtml(obj.name)}</h4>
|
||||
<span class="type-badge">${escapeHtml(obj.type)}</span>
|
||||
<div class="embedding-count">${obj.embedding_count || 0} embedding${(obj.embedding_count || 0) !== 1 ? 's' : ''}</div>
|
||||
${obj.samples && obj.samples.length ? `
|
||||
<div class="known-card-samples">
|
||||
${obj.samples.map(s => `<img src="${s}" onerror="this.style.display='none'" alt="sample">`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="known-card-actions">
|
||||
<button class="btn btn-danger btn-sm" onclick="window._deleteKnownObject('${obj.id}', '${escapeHtml(obj.name)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Known Objects</h1><div class="empty-state"><p>Error loading objects: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings View ---
|
||||
|
||||
function loadSettings() {
|
||||
const content = document.getElementById('main-content');
|
||||
content.innerHTML = '<div class="loading">Loading settings</div>';
|
||||
|
||||
fetch('/api/settings')
|
||||
.then(r => r.json())
|
||||
.then(settings => {
|
||||
content.innerHTML = `
|
||||
<h1>Settings</h1>
|
||||
<div class="settings-form">
|
||||
<div class="settings-section">
|
||||
<h3>Vision Analysis</h3>
|
||||
<div class="form-group">
|
||||
<label>Vision Backend</label>
|
||||
<select id="setting-vision-backend">
|
||||
<option value="haiku" ${settings.vision_backend === 'haiku' ? 'selected' : ''}>Claude Haiku</option>
|
||||
<option value="ollama" ${settings.vision_backend === 'ollama' ? 'selected' : ''}>Ollama (Local)</option>
|
||||
<option value="both" ${settings.vision_backend === 'both' ? 'selected' : ''}>Both (Haiku + Ollama)</option>
|
||||
</select>
|
||||
<div class="help-text">Which AI backend to use for snapshot analysis</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Recognition</h3>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="setting-recognition-enabled" ${settings.recognition_enabled ? 'checked' : ''}>
|
||||
Enable object recognition
|
||||
</label>
|
||||
<div class="help-text">Match detected objects against known objects database</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Data Retention</h3>
|
||||
<div class="form-group">
|
||||
<label>Event Retention (days)</label>
|
||||
<input type="number" id="setting-retention-days" value="${settings.event_retention_days || 30}" min="1" max="365">
|
||||
<div class="help-text">How long to keep event records and snapshots</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" onclick="window._saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.catch(err => {
|
||||
content.innerHTML = `<h1>Settings</h1><div class="empty-state"><p>Error loading settings: ${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
window._saveSettings = function () {
|
||||
const settings = {
|
||||
vision_backend: document.getElementById('setting-vision-backend').value,
|
||||
recognition_enabled: document.getElementById('setting-recognition-enabled').checked,
|
||||
event_retention_days: parseInt(document.getElementById('setting-retention-days').value, 10)
|
||||
};
|
||||
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to save settings');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => showToast('Settings saved', 'success'))
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
};
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
function loadStats() {
|
||||
fetch('/api/stats')
|
||||
.then(r => r.json())
|
||||
.then(stats => {
|
||||
const el = document.getElementById('stats-summary');
|
||||
if (!el) return;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-item">
|
||||
<span>Total Events</span>
|
||||
<span class="stat-value">${stats.total_events || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Unreviewed</span>
|
||||
<span class="stat-value">${stats.unreviewed || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Known</span>
|
||||
<span class="stat-value">${stats.known_matches || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Unknown</span>
|
||||
<span class="stat-value">${stats.unknown_events || 0}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Known Objects</span>
|
||||
<span class="stat-value">${stats.known_objects_count || 0}</span>
|
||||
</div>`;
|
||||
})
|
||||
.catch(() => {
|
||||
// Stats are non-critical, silently fail
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tag Modal ---
|
||||
|
||||
function setupTagModal() {
|
||||
document.getElementById('tag-cancel').addEventListener('click', closeTagModal);
|
||||
document.getElementById('tag-save').addEventListener('click', saveTag);
|
||||
|
||||
document.getElementById('tag-existing-object').addEventListener('change', (e) => {
|
||||
const newFields = document.getElementById('tag-new-fields');
|
||||
newFields.style.display = e.target.value ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('tag-modal').addEventListener('click', (e) => {
|
||||
if (e.target === document.getElementById('tag-modal')) {
|
||||
closeTagModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeTagModal();
|
||||
});
|
||||
}
|
||||
|
||||
window._tagEvent = function (eventId) {
|
||||
currentTagEventId = eventId;
|
||||
|
||||
// Show snapshot in modal
|
||||
const snapshotContainer = document.getElementById('tag-modal-snapshot');
|
||||
snapshotContainer.innerHTML = `<img src="/api/events/${eventId}/snapshot" onerror="this.style.display='none'" alt="snapshot">`;
|
||||
|
||||
// Load known objects for dropdown
|
||||
fetch('/api/known-objects')
|
||||
.then(r => r.json())
|
||||
.then(objects => {
|
||||
knownObjectsCache = objects;
|
||||
const select = document.getElementById('tag-existing-object');
|
||||
select.innerHTML = '<option value="">-- Create new --</option>';
|
||||
objects.forEach(obj => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = obj.id;
|
||||
opt.textContent = `${obj.name} (${obj.type})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
// Reset form
|
||||
document.getElementById('tag-name').value = '';
|
||||
document.getElementById('tag-type').value = 'person';
|
||||
document.getElementById('tag-new-fields').style.display = 'block';
|
||||
document.getElementById('tag-existing-object').value = '';
|
||||
|
||||
document.getElementById('tag-modal').classList.remove('hidden');
|
||||
};
|
||||
|
||||
function closeTagModal() {
|
||||
document.getElementById('tag-modal').classList.add('hidden');
|
||||
currentTagEventId = null;
|
||||
}
|
||||
|
||||
function saveTag() {
|
||||
if (!currentTagEventId) return;
|
||||
|
||||
const existingId = document.getElementById('tag-existing-object').value;
|
||||
let body;
|
||||
|
||||
if (existingId) {
|
||||
body = { known_object_id: existingId };
|
||||
} else {
|
||||
const name = document.getElementById('tag-name').value.trim();
|
||||
const type = document.getElementById('tag-type').value;
|
||||
if (!name) {
|
||||
showToast('Please enter a name', 'error');
|
||||
return;
|
||||
}
|
||||
body = { name: name, type: type };
|
||||
}
|
||||
|
||||
fetch(`/api/events/${currentTagEventId}/tag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to tag event');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Event tagged successfully', 'success');
|
||||
closeTagModal();
|
||||
loadView(currentView);
|
||||
loadStats();
|
||||
})
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
// --- Event Actions ---
|
||||
|
||||
window._dismissEvent = function (eventId) {
|
||||
fetch(`/api/events/${eventId}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to dismiss event');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Event dismissed', 'success');
|
||||
// Remove card from DOM for immediate feedback
|
||||
const card = document.querySelector(`.event-card[data-event-id="${eventId}"]`);
|
||||
if (card) {
|
||||
card.style.opacity = '0';
|
||||
card.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => card.remove(), 300);
|
||||
}
|
||||
loadStats();
|
||||
})
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
};
|
||||
|
||||
// --- Known Object Actions ---
|
||||
|
||||
window._deleteKnownObject = function (objectId, name) {
|
||||
if (!confirm(`Delete known object "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
fetch(`/api/known-objects/${objectId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to delete object');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
showToast(`Deleted "${name}"`, 'success');
|
||||
loadKnown();
|
||||
loadStats();
|
||||
})
|
||||
.catch(err => showToast('Error: ' + err.message, 'error'));
|
||||
};
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Frigate Alerts</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Frigate Alerts</h2>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<a href="#" class="sidebar-item active" data-view="dashboard">Dashboard</a>
|
||||
<a href="#" class="sidebar-item" data-view="review">Review Queue</a>
|
||||
<a href="#" class="sidebar-item" data-view="known">Known Objects</a>
|
||||
<a href="#" class="sidebar-item" data-view="settings">Settings</a>
|
||||
</div>
|
||||
<div class="sidebar-section sidebar-stats">
|
||||
<div id="stats-summary"></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="content">
|
||||
<div id="main-content"></div>
|
||||
</main>
|
||||
|
||||
<div id="tag-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>Tag as Known Object</h3>
|
||||
<div id="tag-modal-snapshot"></div>
|
||||
<div class="form-group">
|
||||
<label>Existing object:</label>
|
||||
<select id="tag-existing-object">
|
||||
<option value="">-- Create new --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="tag-new-fields">
|
||||
<div class="form-group">
|
||||
<label>Name:</label>
|
||||
<input type="text" id="tag-name" placeholder="e.g., Jeremy, Blue F-150, Max">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type:</label>
|
||||
<select id="tag-type">
|
||||
<option value="person">Person</option>
|
||||
<option value="vehicle">Vehicle</option>
|
||||
<option value="pet">Pet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="tag-save" class="btn btn-primary">Save</button>
|
||||
<button id="tag-cancel" class="btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,534 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: 220px;
|
||||
background: #16213e;
|
||||
padding: 20px 0;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0 20px 20px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
color: #00b4d8;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
color: #a0a0a0;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: #0f3460;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: #0f3460;
|
||||
color: #00b4d8;
|
||||
border-left: 3px solid #00b4d8;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
padding: 20px;
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
#content {
|
||||
margin-left: 220px;
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Page headers */
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 6px 10px;
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filters select:focus,
|
||||
.filters input:focus {
|
||||
outline: none;
|
||||
border-color: #00b4d8;
|
||||
}
|
||||
|
||||
/* Event cards */
|
||||
.event-card {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
transition: border-color 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
border-color: #0f3460;
|
||||
}
|
||||
|
||||
.event-card img {
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-camera {
|
||||
font-weight: bold;
|
||||
color: #00b4d8;
|
||||
}
|
||||
|
||||
.event-label {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.event-score {
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
color: #ccc;
|
||||
margin: 8px 0;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-known {
|
||||
background: #2d6a4f;
|
||||
color: #95d5b2;
|
||||
}
|
||||
|
||||
.badge-unknown {
|
||||
background: #9a4c1a;
|
||||
color: #f4a261;
|
||||
}
|
||||
|
||||
.badge-ambiguous {
|
||||
background: #7c6f09;
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.badge-unreviewed {
|
||||
background: #444;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s;
|
||||
color: #ccc;
|
||||
background: #2a2a4a;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #3a3a5a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0f3460;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #00b4d8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #7c2929;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
width: 450px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #00b4d8;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
#tag-modal-snapshot {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#tag-modal-snapshot img {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #a0a0a0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #0f3460;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #00b4d8;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.form-group .checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group .help-text {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Known objects grid */
|
||||
.known-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.known-card {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.known-card:hover {
|
||||
border-color: #0f3460;
|
||||
}
|
||||
|
||||
.known-card h4 {
|
||||
color: #00b4d8;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.known-card .type-badge {
|
||||
font-size: 0.8em;
|
||||
color: #a0a0a0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.known-card .embedding-count {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.known-card .known-card-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.known-card .known-card-samples {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.known-card .known-card-samples img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Settings */
|
||||
.settings-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.settings-form .form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
color: #00b4d8;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
/* Stats in sidebar */
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #00b4d8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #0f3460;
|
||||
border-top-color: #00b4d8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9em;
|
||||
z-index: 2000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #2d6a4f;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #7c2929;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.known-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
from ollama_client import OllamaClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROMPT = (
|
||||
@@ -40,3 +44,66 @@ def analyze_snapshot(client: anthropic.Anthropic, image_bytes: bytes) -> str | N
|
||||
except Exception:
|
||||
logger.exception("Claude Vision API failed")
|
||||
return None
|
||||
|
||||
|
||||
async def analyze_snapshot_ollama(
|
||||
ollama_client: OllamaClient, image_bytes: bytes
|
||||
) -> str | None:
|
||||
"""Send a snapshot to Ollama vision model for description.
|
||||
|
||||
Args:
|
||||
ollama_client: Configured OllamaClient instance.
|
||||
image_bytes: Raw image bytes (JPEG).
|
||||
|
||||
Returns:
|
||||
Description string or None on failure.
|
||||
"""
|
||||
return await ollama_client.analyze_image(image_bytes, PROMPT)
|
||||
|
||||
|
||||
async def get_description(
|
||||
image_bytes: bytes,
|
||||
vision_backend: str,
|
||||
claude_client: Optional[anthropic.Anthropic] = None,
|
||||
ollama_client: Optional[OllamaClient] = None,
|
||||
) -> str | None:
|
||||
"""Get a scene description using the configured vision backend.
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image bytes (JPEG).
|
||||
vision_backend: One of "haiku", "ollama", or "both".
|
||||
"both" tries Ollama first, falls back to Haiku.
|
||||
claude_client: Anthropic client (required for "haiku" and "both").
|
||||
ollama_client: OllamaClient instance (required for "ollama" and "both").
|
||||
|
||||
Returns:
|
||||
Description string or None if all backends fail.
|
||||
"""
|
||||
if vision_backend == "ollama":
|
||||
if ollama_client is None:
|
||||
logger.error("Ollama backend selected but no client provided")
|
||||
return None
|
||||
return await analyze_snapshot_ollama(ollama_client, image_bytes)
|
||||
|
||||
if vision_backend == "haiku":
|
||||
if claude_client is None:
|
||||
logger.error("Haiku backend selected but no client provided")
|
||||
return None
|
||||
return analyze_snapshot(claude_client, image_bytes)
|
||||
|
||||
if vision_backend == "both":
|
||||
# Try Ollama first, fall back to Haiku
|
||||
if ollama_client is not None:
|
||||
result = await analyze_snapshot_ollama(ollama_client, image_bytes)
|
||||
if result is not None:
|
||||
return result
|
||||
logger.info("Ollama failed, falling back to Haiku")
|
||||
|
||||
if claude_client is not None:
|
||||
return analyze_snapshot(claude_client, image_bytes)
|
||||
|
||||
logger.error("Both backend selected but no clients provided")
|
||||
return None
|
||||
|
||||
logger.error("Unknown vision backend: %s", vision_backend)
|
||||
return None
|
||||
|
||||
@@ -12,8 +12,8 @@ services:
|
||||
- MQTT_HOST=192.168.86.192
|
||||
- MQTT_PORT=1883
|
||||
- MATRIX_HOMESERVER=http://192.168.86.192:8008
|
||||
- CLIP_SERVICE_URL=http://192.168.86.196:5200
|
||||
- OLLAMA_URL=http://192.168.86.196:11434
|
||||
- CLIP_SERVICE_URL=http://localhost:5200
|
||||
- OLLAMA_URL=http://192.168.86.150:11434
|
||||
- OLLAMA_MODEL=llava:13b
|
||||
- DB_PATH=/data/frigate-notify.db
|
||||
volumes:
|
||||
|
||||
@@ -13,8 +13,11 @@ services:
|
||||
- /mnt/docker-data/configs/frigate/config:/config
|
||||
- /mnt/frigate-recordings:/media/frigate
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
tmpfs:
|
||||
- /tmp/cache:size=4G
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
mem_limit: 12G
|
||||
memswap_limit: 12G
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
@@ -0,0 +1,55 @@
|
||||
services:
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-latest
|
||||
container_name: litellm
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/litellm/config.yaml:/app/config.yaml
|
||||
env_file:
|
||||
- .env
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
mem_limit: 1G
|
||||
depends_on:
|
||||
litellm-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:4000/health/liveliness',timeout=5).status==200 else 1)\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
litellm-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: litellm-db
|
||||
restart: unless-stopped
|
||||
user: "1024:100"
|
||||
environment:
|
||||
- POSTGRES_DB=litellm
|
||||
- POSTGRES_USER=litellm
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- /mnt/docker-data/databases/litellm-db:/var/lib/postgresql/data
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
mem_limit: 256M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U litellm -d litellm"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,67 @@
|
||||
services:
|
||||
mas-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: mas-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=mas
|
||||
- POSTGRES_PASSWORD=3XRBtczkPrdbNgdJftKGWu7QwhT3
|
||||
- POSTGRES_DB=mas
|
||||
volumes:
|
||||
# LOCAL disk, never NFS (Known Issue #16)
|
||||
- /var/lib/mas-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- mas
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mas -d mas"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
mas:
|
||||
# NOTE: MAS tags are NOT v-prefixed (1.23.0), unlike Synapse (v1.159.0).
|
||||
# Distroless image - no shell, so no container healthcheck is possible.
|
||||
# Liveness is covered by the blackbox probe of https://auth.hobbs.farm/health.
|
||||
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1
|
||||
container_name: mas
|
||||
restart: unless-stopped
|
||||
command: ["server", "--config", "/config/config.yaml"]
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/mas:/config:ro
|
||||
# read-only synapse data dir: syn2mas reads homeserver.yaml from here
|
||||
- /var/lib/synapse:/synapse:ro
|
||||
ports:
|
||||
- "8020:8080" # web: oauth / compat / discovery / human UI
|
||||
- "8021:8081" # internal: /health only
|
||||
networks:
|
||||
- mas # to mas-db
|
||||
- matrix-internal # to synapse (admin API) and back
|
||||
depends_on:
|
||||
mas-db:
|
||||
condition: service_healthy
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
mas:
|
||||
driver: bridge
|
||||
matrix-internal:
|
||||
external: true
|
||||
name: matrix_matrix-internal
|
||||
@@ -1,20 +1,20 @@
|
||||
services:
|
||||
synapse:
|
||||
image: matrixdotorg/synapse:latest
|
||||
# 2026-08-20: matrixdotorg repo is abandoned (froze at 1.148.0); upstream is now element-hq.
|
||||
# Pinned deliberately so the running version is visible in the file - bump manually.
|
||||
image: ghcr.io/element-hq/synapse:v1.163.2
|
||||
container_name: synapse
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SYNAPSE_SERVER_NAME=mess.hobbs.farm
|
||||
- SYNAPSE_REPORT_STATS=no
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/matrix/data:/data
|
||||
# 2026-08-20: moved off NFS -> local NVMe (SQLite locking, Known Issue #16)
|
||||
- /var/lib/synapse:/data
|
||||
ports:
|
||||
- "8008:8008"
|
||||
networks:
|
||||
- matrix-internal
|
||||
depends_on:
|
||||
synapse-db:
|
||||
condition: service_healthy
|
||||
mem_limit: 1G
|
||||
memswap_limit: 1G
|
||||
healthcheck:
|
||||
@@ -52,21 +52,22 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
synapse-db:
|
||||
image: postgres:15-alpine
|
||||
image: postgres:16-alpine
|
||||
container_name: synapse-db
|
||||
user: "1024:100"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=synapse
|
||||
- POSTGRES_PASSWORD=synapse
|
||||
- POSTGRES_PASSWORD=MnLASftPXou9ubOmawmALYwROwgM
|
||||
- POSTGRES_DB=synapse
|
||||
# C collation is MANDATORY - Synapse refuses any other
|
||||
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
|
||||
volumes:
|
||||
- /mnt/docker-data/databases/matrix-db:/var/lib/postgresql/data
|
||||
# LOCAL disk, never NFS (Known Issue #16). No user: 1024:100 - that is an NFS workaround.
|
||||
- /var/lib/synapse-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- matrix-internal
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
mem_limit: 512M
|
||||
memswap_limit: 512M
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
networks:
|
||||
monitoring-net:
|
||||
name: monitoring-net
|
||||
|
||||
services:
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
restart: unless-stopped
|
||||
user: "104"
|
||||
networks:
|
||||
- monitoring-net
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/grafana:/var/lib/grafana
|
||||
mem_limit: 512M
|
||||
memswap_limit: 512M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
|
||||
interval: 30s
|
||||
@@ -27,13 +18,19 @@ services:
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: prometheus
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- monitoring-net
|
||||
ports:
|
||||
- "9095:9090"
|
||||
command:
|
||||
@@ -44,8 +41,6 @@ services:
|
||||
- '--web.enable-lifecycle'
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/prometheus:/etc/prometheus
|
||||
mem_limit: 512M
|
||||
memswap_limit: 512M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:9090/-/healthy || exit 1"]
|
||||
interval: 30s
|
||||
@@ -57,13 +52,19 @@ services:
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:latest
|
||||
container_name: alertmanager
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- monitoring-net
|
||||
ports:
|
||||
- "9093:9093"
|
||||
command:
|
||||
@@ -72,8 +73,6 @@ services:
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/alertmanager:/etc/alertmanager
|
||||
- alertmanager-data:/alertmanager
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:9093/-/healthy || exit 1"]
|
||||
interval: 30s
|
||||
@@ -85,19 +84,49 @@ services:
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
|
||||
node-exporter:
|
||||
# DISABLED 2026-10-26: node-exporter runs STANDALONE (compose/<node>/node-exporter, pid:host).
|
||||
# This legacy copy would collide on :9100 if started; gated behind a profile so a
|
||||
# bare `docker compose up -d` here never starts it.
|
||||
profiles: ["legacy-node-exporter"]
|
||||
image: prom/node-exporter:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9100:9100"
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--path.sysfs=/host/sys'
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: global
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
influxdb:
|
||||
image: influxdb:1.8-alpine
|
||||
container_name: influxdb
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- monitoring-net
|
||||
ports:
|
||||
- "8086:8086"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/influxdb:/var/lib/influxdb
|
||||
mem_limit: 1G
|
||||
memswap_limit: 1G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8086/ping || exit 1"]
|
||||
interval: 30s
|
||||
@@ -109,45 +138,99 @@ services:
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
matrix-alertmanager-receiver:
|
||||
image: metio/matrix-alertmanager-receiver:2026.2.25
|
||||
container_name: matrix-alertmanager-receiver
|
||||
image: metio/matrix-alertmanager-receiver:2026.9.23
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- monitoring-net
|
||||
ports:
|
||||
- "9094:9094"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml:/config/config.yml:ro
|
||||
command: ["--config-path", "/config/config.yml"]
|
||||
mem_limit: 128M
|
||||
memswap_limit: 128M
|
||||
# NOTE: No healthcheck - this image is distroless (no shell), so CMD-SHELL healthchecks fail.
|
||||
# The service is verified healthy by Alertmanager successfully POSTing to it.
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
cadvisor:
|
||||
image: ghcr.io/google/cadvisor:v0.60.6 # moved off gcr.io (no releases there after v0.55)
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
command:
|
||||
- '--docker_only=true'
|
||||
- '--disable_metrics=advtcp,cpu_topology,cpuset,hugetlb,memory_numa,process,referenced_memory,resctrl,sched,tcp,udp,disk,diskIO,percpu'
|
||||
- '--housekeeping_interval=30s'
|
||||
- '--store_container_labels=false'
|
||||
ports:
|
||||
- "8081:8080"
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:ro
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:latest
|
||||
container_name: blackbox-exporter
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- monitoring-net
|
||||
ports:
|
||||
- "9115:9115"
|
||||
command:
|
||||
- '--config.file=/config/blackbox.yml'
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/blackbox:/config:ro
|
||||
mem_limit: 128M
|
||||
memswap_limit: 128M
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints:
|
||||
- node.hostname == hf-pdocker-01
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
volumes:
|
||||
alertmanager-data:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.12.1
|
||||
container_name: node-exporter
|
||||
hostname: hf-pdocker-01
|
||||
network_mode: host
|
||||
pid: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
@@ -1,29 +0,0 @@
|
||||
services:
|
||||
open-webui:
|
||||
image: ghcr.io/open-webui/open-webui:main
|
||||
container_name: open-webui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3010:8080"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=America/Chicago
|
||||
- OLLAMA_BASE_URL=http://192.168.86.196:11434
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/openwebui:/app/backend/data
|
||||
mem_limit: 1G
|
||||
memswap_limit: 1G
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
telegraf:
|
||||
image: telegraf:latest
|
||||
container_name: telegraf
|
||||
hostname: hf-pdocker-01
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HOST_PROC=/host/proc
|
||||
- HOST_SYS=/host/sys
|
||||
- HOST_ETC=/host/etc
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/telegraf/telegraf.conf:/etc/telegraf/telegraf.conf:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /proc:/host/proc:ro
|
||||
- /etc:/host/etc:ro
|
||||
mem_limit: 512M
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,102 @@
|
||||
http:
|
||||
routers:
|
||||
auth:
|
||||
rule: "Host(`auth.hobbs.farm`)"
|
||||
service: mas
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# 2026-08-20: under MAS, Synapse no longer serves the login endpoints
|
||||
# (it returns M_UNRECOGNIZED). These three compat paths must reach MAS or
|
||||
# no client can sign in. Explicit high priority so it wins over `mess`.
|
||||
mess-auth-compat:
|
||||
rule: "Host(`mess.hobbs.farm`) && PathRegexp(`^/_matrix/client/(r0|v3|v1|unstable)/(login|logout|refresh)`)"
|
||||
priority: 1000
|
||||
service: mas
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
yoda:
|
||||
rule: "Host(`yoda.hobbs.farm`)"
|
||||
service: yoda
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers-allow-frames
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
mess:
|
||||
rule: "Host(`mess.hobbs.farm`)"
|
||||
service: mess
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
mess-federation:
|
||||
rule: "Host(`mess.hobbs.farm`) && PathPrefix(`/_matrix/federation`)"
|
||||
service: mess
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
wellknown:
|
||||
rule: "Host(`hobbs.farm`) && PathPrefix(`/.well-known/matrix`)"
|
||||
service: wellknown
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
solo:
|
||||
rule: "Host(`solo.hobbs.farm`)"
|
||||
service: solo
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- solo-basicauth
|
||||
- security-headers-allow-frames
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
mas:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.192:8020"
|
||||
|
||||
yoda:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.192:8123"
|
||||
|
||||
mess:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.192:8008"
|
||||
|
||||
wellknown:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.192:8010"
|
||||
|
||||
solo:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.192:8199"
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
image: traefik:v3.7
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# VoiceCoach Environment Variables
|
||||
# Copy to .env and fill in values
|
||||
|
||||
# Anthropic (required - for Claude LLM)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# LiveKit (auto-generated on first deploy if not set)
|
||||
LIVEKIT_API_KEY=voicecoach-key
|
||||
LIVEKIT_API_SECRET=your-livekit-secret-here
|
||||
@@ -0,0 +1,184 @@
|
||||
services:
|
||||
|
||||
# LiveKit WebRTC Server (unchanged)
|
||||
livekit-server:
|
||||
image: livekit/livekit-server:latest
|
||||
container_name: voicecoach-livekit
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --config
|
||||
- /etc/livekit/livekit.yaml
|
||||
- --keys
|
||||
- "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
- "50000-50100:50000-50100/udp"
|
||||
volumes:
|
||||
- ./livekit/livekit.yaml:/etc/livekit/livekit.yaml:ro
|
||||
dns_search: [""]
|
||||
networks:
|
||||
- voicecoach
|
||||
mem_limit: 512M
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# Speaches: self-hosted Whisper STT + Kokoro TTS (v2 - replaces Piper + in-process Whisper)
|
||||
# NOTE: No WHISPER__MODEL or TTS__* env vars - WhisperConfig has no 'model' field in this version.
|
||||
# Models are loaded on-demand per-request (model ID passed in API body) or pre-downloaded via
|
||||
# POST /v1/models/{model_id}. TTS model/voice are also per-request in POST /v1/audio/speech body.
|
||||
# Volume must map to /home/ubuntu/.cache (container runs as 'ubuntu', not root).
|
||||
speaches:
|
||||
image: ghcr.io/speaches-ai/speaches:latest-cpu
|
||||
container_name: voicecoach-speaches
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- WHISPER__INFERENCE_DEVICE=cpu
|
||||
- WHISPER__COMPUTE_TYPE=int8
|
||||
- WHISPER__TTL=-1
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/voicecoach/speaches-models:/home/ubuntu/.cache
|
||||
dns_search: [""]
|
||||
networks:
|
||||
- voicecoach
|
||||
mem_limit: 3G
|
||||
memswap_limit: 3G
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# SpeechBrain Emotion Analysis (unchanged, internal only)
|
||||
speechbrain-service:
|
||||
build:
|
||||
context: ./speechbrain-service
|
||||
image: voicecoach-speechbrain:latest
|
||||
container_name: voicecoach-speechbrain
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/voicecoach/speechbrain-models:/app/models
|
||||
- /mnt/docker-data/configs/voicecoach/clips:/data/clips
|
||||
environment:
|
||||
- MODEL_DIR=/app/models
|
||||
dns_search: [""]
|
||||
networks:
|
||||
- voicecoach
|
||||
mem_limit: 3G
|
||||
memswap_limit: 3G
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# OpenClaw VoiceCoach Agent (v2 - LLM gateway with memory/skills)
|
||||
voicecoach-openclaw:
|
||||
image: margaret-openclaw:latest
|
||||
container_name: voicecoach-openclaw
|
||||
restart: unless-stopped
|
||||
user: "0:0"
|
||||
ports:
|
||||
- "3100:3100"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOME=/home/glyptic
|
||||
- USER=glyptic
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- OPENCLAW_STATE_DIR=/home/glyptic/.voicecoach
|
||||
- OPENCLAW_CONFIG_PATH=/home/glyptic/.voicecoach/openclaw.json
|
||||
volumes:
|
||||
- /mnt/infrastructure/voicecoach:/home/glyptic/.voicecoach
|
||||
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
|
||||
dns_search: [""]
|
||||
networks:
|
||||
- voicecoach
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# Pipecat Voice Pipeline Agent (v2 - uses Speaches + OpenClaw gateway)
|
||||
pipecat-agent:
|
||||
build:
|
||||
context: ./pipecat-agent
|
||||
image: voicecoach-pipecat:latest
|
||||
container_name: voicecoach-pipecat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit-server:7880
|
||||
- LIVEKIT_PUBLIC_URL=wss://voice.hobbs.farm
|
||||
- SPEACHES_URL=http://192.168.86.150:8000
|
||||
- SPEECHBRAIN_URL=http://speechbrain-service:8001
|
||||
- OPENCLAW_URL=http://voicecoach-openclaw:3100
|
||||
- OPENCLAW_AGENT_ID=voicecoach
|
||||
- WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||
- KOKORO_VOICE=af_nova
|
||||
- CLIPS_DIR=/data/clips/pending
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/voicecoach/clips:/data/clips
|
||||
depends_on:
|
||||
- livekit-server
|
||||
- speechbrain-service
|
||||
- voicecoach-openclaw
|
||||
dns_search: [""]
|
||||
networks:
|
||||
- voicecoach
|
||||
mem_limit: 1G
|
||||
memswap_limit: 1G
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# Web Client (unchanged)
|
||||
voicecoach-web:
|
||||
build:
|
||||
context: ./web-client
|
||||
image: voicecoach-web:latest
|
||||
container_name: voicecoach-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8085:80"
|
||||
depends_on:
|
||||
- pipecat-agent
|
||||
dns_search: [""]
|
||||
networks:
|
||||
- voicecoach
|
||||
mem_limit: 128M
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
voicecoach:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,15 @@
|
||||
port: 7880
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
use_external_ip: true
|
||||
|
||||
logging:
|
||||
json: false
|
||||
level: info
|
||||
|
||||
room:
|
||||
max_participants: 5
|
||||
empty_timeout: 300
|
||||
departure_timeout: 30
|
||||
@@ -0,0 +1,63 @@
|
||||
You are VoiceCoach, a personal communication and leadership coach for Jeremy.
|
||||
You engage with Jeremy during his daily commute via real-time voice conversation.
|
||||
|
||||
## Conversation Style
|
||||
- Keep responses to 2-4 spoken sentences unless Jeremy asks for more
|
||||
- Use natural, conversational language -- no bullet points, no markdown
|
||||
- You are a coach, not a chatbot. Ask questions. Challenge thinking. Give direct feedback.
|
||||
|
||||
## Vocal State Analysis
|
||||
Before each user message, you will receive a [VOCAL_STATE] tag:
|
||||
```
|
||||
[VOCAL_STATE emotion=frustrated confidence=0.82 pitch=215hz energy=0.71 rate=4.8ops voiced=0.91]
|
||||
```
|
||||
|
||||
Use this to coach on HOW Jeremy communicates, not just WHAT he says:
|
||||
- High pitch + high energy + fast rate = stressed, rushed -- note this
|
||||
- Low voiced fraction = hesitant, uncertain -- worth exploring
|
||||
- Elevated pitch from baseline = emotional arousal -- name it gently
|
||||
- Never quote the raw numbers. Translate to natural observations.
|
||||
- Example: "I can hear some tension in how you're describing that" not "your pitch was 215hz"
|
||||
|
||||
Only comment on vocal state when it's meaningfully different from baseline or
|
||||
when it's directly relevant to the coaching topic. Don't narrate every turn.
|
||||
|
||||
## Coaching Modes
|
||||
Jeremy can switch modes mid-conversation. Honor the switch immediately.
|
||||
|
||||
**coaching mode** (default): Active feedback on both content and delivery.
|
||||
Ask probing questions. Notice patterns. Give direct recommendations.
|
||||
|
||||
**vent mode**: Listen and reflect. Minimal advice. Validate feelings.
|
||||
Mirror back what you hear. Let Jeremy process out loud.
|
||||
|
||||
**practice mode**: Roleplay a difficult stakeholder, colleague, or situation.
|
||||
Stay in character. Be realistic -- not easy. Give feedback after.
|
||||
|
||||
**briefing mode**: Summarize Jeremy's current tasks, context, and priorities
|
||||
from memory. Be crisp. Flag anything time-sensitive.
|
||||
|
||||
## Memory Usage
|
||||
You have access to:
|
||||
- `vocal_baseline`: Jeremy's neutral voice characteristics for comparison
|
||||
- `recent_sessions`: Last 3 session summaries with topics and vocal trends
|
||||
- `action_items`: Commitments Jeremy made in previous sessions
|
||||
- `coaching_themes`: Recurring themes across sessions (track these actively)
|
||||
- `jeremy_context`: Jeremy's role, team, current challenges
|
||||
|
||||
At the end of each session, write a summary to `recent_sessions` including:
|
||||
- Topics discussed
|
||||
- Vocal patterns observed
|
||||
- Action items for next session
|
||||
- Any new coaching themes identified
|
||||
|
||||
## Baseline Calibration (First Session)
|
||||
If `vocal_baseline` is empty, the first 60 seconds should be calibration.
|
||||
Say: "Before we dive in, let's do a quick baseline. Can you tell me about
|
||||
your commute in to work today -- just talk naturally for about a minute."
|
||||
Then write the averaged vocal readings to `vocal_baseline`.
|
||||
|
||||
## Things Jeremy Is Working On
|
||||
Read from `coaching_themes` and `jeremy_context` in memory.
|
||||
Reference previous sessions naturally: "Last week you mentioned..."
|
||||
Track whether previous action items were attempted.
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y libsndfile1 ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Patch pipecat LiveKit transport bugs:
|
||||
# 1. Remove duplicate on_connected call in connect() that causes InvalidStateError
|
||||
# 2. Remove redundant push_frame that bypasses VAD
|
||||
RUN python3 -c "\
|
||||
import pipecat.transports.services.livekit as m; \
|
||||
path = m.__file__; \
|
||||
content = open(path).read(); \
|
||||
# Fix 1: Wrap the direct on_connected call to prevent InvalidStateError \
|
||||
content = content.replace( \
|
||||
'await self._callbacks.on_connected()', \
|
||||
'try:\n await self._callbacks.on_connected()\n except Exception:\n pass # Already handled by room event', \
|
||||
1 \
|
||||
); \
|
||||
open(path, 'w').write(content); \
|
||||
print(f'Patched {path}')"
|
||||
|
||||
COPY . .
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8080/health').raise_for_status()"
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
VoiceCoach pipeline entry point -- v2.
|
||||
Runs token server + restarts pipeline immediately after each session.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from pipeline import run_voice_coach_pipeline
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = structlog.get_logger()
|
||||
|
||||
LIVEKIT_API_KEY = os.environ["LIVEKIT_API_KEY"]
|
||||
LIVEKIT_API_SECRET = os.environ["LIVEKIT_API_SECRET"]
|
||||
LIVEKIT_WS_URL = os.getenv("LIVEKIT_URL", "ws://livekit-server:7880")
|
||||
LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://voice.hobbs.farm")
|
||||
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "")
|
||||
|
||||
# --- Token HTTP server ---
|
||||
|
||||
token_app = FastAPI(title="VoiceCoach Token Service")
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
identity: str = "jeremy"
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
token: str
|
||||
url: str
|
||||
|
||||
|
||||
@token_app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "2.0"}
|
||||
|
||||
|
||||
@token_app.post("/token")
|
||||
async def get_token(req: TokenRequest):
|
||||
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
||||
token.with_grants(livekit_api.VideoGrants(
|
||||
room_join=True, room="voicecoach",
|
||||
can_publish=True, can_subscribe=True,
|
||||
))
|
||||
token.identity = req.identity
|
||||
return TokenResponse(token=token.to_jwt(), url=LIVEKIT_PUBLIC_URL)
|
||||
|
||||
|
||||
def run_token_server():
|
||||
uvicorn.run(token_app, host="0.0.0.0", port=8080, log_level="info")
|
||||
|
||||
|
||||
# --- Main: restart entire process after each session ---
|
||||
# This guarantees a completely fresh pipeline for every session.
|
||||
# No stale transport state, no cancelled tasks, no dead queues.
|
||||
|
||||
def run_pipeline_sync():
|
||||
"""Run one pipeline session, then exit the process.
|
||||
Docker's restart: unless-stopped will bring us back immediately.
|
||||
"""
|
||||
agent_identity = f"voicecoach-agent-{uuid.uuid4().hex[:8]}"
|
||||
token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
||||
token.with_grants(livekit_api.VideoGrants(room_join=True, room="voicecoach"))
|
||||
token.identity = agent_identity
|
||||
token.ttl = timedelta(hours=24) # 24 hours - prevents expired token reconnect failures
|
||||
livekit_token = token.to_jwt()
|
||||
|
||||
logger.info("Starting VoiceCoach v2 pipeline", identity=agent_identity)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(run_voice_coach_pipeline(
|
||||
livekit_url=LIVEKIT_WS_URL,
|
||||
livekit_token=livekit_token,
|
||||
openclaw_token=OPENCLAW_TOKEN,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error("Pipeline ended", error=str(e))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Start token server in background
|
||||
server_thread = threading.Thread(target=run_token_server, daemon=True)
|
||||
server_thread.start()
|
||||
logger.info("Token server started on :8080")
|
||||
|
||||
# Run pipeline sessions in a loop
|
||||
# Each iteration creates a completely fresh asyncio event loop and pipeline
|
||||
while True:
|
||||
try:
|
||||
run_pipeline_sync()
|
||||
except Exception as e:
|
||||
logger.error("Pipeline crash", error=str(e))
|
||||
|
||||
# Brief pause to let LiveKit clean up the old participant
|
||||
import time
|
||||
time.sleep(3)
|
||||
logger.info("Restarting pipeline with fresh state...")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Piper TTS client for Pipecat pipeline.
|
||||
Calls the self-hosted Piper HTTP service.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import wave
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PiperTTSClient:
|
||||
"""HTTP client for the Piper TTS service."""
|
||||
|
||||
def __init__(self, base_url: str = "http://piper-tts:8003"):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
async def synthesize(self, text: str) -> bytes:
|
||||
"""Convert text to WAV audio bytes via Piper service."""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.base_url}/synthesize",
|
||||
json={"text": text, "length_scale": 0.95, "sentence_silence": 0.15},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logger.error(f"Piper TTS failed: {e}")
|
||||
return b""
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Builds Claude prompts with embedded vocal state metadata.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
COACHING_SYSTEM_PROMPT = """You are a personal voice coach and communication advisor named VoiceCoach.
|
||||
You are speaking to Jeremy during his commute via voice -- keep responses conversational and concise (2-4 sentences unless asked for more).
|
||||
|
||||
You receive a [VOCAL_STATE] tag before each user message containing paralinguistic analysis of Jeremy's voice:
|
||||
- emotion: the detected emotional state
|
||||
- confidence: how confident the analysis is (0-1)
|
||||
- pitch: average fundamental frequency in Hz (higher = more animated/stressed)
|
||||
- energy: vocal energy level (0-1, higher = louder/more forceful)
|
||||
- rate: speaking rate in onset events/sec (higher = faster speech)
|
||||
- voiced: fraction of voiced frames (lower = more hesitant/uncertain)
|
||||
|
||||
Use this information to provide coaching on BOTH what Jeremy says AND how he sounds saying it.
|
||||
Important: Never quote the raw numbers. Translate them into natural observations.
|
||||
Example: "I notice some tension in your voice..." not "Your pitch was 215hz..."
|
||||
|
||||
{baseline_context}
|
||||
|
||||
{session_history}
|
||||
|
||||
Available coaching modes (Jeremy can switch by saying the phrase):
|
||||
- "coaching mode" (default) -- active feedback on content and delivery
|
||||
- "vent mode" -- listen and reflect, minimal advice
|
||||
- "practice mode" -- roleplay a difficult stakeholder
|
||||
- "briefing mode" -- summarize Jeremy's tasks and context from memory
|
||||
|
||||
Current mode: {current_mode}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class VocalState:
|
||||
emotion: str
|
||||
confidence: float
|
||||
pitch_mean_hz: Optional[float]
|
||||
energy_rms: float
|
||||
speaking_rate_approx: float
|
||||
voiced_fraction: float
|
||||
|
||||
|
||||
def format_vocal_state(state: VocalState) -> str:
|
||||
pitch_str = f"{state.pitch_mean_hz:.0f}hz" if state.pitch_mean_hz else "unknown"
|
||||
return (
|
||||
f"[VOCAL_STATE "
|
||||
f"emotion={state.emotion} "
|
||||
f"confidence={state.confidence:.2f} "
|
||||
f"pitch={pitch_str} "
|
||||
f"energy={state.energy_rms:.2f} "
|
||||
f"rate={state.speaking_rate_approx:.1f}ops "
|
||||
f"voiced={state.voiced_fraction:.2f}]"
|
||||
)
|
||||
|
||||
|
||||
def build_user_message(transcript: str, vocal_state: Optional[VocalState]) -> str:
|
||||
if vocal_state and vocal_state.confidence > 0.3:
|
||||
tag = format_vocal_state(vocal_state)
|
||||
return f"{tag}\n{transcript}"
|
||||
return transcript
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
baseline_context: str = "",
|
||||
session_history: str = "",
|
||||
current_mode: str = "coaching mode",
|
||||
) -> str:
|
||||
return COACHING_SYSTEM_PROMPT.format(
|
||||
baseline_context=baseline_context or "No baseline established yet. First session: ask Jeremy to read a short neutral calibration script.",
|
||||
session_history=session_history or "No previous session history.",
|
||||
current_mode=current_mode,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
pipecat-ai[livekit,silero,openai]==0.0.48
|
||||
httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
structlog==24.4.0
|
||||
livekit-api==0.7.1
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Local STT service using faster-whisper.
|
||||
Replaces Deepgram with fully self-hosted speech-to-text.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import wave
|
||||
import numpy as np
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalWhisperSTT:
|
||||
"""Wraps faster-whisper for use in the Pipecat pipeline."""
|
||||
|
||||
def __init__(self, model_size: str = "medium", device: str = "cpu", compute_type: str = "int8"):
|
||||
logger.info(f"Loading faster-whisper model: {model_size} on {device}")
|
||||
self.model = WhisperModel(model_size, device=device, compute_type=compute_type)
|
||||
logger.info("faster-whisper model loaded.")
|
||||
|
||||
def transcribe(self, audio_bytes: bytes, sample_rate: int = 16000) -> str:
|
||||
"""Transcribe raw PCM audio bytes to text."""
|
||||
audio_array = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
segments, info = self.model.transcribe(
|
||||
audio_array,
|
||||
beam_size=5,
|
||||
language="en",
|
||||
vad_filter=True,
|
||||
vad_parameters=dict(min_silence_duration_ms=500),
|
||||
)
|
||||
|
||||
text = " ".join(seg.text.strip() for seg in segments)
|
||||
return text
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsndfile1 \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Download Piper voice model at build time from HuggingFace (lessac medium - good quality, fast on CPU)
|
||||
RUN mkdir -p /app/models && \
|
||||
wget -q "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx?download=true" \
|
||||
-O /app/models/en_US-lessac-medium.onnx && \
|
||||
wget -q "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json?download=true" \
|
||||
-O /app/models/en_US-lessac-medium.onnx.json
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8003
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8003/health').raise_for_status()"
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8003", "--workers", "1"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Piper TTS HTTP service.
|
||||
Self-hosted text-to-speech, internal network only.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import wave
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
import piper
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_PATH = os.getenv("PIPER_MODEL", "/app/models/en_US-lessac-medium.onnx")
|
||||
voice = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global voice
|
||||
logger.info(f"Loading Piper model: {MODEL_PATH}")
|
||||
voice = piper.PiperVoice.load(MODEL_PATH)
|
||||
logger.info("Piper TTS ready.")
|
||||
yield
|
||||
logger.info("Shutting down.")
|
||||
|
||||
|
||||
app = FastAPI(title="Piper TTS Service", lifespan=lifespan)
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
speaker_id: int = None
|
||||
length_scale: float = 1.0
|
||||
sentence_silence: float = 0.2
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
if voice is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||
return {"status": "ok", "model": os.path.basename(MODEL_PATH)}
|
||||
|
||||
|
||||
@app.post("/synthesize")
|
||||
async def synthesize(req: TTSRequest):
|
||||
if voice is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||
|
||||
if not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Empty text")
|
||||
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
synth_kwargs = {
|
||||
"length_scale": req.length_scale,
|
||||
"sentence_silence": req.sentence_silence,
|
||||
}
|
||||
if req.speaker_id is not None:
|
||||
synth_kwargs["speaker_id"] = req.speaker_id
|
||||
voice.synthesize(req.text, wf, **synth_kwargs)
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="audio/wav",
|
||||
headers={"Content-Disposition": "inline; filename=speech.wav"},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
piper-tts==1.2.0
|
||||
httpx==0.27.0
|
||||
numpy==1.26.4
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsndfile1 \
|
||||
ffmpeg \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Pre-download the SpeechBrain model at build time
|
||||
RUN python -c "\
|
||||
from speechbrain.inference.interfaces import foreign_class; \
|
||||
foreign_class( \
|
||||
source='speechbrain/emotion-recognition-wav2vec2-IEMOCAP', \
|
||||
pymodule_file='custom_interface.py', \
|
||||
classname='CustomEncoderWav2vec2Classifier', \
|
||||
savedir='/app/models', \
|
||||
run_opts={'device': 'cpu'}, \
|
||||
); \
|
||||
print('Model downloaded successfully.')"
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:8001/health').raise_for_status()"
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "1"]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
SpeechBrain + librosa vocal analysis.
|
||||
Performs emotion classification and acoustic feature extraction from audio.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import librosa
|
||||
from speechbrain.inference.interfaces import foreign_class
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
MIN_DURATION_SECONDS = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class VocalAnalysis:
|
||||
emotion: str
|
||||
emotion_confidence: float
|
||||
emotion_scores: dict
|
||||
pitch_mean_hz: Optional[float]
|
||||
pitch_variance: Optional[float]
|
||||
energy_rms: float
|
||||
speaking_rate_approx: float
|
||||
voiced_fraction: float
|
||||
duration_seconds: float
|
||||
analysis_error: Optional[str] = None
|
||||
|
||||
|
||||
class VocalAnalyzer:
|
||||
"""Loads models once at startup and provides analysis per audio chunk."""
|
||||
|
||||
def __init__(self, model_dir: str = "/app/models"):
|
||||
logger.info("Loading SpeechBrain emotion model...")
|
||||
self.classifier = foreign_class(
|
||||
source="speechbrain/emotion-recognition-wav2vec2-IEMOCAP",
|
||||
pymodule_file="custom_interface.py",
|
||||
classname="CustomEncoderWav2vec2Classifier",
|
||||
savedir=model_dir,
|
||||
run_opts={"device": "cpu"},
|
||||
)
|
||||
logger.info("SpeechBrain model loaded successfully.")
|
||||
|
||||
def analyze(self, audio_bytes: bytes, content_type: str = "audio/wav") -> VocalAnalysis:
|
||||
try:
|
||||
audio_buf = io.BytesIO(audio_bytes)
|
||||
audio, sr = sf.read(audio_buf, dtype="float32")
|
||||
|
||||
if audio.ndim > 1:
|
||||
audio = audio.mean(axis=1)
|
||||
|
||||
if sr != SAMPLE_RATE:
|
||||
audio = librosa.resample(audio, orig_sr=sr, target_sr=SAMPLE_RATE)
|
||||
sr = SAMPLE_RATE
|
||||
|
||||
duration = len(audio) / sr
|
||||
|
||||
if duration < MIN_DURATION_SECONDS:
|
||||
return VocalAnalysis(
|
||||
emotion="neutral", emotion_confidence=0.0, emotion_scores={},
|
||||
pitch_mean_hz=None, pitch_variance=None, energy_rms=0.0,
|
||||
speaking_rate_approx=0.0, voiced_fraction=0.0,
|
||||
duration_seconds=duration,
|
||||
analysis_error=f"Audio too short: {duration:.2f}s < {MIN_DURATION_SECONDS}s minimum",
|
||||
)
|
||||
|
||||
import tempfile, os
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
sf.write(tmp.name, audio, sr)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
out_prob, score, index, text_lab = self.classifier.classify_file(tmp_path)
|
||||
emotion = text_lab[0] if isinstance(text_lab, list) else text_lab
|
||||
confidence = float(score[0]) if hasattr(score, "__iter__") else float(score)
|
||||
labels = ["neu", "hap", "sad", "ang", "fru"]
|
||||
prob_list = out_prob[0].tolist() if hasattr(out_prob[0], "tolist") else list(out_prob[0])
|
||||
emotion_scores = {l: round(p, 4) for l, p in zip(labels, prob_list)}
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
f0, voiced_flag, _ = librosa.pyin(
|
||||
audio, fmin=librosa.note_to_hz("C2"), fmax=librosa.note_to_hz("C7"), sr=sr,
|
||||
)
|
||||
|
||||
voiced_f0 = f0[voiced_flag] if voiced_flag is not None else np.array([])
|
||||
pitch_mean = float(np.mean(voiced_f0)) if len(voiced_f0) > 0 else None
|
||||
pitch_var = float(np.var(voiced_f0)) if len(voiced_f0) > 1 else None
|
||||
voiced_fraction = float(np.sum(voiced_flag) / len(voiced_flag)) if voiced_flag is not None else 0.0
|
||||
|
||||
rms = librosa.feature.rms(y=audio)
|
||||
energy_rms = float(np.mean(rms))
|
||||
|
||||
onset_frames = librosa.onset.onset_detect(y=audio, sr=sr)
|
||||
speaking_rate = len(onset_frames) / duration if duration > 0 else 0.0
|
||||
|
||||
return VocalAnalysis(
|
||||
emotion=emotion, emotion_confidence=round(confidence, 4),
|
||||
emotion_scores=emotion_scores,
|
||||
pitch_mean_hz=round(pitch_mean, 2) if pitch_mean else None,
|
||||
pitch_variance=round(pitch_var, 2) if pitch_var else None,
|
||||
energy_rms=round(energy_rms, 4),
|
||||
speaking_rate_approx=round(speaking_rate, 2),
|
||||
voiced_fraction=round(voiced_fraction, 4),
|
||||
duration_seconds=round(duration, 3),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Analysis failed: {e}")
|
||||
return VocalAnalysis(
|
||||
emotion="neutral", emotion_confidence=0.0, emotion_scores={},
|
||||
pitch_mean_hz=None, pitch_variance=None, energy_rms=0.0,
|
||||
speaking_rate_approx=0.0, voiced_fraction=0.0,
|
||||
duration_seconds=0.0, analysis_error=str(e),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
FastAPI service wrapping VocalAnalyzer.
|
||||
Internal-only -- not exposed via Traefik.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from dataclasses import asdict
|
||||
|
||||
from analyzer import VocalAnalyzer
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_DIR = os.getenv("MODEL_DIR", "/app/models")
|
||||
analyzer: VocalAnalyzer = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global analyzer
|
||||
logger.info("Starting VocalAnalyzer...")
|
||||
analyzer = VocalAnalyzer(model_dir=MODEL_DIR)
|
||||
logger.info("VocalAnalyzer ready.")
|
||||
yield
|
||||
logger.info("Shutting down.")
|
||||
|
||||
|
||||
app = FastAPI(title="SpeechBrain Vocal Analysis Service", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
if analyzer is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||
return {"status": "ok", "model": "emotion-recognition-wav2vec2-IEMOCAP"}
|
||||
|
||||
|
||||
@app.post("/analyze")
|
||||
async def analyze(audio: UploadFile = File(...)):
|
||||
if analyzer is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||
|
||||
content = await audio.read()
|
||||
if len(content) == 0:
|
||||
raise HTTPException(status_code=400, detail="Empty audio file")
|
||||
|
||||
result = analyzer.analyze(content, content_type=audio.content_type or "audio/wav")
|
||||
return JSONResponse(content=asdict(result))
|
||||
|
||||
|
||||
@app.post("/calibrate")
|
||||
async def calibrate(audio: UploadFile = File(...)):
|
||||
if analyzer is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||
|
||||
content = await audio.read()
|
||||
if len(content) == 0:
|
||||
raise HTTPException(status_code=400, detail="Empty audio file")
|
||||
|
||||
result = analyzer.analyze(content, content_type=audio.content_type or "audio/wav")
|
||||
result_dict = asdict(result)
|
||||
result_dict["is_baseline"] = True
|
||||
return JSONResponse(content=result_dict)
|
||||
@@ -0,0 +1,12 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
speechbrain==1.0.0
|
||||
huggingface_hub==0.19.4
|
||||
transformers==4.40.2
|
||||
torch==2.3.0
|
||||
torchaudio==2.3.0
|
||||
librosa==0.10.2
|
||||
soundfile==0.12.1
|
||||
numpy==1.26.4
|
||||
httpx==0.27.0
|
||||
python-multipart==0.0.9
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY app.js /usr/share/nginx/html/
|
||||
COPY style.css /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,158 @@
|
||||
const LIVEKIT_URL = "wss://voice.hobbs.farm";
|
||||
let room = null;
|
||||
|
||||
function log(msg, level) {
|
||||
const el = document.getElementById("logOutput");
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const cls = level === "error" ? "log-error" : level === "warn" ? "log-warn" : "log-info";
|
||||
el.innerHTML += `<div class="${cls}"><span class="log-time">${time}</span> ${msg}</div>`;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById("logOutput").innerHTML = "";
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
setStatus("connecting", "Connecting...");
|
||||
log("Requesting token from server...");
|
||||
try {
|
||||
const response = await fetch("/api/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: "jeremy-" + Date.now() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error("Token request failed: " + response.status + " " + text);
|
||||
}
|
||||
const data = await response.json();
|
||||
log("Token received. LiveKit URL: " + data.url);
|
||||
|
||||
room = new LivekitClient.Room({
|
||||
audioCaptureDefaults: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.Connected, () => {
|
||||
setStatus("connected", "Connected");
|
||||
document.getElementById("connectBtn").style.display = "none";
|
||||
document.getElementById("disconnectBtn").style.display = "block";
|
||||
log("Connected to LiveKit room");
|
||||
log("Participants in room: " + room.remoteParticipants.size);
|
||||
if (room.remoteParticipants.size === 0) {
|
||||
log("No agent participant yet - waiting for voicecoach-agent to join...", "warn");
|
||||
}
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.Disconnected, (reason) => {
|
||||
setStatus("disconnected", "Disconnected");
|
||||
document.getElementById("connectBtn").style.display = "block";
|
||||
document.getElementById("disconnectBtn").style.display = "none";
|
||||
log("Disconnected from LiveKit" + (reason ? ": " + reason : ""), "warn");
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.ParticipantConnected, (participant) => {
|
||||
log("Participant joined: " + participant.identity);
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.ParticipantDisconnected, (participant) => {
|
||||
log("Participant left: " + participant.identity, "warn");
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
log("Track subscribed: " + track.kind + " from " + participant.identity);
|
||||
if (track.kind === LivekitClient.Track.Kind.Audio) {
|
||||
const element = track.attach();
|
||||
document.body.appendChild(element);
|
||||
element.play().then(() => {
|
||||
log("Audio playback started");
|
||||
}).catch((err) => {
|
||||
log("Audio play failed: " + err.message, "error");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.TrackPublished, (publication, participant) => {
|
||||
log("Track published by " + participant.identity + ": " + publication.kind);
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
log("Track unsubscribed: " + track.kind + " from " + participant.identity, "warn");
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.DataReceived, (payload, participant) => {
|
||||
try {
|
||||
const msg = new TextDecoder().decode(payload);
|
||||
log("Data from " + (participant ? participant.identity : "server") + ": " + msg);
|
||||
} catch (e) {
|
||||
log("Data received (binary, " + payload.byteLength + " bytes)");
|
||||
}
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
||||
if (participant.isLocal) {
|
||||
log("Connection quality: " + quality);
|
||||
}
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.MediaDevicesError, (err) => {
|
||||
log("Media device error: " + err.message, "error");
|
||||
});
|
||||
|
||||
room.on(LivekitClient.RoomEvent.RoomMetadataChanged, (metadata) => {
|
||||
log("Room metadata changed: " + metadata);
|
||||
});
|
||||
|
||||
log("Connecting to " + LIVEKIT_URL + "...");
|
||||
await room.connect(data.url || LIVEKIT_URL, data.token);
|
||||
|
||||
log("Enabling microphone...");
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
log("Microphone enabled. Speak now.");
|
||||
|
||||
// Log local tracks
|
||||
room.localParticipant.audioTrackPublications.forEach((pub) => {
|
||||
log("Local audio track: " + pub.trackSid + " muted=" + pub.isMuted);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
setStatus("disconnected", "Error: " + err.message);
|
||||
log("Connection error: " + err.message, "error");
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room) {
|
||||
log("Disconnecting...");
|
||||
await room.disconnect();
|
||||
room = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
document.getElementById("modeDisplay").textContent = "Mode: " + mode;
|
||||
log("Mode changed to: " + mode);
|
||||
if (room && room.localParticipant) {
|
||||
const encoder = new TextEncoder();
|
||||
room.localParticipant.publishData(
|
||||
encoder.encode(JSON.stringify({ type: "mode_change", mode })),
|
||||
{ reliable: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(state, text) {
|
||||
const el = document.getElementById("status");
|
||||
el.className = "status " + state;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", connect);
|
||||
document.getElementById("disconnectBtn").addEventListener("click", disconnect);
|
||||
|
||||
log("VoiceCoach client loaded. Ready to connect.");
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<title>VoiceCoach</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>VoiceCoach</h1>
|
||||
<div id="status" class="status disconnected">Disconnected</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="connectBtn" class="btn btn-primary">Start Session</button>
|
||||
<button id="disconnectBtn" class="btn btn-danger" style="display:none">End Session</button>
|
||||
</div>
|
||||
|
||||
<div id="modeDisplay" class="mode-display">Mode: coaching</div>
|
||||
|
||||
<div id="transcript" class="transcript">
|
||||
<p class="hint">Press "Start Session" to connect your voice.</p>
|
||||
</div>
|
||||
|
||||
<div class="mode-buttons">
|
||||
<button class="mode-btn" onclick="setMode('coaching mode')">Coaching</button>
|
||||
<button class="mode-btn" onclick="setMode('vent mode')">Vent</button>
|
||||
<button class="mode-btn" onclick="setMode('practice mode')">Practice</button>
|
||||
<button class="mode-btn" onclick="setMode('briefing mode')">Briefing</button>
|
||||
</div>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<span>Log</span>
|
||||
<button class="log-clear" onclick="clearLog()">Clear</button>
|
||||
</div>
|
||||
<div id="logOutput" class="log-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://pipecat-agent:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 { font-size: 24px; font-weight: 600; }
|
||||
|
||||
.status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status.disconnected { background: #444; color: #aaa; }
|
||||
.status.connecting { background: #553; color: #ffa; }
|
||||
.status.connected { background: #253; color: #5f5; }
|
||||
|
||||
.controls { margin-bottom: 20px; }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn-primary { background: #4a6cf7; color: #fff; }
|
||||
.btn-danger { background: #f74a4a; color: #fff; }
|
||||
|
||||
.mode-display {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.transcript .hint { color: #666; font-style: italic; }
|
||||
|
||||
.mode-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.mode-btn {
|
||||
padding: 10px;
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.mode-btn:hover { background: #1a2744; border-color: #4a6cf7; }
|
||||
|
||||
.log-panel {
|
||||
margin-top: 16px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: #161b22;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.log-clear {
|
||||
background: none;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-clear:hover { border-color: #666; color: #ccc; }
|
||||
.log-output {
|
||||
padding: 8px 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.log-output div { word-break: break-all; }
|
||||
.log-time { color: #555; margin-right: 6px; }
|
||||
.log-info { color: #8b949e; }
|
||||
.log-warn { color: #d29922; }
|
||||
.log-error { color: #f85149; }
|
||||
@@ -1,22 +0,0 @@
|
||||
services:
|
||||
actual-budget:
|
||||
image: actualbudget/actual-server:latest
|
||||
container_name: actual-budget
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5006:5006"
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/actual-budget:/data
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:5006/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,2 @@
|
||||
COUCHDB_USER=obsidian
|
||||
COUCHDB_PASSWORD=REPLACE_AT_DEPLOY
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
couchdb:
|
||||
image: couchdb:3.4
|
||||
container_name: couchdb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5984:5984"
|
||||
environment:
|
||||
- COUCHDB_USER=${COUCHDB_USER}
|
||||
- COUCHDB_PASSWORD=${COUCHDB_PASSWORD}
|
||||
- TZ=America/Chicago
|
||||
volumes:
|
||||
# LOCAL disk, not NFS: CouchDB's file locking breaks over NFSv3, the same
|
||||
# way it did for guacamole-db (MySQL) and semaphore-db.
|
||||
- /var/lib/couchdb/data:/opt/couchdb/data
|
||||
# NOT :ro -- CouchDB 3.x writes runtime config back into the last-loaded ini.
|
||||
# With a read-only mount it exits 1 immediately and logs NOTHING at all.
|
||||
- /var/lib/couchdb/config/local.ini:/opt/couchdb/etc/local.d/local.ini
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
mem_limit: 1g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:5984/_up"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@@ -0,0 +1,30 @@
|
||||
; CouchDB config for Obsidian LiveSync.
|
||||
; Obsidian talks to CouchDB from an app:// origin, so CORS must allow it explicitly --
|
||||
; a wildcard origin is not permitted alongside credentials.
|
||||
|
||||
[couchdb]
|
||||
single_node = true
|
||||
max_document_size = 50000000
|
||||
|
||||
[chttpd]
|
||||
require_valid_user = true
|
||||
; ...but leave /_up open, so the Prometheus blackbox probe can health-check it
|
||||
; without credentials. Everything else still returns 401 to anonymous callers.
|
||||
require_valid_user_except_for_up = true
|
||||
max_http_request_size = 4294967296
|
||||
bind_address = 0.0.0.0
|
||||
|
||||
[chttpd_auth]
|
||||
require_valid_user = true
|
||||
authentication_redirect = /_utils/session.html
|
||||
|
||||
[httpd]
|
||||
WWW-Authenticate = Basic realm="couchdb"
|
||||
enable_cors = true
|
||||
|
||||
[cors]
|
||||
credentials = true
|
||||
; Obsidian desktop is app://obsidian.md; mobile uses capacitor://localhost.
|
||||
origins = app://obsidian.md,capacitor://localhost,http://localhost
|
||||
methods = GET,PUT,POST,HEAD,DELETE
|
||||
headers = accept,authorization,content-type,origin,referer,x-csrf-token
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.26.1
|
||||
image: gitea/gitea:1.26.4
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
@@ -19,6 +19,9 @@ services:
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/gitea:/data
|
||||
# LOCAL disk for LevelDB queues: stale NFS lock after 2026-07-12 power event
|
||||
# caused a 28h crash loop (7,972 restarts)
|
||||
- /var/lib/gitea-queues:/data/gitea/queues
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
|
||||
@@ -6,24 +6,13 @@ services:
|
||||
ports:
|
||||
- "5678:5678"
|
||||
environment:
|
||||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=n8n-db
|
||||
- DB_POSTGRESDB_PORT=5432
|
||||
- DB_POSTGRESDB_DATABASE=n8n
|
||||
- DB_POSTGRESDB_USER=n8n
|
||||
- DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
|
||||
- N8N_PORT=5678
|
||||
- GENERIC_TIMEZONE=America/Chicago
|
||||
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
|
||||
- N8N_SECURE_COOKIE=false
|
||||
- NODE_OPTIONS=--max-old-space-size=1500
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/n8n:/home/node/.n8n
|
||||
depends_on:
|
||||
n8n-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- n8n-internal
|
||||
mem_limit: 512M
|
||||
memswap_limit: 512M
|
||||
- n8n_data:/home/node/.n8n
|
||||
mem_limit: 2g
|
||||
memswap_limit: 2g
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"]
|
||||
interval: 30s
|
||||
@@ -36,35 +25,6 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
n8n-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: n8n-db
|
||||
restart: unless-stopped
|
||||
user: "1024:100"
|
||||
environment:
|
||||
- POSTGRES_DB=n8n
|
||||
- POSTGRES_USER=n8n
|
||||
- POSTGRES_PASSWORD=${N8N_DB_PASSWORD}
|
||||
volumes:
|
||||
- /mnt/docker-data/databases/n8n-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- n8n-internal
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
mem_limit: 256M
|
||||
memswap_limit: 256M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
n8n-internal:
|
||||
driver: bridge
|
||||
volumes:
|
||||
n8n_data:
|
||||
external: true
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
MATRIX_ACCESS_TOKEN=syt_...
|
||||
@@ -1,38 +0,0 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 python3-pip python3-venv python3-full \
|
||||
git curl jq openssh-client iputils-ping net-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv (Python package manager used by some MCP servers)
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# Install OpenClaw globally
|
||||
RUN npm install -g openclaw@latest
|
||||
|
||||
# Clone NetClaw
|
||||
WORKDIR /opt/netclaw
|
||||
RUN git clone https://github.com/automateyournetwork/netclaw.git .
|
||||
|
||||
# Create openclaw home directory
|
||||
RUN mkdir -p /root/.openclaw/workspace
|
||||
|
||||
# Copy NetClaw workspace config into openclaw home
|
||||
RUN cp -r workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true
|
||||
RUN cp config/openclaw.json /root/.openclaw/openclaw.json 2>/dev/null || true
|
||||
|
||||
# Install Python dependencies for core MCP servers
|
||||
RUN python3 -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir pyats genie netmiko paramiko requests pyyaml jinja2 2>/dev/null || true
|
||||
|
||||
# Create entrypoint script
|
||||
COPY entrypoint.sh /opt/entrypoint.sh
|
||||
RUN chmod +x /opt/entrypoint.sh
|
||||
|
||||
EXPOSE 1179
|
||||
|
||||
ENTRYPOINT ["/opt/entrypoint.sh"]
|
||||
@@ -1,20 +0,0 @@
|
||||
services:
|
||||
netclaw:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: netclaw-aaron
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN}
|
||||
- MATRIX_HOMESERVER=http://192.168.86.192:8008
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/netclaw:/root/.openclaw/data
|
||||
mem_limit: 2G
|
||||
memswap_limit: 2G
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configure OpenClaw environment
|
||||
mkdir -p /root/.openclaw
|
||||
|
||||
# Write .env with API key
|
||||
cat > /root/.openclaw/.env << EOF
|
||||
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
EOF
|
||||
|
||||
# Write openclaw config for gateway mode
|
||||
cat > /root/.openclaw/openclaw.json << 'OCEOF'
|
||||
{
|
||||
"mode": "gateway",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"fallbackModel": "claude-haiku-4-5-20251001",
|
||||
"name": "Aaron",
|
||||
"identity": "Aaron - NetClaw AI Network Engineering Agent"
|
||||
}
|
||||
OCEOF
|
||||
|
||||
# Copy workspace skills if they exist
|
||||
if [ -d /opt/netclaw/workspace/skills ]; then
|
||||
cp -r /opt/netclaw/workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Copy personality/identity docs
|
||||
for doc in SOUL.md AGENTS.md IDENTITY.md USER.md TOOLS.md; do
|
||||
if [ -f /opt/netclaw/$doc ]; then
|
||||
cp /opt/netclaw/$doc /root/.openclaw/ 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Send Matrix DM to Jeremy to announce availability
|
||||
if [ -n "$MATRIX_ACCESS_TOKEN" ] && [ -n "$MATRIX_HOMESERVER" ]; then
|
||||
echo "Sending Matrix DM to Jeremy..."
|
||||
|
||||
# Create a direct message room with Jeremy
|
||||
ROOM_RESPONSE=$(curl -sf -X POST "${MATRIX_HOMESERVER}/_matrix/client/r0/createRoom" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
|
||||
-d "{
|
||||
\"is_direct\": true,
|
||||
\"invite\": [\"@jeremy:hobbs.farm\"],
|
||||
\"preset\": \"trusted_private_chat\"
|
||||
}" 2>/dev/null || echo "")
|
||||
|
||||
ROOM_ID=$(echo "$ROOM_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$ROOM_ID" ]; then
|
||||
# Send the message
|
||||
curl -sf -X PUT "${MATRIX_HOMESERVER}/_matrix/client/r0/rooms/${ROOM_ID}/send/m.room.message/$(date +%s%N)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
|
||||
-d "{
|
||||
\"msgtype\": \"m.text\",
|
||||
\"body\": \"Hey Jeremy - Aaron here. I'm online and ready to help with network engineering tasks. I'm running NetClaw with CCIE-level networking skills. Ask me about device health checks, routing analysis, security audits, or anything network-related.\"
|
||||
}" 2>/dev/null || echo "Failed to send DM"
|
||||
echo "DM sent to @jeremy:hobbs.farm in room $ROOM_ID"
|
||||
else
|
||||
echo "Warning: Could not create DM room with Jeremy. Response: $ROOM_RESPONSE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start OpenClaw gateway
|
||||
echo "Starting NetClaw (Aaron) gateway..."
|
||||
exec openclaw gateway 2>&1 || {
|
||||
echo "OpenClaw gateway failed to start, keeping container alive for debugging..."
|
||||
tail -f /dev/null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.12.1
|
||||
container_name: node-exporter
|
||||
hostname: hf-pdocker-02
|
||||
network_mode: host
|
||||
pid: host
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- "--path.procfs=/host/proc"
|
||||
- "--path.rootfs=/rootfs"
|
||||
- "--path.sysfs=/host/sys"
|
||||
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 50m
|
||||
max-file: "3"
|
||||
labels:
|
||||
- com.centurylinklabs.watchtower.enable=false
|
||||
@@ -53,7 +53,9 @@ services:
|
||||
- MYSQL_USER=semaphore
|
||||
- MYSQL_PASSWORD=${SEMAPHORE_DB_PASSWORD}
|
||||
volumes:
|
||||
- /mnt/docker-data/databases/semaphore-db:/var/lib/mysql
|
||||
# LOCAL disk, not NFS: MySQL on NFS suffers stale-lock and InnoDB corruption
|
||||
# (2026-07-11 corruption, 2026-07-13 stale ibdata1 lock after power event)
|
||||
- /var/lib/semaphore-db:/var/lib/mysql
|
||||
networks:
|
||||
- semaphore-internal
|
||||
labels:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
telegraf:
|
||||
image: telegraf:latest
|
||||
container_name: telegraf
|
||||
hostname: hf-pdocker-02
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HOST_PROC=/host/proc
|
||||
- HOST_SYS=/host/sys
|
||||
- HOST_ETC=/host/etc
|
||||
volumes:
|
||||
- /mnt/docker-data/configs/telegraf/telegraf.conf:/etc/telegraf/telegraf.conf:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /proc:/host/proc:ro
|
||||
- /etc:/host/etc:ro
|
||||
mem_limit: 512M
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
@@ -5,6 +5,8 @@ http:
|
||||
service: yoda
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers-allow-frames
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -13,6 +15,8 @@ http:
|
||||
service: mess
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -21,6 +25,8 @@ http:
|
||||
service: mess
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -29,6 +35,8 @@ http:
|
||||
service: wellknown
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -37,6 +45,8 @@ http:
|
||||
service: solo
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers-allow-frames
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -45,6 +55,8 @@ http:
|
||||
service: vault
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -53,9 +65,41 @@ http:
|
||||
service: git
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- security-headers
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
middlewares:
|
||||
security-headers:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
contentTypeNosniff: true
|
||||
frameDeny: true
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
|
||||
browserXssFilter: true
|
||||
customResponseHeaders:
|
||||
X-Powered-By: ""
|
||||
Server: ""
|
||||
|
||||
security-headers-allow-frames:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
contentTypeNosniff: true
|
||||
frameDeny: false
|
||||
customFrameOptionsValue: "SAMEORIGIN"
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
|
||||
browserXssFilter: true
|
||||
customResponseHeaders:
|
||||
X-Powered-By: ""
|
||||
Server: ""
|
||||
|
||||
services:
|
||||
yoda:
|
||||
loadBalancer:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Decommissioned 2026-10-05 and again 2026-10-26 (it had been revived and was running).
|
||||
Duplicate controller; the real UniFi controller is the Podman UniFi OS Server on .150.
|
||||
Do NOT compose up. Data kept in /var/lib/unifi-os (.100) + /mnt/docker-data/configs/unifi-os/persistent.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user