Fix timestamps off by timezone offset in non-UTC containers (#504)

All backend timestamps used datetime.now() (server local time) or the
deprecated datetime.utcnow(). The frontend's parseUTCDate() assumes
timestamps without timezone indicators are UTC and appends 'Z', so
stored timestamps were off by the timezone offset when the container's
timezone wasn't UTC.

Backend: replaced datetime.now() and datetime.utcnow() with
datetime.now(timezone.utc) across 16 files (~80 call sites) for all
database fields and DB comparisons. Cosmetic timestamps (filenames,
user-facing local time formatting) intentionally left as local time.

Frontend: replaced 13 new Date(backendTimestamp) calls with
parseUTCDate() across 8 files to correctly interpret UTC timestamps.
This commit is contained in:
maziggy
2026-02-24 08:18:02 +01:00
parent 974988c4f1
commit ed36eafbec
30 changed files with 207 additions and 203 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
"""API routes for AMS sensor history."""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
@@ -44,7 +44,7 @@ async def get_ams_history(
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Get AMS sensor history for a specific printer and AMS unit."""
since = datetime.now() - timedelta(hours=hours)
since = datetime.now(timezone.utc) - timedelta(hours=hours)
# Get data points
result = await db.execute(
@@ -108,7 +108,7 @@ async def delete_old_history(
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Delete old AMS history data for a printer."""
cutoff = datetime.now() - timedelta(days=days)
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(func.count(AMSSensorHistory.id)).where(
+2 -2
View File
@@ -1438,11 +1438,11 @@ async def scan_timelapse(
# Strategy 4: If only one timelapse exists and archive was recently completed, use it
# This handles cases where printer clock is wrong or timezone issues exist
if not matching_file and len(video_files) == 1:
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
archive_completed = archive.completed_at or archive.created_at
if archive_completed:
time_since_completion = datetime.now() - archive_completed
time_since_completion = datetime.now(timezone.utc) - archive_completed
# If archive was completed within the last hour, assume the single timelapse is for it
if time_since_completion < timedelta(hours=1):
matching_file = video_files[0]
+3 -3
View File
@@ -1,7 +1,7 @@
"""Maintenance tracking API routes."""
import logging
from datetime import datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
@@ -303,7 +303,7 @@ async def _get_printer_maintenance_internal(
due_count = 0
warning_count = 0
now = datetime.utcnow()
now = datetime.now(timezone.utc)
for maint_type in all_types:
# Skip system types that don't apply to this printer model
@@ -591,7 +591,7 @@ async def perform_maintenance(
db.add(history)
# Update item
item.last_performed_at = datetime.utcnow()
item.last_performed_at = datetime.now(timezone.utc)
item.last_performed_hours = current_hours
await db.commit()
+8 -8
View File
@@ -2,7 +2,7 @@
import json
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, desc, func, select
@@ -194,11 +194,11 @@ async def test_all_notification_providers(
# Update provider status
if success:
provider.last_success = datetime.utcnow()
provider.last_success = datetime.now(timezone.utc)
success_count += 1
else:
provider.last_error = message
provider.last_error_at = datetime.utcnow()
provider.last_error_at = datetime.now(timezone.utc)
failed_count += 1
results.append(
@@ -248,7 +248,7 @@ async def get_notification_logs(
if success is not None:
query = query.where(NotificationLog.success == success)
if days is not None:
cutoff = datetime.utcnow() - timedelta(days=days)
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
query = query.where(NotificationLog.created_at >= cutoff)
query = query.offset(offset).limit(limit)
@@ -295,7 +295,7 @@ async def get_notification_log_stats(
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
):
"""Get notification log statistics."""
cutoff = datetime.utcnow() - timedelta(days=days)
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
# Total counts
total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
@@ -341,7 +341,7 @@ async def clear_notification_logs(
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
):
"""Clear old notification logs."""
cutoff = datetime.utcnow() - timedelta(days=older_than_days)
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
await db.commit()
@@ -446,10 +446,10 @@ async def test_notification_provider(
# Update provider status
if success:
provider.last_success = datetime.utcnow()
provider.last_success = datetime.now(timezone.utc)
else:
provider.last_error = message
provider.last_error_at = datetime.utcnow()
provider.last_error_at = datetime.now(timezone.utc)
await db.commit()
+3 -3
View File
@@ -3,7 +3,7 @@
import json
import logging
import zipfile
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
import defusedxml.ElementTree as ET
@@ -732,7 +732,7 @@ async def cancel_queue_item(
raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
item.status = "cancelled"
item.completed_at = datetime.now()
item.completed_at = datetime.now(timezone.utc)
await db.commit()
logger.info("Cancelled queue item %s", item_id)
@@ -775,7 +775,7 @@ async def stop_queue_item(
# Update queue item status regardless - if printer is off, print is already stopped
item.status = "cancelled"
item.completed_at = datetime.now()
item.completed_at = datetime.now(timezone.utc)
item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
await db.commit()
+6 -6
View File
@@ -1,7 +1,7 @@
"""API routes for smart plug management."""
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel
@@ -605,7 +605,7 @@ async def control_smart_plug(
elif expected_state == "OFF" and plug.printer_id:
# Mark printer offline immediately for faster UI update
printer_manager.mark_printer_offline(plug.printer_id)
plug.last_checked = datetime.utcnow()
plug.last_checked = datetime.now(timezone.utc)
await db.commit()
# Trigger associated scripts if this is a main (non-script) plug
@@ -692,7 +692,7 @@ async def get_plug_status(
# Update last state in database
if is_reachable and data.state:
plug.last_state = data.state
plug.last_checked = datetime.utcnow()
plug.last_checked = datetime.now(timezone.utc)
await db.commit()
energy_data = None
@@ -727,7 +727,7 @@ async def get_plug_status(
# Update last state in database
if status["reachable"]:
plug.last_state = status["state"]
plug.last_checked = datetime.utcnow()
plug.last_checked = datetime.now(timezone.utc)
await db.commit()
# Fetch energy data if device is reachable
@@ -756,7 +756,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
# Cooldown: don't alert more than once per 5 minutes
cooldown_minutes = 5
if plug.power_alert_last_triggered:
time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
time_since_last = datetime.now(timezone.utc) - plug.power_alert_last_triggered
if time_since_last < timedelta(minutes=cooldown_minutes):
return
@@ -777,7 +777,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
threshold = plug.power_alert_low
if alert_triggered:
plug.power_alert_last_triggered = datetime.utcnow()
plug.power_alert_last_triggered = datetime.now(timezone.utc)
await db.commit()
# Send notification