Merge pull request #14 from Joulenap/re-review-remediation-0.4.2

release: 0.4.2 — re-review remediation (robustness, session handling, setup banner)
This commit is contained in:
Catubba
2026-07-12 00:09:04 +02:00
committed by GitHub
32 changed files with 875 additions and 64 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ body:
attributes:
label: Joulenap version
description: Shown in the UI footer.
placeholder: "0.4.1"
placeholder: "0.4.2"
validations:
required: true
- type: dropdown
+32 -1
View File
@@ -7,6 +7,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.2]
### Added
- **Missed-backup alert** — if a scheduled backup was due while Joulenap was down (for example
the container was stopped over the backup window), it is detected at the next startup and a
notification is sent. The backup itself is not run automatically — use "Run backup" if you
want it immediately.
- **Interrupted-run alert** — a run left unfinished by a restart is reported at startup, warning
you when the PBS was left powered on so you can check on it.
- **Setup prompt on the dashboard** — when Proxmox VE and PBS aren't configured yet (a fresh
install), the dashboard shows a banner that links straight to the setup wizard.
### Changed
- **Backup notifications now warn when the PBS was left powered on for failed and aborted runs**,
not only for successful ones — so an energy-costing "still awake" box is never silent.
- **The Scheduler "Apply changes" action now shows saving / saved / error feedback** and can't be
double-submitted, matching the settings tabs; a failed save surfaces the reason instead of
doing nothing.
### Fixed
- **Session expiry no longer leaves the UI showing stale data.** When the session expires (or the
backend restarts), the app returns to the login screen with a notice instead of rendering a
frozen last-known status indefinitely; a "can't reach Joulenap" banner appears while the backend
is unreachable and clears on recovery.
- **An invalid Wake-on-LAN MAC address is now rejected when you save the configuration** (with a
clear error) instead of being accepted and only failing later at backup time.
## [0.4.1]
### Fixed
@@ -157,7 +187,8 @@ Backup Server, all from a web UI.
- Config-driven via `config.yaml` (pydantic-validated); secrets stay in `config.yaml` and are
redacted from API responses.
[Unreleased]: https://github.com/Joulenap/joulenap/compare/v0.4.1...HEAD
[Unreleased]: https://github.com/Joulenap/joulenap/compare/v0.4.2...HEAD
[0.4.2]: https://github.com/Joulenap/joulenap/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Joulenap/joulenap/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Joulenap/joulenap/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/Joulenap/joulenap/compare/v0.3.0...v0.3.1
+1 -1
View File
@@ -50,7 +50,7 @@ Joulenap **owns the schedule** itself (internal scheduler), so nothing on the Pr
## Status
**v0.4.1.** Feature-complete: scheduler + Wake-on-LAN + vzdump + retention + GC + verify +
**v0.4.2.** Feature-complete: scheduler + Wake-on-LAN + vzdump + retention + GC + verify +
notifications + setup wizard, packaged as a Docker image — with transport hardening (PBS TLS
pinning + SSH host-key verification) and auth hardening (login rate-limit, session hardening).
Includes a read-only [dashboard integration](docs/INTEGRATIONS.md) (Homepage/Homarr/Dashy/Glance),
+1 -1
View File
@@ -1,3 +1,3 @@
"""Joulenap — web UI + scheduler for energy-saving Proxmox backups to a normally-off PBS."""
__version__ = "0.4.1"
__version__ = "0.4.2"
+16
View File
@@ -21,6 +21,8 @@ from ..config import (
redacted_dict,
restore_secrets,
)
from ..connectors.errors import WolError
from ..connectors.wol import normalize_mac
from ..core.config_store import ConfigStore
from ..core.scheduler import validate_cron
from .deps import Scheduler, get_config_store, get_scheduler, require_auth
@@ -77,6 +79,20 @@ def put_config(
status_code=422, detail=f"Invalid {label} {new_val!r}: {exc}"
) from exc
# Reject a newly-set malformed WoL MAC before persisting (BE-C2), reusing the exact
# WoL parser so "fails at save" == "fails at wake time". Changed-only + non-empty, same
# as the cron block: an empty MAC is the wizard's unconfigured state, and a legacy bad
# MAC on disk carried through an unrelated edit stays saveable (fails later at wake, as
# today) rather than locking the user out of Settings. Not a pydantic validator, so it
# never runs at load time and can't brick startup (the BE-B1 lesson).
if new_config.pbs.mac and new_config.pbs.mac != old.pbs.mac:
try:
normalize_mac(new_config.pbs.mac)
except WolError as exc:
raise HTTPException(
status_code=422, detail=f"Invalid pbs.mac {new_config.pbs.mac!r}: {exc}"
) from exc
try:
store.replace(new_config)
except OSError as exc:
+3
View File
@@ -65,6 +65,9 @@ def get_dashboard(
ds = _probe.resolve_datastore(config.pbs.datastore, live_ds)
if job_service.is_running:
# "backing_up" is reported for *any* active run — a GC-only or verify cycle included,
# not just a backup. It's a coarse "the box is awake and working" signal; the value is
# a frozen public-contract enum (see module docstring), so we don't split it per kind.
pbs_state = "backing_up"
elif pbs_online:
pbs_state = "online"
+73
View File
@@ -0,0 +1,73 @@
"""Startup catch-up check: did a scheduled backup fall in a window the process was down for?
The scheduler's jobstore is in-memory, so ``coalesce`` only collapses missed fires while the
process is alive — a backup due while the container was stopped is simply lost, and the only
symptom is the *absence* of a success notification (BE-R1). At startup we compare the last
finished cycle against the armed schedule; if a fire came due in between, we log and notify
(we do not auto-run — a restart shouldn't silently kick off a heavy PBS-waking backup).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from ..config import Config
from ..db import session_scope
from ..db.models import Run, RunKind, RunStatus
from ..notify import NotificationService
from .scheduler import Scheduler
log = logging.getLogger("joulenap.catchup")
def _last_finished_cycle_start(session) -> datetime | None:
"""Start time of the most recent finished backup cycle (any terminal status).
Anchored on *finished* rather than *successful* on purpose: a slot that fired but
failed/aborted was attempted (and already notified), not missed due to downtime — so it
must not re-trigger a 'missed' alert on every restart while a failure persists."""
run = session.scalars(
select(Run)
.where(Run.kind == RunKind.CYCLE, Run.status != RunStatus.RUNNING)
.order_by(Run.started_at.desc())
.limit(1)
).first()
return run.started_at if run else None
def check_missed_backup(
config: Config,
scheduler: Scheduler,
notifier: NotificationService,
*,
now: datetime | None = None,
) -> datetime | None:
"""If a scheduled backup was due while the process was down, log + notify (BE-R1).
Returns the missed fire time when one was detected and reported, else None. A notify
failure is logged, never raised — this is a best-effort startup safety net."""
now = now or datetime.now(UTC)
with session_scope() as session:
anchor = _last_finished_cycle_start(session)
if anchor is None:
# No completed cycle yet (fresh install) — nothing could have been missed.
return None
missed = scheduler.missed_backup_since(anchor, now)
if missed is None:
return None
log.warning(
"A scheduled backup was missed while Joulenap was down (due %s; last run %s)",
missed,
anchor,
)
try:
notifier.send_missed_backup(config, missed, anchor, scheduler.next_run_time)
except Exception: # noqa: BLE001 - a notify failure must not matter at startup
log.exception("Failed to send missed-backup notification")
return missed
__all__ = ["check_missed_backup"]
+19 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import logging
import os
from collections.abc import Callable
from datetime import UTC, datetime, tzinfo
from datetime import UTC, datetime, timedelta, tzinfo
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler
@@ -246,3 +246,21 @@ class Scheduler:
job = self.backup_job
# A pending job (scheduler not yet started) has no next_run_time computed.
return getattr(job, "next_run_time", None) if job else None
def missed_backup_since(self, anchor: datetime, now: datetime) -> datetime | None:
"""The first scheduled backup fire in ``(anchor, now]``, or None if none was due.
Used at startup to detect a backup that was due while the process was down (the
in-memory jobstore has no memory of fires missed across a restart; ``coalesce`` only
helps while alive — BE-R1). ``anchor`` is the last finished cycle's start; we ask the
*armed job's own trigger* (so the timezone + DOW translation match the real schedule)
for the next fire strictly after it, and report it only if it's already in the past.
Returns None when no backup job is armed (backups disabled / empty schedule)."""
job = self.backup_job
if job is None:
return None
# +1s so the fire that the anchor run itself served isn't re-reported as missed
# (cron fires land on whole seconds; anchor is that run's start ~at fire time).
fire = job.trigger.get_next_fire_time(None, anchor + timedelta(seconds=1))
return fire if fire is not None and fire <= now else None
+5 -4
View File
@@ -20,11 +20,12 @@ _INTERRUPTED_RUN = "Interrupted — Joulenap restarted while the run was in prog
_INTERRUPTED_STEP = "Interrupted at startup"
def sweep_orphaned_runs(session: Session, *, now: datetime | None = None) -> int:
def sweep_orphaned_runs(session: Session, *, now: datetime | None = None) -> list[Run]:
"""Mark every ``RUNNING`` run (and its ``RUNNING`` steps) as ``FAILURE``.
Returns the number of runs swept. The caller owns the transaction — wrap in
``session_scope()`` (or commit) to persist.
Returns the swept runs (``len()`` for the count) so the caller can alert on them — a
crash after wake leaves the PBS on with no notification otherwise (BE-R2). The caller
owns the transaction — wrap in ``session_scope()`` (or commit) to persist.
"""
ts = now or datetime.now(UTC)
orphaned = session.scalars(select(Run).where(Run.status == RunStatus.RUNNING)).all()
@@ -39,7 +40,7 @@ def sweep_orphaned_runs(session: Session, *, now: datetime | None = None) -> int
step.finished_at = ts
if not step.detail:
step.detail = _INTERRUPTED_STEP
return len(orphaned)
return list(orphaned)
__all__ = ["sweep_orphaned_runs"]
+46 -3
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
@@ -19,6 +20,7 @@ from starlette.middleware.sessions import SessionMiddleware
from . import __version__
from .api import api_router
from .config import Config
from .core.config_store import ConfigStore
from .core.ratelimit import LoginRateLimiter
from .core.scheduler import Scheduler
@@ -26,6 +28,7 @@ from .db import init_db, session_scope
from .db.startup import sweep_orphaned_runs
from .jobs import JobService
from .notify import NotificationService
from .notify.messages import build_interrupted_message
log = logging.getLogger("joulenap.main")
@@ -48,13 +51,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Create the SQLite schema before serving requests, then start the in-process
# scheduler and arm the backup job from config.
init_db()
store: ConfigStore = app.state.config_store
# A previous process may have died mid-cycle, leaving runs stuck RUNNING; fail them
# so the dashboard doesn't show a run that never finishes.
# so the dashboard doesn't show a run that never finishes. Build the alert text while the
# swept runs are still session-attached (build_interrupted_message reads run.steps).
with session_scope() as session:
swept = sweep_orphaned_runs(session)
interrupted_alerts = [build_interrupted_message(store.config, run) for run in swept]
if swept:
log.warning("Marked %d interrupted run(s) as failed at startup", swept)
store: ConfigStore = app.state.config_store
log.warning("Marked %d interrupted run(s) as failed at startup", len(swept))
service = JobService(store)
scheduler = Scheduler(
service.submit_backup,
@@ -68,12 +73,50 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.job_service = service
app.state.scheduler = scheduler
app.state.notifier = NotificationService()
# Detect a scheduled backup missed while the process was down (BE-R1). Off the startup
# path on a daemon thread so a slow/black-holing notification channel can't delay the app
# becoming ready, and wrapped so it can never crash boot.
threading.Thread(
target=_startup_missed_backup_check,
args=(store.config, scheduler, app.state.notifier),
daemon=True,
name="missed-backup-check",
).start()
# Alert on any run a restart interrupted (BE-R2) — same off-thread, boot-safe pattern.
if interrupted_alerts:
threading.Thread(
target=_send_startup_alerts,
args=(store.config, app.state.notifier, interrupted_alerts),
daemon=True,
name="interrupted-run-alert",
).start()
try:
yield
finally:
scheduler.shutdown()
def _startup_missed_backup_check(
config: Config, scheduler: Scheduler, notifier: NotificationService
) -> None:
from .core.catchup import check_missed_backup
try:
check_missed_backup(config, scheduler, notifier)
except Exception: # noqa: BLE001 - a startup safety net must never take the app down
log.exception("missed-backup startup check failed")
def _send_startup_alerts(
config: Config, notifier: NotificationService, alerts: list[tuple[str, str]]
) -> None:
for title, body in alerts:
try:
notifier.send_alert(config, title, body)
except Exception: # noqa: BLE001 - a startup safety net must never take the app down
log.exception("startup alert notification failed")
def create_app() -> FastAPI:
# Load (or first-run create) config before building the app: the session
# middleware needs the signing key, and routers read config via app.state.
+86 -7
View File
@@ -8,6 +8,7 @@ the UI locales but kept deliberately tiny (only the strings that ship in a notif
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from ..config import Config
@@ -22,6 +23,15 @@ _MESSAGES: dict[str, dict[str, dict[str, str]]] = {
"success": {"title": "✅ Joulenap — backup succeeded"},
"failure": {"title": "❌ Joulenap — backup failed"},
"aborted": {"title": "⚠️ Joulenap — backup aborted"},
"missed": {
"title": "⚠️ Joulenap — missed scheduled backup",
"intro": "A scheduled backup was skipped because Joulenap was offline when it "
"was due.",
},
"interrupted": {
"title": "⚠️ Joulenap — run interrupted by a restart",
"intro": "Joulenap restarted while a run was in progress; it was marked failed.",
},
"test": {
"title": "🔔 Joulenap — test notification",
"body": "If you can read this, notifications are configured correctly.",
@@ -34,12 +44,25 @@ _MESSAGES: dict[str, dict[str, dict[str, str]]] = {
"free": "free",
"error": "Error",
"pbs_left_on": "⚠️ PBS left powered on — check it",
"missed_run": "Missed run",
"last_run": "Last backup run",
"next_run": "Next scheduled run",
},
},
"it": {
"success": {"title": "✅ Joulenap — backup riuscito"},
"failure": {"title": "❌ Joulenap — backup fallito"},
"aborted": {"title": "⚠️ Joulenap — backup interrotto"},
"missed": {
"title": "⚠️ Joulenap — backup pianificato mancato",
"intro": "Un backup pianificato è stato saltato perché Joulenap era offline "
"al momento previsto.",
},
"interrupted": {
"title": "⚠️ Joulenap — esecuzione interrotta da un riavvio",
"intro": "Joulenap si è riavviato mentre un'esecuzione era in corso; "
"è stata contrassegnata come fallita.",
},
"test": {
"title": "🔔 Joulenap — notifica di prova",
"body": "Se leggi questo messaggio, le notifiche sono configurate correttamente.",
@@ -52,6 +75,9 @@ _MESSAGES: dict[str, dict[str, dict[str, str]]] = {
"free": "liberi",
"error": "Errore",
"pbs_left_on": "⚠️ PBS lasciato acceso — controllalo",
"missed_run": "Esecuzione mancata",
"last_run": "Ultimo backup eseguito",
"next_run": "Prossima esecuzione pianificata",
},
},
}
@@ -89,14 +115,24 @@ def human_bytes(n: int) -> str:
def _pbs_left_on(run: Run) -> bool:
"""True if the cycle finished without powering the PBS off — a POWEROFF step exists but
didn't succeed (poweroff failed, or was skipped because the PBS was busy).
"""True if the cycle woke the PBS but never powered it back off — so the box is still
burning energy and the user should check it.
Only ever called on a success run (the caller guards with ``event == "success"``), so a
failed/aborted run — which may have no POWEROFF step at all — never reaches here."""
return any(
s.name == StepName.POWEROFF and s.status != StepStatus.SUCCESS for s in run.steps
The rule: the WAIT step succeeded (the PBS actually came up) **and** no POWEROFF step
succeeded. That single condition covers every "left on" case uniformly:
* success but power-off failed / was skipped (PBS busy) — POWEROFF present, not SUCCESS;
* failure after the PBS woke (vzdump/GC/verify errored) — no POWEROFF step at all;
* abort after wake (preflight free-space, no guests selected) — no POWEROFF step.
An abort *before* the box came up (wake/wait timeout) leaves the WAIT step non-SUCCESS, so
the PBS is off and this correctly returns False — hence why it keys on WAIT, not on the
run status."""
woke = any(s.name == StepName.WAIT and s.status == StepStatus.SUCCESS for s in run.steps)
powered_off = any(
s.name == StepName.POWEROFF and s.status == StepStatus.SUCCESS for s in run.steps
)
return woke and not powered_off
def build_run_message(
@@ -126,12 +162,55 @@ def build_run_message(
if run.error:
lines.append(f"{labels['error']}: {run.error}")
if event == "success" and _pbs_left_on(run):
if _pbs_left_on(run):
lines.append(labels["pbs_left_on"])
return pack[event]["title"], "\n".join(lines)
def _format_dt(dt: datetime | None) -> str:
"""A short absolute timestamp for notifications, e.g. ``2026-07-11 04:00 CEST``.
The datetimes passed here come straight from the schedule's cron trigger, so they are
already in the user's configured timezone — no re-localisation needed."""
if dt is None:
return ""
return dt.strftime("%Y-%m-%d %H:%M %Z").rstrip()
def build_missed_backup_message(
config: Config, missed_at: datetime, last_run_at: datetime | None, next_at: datetime | None
) -> tuple[str, str]:
"""``(title, body)`` for a scheduled backup that didn't run because the process was down
over its window (BE-R1), in the configured language."""
pack = _pack(config.app.language)
labels = pack["_labels"]
lines = [
pack["missed"]["intro"],
"",
f"{labels['missed_run']}: {_format_dt(missed_at)}",
f"{labels['last_run']}: {_format_dt(last_run_at)}",
f"{labels['next_run']}: {_format_dt(next_at)}",
]
return pack["missed"]["title"], "\n".join(lines)
def build_interrupted_message(config: Config, run: Run) -> tuple[str, str]:
"""``(title, body)`` for a run that a restart interrupted (swept to FAILURE at startup,
BE-R2), in the configured language.
Adds the "PBS left powered on" warning when the box had actually woken before the crash
(WAIT succeeded, no POWEROFF) — the whole point of the alert: a normally-off box that a
crash left awake and burning power."""
pack = _pack(config.app.language)
lines = [pack["interrupted"]["intro"]]
if run.error:
lines.append(f"{pack['_labels']['error']}: {run.error}")
if _pbs_left_on(run):
lines.append(pack["_labels"]["pbs_left_on"])
return pack["interrupted"]["title"], "\n".join(lines)
def build_test_message(config: Config) -> tuple[str, str]:
"""``(title, body)`` for the manual 'send test notification' action."""
pack = _pack(config.app.language)
+33 -1
View File
@@ -24,9 +24,11 @@ import apprise
from ..config import Config, NotificationsConfig
from ..db.models import Run, RunStatus
from .apprise_urls import Channel, build_channels
from .messages import build_run_message, build_test_message
from .messages import build_missed_backup_message, build_run_message, build_test_message
if TYPE_CHECKING:
from datetime import datetime
from ..connectors.pbs import DatastoreStatus
logger = logging.getLogger(__name__)
@@ -153,6 +155,36 @@ class NotificationService:
)
return report
def send_alert(self, config: Config, title: str, body: str) -> NotifyReport:
"""Dispatch a pre-built ``(title, body)`` through the ``on_failure`` routing toggle.
For failure-class startup alerts not tied to a completed Run — a scheduled backup
missed while the process was down (BE-R1) or a run a restart interrupted (BE-R2). A
user who muted failure alerts shouldn't be woken by these either."""
n = config.notifications
if not n.on_failure:
return NotifyReport(sent=False, channels=0, skipped=True, reason="on_failure disabled")
report = self._dispatch(build_channels(n), title, body, n)
for result in report.results:
if not result.ok:
logger.warning(
"alert channel %s failed: %s",
result.channel,
result.error or "no reason reported",
)
return report
def send_missed_backup(
self,
config: Config,
missed_at: datetime,
last_run_at: datetime | None,
next_at: datetime | None,
) -> NotifyReport:
"""Alert that a scheduled backup was skipped while the process was down (BE-R1)."""
title, body = build_missed_backup_message(config, missed_at, last_run_at, next_at)
return self.send_alert(config, title, body)
def send_test(self, config: Config) -> NotifyReport:
"""Send a test message to every configured channel, ignoring the routing toggles."""
title, body = build_test_message(config)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "joulenap"
version = "0.4.1"
version = "0.4.2"
description = "Self-hosted web UI + scheduler for energy-saving Proxmox backups to a normally-off PBS."
readme = "../README.md"
requires-python = ">=3.12"
+18
View File
@@ -133,6 +133,24 @@ def test_config_put_rejects_invalid_backup_schedule(app_ctx, temp_config):
assert load_config(temp_config).backup.schedule == before
def test_config_put_rejects_invalid_mac(app_ctx, temp_config):
# A newly-set malformed WoL MAC must 422 before persisting, not fail silently at wake
# time (BE-C2). "00:11:22:33:44" is only 5 octets.
client, _app = app_ctx
before = load_config(temp_config).pbs.mac
r = client.put("/api/config", json={"pbs": {"mac": "00:11:22:33:44"}})
assert r.status_code == 422
assert "pbs.mac" in str(r.json()["detail"])
assert load_config(temp_config).pbs.mac == before # nothing written
def test_config_put_accepts_valid_mac(app_ctx, temp_config):
client, _app = app_ctx
r = client.put("/api/config", json={"pbs": {"mac": "aa-bb-cc-dd-ee-ff"}})
assert r.status_code == 200
assert load_config(temp_config).pbs.mac == "aa-bb-cc-dd-ee-ff"
def test_config_put_partial_body_preserves_secrets(app_ctx, temp_config):
client, _app = app_ctx
before = load_config(temp_config)
+82
View File
@@ -0,0 +1,82 @@
"""BE-R1: the startup check that detects a scheduled backup missed while the process was down."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.config import Config
from app.core.catchup import check_missed_backup
from app.core.scheduler import Scheduler
from app.db import session_scope
from app.db.models import Run, RunKind, RunStatus, RunTrigger
class _RecordingNotifier:
"""Duck-typed stand-in for NotificationService.send_missed_backup."""
def __init__(self) -> None:
self.calls: list[tuple] = []
def send_missed_backup(self, config, missed_at, last_run_at, next_at):
self.calls.append((missed_at, last_run_at, next_at))
def _add_cycle(started_at: datetime, status: RunStatus = RunStatus.SUCCESS) -> None:
with session_scope() as session:
run = Run(kind=RunKind.CYCLE, trigger=RunTrigger.SCHEDULED, status=status)
run.started_at = started_at
run.finished_at = started_at + timedelta(minutes=2)
session.add(run)
def _sched(schedule: str = "0 4 * * *", enabled: bool = True) -> Scheduler:
cfg = Config()
cfg.backup.enabled = enabled
cfg.backup.schedule = schedule
sched = Scheduler(lambda _t: None, timezone="UTC")
sched.rearm(cfg)
return sched
def test_notifies_when_a_scheduled_backup_was_missed(temp_db):
_add_cycle(datetime(2026, 7, 8, 4, 0, 0, tzinfo=UTC))
notifier = _RecordingNotifier()
now = datetime(2026, 7, 11, 10, 0, 0, tzinfo=UTC)
missed = check_missed_backup(Config(), _sched(), notifier, now=now)
assert missed == datetime(2026, 7, 9, 4, 0, 0, tzinfo=UTC)
assert len(notifier.calls) == 1
assert notifier.calls[0][0] == missed # missed_at
assert notifier.calls[0][1] == datetime(2026, 7, 8, 4, 0, 0, tzinfo=UTC) # anchor
def test_no_notification_when_no_slot_elapsed(temp_db):
_add_cycle(datetime(2026, 7, 11, 4, 0, 5, tzinfo=UTC))
notifier = _RecordingNotifier()
now = datetime(2026, 7, 11, 4, 0, 30, tzinfo=UTC)
assert check_missed_backup(Config(), _sched(), notifier, now=now) is None
assert notifier.calls == []
def test_no_notification_on_fresh_install_with_no_cycles(temp_db):
notifier = _RecordingNotifier()
now = datetime(2026, 7, 11, 10, 0, 0, tzinfo=UTC)
assert check_missed_backup(Config(), _sched(), notifier, now=now) is None
assert notifier.calls == []
def test_aborted_last_run_anchors_and_is_not_reflagged(temp_db):
# A failed/aborted run at the last slot counts as "attempted" (already notified), so its
# own slot must not be re-reported as a downtime miss.
_add_cycle(datetime(2026, 7, 11, 4, 0, 3, tzinfo=UTC), status=RunStatus.ABORTED)
notifier = _RecordingNotifier()
now = datetime(2026, 7, 11, 6, 0, 0, tzinfo=UTC)
assert check_missed_backup(Config(), _sched(), notifier, now=now) is None
assert notifier.calls == []
def test_no_notification_when_backups_disabled(temp_db):
_add_cycle(datetime(2026, 7, 8, 4, 0, 0, tzinfo=UTC))
notifier = _RecordingNotifier()
now = datetime(2026, 7, 11, 10, 0, 0, tzinfo=UTC)
assert check_missed_backup(Config(), _sched(enabled=False), notifier, now=now) is None
assert notifier.calls == []
+138 -8
View File
@@ -16,7 +16,12 @@ from app.jobs.recorder import RunRecorder
from app.main import create_app
from app.notify import NotificationService
from app.notify.apprise_urls import Channel, build_channels
from app.notify.messages import build_run_message, build_test_message
from app.notify.messages import (
build_interrupted_message,
build_missed_backup_message,
build_run_message,
build_test_message,
)
# --- fake Apprise engine -----------------------------------------------------
@@ -197,35 +202,160 @@ def test_run_message_failure_includes_error_and_locale():
assert "vzdump failed" in body
def _woke() -> RunStep:
"""A completed WAIT step — the PBS came up, so 'left on' hinges only on power-off."""
return RunStep(name=StepName.WAIT, status=StepStatus.SUCCESS)
def test_run_message_flags_pbs_left_on_when_poweroff_failed():
run = _run(RunStatus.SUCCESS)
run.steps = [RunStep(name=StepName.POWEROFF, status=StepStatus.FAILURE)]
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.FAILURE)]
_title, body = build_run_message(Config(), run)
assert "left powered on" in body
def test_run_message_flags_pbs_left_on_when_poweroff_skipped():
run = _run(RunStatus.SUCCESS)
run.steps = [RunStep(name=StepName.POWEROFF, status=StepStatus.SKIPPED)]
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.SKIPPED)]
_title, body = build_run_message(Config(), run)
assert "left powered on" in body
def test_run_message_no_pbs_line_when_poweroff_succeeded():
run = _run(RunStatus.SUCCESS)
run.steps = [RunStep(name=StepName.POWEROFF, status=StepStatus.SUCCESS)]
run.steps = [_woke(), RunStep(name=StepName.POWEROFF, status=StepStatus.SUCCESS)]
_title, body = build_run_message(Config(), run)
assert "left powered on" not in body
def test_run_message_no_pbs_line_on_failure():
# A failure notification never gets the success-only "left on" line.
run = _run(RunStatus.FAILURE, error="boom")
run.steps = [RunStep(name=StepName.POWEROFF, status=StepStatus.FAILURE)]
def test_run_message_flags_pbs_left_on_when_backup_fails_after_wake():
# Failure after the PBS woke: no POWEROFF step at all, box is left on for inspection.
run = _run(RunStatus.FAILURE, error="vzdump failed")
run.steps = [_woke(), RunStep(name=StepName.BACKUP, status=StepStatus.FAILURE)]
_title, body = build_run_message(Config(), run)
assert "left powered on" in body
def test_run_message_flags_pbs_left_on_when_aborted_after_wake():
# An abort after wake (e.g. free-space preflight) also leaves the box on.
run = _run(RunStatus.ABORTED, error="datastore too full")
run.steps = [_woke(), RunStep(name=StepName.PRECHECK, status=StepStatus.FAILURE)]
_title, body = build_run_message(Config(), run)
assert "left powered on" in body
def test_run_message_no_pbs_line_when_wait_timed_out():
# Aborted before the PBS came up (WAIT failed): the box never turned on, so no warning.
run = _run(RunStatus.ABORTED, error="PBS not reachable")
run.steps = [
RunStep(name=StepName.WAKE, status=StepStatus.SUCCESS),
RunStep(name=StepName.WAIT, status=StepStatus.FAILURE),
]
_title, body = build_run_message(Config(), run)
assert "left powered on" not in body
def test_missed_backup_message_english():
missed = datetime(2026, 7, 9, 4, 0, tzinfo=UTC)
last = datetime(2026, 7, 8, 4, 0, tzinfo=UTC)
nxt = datetime(2026, 7, 12, 4, 0, tzinfo=UTC)
title, body = build_missed_backup_message(Config(), missed, last, nxt)
assert "missed scheduled backup" in title
assert "was offline" in body
assert "Missed run: 2026-07-09 04:00" in body
assert "Last backup run: 2026-07-08 04:00" in body
assert "Next scheduled run: 2026-07-12 04:00" in body
def test_missed_backup_message_localized_italian():
cfg = Config()
cfg.app.language = "it"
title, body = build_missed_backup_message(
cfg, datetime(2026, 7, 9, 4, 0, tzinfo=UTC), None, None
)
assert "mancato" in title
assert "offline" in body
# A missing last/next time renders as an em dash rather than crashing.
assert "Esecuzione mancata: 2026-07-09 04:00" in body
def test_send_missed_backup_dispatches_when_on_failure_enabled():
cfg = _notifications_config()
cfg.notifications.on_failure = True
fake = FakeApprise()
svc = NotificationService(apprise_factory=lambda: fake)
report = svc.send_missed_backup(
cfg, datetime(2026, 7, 9, 4, 0, tzinfo=UTC), None, datetime(2026, 7, 12, 4, 0, tzinfo=UTC)
)
assert report.sent is True
assert report.channels == 5
assert fake.payload is not None and "missed scheduled backup" in fake.payload[0]
def test_send_missed_backup_skipped_when_on_failure_disabled():
cfg = _notifications_config()
cfg.notifications.on_failure = False
svc = NotificationService(apprise_factory=FakeApprise)
report = svc.send_missed_backup(cfg, datetime(2026, 7, 9, 4, 0, tzinfo=UTC), None, None)
assert report.sent is False
assert report.skipped is True
assert report.reason == "on_failure disabled"
def test_interrupted_message_flags_pbs_left_on_when_it_had_woken():
# Crashed during backup after the PBS woke: WAIT succeeded, no POWEROFF -> warn.
run = _run(RunStatus.FAILURE, error="Interrupted — Joulenap restarted")
run.steps = [
RunStep(name=StepName.WAIT, status=StepStatus.SUCCESS),
RunStep(name=StepName.BACKUP, status=StepStatus.FAILURE),
]
title, body = build_interrupted_message(Config(), run)
assert "interrupted by a restart" in title
assert "Interrupted — Joulenap restarted" in body
assert "left powered on" in body
def test_interrupted_message_no_pbs_line_when_it_never_woke():
# Crashed during WAIT (PBS never came up): no "left on" warning.
run = _run(RunStatus.FAILURE, error="Interrupted")
run.steps = [
RunStep(name=StepName.WAKE, status=StepStatus.SUCCESS),
RunStep(name=StepName.WAIT, status=StepStatus.FAILURE),
]
_title, body = build_interrupted_message(Config(), run)
assert "left powered on" not in body
def test_interrupted_message_localized_italian():
cfg = Config()
cfg.app.language = "it"
run = _run(RunStatus.FAILURE)
run.steps = []
title, _body = build_interrupted_message(cfg, run)
assert "interrotta da un riavvio" in title
def test_send_alert_dispatches_when_on_failure_enabled():
cfg = _notifications_config()
cfg.notifications.on_failure = True
fake = FakeApprise()
svc = NotificationService(apprise_factory=lambda: fake)
report = svc.send_alert(cfg, "a title", "a body")
assert report.sent is True
assert report.channels == 5
assert fake.payload == ("a title", "a body")
def test_send_alert_skipped_when_on_failure_disabled():
cfg = _notifications_config()
cfg.notifications.on_failure = False
svc = NotificationService(apprise_factory=FakeApprise)
report = svc.send_alert(cfg, "t", "b")
assert report.sent is False
assert report.skipped is True
assert report.reason == "on_failure disabled"
def test_test_message_falls_back_to_english_for_unknown_language():
cfg = Config()
cfg.app.language = "xx"
+29
View File
@@ -319,3 +319,32 @@ def test_schedule_fires_at_configured_local_time():
nxt = sched.backup_job.trigger.get_next_fire_time(None, ref)
assert nxt.hour == 2 # 02:00 Rome local
assert nxt.astimezone(UTC).hour == 0 # == 00:00 UTC
def test_missed_backup_since_detects_a_fire_during_downtime():
# Last cycle served the 8th 04:00; we're back up on the 11th at 10:00 having been down
# over the 9th/10th/11th 04:00 slots -> the first missed fire (9th 04:00) is reported.
sched = Scheduler(lambda _t: None, timezone="UTC")
sched.rearm(_config(schedule="0 4 * * *"))
anchor = datetime(2026, 7, 8, 4, 0, 0, tzinfo=UTC)
now = datetime(2026, 7, 11, 10, 0, 0, tzinfo=UTC)
missed = sched.missed_backup_since(anchor, now)
assert missed == datetime(2026, 7, 9, 4, 0, 0, tzinfo=UTC)
def test_missed_backup_since_none_when_no_slot_elapsed():
# Restarted seconds after a run completed: the served slot is not re-reported and the
# next slot is still in the future.
sched = Scheduler(lambda _t: None, timezone="UTC")
sched.rearm(_config(schedule="0 4 * * *"))
anchor = datetime(2026, 7, 11, 4, 0, 5, tzinfo=UTC)
now = datetime(2026, 7, 11, 4, 0, 20, tzinfo=UTC)
assert sched.missed_backup_since(anchor, now) is None
def test_missed_backup_since_none_when_no_job_armed():
sched = Scheduler(lambda _t: None, timezone="UTC")
sched.rearm(_config(enabled=False))
anchor = datetime(2026, 7, 8, 4, 0, 0, tzinfo=UTC)
now = datetime(2026, 7, 11, 10, 0, 0, tzinfo=UTC)
assert sched.missed_backup_since(anchor, now) is None
+23 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from app.config import Config
from app.db import session_scope
from app.db.models import (
Run,
@@ -13,6 +14,7 @@ from app.db.models import (
StepStatus,
)
from app.db.startup import sweep_orphaned_runs
from app.notify.messages import build_interrupted_message
def _add_run(session, status: RunStatus, *, step_status: StepStatus) -> int:
@@ -28,7 +30,7 @@ def test_sweeps_running_run_and_step_to_failure(temp_db):
rid = _add_run(s, RunStatus.RUNNING, step_status=StepStatus.RUNNING)
with session_scope() as s:
assert sweep_orphaned_runs(s) == 1
assert len(sweep_orphaned_runs(s)) == 1
with session_scope() as s:
run = s.get(Run, rid)
@@ -44,7 +46,7 @@ def test_leaves_finished_runs_untouched(temp_db):
ok_id = _add_run(s, RunStatus.SUCCESS, step_status=StepStatus.SUCCESS)
with session_scope() as s:
assert sweep_orphaned_runs(s) == 0
assert len(sweep_orphaned_runs(s)) == 0
with session_scope() as s:
assert s.get(Run, ok_id).status == RunStatus.SUCCESS
@@ -70,6 +72,25 @@ def test_only_running_steps_are_failed(temp_db):
assert by_name[StepName.BACKUP] == StepStatus.FAILURE
def test_swept_run_yields_a_pbs_left_on_alert_when_it_had_woken(temp_db):
# BE-R2: a crash after the PBS woke (WAIT done, no POWEROFF) -> the interrupted-run alert
# built from the swept run warns the box is still on.
with session_scope() as s:
run = Run(kind=RunKind.CYCLE, trigger=RunTrigger.SCHEDULED, status=RunStatus.RUNNING)
run.steps.append(RunStep(name=StepName.WAIT, status=StepStatus.SUCCESS))
run.steps.append(RunStep(name=StepName.BACKUP, status=StepStatus.RUNNING))
s.add(run)
with session_scope() as s:
swept = sweep_orphaned_runs(s)
alerts = [build_interrupted_message(Config(), r) for r in swept]
assert len(alerts) == 1
title, body = alerts[0]
assert "interrupted by a restart" in title
assert "left powered on" in body
def test_preserves_existing_error_message(temp_db):
with session_scope() as s:
run = Run(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "joulenap-frontend",
"private": true,
"version": "0.4.1",
"version": "0.4.2",
"type": "module",
"scripts": {
"dev": "vite",
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { afterEach, test } from 'node:test'
import { ApiError, api, setUnauthorizedHandler } from './client.ts'
// Make every request resolve to the given status, ignoring the URL (req() uses the global
// fetch). Returns a restore function.
function stubFetch(status: number): () => void {
const orig = globalThis.fetch
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: 'nope' }), {
status,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
return () => {
globalThis.fetch = orig
}
}
afterEach(() => setUnauthorizedHandler(null))
test('a 401 on a session-protected endpoint triggers the unauthorized handler', async () => {
const restore = stubFetch(401)
let fired = false
setUnauthorizedHandler(() => {
fired = true
})
await assert.rejects(() => api.status(), (e) => e instanceof ApiError && e.status === 401)
assert.equal(fired, true, 'expired session must route back to login')
restore()
})
test('a 401 on /account does NOT trigger the handler (wrong current password, BE-S9)', async () => {
// Would eject the user mid-form if the handler fired on every 401 — the exact bug the
// exempt-path set prevents.
const restore = stubFetch(401)
let fired = false
setUnauthorizedHandler(() => {
fired = true
})
await assert.rejects(
() => api.updateAccount('wrong-current', 'admin'),
(e) => e instanceof ApiError && e.status === 401,
)
assert.equal(fired, false)
restore()
})
test('a 401 on /login does NOT trigger the handler (wrong credentials)', async () => {
const restore = stubFetch(401)
let fired = false
setUnauthorizedHandler(() => {
fired = true
})
await assert.rejects(
() => api.login('admin', 'bad'),
(e) => e instanceof ApiError && e.status === 401,
)
assert.equal(fired, false)
restore()
})
+20 -4
View File
@@ -17,15 +17,28 @@ import type {
} from './types'
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
status: number
// A plain field assignment, not a `public status` parameter property: the frontend test
// harness runs `node --test` in strip-only TS mode, which rejects parameter properties.
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
// A 401 on a session-protected endpoint means the cookie has expired; a central handler
// (registered by AuthProvider) flips the whole app back to the login screen, so every
// polling loop and page recovers at once instead of rendering stale data forever (FE-H3).
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn
}
// Endpoints that use 401 for their *own* logic — a wrong password on /login or the wrong
// current password on /account (BE-S9) — must NOT eject the user; only a dead session does.
const AUTH_SELF_HANDLED = new Set(['/login', '/account'])
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch('/api' + path, {
method,
@@ -34,6 +47,9 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
if (res.status === 401 && !AUTH_SELF_HANDLED.has(path.split('?')[0])) {
onUnauthorized?.()
}
let detail: string = res.statusText
try {
const j = await res.json()
+18 -3
View File
@@ -1,11 +1,14 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from '../api/client'
import { api, setUnauthorizedHandler } from '../api/client'
interface AuthState {
loading: boolean
authenticated: boolean
setupNeeded: boolean
username: string | null
// True when the session expired under us (a 401 reset auth) rather than an explicit logout,
// so the Login screen can explain the sudden redirect. Cleared on the next successful sign-in.
expired: boolean
}
interface AuthContextValue extends AuthState {
@@ -24,6 +27,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authenticated: false,
setupNeeded: false,
username: null,
expired: false,
})
const refresh = useCallback(async () => {
@@ -33,6 +37,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authenticated: s.authenticated,
setupNeeded: s.setup_needed,
username: s.username,
expired: false,
})
}, [])
@@ -40,14 +45,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refresh().catch(() => setState((p) => ({ ...p, loading: false })))
}, [refresh])
// A 401 anywhere (expired cookie) resets auth client-side — no api.logout(), the session is
// already dead — so the Gate falls back to Login. Guarded on `authenticated` so a stray 401
// while already logged out doesn't spuriously flag "expired".
useEffect(() => {
setUnauthorizedHandler(() =>
setState((p) => (p.authenticated ? { ...p, authenticated: false, expired: true } : p)),
)
return () => setUnauthorizedHandler(null)
}, [])
const login = useCallback(async (username: string, password: string) => {
const u = await api.login(username, password)
setState((p) => ({ ...p, authenticated: true, setupNeeded: false, username: u.username }))
setState((p) => ({ ...p, authenticated: true, setupNeeded: false, username: u.username, expired: false }))
}, [])
const setup = useCallback(async (username: string, password: string, timezone: string) => {
const u = await api.setup(username, password, timezone)
setState((p) => ({ ...p, authenticated: true, setupNeeded: false, username: u.username }))
setState((p) => ({ ...p, authenticated: true, setupNeeded: false, username: u.username, expired: false }))
}, [])
const logout = useCallback(async () => {
+1 -1
View File
@@ -218,7 +218,7 @@ const WIZARD_SSH_TRUST: { trusted: boolean } = { trusted: true }
const WIZARD_RESET: { ok: boolean } = { ok: true }
const ROUTES: Record<string, unknown> = {
'GET /health': { status: 'ok', version: '0.4.1-stub' },
'GET /health': { status: 'ok', version: '0.4.2-stub' },
'GET /auth/status': AUTH_STATUS,
'GET /auth/me': ME,
'GET /status': STATUS,
+15 -3
View File
@@ -1,16 +1,28 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import type { StatusResponse } from '../api/types'
// After this many consecutive failed polls we flag the data as stale, so a monitoring
// dashboard shows "can't reach the backend" instead of a frozen last-known pill (FE-H3).
const STALE_AFTER = 3
// Polls GET /api/status. Shared by the header (status pill) and the dashboard tiles.
export function useStatus(intervalMs = 5000) {
const [status, setStatus] = useState<StatusResponse | null>(null)
const [stale, setStale] = useState(false)
const failures = useRef(0)
const refresh = useCallback(async () => {
try {
setStatus(await api.status())
failures.current = 0
setStale(false)
} catch {
// transient errors keep the last known status; the next tick retries
// Keep the last known status (the next tick retries), but once failures pile up flag
// the data as stale so the UI can say so. A 401 doesn't reach here as "stale": the
// central handler resets auth and routes to Login before we'd count enough failures.
failures.current += 1
if (failures.current >= STALE_AFTER) setStale(true)
}
}, [])
@@ -20,5 +32,5 @@ export function useStatus(intervalMs = 5000) {
return () => clearInterval(id)
}, [refresh, intervalMs])
return { status, refresh }
return { status, refresh, stale }
}
+6 -1
View File
@@ -6,10 +6,14 @@
"confirm": "Confirm",
"loading": "Loading…",
"never": "never",
"saveFailed": "Couldn't save changes"
"saveFailed": "Couldn't save changes",
"backendUnreachable": "Can't reach Joulenap — showing last known data.",
"notConfigured": "Joulenap isn't set up yet — connect your Proxmox VE and PBS to start backing up.",
"runSetup": "Run setup wizard →"
},
"auth": {
"brand": "Joulenap · Scheduler PBS",
"sessionExpired": "Your session expired. Please sign in again.",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm password",
@@ -79,6 +83,7 @@
"backupDays": "Backup days",
"scheduleCustom": "Custom schedule set in config.yaml ({{cron}}) — edit it there to change.",
"apply": "Apply changes",
"saved": "✓ Schedule saved",
"guests": "Guests",
"refresh": "Refresh",
"general": "General backup",
+6 -1
View File
@@ -6,10 +6,14 @@
"confirm": "Conferma",
"loading": "Caricamento…",
"never": "mai",
"saveFailed": "Impossibile salvare le modifiche"
"saveFailed": "Impossibile salvare le modifiche",
"backendUnreachable": "Impossibile raggiungere Joulenap — mostro gli ultimi dati noti.",
"notConfigured": "Joulenap non è ancora configurato — collega Proxmox VE e PBS per iniziare a fare backup.",
"runSetup": "Avvia la configurazione →"
},
"auth": {
"brand": "Joulenap · Scheduler PBS",
"sessionExpired": "La sessione è scaduta. Accedi di nuovo.",
"username": "Nome utente",
"password": "Password",
"confirmPassword": "Conferma password",
@@ -79,6 +83,7 @@
"backupDays": "Giorni di backup",
"scheduleCustom": "Pianificazione personalizzata impostata in config.yaml ({{cron}}) — modificala lì per cambiarla.",
"apply": "Applica modifiche",
"saved": "✓ Pianificazione salvata",
"guests": "Guest",
"refresh": "Aggiorna",
"general": "Backup generale",
+29 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../api/client'
import { ApiError, api } from '../api/client'
import type { Config, GuestInfo, LogLine, StatusResponse } from '../api/types'
import { ConfirmModal, type ConfirmState } from '../components/ConfirmModal'
import { useConfig } from '../config/ConfigContext'
@@ -60,6 +60,11 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
const [refreshing, setRefreshing] = useState(false)
const [logs, setLogs] = useState<LogLine[]>([])
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
// Scheduler "Apply changes" feedback, mirroring the settings tabs (FE-H2): busy disables
// the button (no double-PUT), savedNote shows success, err surfaces a failed save.
const [busy, setBusy] = useState(false)
const [savedNote, setSavedNote] = useState(false)
const [err, setErr] = useState<string | null>(null)
// "Keep PBS on after the job" choice for the backup/GC confirm dialog. A ref mirrors it so
// the confirm's onConfirm (captured at setConfirm time) reads the latest value.
const [keepOn, setKeepOn] = useState(false)
@@ -130,7 +135,11 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
if (!config || !draft) return null
const patch = (p: Partial<Draft>) => setDraft((d) => (d ? { ...d, ...p } : d))
const patch = (p: Partial<Draft>) => {
setDraft((d) => (d ? { ...d, ...p } : d))
setSavedNote(false)
setErr(null)
}
const toggleEnabled = async () => {
const next = !enabled
@@ -174,8 +183,17 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
next.backup.guests.mode = 'include'
next.backup.guests.list = [...draft.selected].sort((a, b) => a - b)
}
await save(next)
loadLogs()
setBusy(true)
setErr(null)
try {
await save(next)
setSavedNote(true)
loadLogs()
} catch (e) {
setErr(e instanceof ApiError ? e.message : t('common.saveFailed'))
} finally {
setBusy(false)
}
}
const runAction = (
@@ -209,13 +227,16 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
})
}
const toggleGuest = (vmid: number) =>
const toggleGuest = (vmid: number) => {
setSavedNote(false)
setErr(null)
setDraft((d) => {
if (!d) return d
const sel = new Set(d.selected)
sel.has(vmid) ? sel.delete(vmid) : sel.add(vmid)
return { ...d, selected: [...sel] }
})
}
return (
<>
@@ -237,6 +258,9 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
patch={patch}
dirty={dirty}
onApply={apply}
busy={busy}
saved={savedNote}
error={err}
/>
<div className="jn-row-guests">
+20 -1
View File
@@ -8,7 +8,7 @@ import { TIMEZONES, detectTimezone } from '../utils/timezones'
export function Login() {
const { t } = useTranslation()
const { setupNeeded, login, setup } = useAuth()
const { setupNeeded, expired, login, setup } = useAuth()
const register = setupNeeded
const [user, setUser] = useState('')
@@ -99,6 +99,25 @@ export function Login() {
{register ? t('auth.registerSubtitle') : t('auth.signInSubtitle')}
</span>
{expired && !register && !error && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'rgba(232,131,15,.1)',
border: '1px solid rgba(232,131,15,.4)',
borderRadius: 7,
padding: '9px 12px',
marginBottom: 14,
fontSize: 12,
color: c.textMid,
}}
>
{t('auth.sessionExpired')}
</div>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span style={labelStyle}>{t('auth.username')}</span>
<input
+6 -3
View File
@@ -8,7 +8,7 @@ import { Localization } from './settings/Localization'
import { Notifications } from './settings/Notifications'
import { SetupWizard } from './settings/SetupWizard'
type Tab = 'localization' | 'account' | 'notifications' | 'setup' | 'safety' | 'integrations'
export type Tab = 'localization' | 'account' | 'notifications' | 'setup' | 'safety' | 'integrations'
const NAV: { key: Tab }[] = [
{ key: 'localization' },
@@ -19,9 +19,12 @@ const NAV: { key: Tab }[] = [
{ key: 'integrations' },
]
export function Settings(_props: { onClose: () => void }) {
export function Settings(_props: { onClose: () => void; initialTab?: Tab }) {
const { t } = useTranslation()
const [tab, setTab] = useState<Tab>('localization')
// Settings is remounted each time the shell switches to it (unmounted on the main view), so
// this initial value applies afresh every open — the dashboard's "Run setup wizard" CTA
// opens straight on the setup tab, while the gear opens Localization as before.
const [tab, setTab] = useState<Tab>(_props.initialTab ?? 'localization')
return (
<div className="jn-settings">
@@ -24,6 +24,9 @@ interface Props {
patch: (p: Partial<SchedulerDraft>) => void
dirty: boolean
onApply: () => void
busy: boolean
saved: boolean
error: string | null
}
const label: React.CSSProperties = {
@@ -51,7 +54,7 @@ const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
const toInt = (v: string) => (v === '' ? 0 : Math.max(0, parseInt(v, 10) || 0))
export function SchedulerCard({ enabled, onToggleEnabled, draft, patch, dirty, onApply }: Props) {
export function SchedulerCard({ enabled, onToggleEnabled, draft, patch, dirty, onApply, busy, saved, error }: Props) {
const { t } = useTranslation()
const advanced = isAdvancedSchedule({ time: draft.time, days: draft.days, dom: draft.dom, month: draft.month })
@@ -227,10 +230,10 @@ export function SchedulerCard({ enabled, onToggleEnabled, draft, patch, dirty, o
)}
<div style={{ height: 1, background: c.border, margin: '18px 0 14px' }} />
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12 }}>
<button
onClick={onApply}
disabled={!dirty}
disabled={!dirty || busy}
style={{
display: 'flex',
alignItems: 'center',
@@ -242,11 +245,13 @@ export function SchedulerCard({ enabled, onToggleEnabled, draft, patch, dirty, o
padding: '11px 28px',
fontSize: 13,
fontWeight: 600,
cursor: dirty ? 'pointer' : 'not-allowed',
cursor: !dirty || busy ? 'not-allowed' : 'pointer',
}}
>
{t('dashboard.apply')}
</button>
{saved && !dirty && <span style={{ fontSize: 12, color: c.green }}>{t('dashboard.saved')}</span>}
{error && <span style={{ fontSize: 12, color: c.red }}>{error}</span>}
</div>
</div>
)
+3 -2
View File
@@ -118,8 +118,9 @@ const pbsCli = (datastore: string) =>
].join('\n')
// A saved config counts as "set up" once the connection identity the wizard writes is
// present. Used to show the completed state (and the reset button) after a reload.
function isConfigured(cfg: Config): boolean {
// present. Used to show the completed state (and the reset button) after a reload, and to
// drive the "not configured yet" banner on the dashboard (single source of truth).
export function isConfigured(cfg: Config): boolean {
return !!(cfg.pve.host && cfg.pve.api_token_id && cfg.pbs.host && cfg.pbs.mac)
}
+74 -4
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api } from '../api/client'
import { useAuth } from '../auth/AuthContext'
import { Spinner } from '../components/Spinner'
import { ConfigProvider, useConfig } from '../config/ConfigContext'
import { Dashboard } from '../pages/Dashboard'
import { Settings } from '../pages/Settings'
import { Settings, type Tab } from '../pages/Settings'
import { isConfigured } from '../pages/settings/SetupWizard'
import { useStatus } from '../hooks/useStatus'
import { c } from '../theme'
import { WizardProvider } from '../wizard/WizardContext'
@@ -13,12 +15,24 @@ import { Header } from './Header'
type View = 'main' | 'settings'
function ShellInner() {
const { t } = useTranslation()
const { logout } = useAuth()
const { config, loading } = useConfig()
const { status, refresh } = useStatus()
const { status, refresh, stale } = useStatus()
const [view, setView] = useState<View>('main')
const [settingsTab, setSettingsTab] = useState<Tab>('localization')
const [version, setVersion] = useState('')
const openSettings = (tab: Tab) => {
setSettingsTab(tab)
setView('settings')
}
// Fresh install / wizard never completed: PVE+PBS aren't wired up, so backups can't run.
// Nudge the user into the wizard — only on the dashboard, so we don't nag while they're
// already in Settings configuring it.
const notConfigured = view === 'main' && !!config && !isConfigured(config)
// Version is static per deploy — fetch the backend's once for the footer.
useEffect(() => {
api
@@ -30,11 +44,67 @@ function ShellInner() {
return (
<div className="jn-shell">
<div style={{ maxWidth: 1220, margin: '0 auto' }}>
{stale && (
<div
role="status"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'rgba(229,103,91,.12)',
border: `1px solid ${c.red}`,
borderRadius: 8,
padding: '9px 14px',
marginBottom: 12,
fontSize: 12.5,
color: c.red,
}}
>
{t('common.backendUnreachable')}
</div>
)}
{notConfigured && (
<div
role="status"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 14,
flexWrap: 'wrap',
background: 'rgba(232,131,15,.1)',
border: '1px solid rgba(232,131,15,.4)',
borderRadius: 8,
padding: '10px 14px',
marginBottom: 12,
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: c.textMid }}>
{t('common.notConfigured')}
</span>
<button
onClick={() => openSettings('setup')}
style={{
background: c.accent,
color: c.accentInk,
border: 'none',
borderRadius: 7,
padding: '7px 16px',
fontSize: 12.5,
fontWeight: 600,
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{t('common.runSetup')}
</button>
</div>
)}
<Header
host={config?.pbs.host ?? ''}
status={status}
view={view}
onToggleView={() => setView((v) => (v === 'main' ? 'settings' : 'main'))}
onToggleView={() => (view === 'main' ? openSettings('localization') : setView('main'))}
onLogout={logout}
/>
{loading ? (
@@ -44,7 +114,7 @@ function ShellInner() {
) : view === 'main' ? (
<Dashboard status={status} refreshStatus={refresh} />
) : (
<Settings onClose={() => setView('main')} />
<Settings onClose={() => setView('main')} initialTab={settingsTab} />
)}
<footer
style={{