feat: display all timestamps in user's timezone

- Add timezone to socket assigns in mount_current_scope
- Detect user timezone from browser using Intl.DateTimeFormat
- Auto-save detected timezone to user profile on first connection
- Update all templates to use TimeHelpers with timezone parameter
- Update agent helper functions to accept timezone parameter
- Templates updated:
  - Alert list (triggered, acknowledged, resolved)
  - Device list (last checked)
  - Agent list and show (created, last seen, updated)
  - Admin dashboard (impersonation logs)
  - Admin org/user lists (created/joined dates)
  - Main dashboard (alert times)
This commit is contained in:
Graham McIntire 2026-01-18 12:19:17 -06:00
parent 35f0ac495b
commit d2e38e351e
No known key found for this signature in database
12 changed files with 66 additions and 38 deletions

View file

@ -350,9 +350,12 @@ if (!csrfToken) {
throw new Error('CSRF token meta tag not found')
}
// Detect user's timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
params: { _csrf_token: csrfToken, timezone: userTimezone },
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash },
})

View file

@ -5,13 +5,13 @@ metadata:
name: towerops
namespace: towerops
spec:
replicas: 4
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # Allow 1 extra pod during rollout
maxUnavailable: 0 # Keep all pods running during rollout
minReadySeconds: 10 # Wait 10s after pod is ready before continuing
maxSurge: 1 # Allow 1 extra pod during rollout
maxUnavailable: 0 # Keep all pods running during rollout
minReadySeconds: 10 # Wait 10s after pod is ready before continuing
selector:
matchLabels:
app: towerops
@ -20,7 +20,7 @@ spec:
labels:
app: towerops
spec:
terminationGracePeriodSeconds: 30 # Allow 30s for graceful shutdown
terminationGracePeriodSeconds: 30 # Allow 30s for graceful shutdown
imagePullSecrets:
- name: gitlab-registry
securityContext:
@ -63,7 +63,7 @@ spec:
fieldRef:
fieldPath: metadata.namespace
- name: DEPLOY_TIMESTAMP
value: "1970-01-01T00:00:00Z" # Placeholder, updated by GitLab CI on deploy
value: "1970-01-01T00:00:00Z" # Placeholder, updated by GitLab CI on deploy
- name: RELEASE_DISTRIBUTION
value: "name"
- name: RELEASE_NODE
@ -108,7 +108,7 @@ spec:
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12 # 10s + (12 * 5s) = 70s max startup time
failureThreshold: 12 # 10s + (12 * 5s) = 70s max startup time
livenessProbe:
httpGet:
path: /health

View file

@ -32,7 +32,7 @@
<:col :let={log} label="Superuser">{log.superuser && log.superuser.email}</:col>
<:col :let={log} label="Target">{log.target_user && log.target_user.email}</:col>
<:col :let={log} label="Time">
{Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S")}
{ToweropsWeb.TimeHelpers.format_iso8601(log.inserted_at, @timezone)}
</:col>
</.table>
</div>

View file

@ -10,7 +10,9 @@
<:col :let={org} label="Name">{org.name}</:col>
<:col :let={org} label="Slug">{org.slug}</:col>
<:col :let={org} label="Members">{length(org.memberships)}</:col>
<:col :let={org} label="Created">{Calendar.strftime(org.inserted_at, "%Y-%m-%d")}</:col>
<:col :let={org} label="Created">
{ToweropsWeb.TimeHelpers.format_date(org.inserted_at, @timezone)}
</:col>
<:col :let={org} label="">
<button
phx-click="delete_org"

View file

@ -10,7 +10,9 @@
<:col :let={user} label="Email">{user.email}</:col>
<:col :let={user} label="Superuser">{if user.is_superuser, do: "Yes", else: "No"}</:col>
<:col :let={user} label="Organizations">{length(user.organizations)}</:col>
<:col :let={user} label="Joined">{Calendar.strftime(user.inserted_at, "%Y-%m-%d")}</:col>
<:col :let={user} label="Joined">
{ToweropsWeb.TimeHelpers.format_date(user.inserted_at, @timezone)}
</:col>
<:col :let={user} label="">
<div class="flex gap-2">
<form action={~p"/admin/impersonate/#{user.id}"} method="post">

View file

@ -84,31 +84,35 @@ defmodule ToweropsWeb.AgentLive.Helpers do
@doc """
Formats a DateTime into a full date and time string.
Returns strings like "Jan 15, 2026 at 2:34 PM UTC".
Returns strings like "Jan 15, 2026 at 2:34 PM EST".
Delegates to ToweropsWeb.TimeHelpers.format_datetime/1.
Delegates to ToweropsWeb.TimeHelpers.format_datetime/2.
"""
defdelegate format_datetime(datetime), to: ToweropsWeb.TimeHelpers
def format_datetime(datetime, timezone \\ "UTC") do
ToweropsWeb.TimeHelpers.format_datetime(datetime, timezone)
end
@doc """
Formats a DateTime into a short date string.
Returns strings like "Jan 15, 2026".
Delegates to ToweropsWeb.TimeHelpers.format_date/1.
Delegates to ToweropsWeb.TimeHelpers.format_date/2.
"""
defdelegate format_date(datetime), to: ToweropsWeb.TimeHelpers
def format_date(datetime, timezone \\ "UTC") do
ToweropsWeb.TimeHelpers.format_date(datetime, timezone)
end
@doc """
Formats a DateTime into a relative time with full datetime in parentheses.
Returns strings like "5m ago (Jan 15, 2026 at 2:34 PM UTC)".
Returns strings like "5m ago (Jan 15, 2026 at 2:34 PM EST)".
"""
def format_last_seen_with_date(nil), do: "Never"
def format_last_seen_with_date(nil, _timezone), do: "Never"
def format_last_seen_with_date(datetime) do
def format_last_seen_with_date(datetime, timezone \\ "UTC") do
relative = format_last_seen(datetime)
full = format_datetime(datetime)
full = format_datetime(datetime, timezone)
"#{relative} (#{full})"
end
end

View file

@ -103,7 +103,7 @@
</div>
<%= if agent.last_seen_at do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{format_datetime(agent.last_seen_at)}
{format_datetime(agent.last_seen_at, @timezone)}
</div>
<% end %>
<%= if agent.last_ip do %>
@ -124,7 +124,7 @@
<:col :let={agent} label="Created">
<div class="text-sm text-zinc-600 dark:text-zinc-400">
{Calendar.strftime(agent.inserted_at, "%b %d, %Y")}
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>

View file

@ -68,7 +68,7 @@
</p>
<%= if @agent_token.last_seen_at do %>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{format_datetime(@agent_token.last_seen_at)}
{format_datetime(@agent_token.last_seen_at, @timezone)}
</p>
<% end %>
<%= if @agent_token.last_ip do %>
@ -150,7 +150,7 @@
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Created</dt>
<dd class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
{format_datetime(@agent_token.inserted_at)}
{format_datetime(@agent_token.inserted_at, @timezone)}
</dd>
<dd class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{format_last_seen(@agent_token.inserted_at)} (created)
@ -160,7 +160,7 @@
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Last Updated</dt>
<dd class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
{format_datetime(@agent_token.updated_at)}
{format_datetime(@agent_token.updated_at, @timezone)}
</dd>
<dd class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{format_last_seen(@agent_token.updated_at)}
@ -172,7 +172,7 @@
Last Seen (Heartbeat)
</dt>
<dd class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
{format_datetime(@agent_token.last_seen_at)}
{format_datetime(@agent_token.last_seen_at, @timezone)}
</dd>
<%= if @agent_token.last_seen_at do %>
<dd class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">

View file

@ -106,13 +106,13 @@
<div class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<div>
<strong class="font-medium">Triggered:</strong>
{Calendar.strftime(alert.triggered_at, "%Y-%m-%d %H:%M:%S UTC")}
{ToweropsWeb.TimeHelpers.format_iso8601(alert.triggered_at, @timezone)}
</div>
<%= if alert.acknowledged_at do %>
<div>
<strong class="font-medium">Acknowledged:</strong>
{Calendar.strftime(alert.acknowledged_at, "%Y-%m-%d %H:%M:%S UTC")}
{ToweropsWeb.TimeHelpers.format_iso8601(alert.acknowledged_at, @timezone)}
<%= if alert.acknowledged_by do %>
by {alert.acknowledged_by.email}
<% end %>
@ -122,7 +122,7 @@
<%= if alert.resolved_at do %>
<div>
<strong class="font-medium">Resolved:</strong>
{Calendar.strftime(alert.resolved_at, "%Y-%m-%d %H:%M:%S UTC")}
{ToweropsWeb.TimeHelpers.format_iso8601(alert.resolved_at, @timezone)}
</div>
<% end %>

View file

@ -163,7 +163,7 @@
<p class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-300">{alert.message}</p>
</div>
<span class="text-xs text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
{Calendar.strftime(alert.triggered_at, "%H:%M")}
{ToweropsWeb.TimeHelpers.format_datetime(alert.triggered_at, @timezone)}
</span>
</div>
<% end %>

View file

@ -94,11 +94,7 @@
</:col>
<:col :let={eq} label="Last Checked">
<span class="text-sm text-zinc-600 dark:text-zinc-400">
<%= if eq.last_checked_at do %>
{Calendar.strftime(eq.last_checked_at, "%Y-%m-%d %H:%M")}
<% else %>
Never
<% end %>
{ToweropsWeb.TimeHelpers.format_datetime(eq.last_checked_at, @timezone)}
</span>
</:col>
</.table>

View file

@ -460,9 +460,30 @@ defmodule ToweropsWeb.UserAuth do
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
build_scope_from_session(session)
end)
socket =
Phoenix.Component.assign_new(socket, :current_scope, fn ->
build_scope_from_session(session)
end)
# Get timezone from user profile, connect params, or default to UTC
user = socket.assigns.current_scope && socket.assigns.current_scope.user
connect_timezone = get_in(socket.private, [:connect_params, "timezone"])
timezone =
cond do
user && user.timezone -> user.timezone
connect_timezone -> connect_timezone
true -> "UTC"
end
# If user is logged in but doesn't have a timezone set, update their profile
if user && !user.timezone && connect_timezone do
Task.start(fn ->
Accounts.update_user_profile(user, %{timezone: connect_timezone})
end)
end
Phoenix.Component.assign(socket, :timezone, timezone)
end
defp build_scope_from_session(session) do