Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RIO

RIO

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.

Why out of band

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.

What it watches

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.

Architecture

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
Loading

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
Loading

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.

Beyond watching

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.

State

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.

Run

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 check

Then open http://localhost:8443 and log in.

Configuration

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.

Odoo side

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.

Layout

  • *.go service code: main, server, poll, alert, sync, store*, auth, monitor, cron, insights*, maintenance*, health
  • schema.sql embedded SQLite schema
  • web/templates, web/static server-rendered UI
  • deploy/rio.service systemd unit

License

MIT. See LICENSE.

About

Out-of-band watchdog for an Odoo/Postgres deployment: read-only checks for staging-record corruption and stopped crons, with its own alerting. Single static Go binary, embedded SQLite, no CDN.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages