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:
parent
35f0ac495b
commit
d2e38e351e
12 changed files with 66 additions and 38 deletions
|
|
@ -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 },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue