No description
  • TypeScript 98.6%
  • Svelte 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Julian Imhof 0f816ff971
All checks were successful
Verify / verify (push) Successful in 43s
fix: stop the repository page restating the numbers its usage bar carries
Same duplication as the overview's, one level deeper: the Size block
listed storage used, free and total as grid values with the "Remote
storage" bar directly beneath showing the same figures. The bar stays —
one repository is one filesystem, so it means something there — and the
restated values go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 21:26:35 +02:00
.forgejo/workflows Initial commit 2026-09-02 15:37:47 +02:00
backend fix: stop the repository page restating the numbers its usage bar carries 2026-09-02 21:26:35 +02:00
frontend feat: monitor and manage borgmatic-driven borg backups 2026-09-02 16:33:14 +02:00
CLAUDE.md feat: edit the borgmatic configuration from the UI, and show sources as a tree 2026-09-02 18:38:45 +02:00
opsdeck.module.json feat: monitor and manage borgmatic-driven borg backups 2026-09-02 16:33:14 +02:00
README.md feat: the tree owns every literal exclude; the form keeps only wildcards 2026-09-02 20:27:31 +02:00
tsconfig.check.json Initial commit 2026-09-02 15:37:47 +02:00

module_borg

Monitor and manage borg backups driven by borgmatic, from OpsDeck — on the web and on the phone.

It is a client of the host's existing setup, not a second way to configure one. borg and borgmatic must already be installed and configured on the host; this module reads that configuration, reports on the repositories it names, and runs borgmatic's own actions against them. The named fields it shows — retention, lock_wait, the sources and the excludes — it can also edit from the configuration page, through borgmatic's own toolchain and only after borgmatic's own validator accepts the result (see "Editing the configuration" below). Everything else in the file it never touches, and it never invents defaults for anything.

Overview        state, last backup, remote capacity, per-repository verdicts
Repository      size, encryption, archives with per-archive statistics
Archive         when, how long, how many files, what it added to the repository
Configuration   the sources as a browsable tree of the host's filesystem,
                where clicking a marker changes what is backed up; forms for
                excludes, retention and lock_wait; repositories, checks and
                database hooks read-only beside them
Backup log      the backup unit's journal

Every page is the portable UI schema, so the Android app renders exactly what the web does. The only custom Svelte in here is the dashboard widget, which is the one thing the schema cannot express.

What makes this different from running borgmatic rinfo in a terminal

A borg repository takes an exclusive lock for the whole of a create. On a real host that is most of the night, and during a first upload it is most of the week. Every read borg offers — info, list, rinfo — wants a shared lock it cannot have meanwhile, waits --lock-wait seconds and exits 2:

Failed to create/acquire the lock /home/borg/lock.exclusive (timeout).

A dashboard that asked the repository a question whenever a page was painted would therefore show an error all night — and would make things worse doing it, because a read holding the shared lock is a read that can make the scheduled backup fail to take the exclusive one.

So this module:

  • never reads the repository to render a page. Readings happen on a schedule, are stored through ctx.storage (so a restart keeps them), and every page shows the age of what it is showing. An old number labelled old is information; an error page is not.
  • knows a backup is running by looking at the host, not the repository. A /proc scan every 30 seconds finds borg and borgmatic processes, including the ones OpsDeck did not start — the nightly systemd timer is the normal case, not the exception. Reads are skipped while one is running.
  • keeps measuring the thing that still answers. df on the far side never opens the repository, so remote capacity keeps updating throughout a backup — and is polled more often while one runs, because watching the far side fill is the only live progress an externally started backup offers at all.
  • passes a short --lock-wait on every read (--override lock_wait=…, 5 seconds by default) so nothing ever piles up blocked on a lock.
  • tells a stale lock from a busy one. A lock nobody on the host is holding is borg having been killed, and only then does the repository page offer to break it.

Consider setting lock_wait: 60 in your borgmatic configuration. Without it a scheduled backup that starts while this module happens to be reading gets borg's one-second default and fails on the lock; with it, it waits the reading out. The configuration page says so when the option is unset.

Watching the remote storage fill up

borg knows how much space its data occupies. It does not know how much space it is allowed, and only the second number says whether tonight's backup will fit. Capacity therefore comes from df on whichever side the repository lives:

Repository How capacity is measured
ssh://[user@]host[:port]/path ssh … df -P -k <path> on the far side
[user@]host:path the same, scp-style
/local/path df -P -k <path> on the host

ssh://box/./borg addresses the remote account's home directory and ssh://box/borg addresses the filesystem root; the two are kept apart. A ssh_command in the borgmatic configuration is used as given, so a custom identity or port keeps working. A restricted remote shell that refuses an argument to df gets a second, bare attempt.

