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
Introduce responsive.css with the jn- layout classes (shell, header,
dashboard rows, scheduler days, activity log) and a single 900px
max-width breakpoint. Base rules are verbatim transcriptions of the
inline styles they replace, keeping desktop layout unchanged. Apply
the first class, .jn-shell, to AppShell's outer container.
VITE_STUB_API=1 npm run dev now renders the full dashboard against
in-memory fixtures (config/status/guests/logs) with no backend, and
freezes Date to 2026-07-09T21:30:00Z for reproducible screenshots.
Statically guarded in main.tsx so Vite drops it from production
builds entirely.
add() is the call handed the Apprise URL, so an exception from it is the
one most likely to quote a secret back. It sat outside the try, so it
escaped unscrubbed into the cycle's exception log.
When the backend is unreachable, fetch throws before an ApiError is built
and the fallback copy said 'Couldn't save changes' for an action that
saved nothing. Give the test its own key.
Delivery failure is a result, not a transport error: the request itself
succeeded. Always answer 200 with one entry per channel, so the UI can
show which channel failed and why. Drops the 400 (no channels) and 502
(delivery failed) responses.
The capture only sees the real failure reason because Apprise dispatches a
single server in the calling thread. Nothing enforced that link; a future
change batching URLs into one engine would silently blank every reason.
Concurrent send_test()/send_run_result() calls on different threads could
attribute one channel's failure reason to another, since the apprise log
capture handler was not thread-scoped. Also close two secret-leak gaps:
scrub each channel's own URL before anything else (covers ntfy, which has
no credential field) and try the colon-safe percent-encoded form of a
secret so tokens containing '/' are fully redacted.
Apprise collapses every channel into one bool, so a failed notification
could only ever say 'delivery failed'. Drive one engine per channel and
capture Apprise's log records for that send, so the reason (DNS failure,
connection refused, 401) is attributed to the channel that produced it.
Secrets are scrubbed from the reason before it leaves the service.
Run notifications log a warning per failed channel; previously a channel
that stopped working was silent.
* fix(docker): build the SPA on node 24 to match CI
The frontend CI job runs on node 24 (npm 11) while the image built the SPA
on node:22-slim (npm 10). The two npm majors disagree on whether a violated
*optional* peer dependency is fatal: npm 11 warns, npm 10 fails with ERESOLVE.
So `npm ci` could pass every CI job and still fail `docker build` on the very
same lockfile — which is exactly what happened on the typescript 7 PR, where
i18next declares `peerOptional typescript@"^5 || ^6"`. CI was green; the image
build was not.
Align the build stage to node:24-slim so CI validates the same toolchain the
shipped image uses. Vite 8 requires node >=22.12, so 24 is in range.
Note this makes the image build as lenient as CI: a violated optional peer no
longer stops the build. That is the correct trade — the two environments agreeing
matters more than one of them tripping on type-only metadata — but it does remove
the tripwire that surfaced the typescript 7 conflict.
* ci: fail on node major drift between Dockerfile and ci.yml
The Dockerfile now carries a "keep this in sync with ci.yml" comment, and
comments drift. Assert it instead: parse the node major from the SPA build
stage and from node-version, and fail the build when they disagree.
Verified the guard actually fails, not just passes: drift (22 vs 24) errors,
a ci.yml declaring two different majors errors, and an unparseable Dockerfile
errors with an explanation rather than dying silently under `set -e`.
* chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Redone on current main rather than merging the Dependabot branch, which
was 62 commits behind and predated the tsconfig change and the frontend
test harness — its CI run proved nothing about today's tree.
Verified locally: tsc --noEmit clean, vite build succeeds, 18/18 tests
pass, npm audit --omit=dev --audit-level=high reports 0 vulnerabilities.
* ci(dependabot): group peer-coupled packages so majors can go green
react/react-dom, vite/@vitejs/*, and i18next/react-i18next are coupled by
peer dependencies. Bumping one half across a major without the other fails
npm ci with ERESOLVE, so the solo PRs Dependabot opened for each half could
never pass CI — four of five open PRs were blocked this way.
Group each family ahead of the catch-all with no update-types filter, so it
moves as one unit at every version level. The catch-all keeps its minor+patch
scope, preserving the original intent that a security patch never rides in
with an untested framework major.
Raise the npm PR limit 5 -> 10: the cap was exactly hit, which silently stops
Dependabot opening the companion half of a pair.
i18n is listed before react so that react-i18next cannot be claimed by the
react group regardless of how patterns are matched.
secrets.compare_digest() rejects non-ASCII str with TypeError, turning
the unauthenticated /api/dashboard endpoint's 401 path into a 500 when
an attacker sends a non-ASCII ?key= or X-API-Key. Compare as UTF-8
bytes instead, and add a regression test.
Also fix the Homepage integration snippet (UI + docs) referencing the
non-existent /favicon.svg; the app serves its icon at
/assets/joulenap-icon.svg.