An out-of-band watchdog and control panel for an Odoo deployment running on PostgreSQL. It reads the database directly, alerts when something is wrong, and gives you a small web UI to see and act on it.
One static Go binary. No cgo, no npm, no external services.
Odoo enforces much of its logic in Python: write() hooks, ORM constraints, validation. None of it runs when another system writes to the same tables with raw SQL. The check exists, it just never fires.
RIO runs those checks from outside, against the database itself, so it does not care how a row got written.
The same reasoning covers everything else here. A scheduled job that quietly stops, a table that bloats, a process that dies: none of them are visible from inside the application they affect.
Record anomalies. Odoo sends a POST /watch when a record is created. RIO holds that id on a watch list and reads it back a few minutes later, so it sees the row as it ended up rather than as Odoo intended it. Self-referencing external ids and statuses outside the accepted set raise an alert, and self-references are repaired.
Stuck crons. Odoo pushes ir_cron.nextcall forward every time a job actually runs, so a nextcall stuck in the past and falling further behind means the job is not running. The threshold is relative to each cron's own interval, so a five-minute job and a daily job are judged fairly.
Monitor kinds are compiled in, since the checking logic has to exist in code. What the UI creates is an instance: one kind bound to one database, with its own schedule and its own alert routing.
flowchart LR
O["Odoo"] -->|"POST /watch"| R["RIO"]
R -->|"read only"| PG[("Source PostgreSQL")]
R --> SQ[("Embedded SQLite<br/>state and config")]
R --> DC["Discord"]
R --> EM["Email"]
R -->|"heartbeat while healthy"| EX["External monitor"]
UI["Web UI"] --> R
The watch cycle is push to register, poll to verify:
sequenceDiagram
participant O as Odoo
participant R as RIO
participant P as PostgreSQL
participant A as Discord and email
O->>R: POST /watch with record id
R->>R: add to watch list
loop every poll interval
R->>P: one batched read for all watched ids
P-->>R: current rows
alt anomaly found
R->>A: one coalesced alert per tick
R->>R: mark resolved, drop from list
else clean
R->>R: keep until TTL expires
end
end
RIO only ever checks ids it was told about, so there is no table scan, and the read stays a single WHERE id = ANY(...) regardless of how large the watch list gets.
DB Insights. Read-only panels over pg_stat_statements, index usage, table bloat, and relation sizes. The query panel needs the extension installed; the index, bloat, and size panels work without it.
Maintenance. VACUUM (ANALYZE) and ANALYZE run as tracked background jobs with honest progress, a stop button, an audit trail, and an optional Discord ping on completion. It replaces logging into the server and leaving a client open on a laptop. Only non-blocking work is included: plain VACUUM never takes more than a SHARE UPDATE EXCLUSIVE lock. The locking operations, VACUUM FULL and REINDEX, are deliberately absent until there is a maintenance-window gate to fence them.
Dead-man switch. RIO cannot alert about its own death, because a dead process sends nothing. Instead it emits a heartbeat to an external receiver (healthchecks.io, Uptime Kuma, any cron monitor) while it is healthy. If RIO dies, hangs, reboots, or loses the network, the heartbeats stop and that receiver raises the alarm. The heartbeat fires only when the background loops are genuinely running, never merely because the process is alive.
Config sync. Email lists and Discord destinations are maintained on the Odoo side, so RIO mirrors them rather than asking anyone to retype them. It is a daily copy plus a manual Sync button, not a live read: alerts resolve against RIO's own tables, so a source database being down never stops an alert going out.
RIO owns all of its state in an embedded SQLite database: watch list, anomaly log, task log, and every piece of alert config. Nothing is written back to Odoo.
Access to the source database is read-only, with two deliberate exceptions: repairing a self-reference, and running maintenance.
A task binds a database, an accepted-status set, a Discord destination, and an email list, and carries its own poll interval and watch window. Multiple tasks can watch different databases.
Requires Go 1.26 or newer. Templates, static assets, and schema are embedded in the binary.
cp .env.example .env # fill in WATCH_API_KEY, SESSION_SECRET, STATUS_AUTH_USERS
# DATABASE_URL is optional, you can add the DB in the UI
make up # builds first if ./rio is missing, make down to stop
make logs # follow the log, make status to checkThen open http://localhost:8443 and log in.
All through .env. The full list lives in .env.example; these are the ones that matter most.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Optional Postgres DSN for the source database. Leave empty and add it in Settings, or set it to seed the first database on first run |
WATCH_API_KEY |
Shared key Odoo sends in X-Api-Key on POST /watch |
SESSION_SECRET |
HMAC key signing the login session cookie |
STATUS_AUTH_USERS |
user:password pairs for the web login |
POLL_INTERVAL |
Default interval between watch-list reads |
WATCH_LIST_EXPIRY |
How long an id stays watched before it is dropped |
HEARTBEAT_URL |
External receiver for the dead-man switch. Unset disables it |
EMAIL_PARAM_TABLE |
Odoo table backing the email lists, since the model is named per installation |
TLS_CERT_FILE / TLS_KEY_FILE |
Both or neither. Serves HTTPS when set, plain HTTP otherwise |
Plain HTTP is the default and is meant for a trusted internal network. Over HTTP the login password and session cookie travel unencrypted.
staging_customer.create() calls a notify helper that POSTs to /watch after commit, off the request path. It is a no-op until an api.security.auth record named api_rio_watch (URL plus X-Api-Key) is configured, so it is safe to deploy before RIO is running.
*.goservice code:main,server,poll,alert,sync,store*,auth,monitor,cron,insights*,maintenance*,healthschema.sqlembedded SQLite schemaweb/templates,web/staticserver-rendered UIdeploy/rio.servicesystemd unit
MIT. See LICENSE.