Two alerts come out of it, both deduplicated per repository and both linking to the repository page:

  • a threshold: warning at 80% full, error at 92% (WARN_PCT, CRIT_PCT).
  • a horizon: a least-squares fit over the module's own 14 days of repo.used_bytes history, which warns when it puts the filesystem full within 14 days (FORECAST_DAYS) — before any threshold fires. A fit rather than a first-to-last slope because a repository steps up on every backup and falls on every prune, so two samples can land either side of a pruning cycle and produce a slope of either sign. It answers "—" rather than a number whenever the history cannot support one: a made-up horizon is worse than none, because nobody can tell it from a real one.

If the far side has no usable df at all, set QUOTA_BYTES and the module measures borg's own deduplicated size against it instead.

The other alerts are a backup that has not happened (MAX_AGE, 26h by default), readings that have gone stale, and a failed backup, prune or check. Every verdict carries the sentence that justifies it, shown on hover over the status dot — a colour whose reason nobody can read is a colour people learn to ignore.

Managing

Action Runs
Back up now the host's backup unit if there is one, else borgmatic create
Prune and compact borgmatic prune --stats, then borgmatic compact
Check repository borgmatic check --only repository --force
Check archives borgmatic check --only archives --force
Break the stale lock borgmatic break-lock, only when nothing is running
Refresh / Check capacity the readings, on demand

All of them are host-owned task runs: they appear in Activity with steps and progress, survive a page reload, and are serialised behind one lock, because one repository can only take one operation at a time.

"Back up now" prefers the host's systemd unit (borgmatic.service by default) where there is one and the container shares the host PID namespace. A borgmatic started as OpsDeck's own child is a process inside the container: an update or a restart kills it, and a first upload measured in days would never survive one. Started through systemd it runs in a host cgroup, in the same journal as the nightly run, and outlives anything that happens to OpsDeck. Set BACKUP_VIA_UNIT=never to always run it directly.

For the same reason there is no backup schedule by default. Leave the scheduling to the host's timer. SCHEDULE exists for hosts without one, and sets up an expectation alert alongside it.

Editing the configuration

The configuration page carries forms for the wildcard excludes, the keep_* retention rules and lock_wait, editing the borgmatic configuration file directly — on a host with several configuration files it shows a file list instead, with one edit page per file, because "which file gets this rule" is then a real question. Paths have exactly one control: the sources tree owns source_directories and every literal exclude, and the wildcard form holds only what a tree click cannot express — wildcard patterns and the sh:/re: styles. The server keeps the two domains apart, so a form submit can never delete a tree decision and neither control shows the other's entries. Nothing else in the file is editable, deliberately — repositories in particular stay read-only, because adding one is borg init and key management, not a form field.

The edit never happens in this process. A borgmatic configuration may carry encryption_passphrase inline, so the module still never holds the file's text: the change runs on the host, inside the interpreter borgmatic's own shebang names, using ruamel.yaml in round-trip mode — the YAML library borgmatic itself depends on — so comments, key order, quoting and !include tags survive the edit. The edited document is written beside the original, borgmatic config validate gets the last word on it, and only a configuration borgmatic accepts replaces the one it accepted before — by an atomic rename, with the previous version kept as <file>.opsdeck-bak. A rejected edit reports borgmatic's own sentence against the form and changes nothing.

The sources, as a tree you can decide in

The configuration page renders the host's filesystem as a lazy, collapsible tree, rooted at /, read through the host-root mount one directory at a time — plain listings, no borg and no lock, so it is safe to browse in the middle of a backup. Only what is on screen is ever fetched; opening a folder fetches its children then.

Every entry carries a marker: backed up, explicitly excluded (with the pattern that did it on hover), mixed — the folder holds both fates — and an empty box for paths outside every source. Mixed folders open themselves, so the tree unrolls exactly down to the deepest folder where a decision still differs and no further; everything uniform starts collapsed.

The markers are also the input. Clicking one cycles the path: outside the backup → a new source_directories entry; backed up → a new exclude_patterns entry (or, when the path is a source directory, the source is removed); excluded by an exact pattern → the pattern is lifted. Every click is the same guarded edit the forms make — validated by borgmatic, backed up as .opsdeck-bak, replaced atomically. Clicks that have no exact meaning are refused with the reason instead of guessed at: a path excluded by a wildcard pattern says which pattern to go edit, a mixed folder says to decide inside it.

Marker verdicts come from this module's implementation of borg's pattern styles (fm unprefixed, sh:, re:, pp:, pf:) — a preview by the same published rules; borg on the host remains the authority. One honest limit: a wildcard pattern marks the entries it matches when they are listed, but cannot mark a closed folder as mixed from the outside — only literal-path patterns roll up. A directory of more than SOURCES_ENTRIES entries is capped with a "… n more" row.

