Skip to content

Development

DumbMonit is one Rust binary (crates/server, which embeds the SvelteKit build) plus VictoriaMetrics for time series, started by the server from the binary shipped in the image, and an embedded SQLite database for configuration and state. One container. Rust code and comments are in French; the web UI is in English.

crates/proto shared types: Sample, Target, Credential, trait Collector (+ ProbeError)
crates/server the binary
api/ axum routes (mod.rs is the route table); spa.rs serves web/build
auth/ single instance password, HttpOnly session cookie, rate limit
collectors/ snmp (profiles/*.yaml loaded at startup), proxmox, pbs, synology, agent, uptime
scheduler.rs runs every enabled target on its interval through the collector registry
tsdb/ VictoriaMetrics writer (batched flush) + query proxy; embedded.rs supervises the child VictoriaMetrics
db/ SQLite + numbered migrations in db/migrations/
alerting/ rules, state machine (Phase/EffectivePhase), suppression by parent, silences, seasonal baseline
notify/ 22 notification channels, described to the UI by notify/catalog.rs
crypto.rs AES-256-GCM for credentials/tokens, key derived from /data/secret.key or DUMBMONIT_SECRET
crates/agent Linux/macOS/FreeBSD/Windows agent; install/ holds install.sh/.ps1 served by the server
web/ SvelteKit (Svelte 5 runes, Tailwind 4, uPlot, Leaflet), adapter-static SPA fallback, ssr=false
profiles/ SNMP collection profiles, auto-applied by sysObjectID
docs/ this documentation (MkDocs, published on Read the Docs)

Compilation happens in Docker; nothing Rust needs to be installed on the host.

Terminal window
# Full stack (builds the image: about 10 min cold) — UI on https://localhost:8080
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
# Rust, inside the builder stage (musl/alpine)
docker build -t dumbmonit-devenv --target builder .
docker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo test
docker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo test -p dumbmonit-server --test alerts_api # one integration test file
docker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo test -p dumbmonit-server test_name # one test by name
docker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo clippy --all-targets --all-features -- -D warnings
docker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo fmt --all --check

With Rust installed locally, cargo test and cargo clippy -- -D warnings work directly. Edition 2024, MSRV 1.95; rustfmt.toml sets max_width = 100 and use_small_heuristics = "Max".

The Docker build is layered: web build → Rust dependencies on dummy sources → real sources (with a touch before the second cargo build, otherwise cargo reuses the dummy artifacts) → agent cross-compiled with cargo-zigbuild for x86_64-unknown-linux-musl, aarch64-unknown-linux-musl and x86_64-pc-windows-gnu → FROM scratch. web/build must exist before cargo build, or the server ships without a UI.

Node 22 is enough on the host.

Terminal window
cd web && npm run dev # Vite on :5173, proxies /api to localhost:8080 (the Docker server)
cd web && npm run check # svelte-check (CI runs this)
cd web && npm run build # static build into web/build, embedded by rust_embed at cargo build

Conventions: Svelte 5 runes only ($props, $state, $derived, $effect, snippets, onclick). All network access goes through src/lib/api/; components never fetch. Types in api/types.ts mirror the Rust structs exactly and must be kept in sync with crates/server/src/api/*.rs. The design system lives in src/app.css (tokens) and src/lib/ui/ (primitives). Never run two vite builds at once: they wipe .svelte-kit/output.

The web UI is translated by pull request, like any other contribution. The source language is English (web/messages/en.json: flat keys, {param} placeholders); each other language is one file, web/messages/<locale>.json, compiled by Paraglide JS into tree-shaken message functions under web/src/lib/paraglide/ (generated, untracked).

  1. Edit web/messages/<locale>.json (fr, de, es, it, pt, pt-BR, ru, zh-Hans), or copy en.json to add a new locale.
  2. Keep every key and every {variable} of en.json; translate only the values. Never put HTML markup in a message.
  3. Plurals use the message syntax, not hand-built strings: keep the match/plural structure of the English message and translate each branch.
  4. Run cd web && npm run i18n:check: it fails when a locale lacks a key or changes its {variables}.
  5. Open a pull request.

New UI strings are added to web/messages/en.json first (that is the only file a feature pull request needs; npm run i18n:merge merges transient fragments). A locale appears in the language picker (Settings, Appearance) once its file exists. See CONTRIBUTING.md for the key naming convention.

web/project.inlang/settings.json points its one plugin (message-format) at ./node_modules/@inlang/plugin-message-format/dist/index.js rather than the jsdelivr URL inlang’s own tooling defaults to: a relative, non-http path is read straight from disk, so npm run build never reaches out to a CDN — important since the image build has no guarantee of a warm npm cache offering that URL, and a homelab tool that needs the internet to rebuild itself is a bad look.

.github/workflows/ci.yml runs, on every push and pull request:

  1. cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings, cargo test --all-features;
  2. cargo deny check licenses bans — only OSI-approved licences are allowed by deny.toml; a copyleft or source-available dependency fails the build;
  3. npm run check and npm run build;
  4. the image (linux/amd64), build only.

.github/workflows/release.yml pushes the same image to ghcr.io/laupernoe/dumbmonit: :edge on every push to main, :X.Y.Z, :X.Y, :X and :latest on every v* tag.

Integration tests live in crates/server/tests/; pure modules (metric conversion, option parsing, alert state machine, silences) are tested in place without network.

  1. Implement Collector from crates/proto/src/collector.rs: kind(), probe(&Target) -> Result<Vec<Sample>, ProbeError> and optionally discover() for profile detection. Return ProbeError variants truthfully: means_down() decides whether a failure counts as “monitor down” (alerted) or as a configuration error (shown on the monitor, not notified).
  2. Read your options from Target::tags in an options.rs, with defaults.
  3. Register it in crates/server/src/main.rs with registry.register(...). The scheduler and the API never know concrete types.
  4. Describe it in crates/server/src/api/collectors.rs: label, summary, examples, credential types, address hint, setup notice and the options list (key, label, help, placeholder, default, input, choices). The UI builds its form from this; a test there checks every registered kind has a notice and that the option keys match what the collector reads.
  5. Name metrics <what>_<unit> without the dumbmonit_ prefix (the writer adds it) and add any new metric a built-in rule targets to the list in alerting/rules.rs tests.
  6. Add a page under docs/devices/.

Samples are Sample::new(name, value, MetricKind::Gauge | Counter, timestamp_ms) with .with_label(k, v); identity labels (target, host, tag_*) are added by the registry. Do not emit them yourself: name a per-object label after the object (proxy_host, esxi, ceph_host). A colliding label is kept as exported_<name>, and the options listed for the kind in api/collectors.rs are not copied as tag_*.

  1. Implement Notifier (crates/server/src/notify/mod.rs): kind() and async fn send(&Message) -> Result<(), NotifyError>. Web channels share the HTTP client and helpers in notify/http.rs; look at services.rs, chat.rs, push.rs, oncall.rs and smtp.rs for siblings.
  2. Add the kind to CHANNEL_KINDS and to the build match in notify/mod.rs, which turns a stored ChannelConfig (settings + decrypted secrets) into a Notifier.
  3. Describe it in notify/catalog.rs: label, summary, doc_url anchor, and the settings and secrets fields (key, label, required, input, help, placeholder, options, shape, default). The UI renders the form from this description, so no UI release is needed.
  4. Document it in docs/notifications.md under Supported channels, with a heading whose slug matches the doc_url anchor: the UI links straight to that anchor on Read the Docs.

web/tools/README.md describes how to render every page in a headless Chromium running in Docker (zenika/alpine-chrome:with-puppeteer, no local browser needed): log in with curl to get the dumbmonit_session cookie, then run web/tools/screenshot.js against the preview server or directly against :8080. It writes web/shots/<page>-<theme>-<viewport>.png for both themes and both viewports, which is how the UI is checked after a change and how the screenshots of this documentation were made.

This site is built with MkDocs and Material for MkDocs from docs/ (mkdocs.yml at the root, .readthedocs.yaml for Read the Docs). To preview:

Terminal window
pip install -r docs/requirements.txt
mkdocs serve
mkdocs build --strict # what Read the Docs runs; must pass with no warnings

Each language is a separate Read the Docs project (dumbmonit for English, then dumbmonit-fr, dumbmonit-de, … linked as translations), all built from the same mkdocs.yml. hooks/rtd_language.py reads READTHEDOCS_LANGUAGE and builds that one language at the root of the site: the page.<lang>.md files replace the English pages, a page without translation stays in English with a notice, and the navigation titles come from hooks/nav_translations.yml. Locally the variable is unset and the build is English; to preview another language: READTHEDOCS_LANGUAGE=fr mkdocs serve (de, es, it, pt, pt-br, ru, zh-cn).