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.
Project layout
Section titled “Project layout”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_SECRETcrates/agent Linux/macOS/FreeBSD/Windows agent; install/ holds install.sh/.ps1 served by the serverweb/ SvelteKit (Svelte 5 runes, Tailwind 4, uPlot, Leaflet), adapter-static SPA fallback, ssr=falseprofiles/ SNMP collection profiles, auto-applied by sysObjectIDdocs/ this documentation (MkDocs, published on Read the Docs)Building without a Rust toolchain
Section titled “Building without a Rust toolchain”Compilation happens in Docker; nothing Rust needs to be installed on the host.
# Full stack (builds the image: about 10 min cold) — UI on https://localhost:8080docker 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 testdocker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo test -p dumbmonit-server --test alerts_api # one integration test filedocker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo test -p dumbmonit-server test_name # one test by namedocker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo clippy --all-targets --all-features -- -D warningsdocker run --rm -v "$PWD:/build" -w /build dumbmonit-devenv cargo fmt --all --checkWith 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.
Web UI
Section titled “Web UI”Node 22 is enough on the host.
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 buildConventions: 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.
Translations
Section titled “Translations”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).
- Edit
web/messages/<locale>.json(fr,de,es,it,pt,pt-BR,ru,zh-Hans), or copyen.jsonto add a new locale. - Keep every key and every
{variable}ofen.json; translate only the values. Never put HTML markup in a message. - Plurals use the message syntax, not hand-built strings: keep the
match/pluralstructure of the English message and translate each branch. - Run
cd web && npm run i18n:check: it fails when a locale lacks a key or changes its{variables}. - 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.
Tests and CI
Section titled “Tests and CI”.github/workflows/ci.yml runs, on every push and pull request:
cargo fmt --all --check,cargo clippy --all-targets --all-features -- -D warnings,cargo test --all-features;cargo deny check licenses bans— only OSI-approved licences are allowed bydeny.toml; a copyleft or source-available dependency fails the build;npm run checkandnpm run build;- 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.
Adding a collector
Section titled “Adding a collector”- Implement
Collectorfromcrates/proto/src/collector.rs:kind(),probe(&Target) -> Result<Vec<Sample>, ProbeError>and optionallydiscover()for profile detection. ReturnProbeErrorvariants truthfully:means_down()decides whether a failure counts as “monitor down” (alerted) or as a configuration error (shown on the monitor, not notified). - Read your options from
Target::tagsin anoptions.rs, with defaults. - Register it in
crates/server/src/main.rswithregistry.register(...). The scheduler and the API never know concrete types. - Describe it in
crates/server/src/api/collectors.rs: label, summary, examples, credential types, address hint, setup notice and theoptionslist (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. - Name metrics
<what>_<unit>without thedumbmonit_prefix (the writer adds it) and add any new metric a built-in rule targets to the list inalerting/rules.rstests. - 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_*.
Adding a notification channel
Section titled “Adding a notification channel”- Implement
Notifier(crates/server/src/notify/mod.rs):kind()andasync fn send(&Message) -> Result<(), NotifyError>. Web channels share the HTTP client and helpers innotify/http.rs; look atservices.rs,chat.rs,push.rs,oncall.rsandsmtp.rsfor siblings. - Add the kind to
CHANNEL_KINDSand to thebuildmatch innotify/mod.rs, which turns a storedChannelConfig(settings + decrypted secrets) into aNotifier. - Describe it in
notify/catalog.rs: label, summary,doc_urlanchor, and thesettingsandsecretsfields (key, label, required, input, help, placeholder, options, shape, default). The UI renders the form from this description, so no UI release is needed. - Document it in
docs/notifications.mdunder Supported channels, with a heading whose slug matches thedoc_urlanchor: the UI links straight to that anchor on Read the Docs.
Headless screenshots
Section titled “Headless screenshots”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.
Documentation
Section titled “Documentation”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:
pip install -r docs/requirements.txtmkdocs servemkdocs build --strict # what Read the Docs runs; must pass with no warningsEach 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).