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.