The three silent ones first. A 0.9 config that fails to convert used to boot an
empty config that looks exactly like a fresh install: the reason now reaches the
UI (GET /api/status.config_error), the activity log and an ERROR line, and the
.bak parachute is written on the failure branch too, so a later save from the
Advanced tab cannot destroy the original.
"PBS left powered on" was wrong in both directions -- every successful run
against an always-on PBS warned, and a sync route that left its target awake did
not. lease.release() returned one False for four situations, only two of which
cost power; it now returns a ReleaseOutcome that names the reason, which becomes
both the POWEROFF step's detail and RunContext.left_on. The interrupted-run path
keeps a step-derived rule, now paired per device and filtered by managed_power.
422 bodies echoed the whole config, secrets included: a config-level validator
raises at loc=(), so pydantic attached every token, the secret key, the password
hash, the SMTP and bot tokens as the error's input. One helper with
include_input=False now serves all three config-shaped 422 sites.
Also: a redaction placeholder with nothing to resolve against is rejected instead
of silently clearing the credential (a renamed device id, or a create from a
copied body); the ad-hoc "Run verify" asks for outdated_after=0, since None meant
"only never-verified" and skipped exactly the snapshots the button exists for;
the manual power-off holds the single-run lock so it cannot cut a vzdump that
started in the check-then-act gap; _current_run_id is cleared when a run ends, so
a stop landing between two runs cannot hit the wrong one; and the pre-migration
.bak is chmod 0600 like every other secret-bearing file.
Tests: 617 passed, 2 skipped. Every finding was reproduced against the real code
before the fix, and each new test confirmed failing on the pre-fix code.
Wire the route model to the outside world and retire the 0.9 single-PVE/PBS one.
Scheduler: one cron job per enabled route (route:<id>), built from schedule.time +
days or the schedule.cron escape hatch, gated by the new app.scheduler_enabled
kill-switch. Missed-run detection is per route.
API: new /api/routes and /api/devices (CRUD, connection test, power, ad-hoc GC and
verify), with a 409 removal guard that names the routes still using a device.
Status, dashboard and metrics report one entry per route and per PBS; metrics keep
their joulenap_ names and gain route=/pbs= labels. Cancel moved to
POST /api/runs/{id}/stop, /api/guests is scoped to one PVE, and the WoL smoke test
moved into the wizard router.
Notifications: the positional 5-tuple becomes a RunContext, filtered per route.
Cycles now return that context instead of sending it; the job service sends it after
releasing the power leases, so the message can report whether the box went back to
sleep, and the wake/power-off steps are recorded in the run's timeline again.
Removes pve:/pbs:/backup:/maintenance.gc/maintenance.verify from the schema (old
files still load: the keys are stripped after the 1.0 migration runs), the 0.9
cycles and job entry points, and the config-shaped connector factories.
Also fixes redacted secrets being matched by list position rather than by device id,
and git-ignores the migration's rollback copy of config.yaml, which holds the same
tokens as the original.
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
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.
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.
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.
Backend:
- JN-019: datastore/node status raise ApiError on {"data": null} so /api/status
degrades to "-" instead of 500ing
- JN-020: warn when a configured WoL interface can't be resolved before falling
back to auto-detection
- JN-021: skip incomplete ARP entries (flags 0x0) and all-zero MACs so the wizard
can't save a dead MAC
- JN-014: percent-encode Telegram/ntfy values in Apprise URLs (bot-token ":" kept)
- JN-013: always run the bcrypt check on login (no username-enumeration timing oracle)
- JN-026: single poweroff-command constant; pbs.wait_task docstring + Callable sleep
hints; empty-IN guard in get_last_backups; typed Depends params across routers;
drop the redundant untranslated notification status line
- JN-024: correct the _mount_frontend comment (SPA uses in-app state, no URL router)
Frontend:
- JN-007: Next-run tile year-aware with a real is-tomorrow check; weekday+date for
runs further out instead of always "Tomorrow"
- JN-015: surface save errors on all four settings pages
- JN-016: rel() rolls multi-day deltas over to days (was "120h")
- JN-017: localize weekdays via Intl; guests refresh tooltip via t()
- JN-018: clear pollAfterAction timers on unmount
The non-fatal power-off path set the POWEROFF step to FAILURE but left step.detail
empty, so the run-history/step timeline showed no reason (only the activity log had it).
Set step.detail = str(exc), matching every other failed step. Also document that
_pbs_left_on is only reached on a success run.