2 Commits

Author SHA1 Message Date
39b6cf3469 (feature) Add scheduler timezone setup to the native installer
Prompt for the scheduler timezone during interactive installs and support
a --time-zone flag for scripted installs.

Detect a sensible default from POBSYNC_TIME_ZONE, timedatectl, or
/etc/timezone before falling back to UTC, and validate the value with
Python zoneinfo before writing it to pobsync.env.

Document that schedules are evaluated in POBSYNC_TIME_ZONE so production
installs can avoid UTC/local-time ambiguity.
2026-05-19 23:09:15 +02:00
124421b85c (bugfix) Show latest successful runs without requiring stats
Separate operational latest-run display from trend-stat collection so
successful backups without parsed stats still appear in dashboard host rows.

Keep trend summaries limited to runs with stats, but use all successful
real runs for the host latest-run indicator.

Render next scheduled run times with an explicit timezone label to avoid
ambiguity between UTC and local scheduler time.
2026-05-19 23:05:22 +02:00
7 changed files with 69 additions and 7 deletions

View File

@@ -55,6 +55,7 @@ Common overrides:
``` ```
sudo scripts/install-systemd \ sudo scripts/install-systemd \
--backup-root /mnt/backups/pobsync \ --backup-root /mnt/backups/pobsync \
--time-zone Europe/Amsterdam \
--allowed-hosts backup.example.com,localhost,127.0.0.1 \ --allowed-hosts backup.example.com,localhost,127.0.0.1 \
--csrf-trusted-origins https://backup.example.com --csrf-trusted-origins https://backup.example.com
``` ```
@@ -64,6 +65,9 @@ installer to rewrite an existing `/etc/pobsync/pobsync.env`.
Use `--non-interactive` for scripted installs. Use `--verbose` when you want to see the underlying apt, pip, Django, and Use `--non-interactive` for scripted installs. Use `--verbose` when you want to see the underlying apt, pip, Django, and
systemd output. systemd output.
Schedules are evaluated in `POBSYNC_TIME_ZONE`. The installer defaults this to the server timezone when it can detect
one, otherwise `UTC`; override it with `--time-zone Europe/Amsterdam` or by editing `/etc/pobsync/pobsync.env`.
For MariaDB support, add: For MariaDB support, add:
``` ```

View File

