backups: show timestamps in the site timezone
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

Backup timestamps read wrong because of two faults stacked, which is why it
looked like a single offset.

The API serialised naive ISO ("2026-08-07T12:00:00"), with nothing saying the
value was UTC. JavaScript's new Date() parses that as BROWSER-LOCAL, so every
timestamp shifted by the viewer's offset before any timezone formatting ran.
Every datetime this plugin stores is naive UTC, so the wire format now carries
a trailing Z.

The history view then formatted with toLocaleString(), i.e. the viewer's zone,
ignoring the site_timezone setting entirely. It now loads that setting and
formats through the shared formatInZone helper, matching NotificationsList.

The panel list label is built server-side with strftime, so a client cannot
correct it afterwards. It now converts to the site zone using the same Setting
lookup the notifications plugin uses - without that it showed UTC, four hours
out at West Jefferson.

Tests cover the wire format and that 16:30Z renders as 12:30 in
America/New_York.
This commit is contained in:
cproudlock
2026-08-08 14:08:33 -04:00
parent c93ec7a949
commit adb8542018
4 changed files with 110 additions and 10 deletions

View File

@@ -12,6 +12,9 @@ because requiring the app server to mount the SFLD share would turn a
permissions slip into an unexplained empty download.
"""
from datetime import timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, current_app, request, Response
from flask_jwt_extended import jwt_required
@@ -22,15 +25,42 @@ from shopdb.api import (
)
from ..models import BackupRevision
from ..models.backup import _utciso
from ..services.registry import REGISTRY, getkind
backups_bp = Blueprint('backups', __name__)
_DEFAULTTZ = 'America/New_York'
def _sitezone():
"""Site-configured IANA zone (settings key site_timezone).
Same lookup the notifications plugin uses. A stored timestamp is UTC, so
anything rendered server-side has to be converted or it shows the wrong
wall clock for the site - four hours out at West Jefferson.
"""
from shopdb.api import Setting
row = Setting.query.filter_by(key='site_timezone').first()
name = row.value if row and row.value else _DEFAULTTZ
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo(_DEFAULTTZ)
def _label(revision):
"""Panel list title: kind-agnostic, readable at a glance."""
"""Panel list title: kind-agnostic, readable at a glance.
Rendered in the SITE zone, not UTC: this string is baked server-side and
the client cannot correct it afterwards.
"""
when = revision.collectedat or revision.createdat
stamp = when.strftime('%Y-%m-%d %H:%M') if when else 'unknown time'
if not when:
return 'unknown time'
local = when.replace(tzinfo=timezone.utc).astimezone(_sitezone())
stamp = local.strftime('%Y-%m-%d %H:%M')
if revision.sourcefilename:
return '{} - {}'.format(stamp, revision.sourcefilename)
return stamp
@@ -129,8 +159,7 @@ def asset_info(assetid):
revision.payload, assetid,
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
data['backuprevisionid'] = revision.backuprevisionid
data['collectedat'] = (revision.collectedat.isoformat()
if revision.collectedat else None)
data['collectedat'] = _utciso(revision.collectedat)
return success_response(data)

View File

@@ -95,7 +95,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
import api, { settingsApi } from '@/api'
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
const route = useRoute()
const router = useRouter()
@@ -111,11 +112,13 @@ const diffs = ref({})
const assetname = ref('')
const loading = ref(true)
const error = ref('')
// Timestamps are stored UTC and must show in the SITE's zone, not the viewer's,
// so a remote admin and someone at the bay read the same wall clock.
const siteTimezone = ref(DEFAULT_TZ)
function formatWhen(value) {
if (!value) return 'unknown'
const d = new Date(value)
return isNaN(d) ? value : d.toLocaleString()
return formatInZone(value, siteTimezone.value, { second: '2-digit' }) || value
}
function display(value) {
@@ -124,6 +127,16 @@ function display(value) {
return String(value)
}
async function loadTimezone() {
try {
const response = await settingsApi.get('site_timezone')
const value = response?.data?.data?.value
if (value) siteTimezone.value = value
} catch (e) {
// Keep the default zone; a missing setting must not blank the page.
}
}
async function load() {
loading.value = true
error.value = ''
@@ -183,7 +196,10 @@ async function download(rev, fmt) {
URL.revokeObjectURL(link.href)
}
onMounted(load)
onMounted(async () => {
await loadTimezone()
await load()
})
</script>
<style scoped>

View File

@@ -27,6 +27,15 @@ from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked as UTC, or None.
Every datetime this plugin stores is naive UTC. Serialising it without the
Z leaves the receiver to guess, and JavaScript guesses browser-local.
"""
return value.isoformat() + 'Z' if value else None
class BackupRevision(db.Model):
"""A single point-in-time configuration snapshot of an asset."""
@@ -123,8 +132,13 @@ class BackupRevision(db.Model):
# download <machinenumber>.reg without a second round trip.
'assetnumber': self.asset.assetnumber if self.asset else None,
'sourcehostname': self.sourcehostname,
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
'createdat': self.createdat.isoformat() if self.createdat else None,
# Both columns hold NAIVE UTC (see collectedat above), so the wire
# format says so with a trailing Z. Without it JavaScript's
# `new Date('2026-08-07T12:00:00')` parses the string as
# BROWSER-LOCAL and the timestamp silently shifts by the viewer's
# offset before any site-timezone formatting is applied.
'collectedat': _utciso(self.collectedat),
'createdat': _utciso(self.createdat),
}
if includepayload:
data['payloadjson'] = self.payload