mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "joulenap-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user