Two things this deliberately does not do:

  • Delete individual archives. Retention is borgmatic's keep_* policy and prune applies it. borgmatic gained a first-class delete action only in 1.9, so a button here would be a version-dependent irreversible operation — the wrong combination to ship.
  • Run the data consistency check. It re-reads every chunk back from the remote, which is a full download of the repository. borgmatic's own checks frequency schedules that; a button would invite someone to start a multi-terabyte transfer by accident.

Requirements

  • borg and borgmatic installed on the host (tested against borg 1.2.8 and borgmatic 1.8.3) with a configuration borgmatic config validate accepts.
  • OpsDeck able to run commands on the host (ctx.host — normally a chroot into the host-root mount). Without it the module still loads and its pages say exactly which half is missing.
  • The host PID namespace (pid: host on the container) only for the two systemd-flavoured features: the next scheduled run, and starting the backup unit. Everything else works without it; the journal is read from files.

Nothing here needs a YAML parser, because the machine that has borgmatic has borgmatic's own configuration loader — the module asks it for JSON through the interpreter borgmatic's shebang names, so a pipx or virtualenv install works too. It reads no configuration file's text: a borgmatic configuration may carry encryption_passphrase inline, and redaction over a format with block scalars and anchors is a denylist over a tarpit. Only named fields are read, and a secret is never one of them. Edits follow the same rule from the other side: they run in that same interpreter with ruamel.yaml, and only a receipt naming the changed keys ever comes back.

Configuration

All optional, all OPSDECK_MOD_BORG_*:

Variable Default What it does
CONFIG borgmatic's own defaults comma-separated config paths (-c)
HOME /root HOME the tools run with — where ~/.ssh/config and ~/.cache/borg are found
PATH the usual system path PATH the tools run with
ENV extra KEY=VALUE pairs, comma-separated
BORGMATIC_BIN / BORG_BIN / SSH_BIN borgmatic / borg / ssh binaries, if they are not on PATH
LOCK_WAIT 5 seconds a reading waits for the repository lock
ARCHIVES 20 archives listed per repository
WARN_PCT / CRIT_PCT 80 / 92 remote capacity thresholds
FORECAST_DAYS 14 warn when the projection fills the disk within this many days
MAX_AGE 26h how old the newest archive may be
SOURCES_ENTRIES 250 entries listed per directory in the sources tree
STALE_AFTER 24h how old a reading may be before its age is a warning
QUOTA_BYTES remote total to use when df is unavailable
REFRESH_EVERY / POLL_EVERY 6h / 1h repository reads / capacity checks
UNIT borgmatic.service the systemd unit that runs backups
BACKUP_VIA_UNIT auto auto, always or never
SCHEDULE cron for OpsDeck-driven backups; off by default
JOURNAL_LINES 200 lines on the log page
BACKUP_TIMEOUT / PRUNE_TIMEOUT / CHECK_TIMEOUT / REFRESH_TIMEOUT 24h / 6h / 12h / 10m command deadlines

Metrics

repo.used_bytes, repo.free_bytes, repo.usage_pct, repo.dedup_bytes, repo.original_bytes, repo.archives and backup.age_sec, each tagged with the repository id.

Installing

OPSDECK_EXTERNAL_MODULES=https://git.imhof.cloud/OpsDeck/module_borg.git#<sha>

Pin the SHA: a module runs with the server's privileges.

Checks

.forgejo/workflows/ci.yml runs on every push and pull request. Two steps need nothing but this repository, and they are the two to run while working:

deno fmt --check .   # `deno fmt .` fixes it; the `.` is required, since this
deno lint            # repo has no deno.json and fmt then refuses to write

The rest need a core checkout — the SDK types, the Svelte runtime, the component library and the module builder all live there:

cp -r . <core>/packages/modules/borg
cd <core>
deno install --frozen
deno check $(find packages/modules/borg -name '*.ts')
deno run -A npm:svelte-check@^4 --workspace packages/modules/borg \
  --tsconfig ./tsconfig.check.json --threshold error
deno test --allow-read --allow-write --allow-env packages/modules/borg
deno run -A packages/module-builder/main.ts \
  packages/modules/borg /tmp/borg-dist borg

None of those runs register(), and neither does deno check — a module that typechecks can still be disabled at load by something core only decides then (an action that declares both console and a target, an invalid UI schema). The cheapest way to see that is to start core with the module:

OPSDECK_AUTH=disabled OPSDECK_MODULES=borg deno task start
# then look for "module loaded" — or "module disabled" — in the log

The parser tests are built from output recorded on a real host, including the lock failure captured while a borg create was three hours into an upload. That message is what the whole "usable during a backup" design turns on, so it is a fixture rather than a guess.