Add run_route(), the single entry point that dispatches a route by kind, plus
the three cycles the backup route was missing.
Sync (PBS -> PBS): sync_direction decides which box executes -- on pull the
target fetches, on push the source sends -- so the job always lives on the
working side with its remote pointing at the other box, and both directions are
one code path with the pair swapped. The remote and the sync job are named
joulenap-<route_id> and are rebuilt on every run rather than patched, since a
pull and a push job of the same id live in different PBS config sections. GC and
verify_after then run on the target, the box that gained the snapshots.
External re-homes the 0.9 monitor cycle onto a route: watch_external_tasks now
takes the two timeouts instead of the whole config, so a route reads them from
the PBS device (how slow a box is belongs to the box) while the 0.9 cycle keeps
passing its global ones. Step detail and log wording are unchanged so history
reads the same. It starts no task of its own, whatever the route options say.
Verify runs the target's verify task with reverify_days as the window.
The last-backup cache can now be claimed wholesale by a source that chose no
guests, which is all an external route can say -- and only when exactly one PVE
maps that PBS, since a foreign snapshot carries no hint of who wrote it.
One run now executes one backup route: N source PVEs (each possibly a
cluster) onto one PBS target. Wake and power-off stay with the power
lease, so the cycle starts with the box awake and never touches its
power.
- PveClient lists guests cluster-wide via /cluster/resources (identical
on a standalone node), tags each with its node and drops templates;
vzdump takes a node, and the task endpoints read theirs from the UPID
so one client can drive several nodes.
- Sources are isolated: a broken one leaves its backup:<pve-id> step
failed and the run continues, finishing failed and naming it. GC and
verify still run - the box is awake and the other snapshots are real.
- The guest tally aggregates across sources; failed guest names keep
working.
- Cache writes carry real ids: datastore_stats keyed by the target,
guest_backups attributing each vmid to the PVE that backed it up.
- CycleDeps gains device-shaped connect_pve/connect_pbs beside the
config-shaped pair, and RunRecorder.step takes a label.
The 0.9 cycle and the flat pve/pbs/backup config sections stay for now:
their other consumers (the GC/verify/monitor cycles, the scheduler, the
API routers) are ported in the next milestones and delete both halves
together.
Per-route schedules mean two routes can target the same PBS minutes apart,
so no single cycle can decide when the box goes back to sleep.
Add a FIFO run queue to JobService (enqueue/pending/current/dequeue, backed
by a drain worker) and a PowerLease that refcounts each PBS: the first holder
wakes it or finds it awake, the last one powers it off, and only when the run
succeeded, the device manages its power, and no queued route still needs it.
Sync routes hold two leases, released independently. An unmanaged PBS is
probed, never woken, never powered off.
The queue sits beside the existing single-run lock rather than replacing it:
the worker takes the same lock, so queued runs and the 0.9 entry points still
serialise against each other while the cycle, scheduler and API are ported.
create_all creates missing tables and never touches one that already exists,
so a column added to a model appeared on a fresh install and was simply absent
on every upgraded one. db/upgrade.py closes that gap without Alembic: it runs
from init_db before create_all, diffs PRAGMA table_info against the model
metadata and emits ALTER TABLE ... ADD COLUMN. Being metadata-driven rather
than a list of DDL, a later change only has to add the column to its model.
runs gains route_id and route_name, both nullable - a run recorded before 1.0
has no route, and a manual one-off need not belong to one. route_name is
denormalised so history still reads correctly after its route is deleted.
guest_backups and datastore_stats gain the device dimensions their keys were
missing: a vmid is only unique within one PVE and can be backed up to several
PBSs, and two PBSs may each have a datastore called "backup". That changes
their primary keys, which SQLite cannot ALTER, so the upgrade drops and
recreates them. Both are caches the backup cycle re-upserts whenever it has
the PBS awake, so the cost is that the dashboard shows no datastore usage and
no per-guest last-backup dates until the next cycle runs, after which it heals
itself. Run history is never dropped.
Two limits are handled rather than hit: a NOT NULL column with no default
cannot be added, so it is logged and skipped instead of raising, because a
schema that cannot be fully repaired must not stop the app from booting; and
the diff is add-only, so columns removed from a model in the past stay on disk
harmlessly.
The cache helpers take the new ids and the four call sites pass empty strings
for now, marked TODO(M05): the cycle is still driven by the 0.9 config, and
writes and reads use the same placeholder so the cache stays self-consistent.
They must be replaced together, since changing only the writer would silently
empty the cached values the dashboard reads.
Verified against a real pre-upgrade database as well as a synthetic fixture:
runs, run_steps, log_events and task_log_lines all preserved, and repeat runs
of the upgrade apply nothing.
On the first start after the upgrade, a config.yaml written by 0.9 is copied
to config.yaml.pre-overhaul.bak and converted: pve -> pves[0], pbs -> pbss[0]
(taking over the External watch timeouts, which describe that box rather than
the job), and the backup job -> one route, plus a second verify route when
scheduled verification was on. External-schedules mode produces a route of kind
external instead of backup. Existing users notice nothing.
The converted config is validated before anything reaches disk. Any failure
logs and starts on the untouched original, so a config that cannot be converted
can never stop the app from booting. The backup copy is written once and never
overwritten, and a partially configured 0.9 install degrades rather than
producing an invalid config: no host means no device, no storage means no
backup route, no MAC means the PBS migrates as managed_power false.
Two mappings cannot be faithful and say so at WARNING. Guest mode "exclude"
widens to "all", since inverting the list needs a live guest list that is not
available at load time and widening cannot drop a guest from a backup. A cron
the time+days form cannot express - a day-of-month or month pattern, a step
value, a weekday range or names - is preserved verbatim in the new
schedule.cron field, which wins over time/days when set. The parser is a port
of the frontend cron helper, additionally rejecting non-integer and
out-of-range minute/hour values that it used to mangle into a wrong schedule.
Detection keys off the presence of the routes key rather than a non-empty list,
so deleting the last route does not bring one back on the next start. The 0.9
sections are left in place: they are still what the cycle and scheduler read
until those are ported.
Adds RouteOptions.reverify_days, which a migrated verify route would otherwise
have discarded silently.
Add the v1.0 route model alongside the 0.9 single-PVE/single-PBS sections.
Devices are listed once under pves[]/pbss[] and referenced by id; a route is
"sources -> target + schedule" in four kinds (backup, sync, external, verify).
Validation fails clearly and names the offending id: kind coherence (a backup
route needs PVE sources, a sync route needs a different source PBS, external and
verify take none), unique ids, resolvable references, a per-PVE storage mapping
for every backup route target, and External routes rejected onto a PBS whose
power Joulenap does not manage.
The 0.9 sections stay live for now: nothing imports their classes, so every
consumer reads them by attribute and would break at runtime rather than at
import. They are removed by the milestones that replace them - pve/pbs/backup
with the backup cycle, maintenance.gc/verify with the sync/external/verify
cycles.
config.example.yaml ships the three lists empty with the full shape in comments;
it is copied verbatim on first run, so a sample entry would appear as a phantom
device on a fresh install.
Let PVE/PBS run their own scheduled jobs (backup, prune, GC, sync) and
use Joulenap purely as the power manager: at the scheduled time it wakes
the PBS, watches its tasks, and powers it off once they have been quiet
for a configurable window (issue #27).
- backup.external config block (enabled, first_task_wait, idle_wait);
both knobs are timeouts, not fixed delays — watching starts as soon
as the first task appears, and the quiet countdown restarts whenever
a new task starts, so staggered/chained jobs are never cut short
- new monitor run kind + step: wake -> wait -> watch -> power off,
recorded in run history with the number of tasks observed; a wake
where no job ever appears still powers off and warns in the
notification that the external schedule did not fire
- monitor runs count as the backup slot for last-backup status and the
missed-backup startup check
- Schedule card mode switch (Scheduled by Joulenap / External
schedules): external mode hides GC/retention/guest selection and
shows the two timers with explanatory tooltips; manual panel runs
the watch cycle instead of a backup and hides the GC button
- notification titles, body lines and duration phase for the watch
cycle, in both languages
A self-contained copy of the UI that answers its own API calls from
fixtures, for hosting as static files at joulenap.com/demo: real clock,
fixtures slid onto today's calendar, an orange banner making clear the
data is fake, and a scripted backup cycle (demoTimeline.ts) replayed
live when the visitor presses Run backup now.
Only --mode demo ships the stub; the default build that CI and the
Docker image run drops it entirely. Brand asset paths now honour
BASE_URL so the demo can live under /demo/.
Notifications now carry what triggered the run, a per-phase duration
breakdown, guests as ok/total with the names of the ones that failed,
the next scheduled run and the run number. Per-guest outcomes are read
off the vzdump task log as it streams, into a summary the caller owns,
so a failed task still reports which guest broke.
Also fixes two delivery bugs: bodies were collapsed onto a single line
on HTML channels (Apprise was never told the text was plain), and the
missed-backup alert mixed UTC with the configured timezone.
The run number is now the first column of the run history, so the id a
notification quotes can be looked up in the interface.
The dark/light palettes now live as CSS variables in index.css, switched by a
data-theme attribute on <html>; theme.ts tokens reference the variables so the
whole UI (CodeMirror included) restyles without re-rendering. Scattered hex
literals are folded into shared tokens, and the four hex+alpha concatenations
are replaced with color-mix so they work on variable references.
The header gains a moon/sun toggle between Settings and Logout. The choice is
persisted to app.theme through the config API (the value existed since 0.1.0
but was never read) and mirrored to localStorage, which an inline script in
index.html applies before first paint so there is no flash of the wrong theme.
Switching cross-fades over 300ms, skipped under prefers-reduced-motion.
Also: header action buttons stretch to equal height (Logout was 3px short),
the dev stub persists PUT /config edits instead of silently reverting them,
and a parity test asserts both palettes define the identical token set.
Run history in the UI: the activity card gains a second tab listing every run
with job type, trigger, result, duration and guest count. Rows expand in place
to that run's steps and log lines. Backed by /api/runs, which had existed since
0.1 with no consumer; RunSummary gains guests_ok.
Job cancellation: Run backup / Run GC turn into Stop while a job is in flight,
behind a confirmation that can also power the PBS off afterwards. Cooperative
cancellation checked in the existing poll loops (task wait, PBS wake wait,
between steps), and the underlying PVE/PBS task is stopped, not abandoned, so a
cancelled backup does not keep running on the server. A running verify is
stoppable too. Previously a stuck job blocked every later run and manual
power-off until restart.
Prometheus /metrics for Grafana, protected by the existing dashboard API key.
Sixteen gauges including per-guest last-backup times, so a guest dropping out of
the backup set can be alerted on. Written directly in the text exposition format
rather than adding a dependency; a scrape never wakes the PBS.
Notifications now name the job that ran: a failed verify or GC no longer reports
"backup failed".
Removed the dead backup.guests.auto_include_new key. It was never read, while
its name and default implied new guests were picked up automatically. Existing
configs still load (the key is stripped) and the docs now state the real rule.
Documentation accuracy pass over README, ARCHITECTURE, INSTALL, INTEGRATIONS,
SECURITY and config.example: corrected the PVE and PBS token privilege lists,
the garbage-collection and guest-selection descriptions, the supported-versions
table and the API reference, and added a Settings walkthrough.
Toggle switches are announced as switches by screen readers and can no longer
submit a surrounding form.
Closes re-review findings 11.8, 11.10 and 11.14.
- Advanced settings tab exposing the config-only knobs: backup mode,
bwlimit, keep_last/keep_yearly, history retention, port, session
lifetime and the HTTPS-only cookie flag.
- In-browser config.yaml editor (CodeMirror 6, lazy-loaded chunk) over
new GET/PUT /api/config/yaml. The PUT path reuses the extracted
_apply_config helper, so the editor gets the same redaction,
deep-merge and validation as PUT /api/config; a Copy button yields a
secret-free config for bug reports.
- Opt-in update check (app.update_check, default off): GET /api/update
asks GitHub once a day behind a 24h cache and never touches
/api/health, which the Docker healthcheck depends on.
Frontend UX and accessibility fixes from the v0.3.1 re-review, plus
self-hosted fonts.
- Accessible confirmation dialog: role=alertdialog, aria-modal, focus
trap, Escape to close, focus restored to the trigger on close
- Self-hosted IBM Plex fonts (latin subset) — no Google Fonts request;
the UI renders offline / air-gapped
- Login and first-account screens are proper forms with password-manager
autocomplete hints and a progress state on submit
- Header status label shows "GC running" / "Verify running" during those
jobs instead of always "Backup running" (new /api/status running_kind)
- Block Selective backup mode with no guests selected, with an explanation
- Setup wizard: errors on lower steps scroll into view + role=alert;
Detect MAC reports when auto-detection finds nothing
- Empty guest-list state in the guests panel
- Harden shared Dropdown buttons with type="button"
Also fixes the CHANGELOG compare links (the 0.4.3 release omitted them).
Re-review remediation (FE-H2, FE-H3) plus a requested not-configured banner,
all patch-level:
- FE-H3: session/401 and stale-data handling. A central setUnauthorizedHandler
in the api client fires on any 401 (exempting /login and /account, whose 401s
mean wrong credentials, not a dead session); AuthProvider resets auth
client-side so the app returns to Login with a "session expired" notice.
useStatus flags data stale after 3 failed polls, driving a "can't reach
Joulenap" banner that clears on recovery. ApiError's parameter property was
rewritten to a plain field so node --test can import the client module.
- FE-H2: the Scheduler "Apply changes" action gained busy/saved/error feedback
matching the settings tabs (disabled while saving, green saved note, the
backend's error surfaced).
- Not-configured banner: an amber dashboard banner shown when PVE/PBS aren't
wired up (reusing the wizard's isConfigured helper), with a CTA that
deep-links to Settings -> Setup via a new initialTab prop.
New i18n keys (en/it, parity 306/306). tsc + 23 tests + build clean.
Re-review remediation (BE-B8, BE-R1, BE-R2, BE-C2), all patch-level:
- BE-R1: detect a scheduled backup missed while the process was down
(in-memory jobstore has no cross-restart memory). New
Scheduler.missed_backup_since() queries the armed job's own trigger;
core/catchup.py anchors on the last finished cycle and, on a hit, logs
and notifies (via the new NotificationService.send_alert, on_failure
routed). Wired into lifespan on a daemon thread. Notify-only, no auto-run.
- BE-R2: alert when a run was interrupted by a restart. sweep_orphaned_runs
now returns the swept runs; lifespan builds a localized interrupted-run
message (reusing the wake/power-off check so it flags "PBS left on" only
when the box had actually woken) and dispatches it off-thread.
- BE-B8.4: failure/abort-after-wake notifications now include the
"PBS left powered on" line. _pbs_left_on rewritten to the accurate rule
(WAIT succeeded AND no POWEROFF succeeded). B8.2: dashboard pbs_state
doc-comment clarifying "backing_up" covers any active run.
- BE-C2: reject a malformed pbs.mac at save time (PUT /api/config, 422,
changed-only) reusing wol.normalize_mac — not a load-time pydantic
validator, which would brick startup on a legacy bad value.
New backend i18n (missed + interrupted events, en/it). 331 tests pass.
Patch release: backend robustness fixes (SQLite WAL/busy_timeout/foreign_keys,
manual power-off race, worker-thread lock leak, prune-job timezone) and a
security hardening (config.yaml written owner-only). No feature or API changes.
- BE-B3: open SQLite with WAL + busy_timeout + foreign_keys via a connect
listener, so a running cycle's frequent commits don't risk "database is
locked" against dashboard polling, and the CASCADE FKs actually enforce.
- BE-S2: write config.yaml owner-only (0600) — it holds API tokens, the
session key and notification secrets, so it must not be world-readable on
the host/bind mount (matches the SSH key). Best-effort; re-asserted on save.
- BE-B5: hold the single-run lock across a manual power-off (JobService
.exclusive()) so a scheduled cycle can't start in the check-then-act gap
and get its PBS shut down mid-backup.
- BE-B6: if the worker thread fails to start, fail the run and release the
single-run lock instead of leaking it (which would 409 every later run).
- BE-B7: re-arm the daily history-prune job on rearm so a runtime timezone
change moves it into the new zone instead of the boot-time zone.
Bump every version reference to 0.4.0 and add the 0.4.0 CHANGELOG entry
(keep-PBS-on manual jobs + manual GC cycle; account current-password,
cron-validation, wizard-secret, and exclude-mode fixes).
Minor bump: new user-facing feature and an additive, backward-compatible
API field (keep_on defaults to false = prior behavior).
Feature — manual jobs can keep the PBS awake:
- Run backup / Run GC are now available while the PBS is asleep; they wake
it themselves and only require that no other run is in progress.
- Manual GC runs as a full wake -> GC -> power-off cycle (run_gc_cycle),
mirroring the verify cycle, instead of failing on a sleeping PBS.
- A "Keep PBS powered on after the job" toggle on the confirm dialog leaves
the box on instead of powering it off, defaulted to its current power
state (already-on stays on; asleep goes back to sleep). POST
/api/backup/run and /api/gc/run accept an optional {keep_on} body.
Scheduled runs always power off, unchanged.
Fixes:
- BE-S9: PUT /api/account now requires the current password, so a stolen
session alone can't rotate the admin credentials.
- BE-B1: an invalid backup cron is rejected on save (422) and, if already
on disk, is skipped with a warning instead of crashing the scheduler on
every restart.
- FE-C1: the setup wizard no longer clears the stored PVE token secret on
re-save (mirrors the PBS redaction guard).
- FE-H1: exclude guest mode is shown read-only and preserved on save,
instead of being displayed as an include set and inverted on Apply.
frontend/package.json had drifted at 0.2.0 since the 0.2.0 release, and the
README status heading was never updated for 0.3.0. The lockfile pins the root
version too, so it is synced here to keep `npm ci` happy.
The Docker references all use `:latest` and need no change.
`vite build --mode stub` loads .env.stub, which sets VITE_STUB_API=1, so the
guard admitted the stub into a production bundle — one that patches fetch and
Date and answers /auth/status with an authenticated admin. No npm script does
this, but the file must not be able to reach a build at all.
`import.meta.env.DEV` is false for `vite build` under every mode, so the stub
now cannot ship. Verified: `vite build --mode stub` no longer bundles it, and
the dev server still serves it.
Also bump the stub's fake /health version to match the release.
The dev-only stub answered every /wizard/* route with a generic
{ ok: true }, so the setup wizard could never advance past its first
card without a real backend. Add typed fixtures for pve/connect,
pbs/check, pbs/provision, interfaces, wol/detect-mac, ssh/keygen,
ssh/hostkey, ssh/trust, ssh/install and storage/derive so the wizard
can be clicked all the way through (manual or quick mode) at any
viewport with no backend running.