@@ -17,6 +17,7 @@ if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then
BACKUP_ROOT_EXPLICIT=1 BACKUP_ROOT_EXPLICIT=1
fi fi
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010} WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
TIME_ZONE=${POBSYNC_TIME_ZONE:-}
FORCE_ENV=0 FORCE_ENV=0
INSTALL_OS_PACKAGES=1 INSTALL_OS_PACKAGES=1
WITH_NGINX=0 WITH_NGINX=0
@@ -74,6 +75,10 @@ while [ "$#" -gt 0 ]; do
WEB_BIND=$2 WEB_BIND=$2
shift 2 shift 2
;; ;;
--time-zone)
TIME_ZONE=$2
shift 2
;;
--force-env) --force-env)
FORCE_ENV=1 FORCE_ENV=1
shift shift
@@ -148,6 +153,38 @@ if [ -f "$ENV_FILE" ] && [ "$FORCE_ENV" -ne 1 ] && [ "$BACKUP_ROOT_EXPLICIT" -ne
fi fi
fi fi
detect_time_zone() {
if [ -n "$TIME_ZONE" ]; then
printf '%s\n' "$TIME_ZONE"
return
fi
if [ -n "${POBSYNC_TIME_ZONE:-}" ]; then
printf '%s\n' "$POBSYNC_TIME_ZONE"
return
fi
if command -v timedatectl >/dev/null 2>&1; then
detected=$(timedatectl show -p Timezone --value 2>/dev/null || true)
if [ -n "$detected" ]; then
printf '%s\n' "$detected"
return
fi
fi
if [ -f /etc/timezone ]; then
detected=$(sed -n '1p' /etc/timezone | tr -d '[:space:]')
if [ -n "$detected" ]; then
printf '%s\n' "$detected"
return
fi
fi
printf 'UTC\n'
}
TIME_ZONE=$(detect_time_zone)
run_step() { run_step() {
label=$1 label=$1
shift shift
@@ -261,6 +298,7 @@ if [ "$INTERACTIVE" -eq 1 ]; then
SERVICE_GROUP=$(prompt_value "Service group" "$SERVICE_GROUP") SERVICE_GROUP=$(prompt_value "Service group" "$SERVICE_GROUP")
BACKUP_ROOT=$(prompt_value "Backup storage path" "$BACKUP_ROOT") BACKUP_ROOT=$(prompt_value "Backup storage path" "$BACKUP_ROOT")
WEB_BIND=$(prompt_value "Gunicorn bind address" "$WEB_BIND") WEB_BIND=$(prompt_value "Gunicorn bind address" "$WEB_BIND")
TIME_ZONE=$(prompt_value "Scheduler time zone" "$TIME_ZONE")
ALLOWED_HOSTS=$(prompt_value "Allowed hosts" "$ALLOWED_HOSTS") ALLOWED_HOSTS=$(prompt_value "Allowed hosts" "$ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS=$(prompt_value "CSRF trusted origins, comma-separated or blank" "$CSRF_TRUSTED_ORIGINS") CSRF_TRUSTED_ORIGINS=$(prompt_value "CSRF trusted origins, comma-separated or blank" "$CSRF_TRUSTED_ORIGINS")
INSTALL_OS_PACKAGES=$(prompt_yes_no "Install required OS packages with apt-get" "$INSTALL_OS_PACKAGES") INSTALL_OS_PACKAGES=$(prompt_yes_no "Install required OS packages with apt-get" "$INSTALL_OS_PACKAGES")
@@ -331,6 +369,12 @@ if ! command -v python3 >/dev/null 2>&1; then
exit 1 exit 1
fi fi
if ! env POBSYNC_INSTALL_TIME_ZONE="$TIME_ZONE" python3 -c "import os; from zoneinfo import ZoneInfo; ZoneInfo(os.environ['POBSYNC_INSTALL_TIME_ZONE'])" >/dev/null 2>&1; then
echo "Invalid time zone: $TIME_ZONE" >&2
echo "Use an IANA timezone such as UTC or Europe/Amsterdam." >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then if ! command -v rsync >/dev/null 2>&1; then
echo "rsync is required." >&2 echo "rsync is required." >&2
exit 1 exit 1
@@ -416,6 +460,7 @@ POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=$CSRF_TRUSTED_ORIGINS
POBSYNC_HOME=/var/lib/pobsync POBSYNC_HOME=/var/lib/pobsync
POBSYNC_BACKUP_ROOT=$BACKUP_ROOT POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
POBSYNC_TIME_ZONE=$TIME_ZONE
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3 POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static POBSYNC_STATIC_ROOT=/var/lib/pobsync/static

View File

@@ -54,23 +54,23 @@ def collect_dashboard_stats(*, hosts: Iterable[HostConfig], global_config: Globa
def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]: def collect_host_stats(*, host: HostConfig, limit: int = 8) -> dict[str, Any]:
runs = list(host.runs.select_related("snapshot").filter(status=BackupRun.Status.SUCCESS).order_by("-started_at", "-created_at")[:50]) runs = list(host.runs.select_related("snapshot").filter(status=BackupRun.Status.SUCCESS).order_by("-started_at", "-created_at")[:50])
real_runs = [_run_summary(run) for run in runs if _is_real_run(run)] real_runs = [_run_summary(run) for run in runs if _is_real_run(run)]
real_runs = [run for run in real_runs if run["has_stats"]][:limit] trend_runs = [run for run in real_runs if run["has_stats"]][:limit]
latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first() latest_snapshot = host.snapshots.order_by("-started_at", "-discovered_at", "-id").first()
latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {} latest_snapshot_stats = _snapshot_summary(latest_snapshot) if latest_snapshot else {}
literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in real_runs] literal_values = [_int_at(run, "rsync", "literal_data_bytes") for run in trend_runs]
literal_values = [value for value in literal_values if value is not None] literal_values = [value for value in literal_values if value is not None]
matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in real_runs] matched_values = [_int_at(run, "rsync", "matched_data_bytes") for run in trend_runs]
matched_values = [value for value in matched_values if value is not None] matched_values = [value for value in matched_values if value is not None]
max_literal = max(literal_values) if literal_values else 0 max_literal = max(literal_values) if literal_values else 0
max_matched = max(matched_values) if matched_values else 0 max_matched = max(matched_values) if matched_values else 0
return { return {
"runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in real_runs], "runs": [_with_bar_percentages(run, max_literal=max_literal, max_matched=max_matched) for run in trend_runs],
"latest_run": real_runs[0] if real_runs else {}, "latest_run": real_runs[0] if real_runs else {},
"latest_snapshot": latest_snapshot_stats, "latest_snapshot": latest_snapshot_stats,
"avg_literal_data_bytes": _average(literal_values), "avg_literal_data_bytes": _average(literal_values),
"avg_daily_literal_data_bytes": _average_daily_literal(real_runs), "avg_daily_literal_data_bytes": _average_daily_literal(trend_runs),
"total_literal_data_bytes": sum(literal_values), "total_literal_data_bytes": sum(literal_values),
"total_matched_data_bytes": sum(matched_values), "total_matched_data_bytes": sum(matched_values),
} }

View File

@@ -90,7 +90,14 @@
<span class="muted">none</span> <span class="muted">none</span>
{% endif %} {% endif %}
</td> </td>
<td>{% if host.next_run_at %}{{ host.next_run_at }}{% else %}<span class="muted">none</span>{% endif %}</td> <td>
{% if host.next_run_at %}
{{ host.next_run_at|date:"Y-m-d H:i T" }}
<div class="muted">{{ scheduler_timezone }}</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
<td>{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</td> <td>{{ host.stats_summary.latest_run.rsync.literal_data_bytes|filesizeformat }}</td>
<td>{{ host.run_count }}</td> <td>{{ host.run_count }}</td>
<td>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</td> <td>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</td>

View File

@@ -52,7 +52,7 @@
<div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div> <div><strong>Schedule expression:</strong> {{ schedule.cron_expr }}</div>
<div class="muted">Evaluated by the pobsync scheduler service.</div> <div class="muted">Evaluated by the pobsync scheduler service.</div>
<div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div> <div><strong>Enabled:</strong> {{ schedule.enabled|yesno:"yes,no" }}</div>
<div><strong>Next run:</strong> {{ next_run_at|default:"" }}</div> <div><strong>Next run:</strong> {% if next_run_at %}{{ next_run_at|date:"Y-m-d H:i T" }} <span class="muted">{{ scheduler_timezone }}</span>{% endif %}</div>
<div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div> <div><strong>Prune:</strong> {{ schedule.prune|yesno:"yes,no" }}</div>
<div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div> <div><strong>Last status:</strong> {{ schedule.last_status|default:"" }}</div>
<div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div> <div><strong>Last started:</strong> {{ schedule.last_started_at|default:"" }}</div>

View File

@@ -50,6 +50,8 @@ class ViewTests(TestCase):
self.assertContains(response, "web-01") self.assertContains(response, "web-01")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "success") self.assertContains(response, "success")
self.assertContains(response, f"Run {run.id}")
self.assertContains(response, "manual")
def test_dashboard_renders_backup_trend_summary(self) -> None: def test_dashboard_renders_backup_trend_summary(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -91,6 +93,7 @@ class ViewTests(TestCase):
self.assertContains(response, "Avg Daily New") self.assertContains(response, "Avg Daily New")
self.assertContains(response, "Days Until Full") self.assertContains(response, "Days Until Full")
self.assertContains(response, "Next Run") self.assertContains(response, "Next Run")
self.assertContains(response, "UTC")
self.assertContains(response, "10") self.assertContains(response, "10")
self.assertContains(response, f"Run {run.id}") self.assertContains(response, f"Run {run.id}")
self.assertContains(response, "manual") self.assertContains(response, "manual")
@@ -554,6 +557,7 @@ class ViewTests(TestCase):
self.assertContains(response, "Schedule expression") self.assertContains(response, "Schedule expression")
self.assertContains(response, "Evaluated by the pobsync scheduler service.") self.assertContains(response, "Evaluated by the pobsync scheduler service.")
self.assertContains(response, "Next run:") self.assertContains(response, "Next run:")
self.assertContains(response, "UTC")
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "Discover snapshots") self.assertContains(response, "Discover snapshots")
self.assertContains(response, "Edit schedule") self.assertContains(response, "Edit schedule")

View File

@@ -58,6 +58,7 @@ def dashboard(request):
"hosts": hosts, "hosts": hosts,
"global_config": global_config, "global_config": global_config,
"stats_summary": stats_summary, "stats_summary": stats_summary,
"scheduler_timezone": timezone.get_current_timezone_name(),
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10], "latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
"counts": { "counts": {
"global_configs": GlobalConfig.objects.count(), "global_configs": GlobalConfig.objects.count(),
@@ -271,6 +272,7 @@ def host_detail(request, host: str):
"host": host_config, "host": host_config,
"schedule": schedule, "schedule": schedule,
"next_run_at": _next_run_for_schedule(schedule, host_config), "next_run_at": _next_run_for_schedule(schedule, host_config),
"scheduler_timezone": timezone.get_current_timezone_name(),
"discovery": inspect_snapshot_discovery(host=host_config), "discovery": inspect_snapshot_discovery(host=host_config),
"host_checks": host_checks, "host_checks": host_checks,
"host_check_summary": summarize_self_checks(host_checks), "host_check_summary": summarize_self_checks(host_checks),