Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Responsive layout
- Timezone-corrected timestamps — all timestamps across jobs, failed jobs, history, processes, queues, recurring tasks, cache entries, cable channels, and the dashboard are now rendered as `<time datetime="ISO8601">` elements; a `timestamp` Stimulus controller reformats each one in the browser's local timezone using `Intl.DateTimeFormat`; relative timestamps (oldest cache entry, oldest cable message, cable message list) use `Intl.RelativeTimeFormat` and expose the full local timestamp as a hover title; degrades gracefully to UTC text when JS is unavailable

### Fixed

Expand Down
13 changes: 13 additions & 0 deletions app/helpers/solid_stack_web/application_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
module SolidStackWeb
module ApplicationHelper
def local_time(time, format: :short, placeholder: "—")
return placeholder if time.nil?

iso = time.utc.iso8601
fallback = case format
when :long then time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
when :relative then "#{time_ago_in_words(time)} ago"
else time.utc.strftime("%b %-d %H:%M UTC")
end
tag.time(fallback, datetime: iso,
data: { controller: "timestamp", timestamp_format_value: format })
end

def format_cache_value(raw)
str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
parsed = JSON.parse(str)
Expand Down
4 changes: 3 additions & 1 deletion app/javascript/solid_stack_web/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import SearchController from "solid_stack_web/search_controller"
import SelectionController from "solid_stack_web/selection_controller"
import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
import ThemeController from "solid_stack_web/theme_controller"
import TimestampController from "solid_stack_web/timestamp_controller"

const application = Application.start()
application.register("refresh", RefreshController)
application.register("search", SearchController)
application.register("selection", SelectionController)
application.register("sparkline-tooltip", SparklineTooltipController)
application.register("theme", ThemeController)
application.register("theme", ThemeController)
application.register("timestamp", TimestampController)
59 changes: 59 additions & 0 deletions app/javascript/solid_stack_web/timestamp_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static values = { format: { type: String, default: "short" } }

connect() {
const dt = this.element.getAttribute("datetime")
if (!dt) return
const date = new Date(dt)
if (isNaN(date.getTime())) return
this.element.textContent = this.formatDate(date)
if (this.formatValue === "relative") {
this.element.title = this.fullFormat(date)
}
}

formatDate(date) {
switch (this.formatValue) {
case "long":
return new Intl.DateTimeFormat(undefined, {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit"
}).format(date)
case "relative":
return this.relativeFormat(date)
default:
return new Intl.DateTimeFormat(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit"
}).format(date)
}
}

fullFormat(date) {
return new Intl.DateTimeFormat(undefined, {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
timeZoneName: "short"
}).format(date)
}

relativeFormat(date) {
const diff = date.getTime() - Date.now()
const absDiff = Math.abs(diff)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })

const seconds = Math.floor(absDiff / 1000)
if (seconds < 60) return rtf.format(diff < 0 ? -seconds : seconds, "second")

const minutes = Math.floor(absDiff / 60000)
if (minutes < 60) return rtf.format(diff < 0 ? -minutes : minutes, "minute")

const hours = Math.floor(absDiff / 3600000)
if (hours < 24) return rtf.format(diff < 0 ? -hours : hours, "hour")

const days = Math.floor(absDiff / 86400000)
return rtf.format(diff < 0 ? -days : days, "day")
}
}
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/cable/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<%= link_to channel[:channel], cable_channel_messages_path(channel[:channel_hash]), class: "sqw-link" %>
</td>
<td><%= channel[:message_count] %></td>
<td class="sqw-muted"><%= channel[:last_message_at]&.strftime("%b %d %H:%M") %></td>
<td class="sqw-muted"><%= local_time(channel[:last_message_at]) %></td>
</tr>
<% end %>
</tbody>
Expand Down
4 changes: 2 additions & 2 deletions app/views/solid_stack_web/cable_messages/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
<td class="sqw-monospace sqw-truncate" title="<%= message.payload %>">
<%= truncate(message.payload.to_s, length: 120) %>
</td>
<td class="sqw-muted" title="<%= message.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %>">
<%= time_ago_in_words(message.created_at) %> ago
<td class="sqw-muted">
<%= local_time(message.created_at, format: :relative) %>
</td>
</tr>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/cache_entries/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
</td>
<td><%= number_to_human_size(entry.byte_size) %></td>
<td class="sqw-muted"><%= entry.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-muted"><%= local_time(entry.created_at) %></td>
<td class="sqw-actions">
<%= button_to "Delete",
cache_entry_path(entry, q: @search, column: @sort["column"], direction: @sort["direction"]),
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/cache_entries/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</div>
<div class="sqw-detail__row">
<dt>Created</dt>
<dd class="sqw-muted"><%= @entry.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %></dd>
<dd class="sqw-muted"><%= local_time(@entry.created_at, format: :long) %></dd>
</div>
</dl>

