- header pill now reports health: red with route and time when the last
run failed, green all-OK with the next fire when it succeeded
- Run button on every route card; Run GC / Run verify on every PBS card,
each opening the confirm dialog pre-targeted (no subject dropdown)
- Manual run panel and the backup-map legend removed; Upcoming runs
fills the rail and shows roughly twice the schedule
- guests never backed up render amber with a count in the panel header
- action failures scroll their banner into view; a failed route toggle
reports the reason instead of silently snapping back
- Enter submits the route modal; disclosure in run history is a real
button; legend pills and route cards no longer focusable non-widgets
- 44px touch targets for power, chips and card buttons on phones
- palette: AA contrast for muted/faint text, a light-theme accent-as-text
token, 3:1 input borders, ink-on-red danger buttons in dark
- semantics: html lang follows the active language, main landmark and a
hidden h1, labeled header buttons, app-wide keyboard focus outline
- dialogs: focus starts on the first field, validation banners scroll
into view instead of mounting off-screen
- type scale collapsed to five steps (11/12.5/13.5/16/19); panel titles
step up to 16px
- mobile: run history renders as cards, the topology stacks vertically,
route and guest rows wrap instead of clipping at 320px
- language and timezone pickers are native selects
- activity log timestamps carry the date; an idle route no longer reads
as paused
Checked every tracked .md against the code rather than for plausibility. The
API table, the metric names, the dashboard payload, the permissions
cheat-sheet, every config field and SECURITY's numbers all already matched.
Four things did not.
ARCHITECTURE contradicted itself on the power lease: keying it on the host
updated the description a few lines up but left a later paragraph still saying
the lease is per device, complete with the extra sleep/wake cycle that no
longer happens. Replaced with the consequence that is now true -- every device
on a held machine reads busy, which is what disables the power button on its
siblings.
The startup catch-up was documented nowhere despite sending notifications.
ARCHITECTURE's scheduler section now covers it and the heartbeat that decides
what counts as downtime.
frontend/README pointed at design/joulenap-remix/, which .gitignore excludes --
a path no clone has. It also filed the setup wizard under the settings panels,
where it has not lived since it moved to src/wizard/, and said npm install
where the lockfile wants npm ci.
Two wordings tightened: the WoL interface is a dropdown now, not free text with
an override; and "stores a copy needed to verify requests" read as though the
API key were hashed, when it sits in config.yaml like every other secret.
Both files spent most of their length on content any given reader skips. The
three install paths are mutually exclusive and Option A is the recommended one,
yet B and C sat between its last step and First run -- a hundred lines
interrupting the path most people are on. The four dashboard snippets are the
same shape: a reader runs one of Homepage, Glance, Homarr or Dashy and scrolls
past the other three.
INSTALL keeps its Option B and Option C headings and collapses only the bodies,
because the file's own table of contents links to those anchors and a summary
in place of the heading would break them. The dashboard blocks have no
incoming links, so there the summary replaces the heading and names the widget
each one configures.
INTEGRATIONS' 0.9 -> 1.0 field mapping is collapsed too: it is legacy-only and
it was the first thing every reader met, including everyone who never ran 0.9.
Its summary keeps the words README and CHANGELOG use to point at it.
Visible length: INSTALL 339 -> 251 lines, INTEGRATIONS 397 -> 267. Reference
tables were deliberately left open -- find-in-page does not reliably reach
inside a collapsed block, which is the whole reason only alternatives and
legacy content are hidden.
It is a page of instructions for something most readers will never do, sitting
in the middle of the upgrade section. Collapsed, the upgrade notes stay short
and the steps are one click away for the person who needs them.
The summary line carries the conclusion -- config rolls back by itself, the
database needs a step -- so a reader learns the important half without opening
it. First body sentence dropped as a duplicate of that summary.
The upgrade section promised "the .bak is your rollback" and never said what to
do with it. The config half is straightforward -- the backup is the file 0.9
wrote. The database is not: 1.0 widened guest_backups and datastore_stats with
NOT NULL columns 0.9 never writes, so 0.9 on a 1.0 database hits an
IntegrityError on both caches. The cycle swallows those (a WARN in the run log),
but resolve_datastore does not, so the dashboard 500s whenever a backup server
is awake -- exactly when someone is watching it.
Verified against 0.9's own code from the tag, on a 1.0 database: both cache
writes fail before the drop, both succeed after it, and the run history survives
either way. Dropping the two caches is enough because both versions rebuild them
from the backup server.
Also states what the failure looks like if the config step is skipped: 0.9's
model forbids unknown keys, so it refuses to start rather than coming up empty
and later saving over the 1.0 file.
The readme, the changelog and the architecture notes all told a first-time
reader that two datastores on one backup server carry independent power leases
and can therefore sleep and wake that machine once more than needed. Keying the
lease on the host removed it, so what shipped in three files was no longer true.
The architecture notes now describe the key itself, and what a sync route
between two datastores of one machine actually does: one lease, one wake, one
power-off.
The startup check treated "a schedule slot came round and no run happened" as
proof of downtime. It is equally true when the schedule was changed since, when
a route was disabled and re-enabled, and when the kill-switch was off -- so
setting a route to run earlier in the day produced "A scheduled route did not
run because Joulenap was offline when it was due" about a process that had been
running the whole time, and re-sent it on every restart until that route next
ran. Reproduced on real hardware: a schedule changed at 10:21 alerted about a
02:00 slot that never existed, on a container with an uptime spanning both.
Nothing recorded that the app was alive, so nothing could tell the difference.
It does now: a file under the data dir is touched every minute, and its mtime is
the whole payload -- no table and no migration. A slot is reported only if it
fell after the app was last known to be running; unknown liveness (first boot,
an unwritable data dir) reports nothing, because inventing downtime is the
failure being fixed.
The stamp is read before the heartbeat starts overwriting it, and touched again
on clean shutdown so a graceful restart does not leave a minute looking like an
outage. The heartbeat is armed outside rearm(): it records that the app is up,
which is just as true with the kill-switch off, and one that stopped there would
make the next restart invent downtime. The notification still shows the real
last run -- only the decision to send it moved.
Two datastores on one box are two devices by design, but one power switch. The
lease refcounted per device id, so a single sync route between them held two
independent leases on one machine: releasing the first shut the box down, and
releasing the second reached a machine already going down -- an SSL EOF from the
idle check, then a closed port 22 -- and recorded LEFT_ON. The run's
notification therefore warned "PBS left powered on" about a box that had gone to
sleep exactly as intended. Acquire was already correct by accident, because
_bring_up probes the host; only release, which never probes, was wrong.
lease_key() is the host, normalised the way discovery normalises it, falling
back to the device id when there is no host yet -- half-configured entries are
legal mid-wizard and would otherwise all collide on "". state() takes the device
rather than an id, and _pending_pbs_ids became _pending_pbs_keys: the queue's
answer and the lease's key must be the same space, or a queued run on a box's
other datastore stops holding it.
Re-keying alone was not enough. A same-machine sync then acquired one lease
twice and rendered "left on: still needed by another run" -- true only in the
sense that the same run holds it, so one wrong sentence for another. A run's
devices are now deduplicated by lease key: one machine, one wake, one power-off,
and the multi-device step labels fall away on their own.
Every device on a held machine now reports holders > 0, so the power button is
disabled on the sibling too. That is the point: an SSH poweroff takes down every
PBS instance on the box, including the one a run is using. Port is deliberately
not part of the key for the same reason.
The picker existed everywhere except the screen: GET /api/wizard/interfaces,
net.list_interfaces(), the typed client method and a stub fixture were all
there, with no caller. Both forms rendered free text, so a mistyped NIC name was
accepted, logged as not-found and silently replaced by auto-detection with
nothing said in the interface. Blank still means auto, and is still the right
default.
useWolInterfaces fetches once -- the list cannot change while a form is open --
and returns ready-made options. A configured value missing from the list is kept
as its own option, so a renamed NIC (or a request that failed, which yields an
empty list rather than throwing) cannot be silently rewritten to whatever
happens to be first on the next save. A NIC with several addresses arrives once
per address and is deduplicated by name.
No new translations: the blank option reuses settings.devices.auto, already the
placeholder both forms used. The one CSS line is the specificity trap 042efb6
fixed for the typeless inputs -- .in-mono loses to .jn-settings select, which
would have left a proportional control in a row of mono fields.
Detect MAC primed the ARP cache with a ping subprocess and then read the cache.
The runtime image is python:3.12-slim with no apt layer, so it has neither ping
nor arp; _ping swallowed the OSError, leaving the priming step dead code in
every shipped container. The lookup then read a table nothing had populated, and
succeeded only when other traffic happened to have kept the neighbour warm --
which a from-zero wizard, with no registered device and so no probe traffic, has
no reason to.
Priming is now a TCP connect through the existing net.tcp_reachable. ARP is
resolved below TCP, so the kernel must learn the MAC before it can send the SYN
and a refused port primes the cache just as well as an open one; the port is
therefore not threaded through from the caller, since it cannot change the
outcome.
_ping, _read_arp_command and the Windows branch go with it, which removes the
last use of subprocess in the backend. /proc/net/arp is now the only reader, so
detect_mac returns None off Linux -- Joulenap ships as a Linux container and the
frontend dev loop runs against the stub.
rearm() already ends by arming the prune job, so the arm_prune() call right
after it re-added the same job id and logged the line a second time. One job
always existed; it was only announced twice. Arming stays unconditional inside
rearm, so the kill-switch case is unchanged.
INSTALL.md's prerequisites said "1-2 GB disk is plenty" while its own Create-CT
step said 2 GB, so the two disagreed and the smaller figure cannot work at all.
Measured on a fresh debian-12-standard CT: 0.5 GB after creation, 1.3 GB once
Docker is installed, 1.6 GB with the image loaded. That leaves 267 MB on a 2 GB
disk for the database, its WAL, and an update that has to pull the new image
before it can drop the old one.
Both lines now say 4 GB.
Both shipped images were captured against the development stub, and the wizard
one still showed a v1.0.0-stub footer. These are from the from-zero install on
real hardware.
The bottom grid takes its row height from Run History alone, on purpose: the
guest panel fills its cell absolutely and contributes no intrinsic height. That
only works if the history panel's height is constant, and it was not.
.table-scroll capped at 418px instead of holding it, and with no runs at all the
panel rendered its empty state instead of .table-scroll, so the box did not
exist. A fresh install therefore showed one guest row.
The scroller now has a fixed height, the empty state lives inside it so the box
exists in every state, and an expanded run grows past it from a min-height.
A garbage collection or verification started by hand builds its run context
with no route, so the message body opened at "Trigger:" and never said which
machine it ran on. With more than one backup server configured the notification
was ambiguous.
RunContext carries a pbs_id, set only for ad-hoc runs, rendered in the route
line's place. Route runs are unchanged: the route name already identifies the
box.
The outcome pill shared the route card grid's only `auto` column with the toggle
and the Edit button. `auto` sizes to its content and each card is an independent
grid, so a route reading "OK · Sun 05/07 07:41" computed a wider column than one
reading "Running", the two fr columns absorbed the difference, and the schedule
and guest count started at a different x on every row.
The pill now has its own fr column and `auto` holds only the toggle and the Edit
button, which are the same width on every card. Alignment therefore survives any
pill text, including the longer Italian outcomes a fixed width would have clipped.
The Settings control block selects on [type='text'], which matches the attribute
and not the resolved default, so four inputs written without a type escaped it
entirely and rendered as raw browser controls - wrong height, wrong colour, and
white whatever the theme. The account username was the visible one; the API key
and the two custom Apprise URL rows had the same fault. They now declare a type,
and :not([type]) is a backstop for the next one.
Those three carry .in-mono, which the block's `font-family: inherit` outspecified,
so the class only ever worked on inputs that escaped the block. Moved after the
block to win on source order - which also restores monospace to the Advanced
numeric fields, where it was always intended.
The shell narrowed from 1400px to 1220px on the Settings view, and the header sits
in that same wrapper, so switching views visibly resized the chrome. One width for
every view now, with the field columns capped instead: 420px in two-column rows and
300px in three-column ones, above the 640px collapse only, and scoped so the modals
- already below both caps at their 700px width - are untouched.
"Topology" is networking jargon for what the card actually shows: which hosts back
up to which backup servers. The key is renamed too, so a translator is not handed
"topology" holding "Mappa dei backup".
The word had leaked into body copy as well - the power-on note and the route-delete
confirmation both referred to "the topology" - plus the changelog, the install guide
and the route colour comment in the example config. All follow the card.
The component, its geometry helpers and the CSS classes keep the Topology name:
it is a fine internal name for the diagram and no user sees it.
Both panels rendered every row they were given, so a busy instance turned the
homepage into a 60-row wall the user had to scroll past to reach anything below.
The activity log now caps at 12 rows and the run history at 10, both scrolling
inside the panel, with the run history's column header pinned. A collapsed border
does not travel with a sticky cell, so that rule is redrawn as an inset shadow.
Expanding a run lifts the cap for as long as the row is open: its steps and task
log are far too tall to read through a 10-row window.
Namespaces appeared nowhere in any shipped file. Joulenap lists a datastore's
root namespace, and a PBS namespace is configured on the Proxmox storage entry
where Joulenap never sees it, so a namespaced setup backs up, prunes and
collects garbage correctly while every one of its guests reads "never backed
up". Silence made that a wrong number with no explanation; for a backup tool
it is the worst direction for a false negative.
The power lease is also keyed per device rather than per machine, so two
datastores on one backup server can sleep and wake it once more than needed
between consecutive routes. Runs are serialised, so this is waste rather than
incorrectness.
Both are in the readme, the changelog, the example config and the architecture
notes -- the four places someone configuring this actually reads.
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.
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.
Grid items default to `min-width: auto`, so a `1fr` track cannot shrink below
its item's min-content. The guests panel's min-content is 378px, because the
guest-name span is `white-space: nowrap` and a nowrap span's min-content is the
whole string. That floored the collapsed track above the viewport and left the
page 1px wider than a 393px phone.
Set `min-width: 0` on the children of both dashboard rows, inside the mobile
block only. The ellipsis already on that span engages, as it was always meant
to. Desktop is unaffected: there the guests panel sits in a fixed 400px track
and the activity log's min-content is 151px, well under its share.
Header now renders brand / status / actions as three flex children
instead of brand / controls, using .jn-header-brand's margin-right:auto
plus .jn-header's gap in place of justify-content:space-between. This
lets the mobile stylesheet wrap actions onto row one (with brand) and
push status to a full-width second row via order + flex-basis, fixing
header overflow on narrow viewports without changing desktop layout.
- Class dividers with .jn-sched-divider to hide on mobile
- Class days container with .jn-days to grid-wrap on mobile
- Class day buttons with .jn-day-btn and remove flex layout
- Keeps state-dependent styles (background, color, etc) inline
- Resolves overflow on ~331px mobile panel
Remove inline display and gridTemplateColumns from header and log row divs,
delegating layout control to responsive CSS classes. Add minWidth: 0 and
overflowWrap: 'anywhere' to message span to allow text wrapping on mobile.
Replace two inline grid styles in Dashboard.tsx with className references
to .jn-row-actions and .jn-row-guests from responsive.css. This enables
the existing media queries to control layout on mobile devices without
the inline styles overriding them.
- Line 193: gridTemplateColumns '360px 1fr' → .jn-row-actions
- Line 213: gridTemplateColumns '400px 1fr' → .jn-row-guests