Discovery matched a Proxmox storage entry to a registered backup server by
comparing host strings, so the same machine written 192.168.1.50 in one place
and pbs.lan in the other looked like two. An already-registered server was
then offered as new, and configuring it provisioned a token over the one that
device was using -- with the datastore matching, the derived token name
matches too, so per-datastore naming does not save it.
The certificate fingerprint settles identity when the spellings differ: the
Proxmox storage config pins it and the device stores it, so both sides already
have it. An empty fingerprint never matches, including another empty one --
the field defaults to "", and equality alone would fold every unpinned server
into whichever device was registered first.
Resolving the hostnames would be the obvious fix and is the wrong one here:
the container's resolver cannot see internal-only hostnames, which is a known
open problem, so device identity would rest on a lookup that fails in exactly
this deployment.
A backup server serving two datastores is two devices, which the duplicate
guard deliberately allows -- but both wanted a token called joulenap, so
setting up the second one deleted and recreated the first one's token. The
first device was left holding a dead secret, and re-entering the new one did
not repair it: deleting a token also drops its ACL entries, and provisioning
re-grants only /datastore/<its own datastore>, so the other datastore stayed
locked out until root ran acl update by hand. The product supported a
configuration its own wizard could not provision.
Tokens on a backup server are now named joulenap-<datastore>, sanitised to the
character set PBS accepts and falling back to the bare prefix if nothing
survives. The two never meet, and each keeps the narrow per-datastore grant
rather than widening to /datastore. A Proxmox host is a single device and
cannot collide with itself, so its token stays plain joulenap. The name is
derived rather than exposed: a field would only invite tokens Joulenap later
fails to find. Tokens already in use are untouched.
The conflict dialog no longer claims the name is "joulenap", since on a backup
server it is not.
A device card also stops reporting "Connected - API OK" for what is a one
second TCP connect to the API port. The authenticated call behind it is made
and its failure discarded, so a server whose credential had been revoked
advertised itself as healthy indefinitely, with cached usage figures beside it
to match. The label now reads "Reachable", which is what is actually checked;
the Test button, which surfaces the same call's error, owns the API verdict.
Changing the underlying field was rejected: it is a published contract, both
in the dashboard payload and as joulenap_pbs_online, documented as answering
on the API port.
Documented in the architecture, the wizard guide and the example config,
including that replacing a token clears its permissions -- so a hand-made
setup where one token served several datastores needs re-granting.
Approving "replace the existing token" for the Proxmox host also replaced it
on the backup server, without asking a second time. One ref held the answer
for the whole wizard run -- deliberately, the comment above it said "for both
boxes this flow provisions" -- but the two boxes are different machines whose
tokens are held by different things, so consent for one is not consent for the
other. It is now kept per box, keyed the way the confirmation already picks
which host to name.
Reproduced on real hardware before fixing: one approval, given for a Proxmox
host, rotated the backup server's token and left a third machine's storage
entry answering 401 Unauthorized -- the outage the confirmation exists to
prevent, reached straight through it. It needs a host that still holds a
joulenap token but is no longer registered, which is what removing a device
leaves behind, plus a discovered storage that matches no registered server.
The topology card said "1 guests": both keys interpolated a variable that was
not named count, so they never pluralised. The parity test cannot see this --
Italian borrows "guest" uninflected -- and neither can the plural test, which
only checks that keys already using count come in complete sets.
The wizard documentation still described the duplicate-host refusal as
happening after a token had been provisioned. Since the guard moved to the
connection step nothing is provisioned before it.
Bump the backend package, the Python project metadata, the frontend package
and the bug-report template's version placeholder from 0.9.0 to 1.0.0. The
docs, the changelog and SECURITY.md have said 1.0.0 since M14; these are the
strings the footer and /health actually read.
The changelog's token-replacement entry also gains the second half of that
fix: a wizard pointed at a host that is already registered is now refused at
the connection step, which was the remaining way to reach the replacement by
accident.
An API token belongs to one server, so adding a second Proxmox host or a
second backup server can never disturb the ones already configured. The one
way to lose a working credential is to point a wizard at a box that already
has a token by the name it wants — and both routes to that are now closed.
Adding a box that is already registered is refused at the connection step,
before any password or token leaves the browser, naming the device that
already exists. Only the id was checked before, and the id is auto-picked so
it never collided: the save succeeded, root-mode provisioning replaced the
token on that host, and the original entry was left holding a secret that no
longer worked. A backup server serving a second datastore is still a
legitimate second device and is still allowed.
When a token really does have to be replaced, the confirmation now names
what Joulenap can see will break instead of warning in the abstract: another
of its own devices on that host, and — the case that actually caused an
outage — a Proxmox host whose PBS storage entry authenticates with that very
token, where the symptom is every backup through that storage failing with a
401 that mentions nothing about a token. Finding those means asking each
registered host what storages it has, so `GET /api/devices/pves/{id}/storages`
lists them without writing anything; a host that is down simply cannot be
warned about and the dialog falls back to the general wording.
Joulenap still cannot see every holder of a token — a script, another tool,
an unmanaged host — so the wizard documentation now points at pasting an
existing token, or giving Joulenap a name of its own, for those cases.
Four small ones found alongside the Gate 2 defects.
Nothing configured the root logger, so every `log.info` in the package went
nowhere: `docker logs` showed uvicorn's handful of lines and nothing else,
including across a 0.9 -> 1.0 config migration — the riskiest thing the app
ever does, and it left no trace of having run. Both entry points now set
logging up first, with `JOULENAP_LOG_LEVEL` to turn it up.
The route editor's preview chip printed the internal kind-prefixed key
(`pve:pve`) instead of the device id, ever since the draft started carrying
keys so a PVE and a backup server could share a name.
SECURITY.md said backup-server API traffic is pinned to a stored fingerprint
without saying where that fingerprint comes from. Adding a server through a
Proxmox host takes it from that host's storage configuration; adding one
directly reads it off the box over a connection nothing has authenticated,
which is trust on first use. Pinning protects everything after setup, not
setup itself, and the document now says so.
The changelog now warns upgraders that history is tracked per route, so
converted routes read "never run" and every guest reads "never backed up"
until the first 1.0 run — with the old runs still listed underneath, which
makes it look like data was lost when nothing was.
Two bugs on the same path, both found by stopping a real run on real
hardware.
The magic packet goes out while the lease still has no holder — `acquire`
registers one only once the box answers. Stop the run during that wait and
it raised before any lease existed, so `held` was empty and `_release_all`
had nothing to release: no power-off step in the timeline, no release, and a
machine that finished booting seconds later and stayed on until someone
noticed. The stop dialog's power-off toggle had nothing to act on.
The second only shows on a route with more than one server. The cancel probe
was passed to the *instant* reachability check as well, and that check runs
before the TCP connect — so once the flag was set the next device's probe
returned "unreachable" without touching the network, and the code sent a
magic packet to a box it had never even asked. A stopped run woke a machine
nobody wanted woken.
Both come down to one invariant, now held either side of the packet: don't
send one for a run that is already stopping, and once one is sent, see the
wake through so the caller gets a lease. Everything that puts a box back to
sleep — the power-off step, the queued-route check, the unmanaged check, the
"left powered on" warning, the stop dialog's toggle — already hangs off that
lease and now applies unchanged. The grace wait is bounded by what remains
of the interrupted attempt, so stopping a run never costs more than the wake
it interrupted.
A run stopped while its servers were coming up also no longer starts its
cycle: the first thing that cycle does is talk to a server that booted
seconds ago, and an error there filed the run as FAILURE and notified about
it — for a run the user stopped by hand.
Which PVE storage points at which backup server is discovered, never typed
— and until now it was only ever discovered while the Add-PVE wizard was
open. Register a backup server afterwards, which is the entire point of the
Add-PBS flow, and there was no way to complete the map from the interface:
the wizard only ever creates so it refuses a host it already knows, the
device editor renders storages read-only, and the connection test reads
guests rather than storages. Backup routes onto that server stayed
impossible, and the only way through was hand-editing YAML.
Worse, the advice the product gave was actively harmful. Both the Add-PBS
wizard's closing warning and the device editor's hint said to re-run
"+ Add -> Proxmox VE" — which provisions a token *before* it fails on the
duplicate id, so following it rotated the working host's token and then
errored out, taking every backup route with it.
Settings -> Devices -> edit the PVE -> Re-read from Proxmox now rebuilds the
map with the credentials the device already has, matching each storage to a
registered server by host and datastore. The map is replaced rather than
merged, so a storage removed on the Proxmox side goes away here too; the
ordinary save-time cross-reference check refuses a result that would leave
an existing route without a mapping. Both strings now point at it.
Provisioning a device from root credentials silently rotated any API token
that already carried the name it wanted. A token's secret is only revealed
when it is created, so `create_token` treated the create's 400 as "already
exists" and deleted and recreated it — invalidating the secret every other
consumer of that token id still held.
The obvious victim is the backup server's storage entry on a Proxmox host,
which typically uses the same `joulenap` token — and which the Add-PBS
wizard's own final step tells the user to create. Found on real hardware:
re-running the wizard against a configured server made the next backup fail
with `could not activate storage 'pbs2': error fetching datastores - 401
Unauthorized`, naming neither the token nor the rotation.
Replacing a token is now something the user has to ask for. The wizard
surfaces the refusal as a confirmation naming what breaks, and only retries
with `replace_token` once they accept.
Also stops a latent hazard on the way: the create's 400/500 was only ever a
hint — `ensure_role` uses the same pair for its own "already exists" — so a
400 for any other reason would trigger the DELETE. Existence is now
confirmed with a GET before anything destructive happens, and any other 400
surfaces as itself.
Four review passes over the branch — backend, frontend, docs/packaging,
security — and the findings each one confirmed.
Config and secrets:
- omitting a device secret in `PUT /api/config` silently cleared it.
`deep_merge` merges mappings key-by-key but replaces lists wholesale, and
1.0 moved devices from mappings into lists, so deleting an
`api_token_secret:` line in the YAML editor returned 200 and wiped the
token. A stored secret is now carried over when the field is absent
entirely; an explicit `""` still clears it.
- the pre-migration `.bak` was created by `copyfile` at 0644 and chmod'ed
after, leaving a window in which every credential in it was world-readable.
Opened 0600 up front, like every other secret-writing path.
Frontend:
- a failed SSH keygen put both wizards into an unbounded POST loop: the
effect depended on the whole setup object, which is a fresh literal every
render, and a failure clears the request cache so the retry fired from the
next render instead of from a click.
- an expanded run's timeline froze at the finish, leaving `poweroff` stuck on
"Running" forever, because polling stopped the moment the run did — and the
power-off steps are recorded after the run reaches success.
- the route editor merged the PVE and PBS id namespaces, which are unique
only per list. With a PVE and a PBS sharing a name, clicking the PVE chip
lit the PBS one, flipped the badge to Sync and saved a route that synced a
box to itself. The draft now carries kind-prefixed keys, as the topology
already did.
- Add-PVE could not recover when the PBS landed but the PVE create then
failed: each retry minted another orphan token on the PBS and re-posted a
device that already existed. It now resumes instead of repeating.
- the route-enable and guest-include switches had no accessible name.
- upcoming occurrences drifted by an hour across a DST boundary — a fixed
86_400_000 ms step is a UTC day, not a calendar day.
- a route whose last run aged out of the shared 50-run window claimed it had
never run.
- the wizard's manual SSH-key step showed the unrestricted public key above
the restricted `authorized_keys` line, inviting a copy of the wrong one.
Docs: the migration-failure fallback was documented backwards in three files
(the file on disk is preserved, but nothing in 1.0 reads the 0.9 sections, so
the app boots empty with the banner set); TLS pinning and SSH host-key
verification were both documented as unconditional when the runtime degrades
without a fingerprint and trusts on first use; the sync-route credential
propagation was documented nowhere; plus a dead Localization page, a wrong
endpoint for run steps, an omitted session-less endpoint, a self-contradiction
about which wizard calls write to disk, a password-change claim that
contradicted SECURITY.md, and a stale `[Unreleased]` link.
A Sync route needs the `/remote` roles on the executing box's token. A token
minted by the 1.0 wizard has them; one pasted by hand, or provisioned by 0.9,
does not — and there was no way to add them from the product, because PBS
answers "Unprivileged API tokens can't set ACL items" to any token. The grant
has to come from a root login.
`POST /api/wizard/pbs/grant-sync` takes root credentials, adds the two roles
to the named token and discards the password, exactly as `pbs/provision`
already does. Settings -> Devices -> a PBS exposes it as a Sync routes
section; the password lives outside the draft and is wiped on success.
Also refuses to send root credentials to a PBS over a connection nothing has
authenticated. Both root-carrying calls pinned only when a fingerprint was
stored and silently fell back to `verify=False` otherwise; a device created
straight from Settings, or a migrated 0.9 config, could reach that path.
Step details were stored as English sentences, so the run timeline stayed
English whatever the UI language was. `run_steps` now carries a
`detail_key`/`detail_params` pair alongside the text, the same seam
`runs.error_key` already had: the English rendering stays on the row for
pre-1.0 rows and for foreign strings (a task UPID, another product's error
text), while the API rebuilds Joulenap's own copy in the user's language on
read. `set_detail()` writes both halves in one call so they cannot drift.
Fixes carried in the same files:
- a run whose PBS never came up recorded a history row and nothing else.
The unreachable handler re-raised, unwinding past both the lease release
and the notification, so the one failure this product exists to have an
opinion about sent no Telegram, no ntfy, no email — a regression against
0.9, which did notify. On a sync route it also left an already-woken box
burning power with no POWEROFF step. It now falls through the same
release-and-notify path a completed run uses.
- the notification's duration breakdown dropped the backup phase on every
real backup route: the lookup matched bare step names, but a route labels
its backup step per source (`backup:pve-alpha`). Matched with `_step_is`
now, and summed per phase.
- a PBS sync remote outlived the route that created it, parking the peer's
full API token in the executing box's `remote.cfg`. Remote and sync job
are both removed once the sync is over.
- a step that set a detail and then failed rendered the detail instead of
the error, because a resolvable key wins over the raw string.
The suite failed roughly one full run in five, differently each time. Three
independent causes, not one:
1. The lifespan started `missed-backup-check` and `interrupted-run-alert` as
daemon threads that nothing joined, and the missed-run check opens a
database session. Every test that builds an app leaked one, so a thread
routinely outlived its test and kept using the database while the next test
tore the engine down and built its own. They are now joined on shutdown,
which is also the right behaviour in production: a restart no longer
abandons an alert halfway out. The join is bounded well under Docker's stop
grace, so a black-holing notification channel still cannot hold the process
open. The job service's queue worker stays unjoined on purpose - it may be
mid-backup, and blocking shutdown on a running vzdump is worse.
2. `_ensure_ready()` initialised the database lazily when the session factory
was missing, which built a schema at `paths.db_path()` - the real database -
from whichever caller got there first. `create_all` reflects once and then
issues CREATEs, so two of those at the same time collide with "table X
already exists", the error the suite reported. It now raises instead, so a
caller that outlived its setup fails loudly rather than racing. One test was
relying on that lazy path and gained the fixture it always needed; it was
the only one, i.e. the only test that had been writing to the real database.
3. The 409 came from a test helper, not from the database race. `_run_route`
waited for the run row to leave RUNNING, but the row is finalised inside the
cycle while the power-off, the notification, the single-run lock and the
queue's current entry all still follow. A second run of the same route
inside that window is correctly rejected as already queued. The helper now
waits for the queue itself to go idle.
Both races were reproduced before being fixed - the schema collision by racing
threads through a probe with no initialisation, the 409 by widening the window
with a temporary sleep, which made it fail every run - and both stop
reproducing with the fixes in place.
Two regression guards, each confirmed to fail on the pre-fix code: an autouse
fixture that diffs the live threads around every test and fails the one that
leaves any behind, and a test pinning that using the database before
initialising it raises.
636 passed, 2 skipped; 10 consecutive full runs green, with the real database
untouched.
Rewrite demoTimeline.ts around the three-route scenario and rebuild the
scripted replay inside devStub.ts on top of the 1.0 endpoints, then bring
the shipped documentation in line with what actually ships.
Demo:
- demoTimeline.ts keys its online windows by PBS id, so one field covers a
single-box backup and a sync route's two; steps carry the per-device
names the backend really emits, plus their detail.
- The demo auto-plays: it opens mid-backup on Nightly and the queued Lab
route starts by itself when that lands, with the target left awake
between them and the skipped power-off recording why. The queue and the
power lease are visible without a click.
- The clock ticks and the fixture calendar shifts by whole weeks, so
weekdays and times survive and the schedules stay self-consistent. Dev
stub mode keeps its frozen clock; every replay mutation sits behind the
demo flag.
- Restore the "fake data" banner and make logout reload rather than strand
the visitor on a login form.
- build:demo now type-checks first, which it never did.
Fix the expanded run history row refetching its detail only once, so a run
in flight showed a frozen step timeline while its task log kept streaming.
Docs:
- ARCHITECTURE: the route model, the queue and lease, a cycle per kind, the
migration, and REST tables rebuilt from the shipped routers.
- CONFIG-WIZARD: the two device flows, and the /remote grant a sync route
needs on a peer configured before 1.0.
- INTEGRATIONS: the new dashboard payload, snippets matching the ones the
app generates, the labelled metric names, and a 0.9 mapping table.
- README, INSTALL: routes, the five settings tabs, upgrading from 0.9, and
the Node version CI and the image actually build with.
- SECURITY: transport pinning, auth hardening, the two API-key endpoints
outside the session, and what Joulenap deliberately does not do.
- CONTRIBUTING: npm test is a separate CI step, and the demo section now
describes the demo that exists.
- CHANGELOG: the 1.0.0 entry, including the breaking dashboard and metrics
shapes and the exclude guest mode widening to all.
Close the frontend block. The locale packs go 727 -> 498 keys and the
strings that survive are now enforced to stay in step across languages.
Purge: delete 257 keys left over from the 0.9 UI (settings.setup/nav/
safety, the old dashboard panels, the manual-run dialogs). The nine
settings.setup.* strings the wizard still uses move into wizard.* rather
than keeping the tree alive for them.
Fix four defects a grep cannot see:
- the backup-mode dropdown rendered its options untranslated under a
translated label; the strings already existed with no caller
- RouteStrip never called dashboard.onDays, so Italian lost the
preposition around the day list
- six count strings interpolated {{n}}, which i18next cannot pluralise
("1 events"); they take {{count}} with _one/_other now
- the retention inputs built their accessible name by string surgery
Localize a run's failure message. It is shown in the notification body
and in the run-history row, so it is stored as a key plus parameters
(runs.error_key / runs.error_params, both nullable) and rendered on read
in the configured language. runs.error keeps the English rendering as the
fallback for pre-1.0 rows and for text from other software. CycleAbort
and PbsUnreachableError carry codes; text bubbling out of a connector
lands under a generic key with its message as a parameter.
Reword the missed-run notification, which said "missed scheduled backup"
while catchup fires for every route kind, and give the MONITOR step's
detail a single definition: the notifier parses the observed count back
out of it, which was an unpinned contract between two modules.
Add the en/it parity tests neither side had. The backend one covers
_KIND_LABEL and _ERRORS as well as _MESSAGES, because _pack() falls back
whole-pack and a key present in one language only raises at send time.
Guided setup returns as two multi-step modal flows, replacing the 0.9
wizard deleted in M08.
Flow A (Add PVE, also first-run): connect with transient root or an
existing token, discover PBS storages from the PVE's own storage config,
auto-link the ones already registered, optionally configure a new one,
finish. No route is created; the last step points at "+ New route".
Flow B (Add PBS): connect, wake-up, power-off, verification, with the two
power steps skipped for an always-on box and an orphan warning when no
registered PVE maps the datastore.
Backend, two gaps the flows exposed:
- PbsProvisioner.provision_token grants both /remote roles (RemoteAdmin
and RemoteSyncPushOperator). It previously granted nothing there, so a
provisioned token could not create a remote or a sync job at all. PBS
refuses ACL writes from tokens, so this can only happen while the root
ticket is held.
- /wizard/ssh/keygen is get-or-create and reports which happened. It
overwrote data/id_ed25519 unconditionally while every device's
ssh_key_path defaults to that one file, so adding a second PBS would
have broken power-off on the first.
The navigation rules, id derivation and validation, storage matching and
the request bodies live in utils/wizardFlow.ts so they are covered by the
DOM-less test harness. WizardContext is deleted: a modal cannot be
navigated away from, so each flow holds its own state and clears its
secrets on unmount.
theme.test.ts now scans the stylesheets as well, where nearly every
var(--jn-*) reference actually lives.
Replace the Settings sidebar with the five top tabs from the design
(Devices, Account, Notifications, Integrations, Advanced). Devices is
new: PVE/PBS cards with connection state and per-device edit modals,
guarded removal that names the routes still using a device, and stubbed
entry points for the wizard flows.
Account absorbs the Localization tab, Notifications is restyled into
channel cards, Integrations gains a Prometheus panel, and Advanced holds
the scheduler kill-switch, a single Application card and the config.yaml
editor with an export button.
The dashboard widget snippets are re-authored for the 1.0 /api/dashboard
response, which is a list per route and per backup server rather than the
flat single-PBS object the old snippets read.
Make the unsaved-changes guard keyed instead of single-slot: two forms on
one tab used to unregister each other, so the first one's edits were
discarded without a prompt.
Wire the four dialog stubs the homepage left behind: routes can be created,
edited and deleted from the UI again, and every manual action has its form.
- RouteModal reproduces the mockup section for section, with the kind inferred
from the device selection (backup / sync / external / verify) driving which
sections render. Guest selection is per source, keyed by pve and vmid, and a
source that was never narrowed stays "all" -- a PVE whose listing has not
arrived can never be saved as "back up nothing".
- ActionDialog covers run route, run GC, run verify, stop run and PBS power
over one shell. The single "power off when finished" toggle maps to keep_on
(inverted) or power_off (direct) at each call site.
- Both build on components/Modal.tsx; the Escape handling, Tab trap and focus
restoration move out of ConfirmModal into useDialogKeys so there is one
implementation.
- utils/routeForm.ts holds every rule -- kind inference, guest state, draft to
Route round-trip, validation, the retention-overlap warning, 422 unpacking --
because the test harness has no DOM. 42 tests.
- Validation mirrors config.py client-side, including the check that a backup
target is mapped in every source PVE's storages, so the error arrives before
the round trip.
- Add a Delete route action; api.deleteRoute had had no caller, so removing a
route meant editing config.yaml by hand.
- Add --jn-backdrop to both palettes; scope the ported input/select rules under
.modal so they cannot reach the inline-styled Settings pages.
- devStub's route CRUD now mutates its config, so a created route really shows
up in the strip and the topology.
Also: a sync task ending "WARNINGS: 1" no longer reports a bare UPID. The sync
step re-reads the task log and names the direction, both boxes and the first
warning line, which is where the two real causes (a group not owned by the
remote user on the target, snapshots older than the target's newest) live.
Rebuild the dashboard as the operational centre of the app: topology hero with
SVG route wires, manual-run and upcoming-runs rail, full-width route strip, run
history with expandable per-run task logs, and the read-only guest panel.
The homepage is class-based (new dashboard.css) rather than inline-styled like
the rest of the app: hover dim/highlight, keyframes, sticky dividers, the
absolute-fill equal-height rule and the 1200px breakpoint cannot live in a style
object. Every colour is a --jn-* token, so both themes follow; only a route's own
hex stays inline. Adds the three route hues to both palettes and drops the seven
dead 0.9 dashboard rules from responsive.css.
Two additive backend endpoints, without which two panels are unbuildable:
GET /api/tasklog takes run=<id>, since an expanded history row asks for a run
that is never the newest; and GuestInfo carries pbs_ids, read per (pve, pbs)
via list_last_backups. The latter also fixes a real bug: get_last_backups
collapsed newest-per-vmid across every PVE, so two PVEs sharing a vmid reported
the wrong one's backup date.
Logic that can be tested is extracted out of the components (the harness has no
DOM): route wires and schedule summaries, upcoming-run generation and the fill
rule, wire geometry, guest grouping and search.
Adapt the shell to the v1.0 backend, first of the frontend block.
api/types.ts now mirrors the backend exactly: pves/pbss/routes replace the
single-PVE/single-PBS config, StatusResponse carries state/running/queued/
next_runs/pves/pbss/config_error, and the device, route and dashboard shapes
are added. api/client.ts keeps its fetch wrapper untouched (cookie auth,
timeout backstop, 401 handling) and swaps the endpoint map: routes CRUD and
run, devices CRUD with test/power/maintenance, run stop, dashboard, the
stateless WoL test; the removed 0.9 endpoints are gone.
The header pill now describes Joulenap rather than a PBS, which no longer
means anything with several backup servers: Running - <route> with a spinner,
Scheduler paused, or Idle - next <when>. It is computed by a pure headerPill()
so the states are unit-tested, and the single-PBS host readout beside it is
dropped. Two banners join the shell under the header: the scheduler
kill-switch, and the refused-migration notice, without which an empty config
looks like a fresh install.
devStub serves the route world (three PVEs, two backup servers, three routes,
one of them mid-run) and stops holding the config twice - the hand-written
YAML fixture, which had already drifted from the typed object, is replaced by
serialising that object. The scripted demo replay is removed with the
endpoints it drove; the timeline engine behind it is untouched.
The dashboard becomes a placeholder and its single-target panels are deleted,
as are the two Settings tabs that wrote the removed config sections: both were
already rejected by the backend. The backup mode, bandwidth and retention
knobs leave the Advanced tab, being per-route settings now.
skip_step passed finished_at in the constructor and let started_at fall back to
its column default, which SQLAlchemy evaluates at flush - after the call. Every
skipped step was therefore stored finishing a few hundred microseconds before
it started, i.e. a negative duration for anything rendering the timeline.
Both ends now come from one clock read, so a skipped step lasts exactly 0s.
A sync route rebuilds its remote and its job on every run. It rebuilt them in
that order, but PBS refuses to delete a remote a sync job still points at:
remote 'joulenap-r1' is used by sync job 'joulenap-r1' (datastore 'lab')
So the first run of a sync route succeeded and every run after it failed, in
both directions. Found on real hardware; no fake models that constraint.
PbsClient.delete_sync_job() now clears the job first, and FakePbs records the
call order so a test pins delete_sync_job -> ensure_remote -> ensure_sync_job.
Verified against a live PBS 4.2 at the hardware gate: sync-direction describes
the job, so only the create takes it. Both other calls answered HTTP 400
"schema does not allow additional properties" and resolve the job from its id
alone, so neither sends it any more:
POST /admin/sync/{id}/run
DELETE /config/sync/{id}
Listing is the opposite case and the reason a push route failed on its second
run: PBS's default sync listing hides push jobs, so the existence check never
saw an existing one, never deleted it, and the create that followed failed with
"job already exists". It now asks for sync-direction=all.
The remote section takes no such parameter, so the listing stays untouched
there.
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.
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.