Expand Down
4 changes: 2 additions & 2 deletions app/views/solid_stack_web/dashboard/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
<% if @cache_stats[:oldest_entry] %>
<div class="sqw-inline-stat sqw-inline-stat--cache">
<span class="sqw-inline-stat__label">Oldest</span>
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm" title="<%= @cache_stats[:oldest_entry].strftime("%b %d, %Y %H:%M") %>"><%= time_ago_in_words(@cache_stats[:oldest_entry]) %></span>
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm"><%= local_time(@cache_stats[:oldest_entry], format: :relative) %></span>
</div>
<% end %>
</div>
Expand All @@ -114,7 +114,7 @@
<% if @cable_stats[:oldest_message] %>
<div class="sqw-inline-stat sqw-inline-stat--cable">
<span class="sqw-inline-stat__label">Oldest</span>
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm" title="<%= @cable_stats[:oldest_message].strftime("%b %d, %Y %H:%M") %>"><%= time_ago_in_words(@cable_stats[:oldest_message]) %></span>
<span class="sqw-inline-stat__value sqw-inline-stat__value--sm"><%= local_time(@cable_stats[:oldest_message], format: :relative) %></span>
</div>
<% end %>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<td class="sqw-monospace"><%= link_to execution.job.class_name, failed_job_path(execution) %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
<td class="sqw-muted sqw-truncate" title="<%= execution.exception_class %>: <%= execution.message %>"><%= execution.exception_class %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-muted"><%= local_time(execution.created_at) %></td>
<td class="sqw-actions">
<%= button_to "Retry", retry_failed_job_path(execution),
method: :post, class: "sqw-btn sqw-btn--sm" %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/failed_jobs/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<dd class="sqw-monospace sqw-truncate" title="<%= @execution.job.active_job_id %>"><%= @execution.job.active_job_id.presence || "—" %></dd>

<dt>Failed At</dt>
<dd class="sqw-monospace"><%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
<dd class="sqw-monospace"><%= local_time(@execution.created_at, format: :long) %></dd>

<dt>Error</dt>
<dd class="sqw-monospace"><%= @execution.exception_class %></dd>
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/history/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
class: "sqw-badge sqw-badge--queue" %>
</td>
<td class="sqw-monospace"><%= format_duration(job.finished_at - job.created_at) %></td>
<td class="sqw-muted"><%= job.finished_at.strftime("%b %d %H:%M:%S") %></td>
<td class="sqw-muted"><%= local_time(job.finished_at) %></td>
</tr>
<% end %>
</tbody>
Expand Down
4 changes: 2 additions & 2 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@
<td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
<td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
<td><%= execution.job.priority %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-muted"><%= local_time(execution.created_at) %></td>
<% if @status == "scheduled" %>
<td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= execution.scheduled_at&.strftime("%b %d %H:%M") %></td>
<td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= local_time(execution.scheduled_at) %></td>
<% end %>
<td class="sqw-actions">
<% if @status == "scheduled" %>
Expand Down
8 changes: 4 additions & 4 deletions app/views/solid_stack_web/jobs/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@

<% if @status == "blocked" %>
<dt>Blocked Until</dt>
<dd class="sqw-monospace"><%= @execution.expires_at ? @execution.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<dd class="sqw-monospace"><%= local_time(@execution.expires_at, format: :long) %></dd>
<% end %>

<dt>Enqueued At</dt>
<dd class="sqw-monospace"><%= @execution.job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
<dd class="sqw-monospace"><%= local_time(@execution.job.created_at, format: :long) %></dd>

<dt>Scheduled At</dt>
<dd class="sqw-monospace"><%= @execution.job.scheduled_at ? @execution.job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<dd class="sqw-monospace"><%= local_time(@execution.job.scheduled_at, format: :long) %></dd>

<dt>Finished At</dt>
<dd class="sqw-monospace"><%= @execution.job.finished_at ? @execution.job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
<dd class="sqw-monospace"><%= local_time(@execution.job.finished_at, format: :long) %></dd>
</dl>
</div>

Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/processes/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<td class="sqw-monospace"><%= process.name %></td>
<td class="sqw-monospace"><%= process.pid %></td>
<td class="sqw-muted"><%= process.hostname %></td>
<td class="sqw-muted"><%= process.last_heartbeat_at&.strftime("%b %d %H:%M:%S") %></td>
<td class="sqw-muted"><%= local_time(process.last_heartbeat_at) %></td>
</tr>
<% end %>
</tbody>
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_stack_web/queues/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
data: { turbo_frame: "_top" } %>
</td>
<td><%= execution.job.priority %></td>
<td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
<td class="sqw-muted"><%= local_time(execution.created_at) %></td>
<td class="sqw-actions">
<%= button_to "Discard", job_path(execution, status: "ready", queue: @queue_name),
method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
Expand Down
7 changes: 3 additions & 4 deletions app/views/solid_stack_web/recurring_tasks/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,11 @@
</td>
<td><span class="sqw-badge sqw-badge--queue"><%= task.queue_name.presence || "default" %></span></td>
<td class="sqw-muted sqw-monospace">
<% next_run = begin; task.next_time.strftime("%b %d %H:%M"); rescue; nil; end %>
<%= next_run || "—" %>
<% next_run = begin; task.next_time; rescue; nil; end %>
<%= local_time(next_run) %>
</td>
<td class="sqw-muted sqw-monospace">
<% last_run = task.last_enqueued_time %>
<%= last_run ? last_run.strftime("%b %d %H:%M") : "—" %>
<%= local_time(task.last_enqueued_time) %>
</td>
<td>
<% if task.static? %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<% else %>
<%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
<td id="scheduled_at_<%= @execution.id %>" class="sqw-muted">
<%= @execution.scheduled_at.strftime("%b %d %H:%M") %>
<%= local_time(@execution.scheduled_at) %>
</td>
<% end %>
<% end %>
1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
pin "solid_stack_web/theme_controller", to: "solid_stack_web/theme_controller.js"
pin "solid_stack_web/timestamp_controller", to: "solid_stack_web/timestamp_controller.js"