backups: show timestamps in the site timezone
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:
@@ -12,6 +12,9 @@ because requiring the app server to mount the SFLD share would turn a
|
|||||||
permissions slip into an unexplained empty download.
|
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 import Blueprint, current_app, request, Response
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
@@ -22,15 +25,42 @@ from shopdb.api import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..models import BackupRevision
|
from ..models import BackupRevision
|
||||||
|
from ..models.backup import _utciso
|
||||||
from ..services.registry import REGISTRY, getkind
|
from ..services.registry import REGISTRY, getkind
|
||||||
|
|
||||||
backups_bp = Blueprint('backups', __name__)
|
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):
|
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
|
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:
|
if revision.sourcefilename:
|
||||||
return '{} - {}'.format(stamp, revision.sourcefilename)
|
return '{} - {}'.format(stamp, revision.sourcefilename)
|
||||||
return stamp
|
return stamp
|
||||||
@@ -129,8 +159,7 @@ def asset_info(assetid):
|
|||||||
revision.payload, assetid,
|
revision.payload, assetid,
|
||||||
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
|
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
|
||||||
data['backuprevisionid'] = revision.backuprevisionid
|
data['backuprevisionid'] = revision.backuprevisionid
|
||||||
data['collectedat'] = (revision.collectedat.isoformat()
|
data['collectedat'] = _utciso(revision.collectedat)
|
||||||
if revision.collectedat else None)
|
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -111,11 +112,13 @@ const diffs = ref({})
|
|||||||
const assetname = ref('')
|
const assetname = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
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) {
|
function formatWhen(value) {
|
||||||
if (!value) return 'unknown'
|
if (!value) return 'unknown'
|
||||||
const d = new Date(value)
|
return formatInZone(value, siteTimezone.value, { second: '2-digit' }) || value
|
||||||
return isNaN(d) ? value : d.toLocaleString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function display(value) {
|
function display(value) {
|
||||||
@@ -124,6 +127,16 @@ function display(value) {
|
|||||||
return String(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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -183,7 +196,10 @@ async function download(rev, fmt) {
|
|||||||
URL.revokeObjectURL(link.href)
|
URL.revokeObjectURL(link.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(async () => {
|
||||||
|
await loadTimezone()
|
||||||
|
await load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ from datetime import datetime
|
|||||||
from shopdb.api import db
|
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):
|
class BackupRevision(db.Model):
|
||||||
"""A single point-in-time configuration snapshot of an asset."""
|
"""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.
|
# download <machinenumber>.reg without a second round trip.
|
||||||
'assetnumber': self.asset.assetnumber if self.asset else None,
|
'assetnumber': self.asset.assetnumber if self.asset else None,
|
||||||
'sourcehostname': self.sourcehostname,
|
'sourcehostname': self.sourcehostname,
|
||||||
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
|
# Both columns hold NAIVE UTC (see collectedat above), so the wire
|
||||||
'createdat': self.createdat.isoformat() if self.createdat else None,
|
# 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:
|
if includepayload:
|
||||||
data['payloadjson'] = self.payload
|
data['payloadjson'] = self.payload
|
||||||
|
|||||||
@@ -669,3 +669,44 @@ def test_a_kind_that_sets_emptytext_still_gets_it():
|
|||||||
|
|
||||||
def test_base_kind_defaults_to_hiding_when_empty():
|
def test_base_kind_defaults_to_hiding_when_empty():
|
||||||
assert registry.BackupKind.emptytext is None
|
assert registry.BackupKind.emptytext is None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Timestamp wire format
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_timestamps_are_serialised_as_utc(bk_app, bk_plugin):
|
||||||
|
"""Naive ISO is parsed as BROWSER-LOCAL by JavaScript, silently shifting
|
||||||
|
every timestamp by the viewer's offset before any site-timezone formatting
|
||||||
|
runs. The wire format has to say the value is UTC."""
|
||||||
|
from plugins.backups.models import BackupRevision
|
||||||
|
with bk_app.app_context():
|
||||||
|
result = bk_plugin.apply_collector_payload(
|
||||||
|
_payload(collectedat='2026-08-07T12:00:00Z'))
|
||||||
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||||
|
data = revision.to_dict()
|
||||||
|
assert data['collectedat'].endswith('Z')
|
||||||
|
assert data['createdat'].endswith('Z')
|
||||||
|
# 12:00Z stored naive-UTC, so it round-trips as the same wall clock.
|
||||||
|
assert data['collectedat'].startswith('2026-08-07T12:00:00')
|
||||||
|
|
||||||
|
|
||||||
|
def test_utciso_helper_passes_none_through():
|
||||||
|
from plugins.backups.models.backup import _utciso
|
||||||
|
assert _utciso(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_is_rendered_in_the_site_zone(bk_app, bk_plugin):
|
||||||
|
"""The label is baked server-side, so the client cannot correct it later.
|
||||||
|
A UTC afternoon is the morning of the same day at West Jefferson."""
|
||||||
|
from shopdb.core.models import Setting
|
||||||
|
from plugins.backups.models import BackupRevision
|
||||||
|
from plugins.backups.api.routes import _label
|
||||||
|
with bk_app.app_context():
|
||||||
|
_db.session.add(Setting(key='site_timezone', value='America/New_York'))
|
||||||
|
_db.session.commit()
|
||||||
|
result = bk_plugin.apply_collector_payload(
|
||||||
|
_payload(collectedat='2026-08-07T16:30:00Z'))
|
||||||
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||||
|
# 16:30 UTC on 2026-08-07 is 12:30 EDT.
|
||||||
|
assert '12:30' in _label(revision)
|
||||||
|
|||||||
Reference in New Issue
Block a user