solid_stack_web 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -0
  3. data/app/assets/stylesheets/solid_stack_web/_01_base.css +3 -2
  4. data/app/assets/stylesheets/solid_stack_web/_02_layout.css +28 -0
  5. data/app/assets/stylesheets/solid_stack_web/_04_table.css +13 -1
  6. data/app/assets/stylesheets/solid_stack_web/_10_responsive.css +54 -0
  7. data/app/assets/stylesheets/solid_stack_web/_12_dark_mode.css +34 -0
  8. data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +4 -3
  9. data/app/controllers/solid_stack_web/failed_jobs_controller.rb +2 -1
  10. data/app/controllers/solid_stack_web/jobs/selections_controller.rb +2 -2
  11. data/app/controllers/solid_stack_web/jobs_controller.rb +4 -2
  12. data/app/helpers/solid_stack_web/application_helper.rb +13 -0
  13. data/app/javascript/solid_stack_web/application.js +5 -1
  14. data/app/javascript/solid_stack_web/theme_controller.js +26 -0
  15. data/app/javascript/solid_stack_web/timestamp_controller.js +59 -0
  16. data/app/views/layouts/solid_stack_web/application.html.erb +8 -4
  17. data/app/views/solid_stack_web/cable/index.html.erb +8 -2
  18. data/app/views/solid_stack_web/cable_messages/index.html.erb +9 -3
  19. data/app/views/solid_stack_web/cache_entries/index.html.erb +8 -2
  20. data/app/views/solid_stack_web/cache_entries/show.html.erb +1 -1
  21. data/app/views/solid_stack_web/dashboard/index.html.erb +2 -2
  22. data/app/views/solid_stack_web/failed_jobs/destroy.turbo_stream.erb +3 -0
  23. data/app/views/solid_stack_web/failed_jobs/index.html.erb +3 -2
  24. data/app/views/solid_stack_web/failed_jobs/show.html.erb +1 -1
  25. data/app/views/solid_stack_web/history/index.html.erb +8 -2
  26. data/app/views/solid_stack_web/jobs/_empty.html.erb +19 -2
  27. data/app/views/solid_stack_web/jobs/destroy.turbo_stream.erb +3 -0
  28. data/app/views/solid_stack_web/jobs/index.html.erb +2 -2
  29. data/app/views/solid_stack_web/jobs/show.html.erb +4 -4
  30. data/app/views/solid_stack_web/processes/index.html.erb +3 -2
  31. data/app/views/solid_stack_web/queues/index.html.erb +2 -1
  32. data/app/views/solid_stack_web/queues/show.html.erb +1 -1
  33. data/app/views/solid_stack_web/recurring_tasks/index.html.erb +5 -5
  34. data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +1 -1
  35. data/app/views/solid_stack_web/shared/_flash.html.erb +1 -0
  36. data/app/views/solid_stack_web/stats/index.html.erb +2 -1
  37. data/config/importmap.rb +2 -0
  38. data/lib/solid_stack_web/version.rb +1 -1
  39. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d16b6a549b38e32fd792d4bff55d990ae5a80d458dcbc47df54759c947536c6
4
- data.tar.gz: 2ff73287be32fa8a3d3a1089ebd71656bed2ce80742cd11296a3458e0ebeaae3
3
+ metadata.gz: 8466d9dd8464ac19cb839fa2a2396b28472564503cf07779368ba9fb784e1d5d
4
+ data.tar.gz: b4b3720aa4fb16a595d1670cc6f0f59c99da6ab19fab81aa52dc52119bca6edf
5
5
  SHA512:
6
- metadata.gz: bd5aefe87facc4b0ac5f9c2265fa714d6d70127b4c785fc450a210dbcb0958bd03b7aef09de7b0555d4b54de7defe702a464c296357fdb49411b5b532dce341d
7
- data.tar.gz: 8a0527f29907807f16711e72bbfc13833a0be47481d4e540e1ad1a324dad63eb73da4c5f5c0043bfcdfd6ae458d5163af66e2e350fd9c5ecd76fe13c70b35913
6
+ metadata.gz: edb56bb1198b125201acc8bffb09a364c487890eab1eb6f18a6fc5759a3786e8748fcda239a83ef6e232064d538dc0d439f7c07775d022e42384de665b793644
7
+ data.tar.gz: e0f469d95f9d9fb03e1654110f7e9751859f735aa3402407f022eb75d2c4c6ad1546d398692324a6bb9c079b6f767fc901aa1e714f0d9e1c27a22b613ca2641b
data/README.md CHANGED
@@ -48,6 +48,10 @@ The dashboard will be available at `/solid_stack` (or whatever path you choose).
48
48
  - **Job history view** — paginated list of all finished jobs with class name, queue, duration, and finished-at time; filterable by queue (click a badge), class substring, and time period; CSV export respects active filters
49
49
  - **Auto-refresh** — dashboard, jobs, processes, and history views poll automatically; pauses when the tab is hidden or a checkbox is checked; intervals configurable via `dashboard_refresh_interval` and `default_refresh_interval`
50
50
  - **Turbo Stream** job discard — removes the row inline without a full page reload
51
+ - **Dark mode** — toggle button in the header switches between light and dark palettes; preference persisted in `localStorage`; respects `prefers-color-scheme` on first visit
52
+ - **Responsive layout** — stats cards, tables, and two-column grids adapt to narrow viewports; tables scroll horizontally rather than overflow; split page headers stack on small screens
53
+ - **Empty-state improvements** — all list views show a contextual title and an actionable hint; search empty states include a "Clear search" link; filters-active history view offers "Clear filters"; processes and recurring tasks explain the next step
54
+ - **Inline notifications** — bulk and single-job actions surface a flash notice; Turbo Stream discard responses inject the message inline without a full page reload; bulk actions report the affected count ("3 jobs discarded")
51
55
 
52
56
  ### Configuration
53
57
 
@@ -12,8 +12,9 @@
12
12
  --success: #198754;
13
13
  --info: #0dcaf0;
14
14
  --purple: #6f42c1;
15
- --radius: 6px;
16
- --shadow: 0 1px 3px rgba(0,0,0,.08);
15
+ --radius: 6px;
16
+ --shadow: 0 1px 3px rgba(0,0,0,.08);
17
+ --surface-hover: #f9fafb;
17
18
  }
18
19
 
19
20
  body {
@@ -17,6 +17,27 @@
17
17
  height: 52px;
18
18
  }
19
19
 
20
+ .sqw-header__inner .sqw-nav { flex: 1; }
21
+
22
+ .sqw-theme-toggle {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ width: 32px;
27
+ height: 32px;
28
+ padding: 0;
29
+ background: none;
30
+ border: 1px solid var(--border);
31
+ border-radius: var(--radius);
32
+ cursor: pointer;
33
+ font-size: 16px;
34
+ color: var(--text);
35
+ line-height: 1;
36
+ flex-shrink: 0;
37
+ transition: background 0.1s, border-color 0.1s;
38
+ }
39
+ .sqw-theme-toggle:hover { background: var(--bg); }
40
+
20
41
  .sqw-header__logo {
21
42
  font-weight: 700;
22
43
  font-size: 16px;
@@ -71,6 +92,13 @@
71
92
  }
72
93
 
73
94
  .sqw-page-header { margin-bottom: 1.25rem; }
95
+ .sqw-page-header--split {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ gap: 0.75rem;
100
+ flex-wrap: wrap;
101
+ }
74
102
  .sqw-page-title { font-size: 20px; font-weight: 600; }
75
103
  .sqw-page-title-row { display: flex; align-items: center; gap: 0.5rem; }
76
104
 
@@ -25,7 +25,7 @@
25
25
  }
26
26
 
27
27
  .sqw-table tbody tr:last-child td { border-bottom: none; }
28
- .sqw-table tbody tr:hover { background: #f9fafb; }
28
+ .sqw-table tbody tr:hover { background: var(--surface-hover); }
29
29
 
30
30
  .sqw-actions { text-align: right; white-space: nowrap; }
31
31
  .sqw-actions form { display: inline; }
@@ -42,3 +42,15 @@
42
42
  text-align: center;
43
43
  color: var(--muted);
44
44
  }
45
+
46
+ .sqw-empty__title {
47
+ font-size: 15px;
48
+ font-weight: 600;
49
+ color: var(--text);
50
+ margin-bottom: 0.375rem;
51
+ }
52
+
53
+ .sqw-empty__hint {
54
+ font-size: 13px;
55
+ margin-top: 0.375rem;
56
+ }
@@ -0,0 +1,54 @@
1
+ @media (max-width: 768px) {
2
+ .sqw-size-grid { grid-template-columns: 1fr; }
3
+ .sqw-timeline-grid:not(.sqw-timeline-grid--single) { grid-template-columns: 1fr; }
4
+ .sqw-timeline-chart + .sqw-timeline-chart { border-left: none; border-top: 1px solid var(--border); }
5
+ }
6
+
7
+ @media (max-width: 640px) {
8
+ .sqw-main {
9
+ padding: 1rem;
10
+ }
11
+
12
+ .sqw-page-header--split {
13
+ flex-direction: column;
14
+ align-items: flex-start;
15
+ }
16
+
17
+ .sqw-table {
18
+ display: block;
19
+ overflow-x: auto;
20
+ border-radius: 0;
21
+ }
22
+
23
+ .sqw-stats-grid {
24
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
25
+ }
26
+
27
+ .sqw-gem-grid {
28
+ grid-template-columns: 1fr;
29
+ }
30
+
31
+ .sqw-truncate {
32
+ max-width: 160px;
33
+ }
34
+
35
+ .sqw-period-filter {
36
+ margin-left: 0;
37
+ }
38
+ }
39
+
40
+ @media (max-width: 576px) {
41
+ .sqw-header__inner {
42
+ padding: 0 1rem;
43
+ gap: 1rem;
44
+ }
45
+
46
+ .sqw-subnav__inner {
47
+ padding: 0 1rem;
48
+ height: auto;
49
+ min-height: 36px;
50
+ flex-wrap: wrap;
51
+ padding-top: 0.25rem;
52
+ padding-bottom: 0.25rem;
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ [data-theme="dark"] {
2
+ --bg: #0d1117;
3
+ --surface: #161b22;
4
+ --border: #30363d;
5
+ --text: #e6edf3;
6
+ --muted: #8b949e;
7
+ --primary: #58a6ff;
8
+ --danger: #f85149;
9
+ --warning: #d29922;
10
+ --success: #3fb950;
11
+ --info: #39c5cf;
12
+ --purple: #bc8cff;
13
+ --shadow: 0 1px 3px rgba(0,0,0,.4);
14
+ --surface-hover: #1c2128;
15
+ }
16
+
17
+ [data-theme="dark"] .sqw-badge--ready { background: rgba(63,185,80,.15); color: var(--success); }
18
+ [data-theme="dark"] .sqw-badge--scheduled { background: rgba(57,197,207,.15); color: var(--info); }
19
+ [data-theme="dark"] .sqw-badge--claimed { background: rgba(88,166,255,.15); color: var(--primary); }
20
+ [data-theme="dark"] .sqw-badge--blocked { background: rgba(210,153,34,.15); color: var(--warning); }
21
+ [data-theme="dark"] .sqw-badge--failed { background: rgba(248,81,73,.15); color: var(--danger); }
22
+ [data-theme="dark"] .sqw-badge--paused { background: rgba(139,148,158,.15); color: var(--muted); }
23
+ [data-theme="dark"] .sqw-badge--queue { background: rgba(139,148,158,.15); color: var(--muted); }
24
+ [data-theme="dark"] .sqw-badge--worker { background: rgba(88,166,255,.15); color: var(--primary); }
25
+ [data-theme="dark"] .sqw-badge--supervisor { background: rgba(63,185,80,.15); color: var(--success); }
26
+ [data-theme="dark"] .sqw-badge--dispatcher { background: rgba(210,153,34,.15); color: var(--warning); }
27
+
28
+ [data-theme="dark"] .sqw-flash--notice { background: #1b3a2b; color: #3fb950; border-color: #2d6a4f; }
29
+ [data-theme="dark"] .sqw-flash--alert { background: #3d1118; color: #f85149; border-color: #6a2030; }
30
+
31
+ [data-theme="dark"] .sqw-theme-toggle {
32
+ border-color: var(--border);
33
+ color: var(--text);
34
+ }
@@ -4,16 +4,17 @@ module SolidStackWeb
4
4
  before_action :set_ids
5
5
 
6
6
  def create
7
+ count = @ids.size
7
8
  SolidQueue::FailedExecution.where(id: @ids).each(&:retry)
8
- redirect_to failed_jobs_path
9
+ redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} retried."
9
10
  rescue => e
10
11
  redirect_to failed_jobs_path, alert: "Could not retry jobs: #{e.message}"
11
12
  end
12
13
 
13
14
  def destroy
14
15
  job_ids = SolidQueue::FailedExecution.where(id: @ids).pluck(:job_id)
15
- SolidQueue::Job.where(id: job_ids).destroy_all
16
- redirect_to failed_jobs_path
16
+ count = SolidQueue::Job.where(id: job_ids).destroy_all.size
17
+ redirect_to failed_jobs_path, notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
17
18
  rescue => e
18
19
  redirect_to failed_jobs_path, alert: "Could not discard jobs: #{e.message}"
19
20
  end
@@ -25,6 +25,7 @@ module SolidStackWeb
25
25
  @execution = ::SolidQueue::FailedExecution.find(params[:id])
26
26
  @execution.job.destroy!
27
27
  @executions_remain = ::SolidQueue::FailedExecution.exists?
28
+ @notice = "Job discarded."
28
29
 
29
30
  respond_to do |format|
30
31
  format.html { redirect_to failed_jobs_path }
@@ -35,7 +36,7 @@ module SolidStackWeb
35
36
  def retry
36
37
  execution = ::SolidQueue::FailedExecution.find(params[:id])
37
38
  execution.retry
38
- redirect_to failed_jobs_path
39
+ redirect_to failed_jobs_path, notice: "Job retried."
39
40
  end
40
41
 
41
42
  private
@@ -7,7 +7,7 @@ module SolidStackWeb
7
7
 
8
8
  ids = Array(params[:job_ids]).map(&:to_i).reject(&:zero?)
9
9
  job_ids = Job::EXECUTION_MODELS[status].where(id: ids).pluck(:job_id)
10
- SolidQueue::Job.where(id: job_ids).destroy_all
10
+ count = SolidQueue::Job.where(id: job_ids).destroy_all.size
11
11
 
12
12
  redirect_to jobs_path(
13
13
  status: status,
@@ -15,7 +15,7 @@ module SolidStackWeb
15
15
  queue: params[:queue].presence,
16
16
  period: params[:period].presence_in(PERIOD_DURATIONS.keys),
17
17
  priority: params[:priority].presence
18
- )
18
+ ), notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
19
19
  rescue ArgumentError => e
20
20
  redirect_to jobs_path(status: params[:status]), alert: e.message
21
21
  end
@@ -30,6 +30,7 @@ module SolidStackWeb
30
30
  @execution = Job::EXECUTION_MODELS[@status].find(params[:id])
31
31
  @execution.job.destroy!
32
32
  @executions_remain = Job::EXECUTION_MODELS[@status].exists?
33
+ @notice = "Job discarded."
33
34
 
34
35
  respond_to do |format|
35
36
  format.html { redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority) }
@@ -37,8 +38,9 @@ module SolidStackWeb
37
38
  end
38
39
  else
39
40
  job_ids = filtered_scope.pluck(:job_id)
40
- SolidQueue::Job.where(id: job_ids).destroy_all
41
- redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority)
41
+ count = SolidQueue::Job.where(id: job_ids).destroy_all.size
42
+ redirect_to jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
43
+ notice: "#{count} #{count == 1 ? "job" : "jobs"} discarded."
42
44
  end
43
45
  end
44
46
 
@@ -1,5 +1,18 @@
1
1
  module SolidStackWeb
2
2
  module ApplicationHelper
3
+ def local_time(time, format: :short, placeholder: "—")
4
+ return placeholder if time.nil?
5
+
6
+ iso = time.utc.iso8601
7
+ fallback = case format
8
+ when :long then time.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
9
+ when :relative then "#{time_ago_in_words(time)} ago"
10
+ else time.utc.strftime("%b %-d %H:%M UTC")
11
+ end
12
+ tag.time(fallback, datetime: iso,
13
+ data: { controller: "timestamp", timestamp_format_value: format })
14
+ end
15
+
3
16
  def format_cache_value(raw)
4
17
  str = raw.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
5
18
  parsed = JSON.parse(str)
@@ -4,9 +4,13 @@ import RefreshController from "solid_stack_web/refresh_controller"
4
4
  import SearchController from "solid_stack_web/search_controller"
5
5
  import SelectionController from "solid_stack_web/selection_controller"
6
6
  import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
7
+ import ThemeController from "solid_stack_web/theme_controller"
8
+ import TimestampController from "solid_stack_web/timestamp_controller"
7
9
 
8
10
  const application = Application.start()
9
11
  application.register("refresh", RefreshController)
10
12
  application.register("search", SearchController)
11
13
  application.register("selection", SelectionController)
12
- application.register("sparkline-tooltip", SparklineTooltipController)
14
+ application.register("sparkline-tooltip", SparklineTooltipController)
15
+ application.register("theme", ThemeController)
16
+ application.register("timestamp", TimestampController)
@@ -0,0 +1,26 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toggle"]
5
+
6
+ connect() {
7
+ const saved = localStorage.getItem("sqw-theme")
8
+ const preferred = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
9
+ this.apply(saved || preferred)
10
+ }
11
+
12
+ toggle() {
13
+ const current = document.documentElement.getAttribute("data-theme") || "light"
14
+ const next = current === "dark" ? "light" : "dark"
15
+ localStorage.setItem("sqw-theme", next)
16
+ this.apply(next)
17
+ }
18
+
19
+ apply(theme) {
20
+ document.documentElement.setAttribute("data-theme", theme)
21
+ if (this.hasToggleTarget) {
22
+ this.toggleTarget.textContent = theme === "dark" ? "☀" : "☽"
23
+ this.toggleTarget.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,59 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { format: { type: String, default: "short" } }
5
+
6
+ connect() {
7
+ const dt = this.element.getAttribute("datetime")
8
+ if (!dt) return
9
+ const date = new Date(dt)
10
+ if (isNaN(date.getTime())) return
11
+ this.element.textContent = this.formatDate(date)
12
+ if (this.formatValue === "relative") {
13
+ this.element.title = this.fullFormat(date)
14
+ }
15
+ }
16
+
17
+ formatDate(date) {
18
+ switch (this.formatValue) {
19
+ case "long":
20
+ return new Intl.DateTimeFormat(undefined, {
21
+ year: "numeric", month: "2-digit", day: "2-digit",
22
+ hour: "2-digit", minute: "2-digit", second: "2-digit"
23
+ }).format(date)
24
+ case "relative":
25
+ return this.relativeFormat(date)
26
+ default:
27
+ return new Intl.DateTimeFormat(undefined, {
28
+ month: "short", day: "numeric",
29
+ hour: "2-digit", minute: "2-digit"
30
+ }).format(date)
31
+ }
32
+ }
33
+
34
+ fullFormat(date) {
35
+ return new Intl.DateTimeFormat(undefined, {
36
+ year: "numeric", month: "short", day: "numeric",
37
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
38
+ timeZoneName: "short"
39
+ }).format(date)
40
+ }
41
+
42
+ relativeFormat(date) {
43
+ const diff = date.getTime() - Date.now()
44
+ const absDiff = Math.abs(diff)
45
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
46
+
47
+ const seconds = Math.floor(absDiff / 1000)
48
+ if (seconds < 60) return rtf.format(diff < 0 ? -seconds : seconds, "second")
49
+
50
+ const minutes = Math.floor(absDiff / 60000)
51
+ if (minutes < 60) return rtf.format(diff < 0 ? -minutes : minutes, "minute")
52
+
53
+ const hours = Math.floor(absDiff / 3600000)
54
+ if (hours < 24) return rtf.format(diff < 0 ? -hours : hours, "hour")
55
+
56
+ const days = Math.floor(absDiff / 86400000)
57
+ return rtf.format(diff < 0 ? -days : days, "day")
58
+ }
59
+ }
@@ -10,7 +10,7 @@
10
10
  <%= inline_styles %>
11
11
  <%= javascript_importmap_tags "solid_stack_web" %>
12
12
  </head>
13
- <body>
13
+ <body data-controller="theme">
14
14
  <header class="sqw-header">
15
15
  <div class="sqw-header__inner">
16
16
  <%= link_to "Solid Stack", root_path, class: "sqw-header__logo" %>
@@ -22,6 +22,8 @@
22
22
  <%= link_to "Cable", cable_path,
23
23
  class: "sqw-nav__link#{" sqw-nav__link--active" if current_section == :cable}" %>
24
24
  </nav>
25
+ <button class="sqw-theme-toggle" aria-label="Switch to dark mode"
26
+ data-theme-target="toggle" data-action="theme#toggle">☽</button>
25
27
  </div>
26
28
  </header>
27
29
 
@@ -67,9 +69,11 @@
67
69
  <% end %>
68
70
 
69
71
  <main class="sqw-main">
70
- <% flash.each do |type, message| %>
71
- <div class="sqw-flash sqw-flash--<%= type %>"><%= message %></div>
72
- <% end %>
72
+ <div id="sqw-flash-container">
73
+ <% flash.each do |type, message| %>
74
+ <div class="sqw-flash sqw-flash--<%= type %>"><%= message %></div>
75
+ <% end %>
76
+ </div>
73
77
  <%= yield %>
74
78
  </main>
75
79
  </body>
@@ -62,13 +62,19 @@
62
62
  <%= link_to channel[:channel], cable_channel_messages_path(channel[:channel_hash]), class: "sqw-link" %>
63
63
  </td>
64
64
  <td><%= channel[:message_count] %></td>
65
- <td class="sqw-muted"><%= channel[:last_message_at]&.strftime("%b %d %H:%M") %></td>
65
+ <td class="sqw-muted"><%= local_time(channel[:last_message_at]) %></td>
66
66
  </tr>
67
67
  <% end %>
68
68
  </tbody>
69
69
  </table>
70
70
  <% else %>
71
71
  <div class="sqw-empty">
72
- <p><%= @search.present? ? "No channels matching &ldquo;#{@search}&rdquo;.".html_safe : "No cable messages." %></p>
72
+ <% if @search.present? %>
73
+ <p class="sqw-empty__title">No channels matching &ldquo;<%= @search %>&rdquo;</p>
74
+ <p class="sqw-empty__hint"><%= link_to "Clear search", cable_path %></p>
75
+ <% else %>
76
+ <p class="sqw-empty__title">No cable messages</p>
77
+ <p class="sqw-empty__hint">Messages will appear here once clients connect and broadcast over Action Cable.</p>
78
+ <% end %>
73
79
  </div>
74
80
  <% end %>
@@ -35,8 +35,8 @@
35
35
  <td class="sqw-monospace sqw-truncate" title="<%= message.payload %>">
36
36
  <%= truncate(message.payload.to_s, length: 120) %>
37
37
  </td>
38
- <td class="sqw-muted" title="<%= message.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %>">
39
- <%= time_ago_in_words(message.created_at) %> ago
38
+ <td class="sqw-muted">
39
+ <%= local_time(message.created_at, format: :relative) %>
40
40
  </td>
41
41
  </tr>
42
42
  <% end %>
@@ -45,6 +45,12 @@
45
45
  <%== @pagy.series_nav if @pagy.pages > 1 %>
46
46
  <% else %>
47
47
  <div class="sqw-empty">
48
- <p><%= @search.present? ? "No messages matching &ldquo;#{@search}&rdquo;.".html_safe : "No messages for this channel." %></p>
48
+ <% if @search.present? %>
49
+ <p class="sqw-empty__title">No messages matching &ldquo;<%= @search %>&rdquo;</p>
50
+ <p class="sqw-empty__hint"><%= link_to "Clear search", cable_channel_messages_path(params[:channel_hash]) %></p>
51
+ <% else %>
52
+ <p class="sqw-empty__title">No messages for this channel</p>
53
+ <p class="sqw-empty__hint">Messages may have been purged or the channel has gone quiet. <%= link_to "Back to channels →", cable_path %></p>
54
+ <% end %>
49
55
  </div>
50
56
  <% end %>
@@ -46,7 +46,7 @@
46
46
  <%= link_to entry.key, cache_entry_path(entry), class: "sqw-link" %>
47
47
  </td>
48
48
  <td><%= number_to_human_size(entry.byte_size) %></td>
49
- <td class="sqw-muted"><%= entry.created_at.strftime("%b %d %H:%M") %></td>
49
+ <td class="sqw-muted"><%= local_time(entry.created_at) %></td>
50
50
  <td class="sqw-actions">
51
51
  <%= button_to "Delete",
52
52
  cache_entry_path(entry, q: @search, column: @sort["column"], direction: @sort["direction"]),
@@ -61,6 +61,12 @@
61
61
  <%== @pagy.series_nav if @pagy.pages > 1 %>
62
62
  <% else %>
63
63
  <div class="sqw-empty">
64
- <p><%= @search.present? ? "No entries matching &ldquo;#{@search}&rdquo;.".html_safe : "No cache entries." %></p>
64
+ <% if @search.present? %>
65
+ <p class="sqw-empty__title">No entries matching &ldquo;<%= @search %>&rdquo;</p>
66
+ <p class="sqw-empty__hint"><%= link_to "Clear search", cache_entries_path %></p>
67
+ <% else %>
68
+ <p class="sqw-empty__title">No cache entries</p>
69
+ <p class="sqw-empty__hint">Entries will appear here once your application writes to the cache.</p>
70
+ <% end %>
65
71
  </div>
66
72
  <% end %>
@@ -21,7 +21,7 @@
21
21
  </div>
22
22
  <div class="sqw-detail__row">
23
23
  <dt>Created</dt>
24
- <dd class="sqw-muted"><%= @entry.created_at.strftime("%b %d, %Y %H:%M:%S %Z") %></dd>
24
+ <dd class="sqw-muted"><%= local_time(@entry.created_at, format: :long) %></dd>
25
25
  </div>
26
26
  </dl>
27
27
 
@@ -87,7 +87,7 @@
87
87
  <% if @cache_stats[:oldest_entry] %>
88
88
  <div class="sqw-inline-stat sqw-inline-stat--cache">
89
89
  <span class="sqw-inline-stat__label">Oldest</span>
90
- <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>
90
+ <span class="sqw-inline-stat__value sqw-inline-stat__value--sm"><%= local_time(@cache_stats[:oldest_entry], format: :relative) %></span>
91
91
  </div>
92
92
  <% end %>
93
93
  </div>
@@ -114,7 +114,7 @@
114
114
  <% if @cable_stats[:oldest_message] %>
115
115
  <div class="sqw-inline-stat sqw-inline-stat--cable">
116
116
  <span class="sqw-inline-stat__label">Oldest</span>
117
- <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>
117
+ <span class="sqw-inline-stat__value sqw-inline-stat__value--sm"><%= local_time(@cable_stats[:oldest_message], format: :relative) %></span>
118
118
  </div>
119
119
  <% end %>
120
120
  </div>
@@ -1,3 +1,6 @@
1
+ <%= turbo_stream.prepend "sqw-flash-container",
2
+ partial: "solid_stack_web/shared/flash",
3
+ locals: { type: "notice", message: @notice } %>
1
4
  <% if @executions_remain %>
2
5
  <%= turbo_stream.remove "execution_#{@execution.id}" %>
3
6
  <% else %>
@@ -50,7 +50,7 @@
50
50
  <td class="sqw-monospace"><%= link_to execution.job.class_name, failed_job_path(execution) %></td>
51
51
  <td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
52
52
  <td class="sqw-muted sqw-truncate" title="<%= execution.exception_class %>: <%= execution.message %>"><%= execution.exception_class %></td>
53
- <td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
53
+ <td class="sqw-muted"><%= local_time(execution.created_at) %></td>
54
54
  <td class="sqw-actions">
55
55
  <%= button_to "Retry", retry_failed_job_path(execution),
56
56
  method: :post, class: "sqw-btn sqw-btn--sm" %>
@@ -66,7 +66,8 @@
66
66
  </div>
67
67
  <% else %>
68
68
  <div class="sqw-empty">
69
- <p>No failed jobs.</p>
69
+ <p class="sqw-empty__title">No failed jobs</p>
70
+ <p class="sqw-empty__hint">All clear — your jobs are running without errors.</p>
70
71
  </div>
71
72
  <% end %>
72
73
  </div>
@@ -29,7 +29,7 @@
29
29
  <dd class="sqw-monospace sqw-truncate" title="<%= @execution.job.active_job_id %>"><%= @execution.job.active_job_id.presence || "—" %></dd>
30
30
 
31
31
  <dt>Failed At</dt>
32
- <dd class="sqw-monospace"><%= @execution.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
32
+ <dd class="sqw-monospace"><%= local_time(@execution.created_at, format: :long) %></dd>
33
33
 
34
34
  <dt>Error</dt>
35
35
  <dd class="sqw-monospace"><%= @execution.exception_class %></dd>
@@ -61,7 +61,7 @@
61
61
  class: "sqw-badge sqw-badge--queue" %>
62
62
  </td>
63
63
  <td class="sqw-monospace"><%= format_duration(job.finished_at - job.created_at) %></td>
64
- <td class="sqw-muted"><%= job.finished_at.strftime("%b %d %H:%M:%S") %></td>
64
+ <td class="sqw-muted"><%= local_time(job.finished_at) %></td>
65
65
  </tr>
66
66
  <% end %>
67
67
  </tbody>
@@ -70,7 +70,13 @@
70
70
  </div>
71
71
  <% else %>
72
72
  <div class="sqw-empty">
73
- <p>No finished jobs found.</p>
73
+ <% if @search.present? || @queue.present? || @period.present? %>
74
+ <p class="sqw-empty__title">No finished jobs match your filters</p>
75
+ <p class="sqw-empty__hint"><%= link_to "Clear filters", history_path %></p>
76
+ <% else %>
77
+ <p class="sqw-empty__title">No finished jobs yet</p>
78
+ <p class="sqw-empty__hint">Completed jobs will appear here once workers process them.</p>
79
+ <% end %>
74
80
  </div>
75
81
  <% end %>
76
82
  <% end %>
@@ -1,3 +1,20 @@
1
1
  <div id="sqw-empty" class="sqw-empty">
2
- <p>No <strong><%= @status %></strong> jobs.</p>
3
- </div>
2
+ <% if @search.present? %>
3
+ <p class="sqw-empty__title">No <strong><%= @status %></strong> jobs matching &ldquo;<%= @search %>&rdquo;</p>
4
+ <p class="sqw-empty__hint"><%= link_to "Clear search", jobs_path(status: @status) %></p>
5
+ <% else %>
6
+ <p class="sqw-empty__title">No <strong><%= @status %></strong> jobs</p>
7
+ <p class="sqw-empty__hint">
8
+ <% case @status %>
9
+ <% when "ready" %>
10
+ Jobs will appear here once they are enqueued and ready to be picked up.
11
+ <% when "scheduled" %>
12
+ No jobs are scheduled to run in the future.
13
+ <% when "claimed" %>
14
+ No jobs are currently being processed by a worker.
15
+ <% when "blocked" %>
16
+ No jobs are blocked by concurrency controls.
17
+ <% end %>
18
+ </p>
19
+ <% end %>
20
+ </div>
@@ -1,3 +1,6 @@
1
+ <%= turbo_stream.prepend "sqw-flash-container",
2
+ partial: "solid_stack_web/shared/flash",
3
+ locals: { type: "notice", message: @notice } %>
1
4
  <% if @executions_remain %>
2
5
  <%= turbo_stream.remove "execution_#{@execution.id}" %>
3
6
  <% else %>
@@ -121,9 +121,9 @@
121
121
  <td class="sqw-monospace"><%= link_to execution.job.class_name, job_path(execution.id, status: @status), data: { turbo_frame: "_top" } %></td>
122
122
  <td><span class="sqw-badge sqw-badge--queue"><%= execution.job.queue_name %></span></td>
123
123
  <td><%= execution.job.priority %></td>
124
- <td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
124
+ <td class="sqw-muted"><%= local_time(execution.created_at) %></td>
125
125
  <% if @status == "scheduled" %>
126
- <td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= execution.scheduled_at&.strftime("%b %d %H:%M") %></td>
126
+ <td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= local_time(execution.scheduled_at) %></td>
127
127
  <% end %>
128
128
  <td class="sqw-actions">
129
129
  <% if @status == "scheduled" %>
@@ -36,17 +36,17 @@
36
36
 
37
37
  <% if @status == "blocked" %>
38
38
  <dt>Blocked Until</dt>
39
- <dd class="sqw-monospace"><%= @execution.expires_at ? @execution.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
39
+ <dd class="sqw-monospace"><%= local_time(@execution.expires_at, format: :long) %></dd>
40
40
  <% end %>
41
41
 
42
42
  <dt>Enqueued At</dt>
43
- <dd class="sqw-monospace"><%= @execution.job.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
43
+ <dd class="sqw-monospace"><%= local_time(@execution.job.created_at, format: :long) %></dd>
44
44
 
45
45
  <dt>Scheduled At</dt>
46
- <dd class="sqw-monospace"><%= @execution.job.scheduled_at ? @execution.job.scheduled_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
46
+ <dd class="sqw-monospace"><%= local_time(@execution.job.scheduled_at, format: :long) %></dd>
47
47
 
48
48
  <dt>Finished At</dt>
49
- <dd class="sqw-monospace"><%= @execution.job.finished_at ? @execution.job.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC") : "—" %></dd>
49
+ <dd class="sqw-monospace"><%= local_time(@execution.job.finished_at, format: :long) %></dd>
50
50
  </dl>
51
51
  </div>
52
52
 
@@ -22,14 +22,15 @@
22
22
  <td class="sqw-monospace"><%= process.name %></td>
23
23
  <td class="sqw-monospace"><%= process.pid %></td>
24
24
  <td class="sqw-muted"><%= process.hostname %></td>
25
- <td class="sqw-muted"><%= process.last_heartbeat_at&.strftime("%b %d %H:%M:%S") %></td>
25
+ <td class="sqw-muted"><%= local_time(process.last_heartbeat_at) %></td>
26
26
  </tr>
27
27
  <% end %>
28
28
  </tbody>
29
29
  </table>
30
30
  <% else %>
31
31
  <div class="sqw-empty">
32
- <p>No active processes.</p>
32
+ <p class="sqw-empty__title">No active processes</p>
33
+ <p class="sqw-empty__hint">Start a Solid Queue worker to begin processing jobs.</p>
33
34
  </div>
34
35
  <% end %>
35
36
  <% end %>
@@ -44,6 +44,7 @@
44
44
  </table>
45
45
  <% else %>
46
46
  <div class="sqw-empty">
47
- <p>No queues with ready jobs.</p>
47
+ <p class="sqw-empty__title">No queues with ready jobs</p>
48
+ <p class="sqw-empty__hint">Workers are idle or all jobs are in another state. <%= link_to "Check job statuses →", jobs_path %></p>
48
49
  </div>
49
50
  <% end %>
@@ -49,7 +49,7 @@
49
49
  data: { turbo_frame: "_top" } %>
50
50
  </td>
51
51
  <td><%= execution.job.priority %></td>
52
- <td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
52
+ <td class="sqw-muted"><%= local_time(execution.created_at) %></td>
53
53
  <td class="sqw-actions">
54
54
  <%= button_to "Discard", job_path(execution, status: "ready", queue: @queue_name),
55
55
  method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
@@ -36,12 +36,11 @@
36
36
  </td>
37
37
  <td><span class="sqw-badge sqw-badge--queue"><%= task.queue_name.presence || "default" %></span></td>
38
38
  <td class="sqw-muted sqw-monospace">
39
- <% next_run = begin; task.next_time.strftime("%b %d %H:%M"); rescue; nil; end %>
40
- <%= next_run || "—" %>
39
+ <% next_run = begin; task.next_time; rescue; nil; end %>
40
+ <%= local_time(next_run) %>
41
41
  </td>
42
42
  <td class="sqw-muted sqw-monospace">
43
- <% last_run = task.last_enqueued_time %>
44
- <%= last_run ? last_run.strftime("%b %d %H:%M") : "—" %>
43
+ <%= local_time(task.last_enqueued_time) %>
45
44
  </td>
46
45
  <td>
47
46
  <% if task.static? %>
@@ -62,6 +61,7 @@
62
61
  </table>
63
62
  <% else %>
64
63
  <div class="sqw-empty">
65
- <p>No recurring tasks configured.</p>
64
+ <p class="sqw-empty__title">No recurring tasks configured</p>
65
+ <p class="sqw-empty__hint">Define recurring tasks in your Solid Queue configuration to see them here.</p>
66
66
  </div>
67
67
  <% end %>
@@ -3,7 +3,7 @@
3
3
  <% else %>
4
4
  <%= turbo_stream.replace "scheduled_at_#{@execution.id}" do %>
5
5
  <td id="scheduled_at_<%= @execution.id %>" class="sqw-muted">
6
- <%= @execution.scheduled_at.strftime("%b %d %H:%M") %>
6
+ <%= local_time(@execution.scheduled_at) %>
7
7
  </td>
8
8
  <% end %>
9
9
  <% end %>
@@ -0,0 +1 @@
1
+ <div class="sqw-flash sqw-flash--<%= type %>"><%= message %></div>
@@ -43,6 +43,7 @@
43
43
  </table>
44
44
  <% else %>
45
45
  <div class="sqw-empty">
46
- <p>No finished jobs yet.</p>
46
+ <p class="sqw-empty__title">No finished jobs yet</p>
47
+ <p class="sqw-empty__hint">Performance stats appear here once jobs complete. <%= link_to "View active jobs →", jobs_path %></p>
47
48
  </div>
48
49
  <% end %>
data/config/importmap.rb CHANGED
@@ -5,3 +5,5 @@ pin "solid_stack_web/refresh_controller", to: "solid_stack_web/refresh_controlle
5
5
  pin "solid_stack_web/search_controller", to: "solid_stack_web/search_controller.js"
6
6
  pin "solid_stack_web/selection_controller", to: "solid_stack_web/selection_controller.js"
7
7
  pin "solid_stack_web/sparkline_tooltip_controller", to: "solid_stack_web/sparkline_tooltip_controller.js"
8
+ pin "solid_stack_web/theme_controller", to: "solid_stack_web/theme_controller.js"
9
+ pin "solid_stack_web/timestamp_controller", to: "solid_stack_web/timestamp_controller.js"
@@ -1,3 +1,3 @@
1
1
  module SolidStackWeb
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_stack_web
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -142,6 +142,8 @@ files:
142
142
  - app/assets/stylesheets/solid_stack_web/_07_dashboard.css
143
143
  - app/assets/stylesheets/solid_stack_web/_08_filters.css
144
144
  - app/assets/stylesheets/solid_stack_web/_09_detail.css
145
+ - app/assets/stylesheets/solid_stack_web/_10_responsive.css
146
+ - app/assets/stylesheets/solid_stack_web/_12_dark_mode.css
145
147
  - app/assets/stylesheets/solid_stack_web/application.css
146
148
  - app/controllers/solid_stack_web/application_controller.rb
147
149
  - app/controllers/solid_stack_web/cable/channel_purges_controller.rb
@@ -172,6 +174,8 @@ files:
172
174
  - app/javascript/solid_stack_web/search_controller.js
173
175
  - app/javascript/solid_stack_web/selection_controller.js
174
176
  - app/javascript/solid_stack_web/sparkline_tooltip_controller.js
177
+ - app/javascript/solid_stack_web/theme_controller.js
178
+ - app/javascript/solid_stack_web/timestamp_controller.js
175
179
  - app/models/solid_stack_web/alert_webhook.rb
176
180
  - app/models/solid_stack_web/cable_stats.rb
177
181
  - app/models/solid_stack_web/cable_timeline.rb
@@ -202,6 +206,7 @@ files:
202
206
  - app/views/solid_stack_web/queues/show.html.erb
203
207
  - app/views/solid_stack_web/recurring_tasks/index.html.erb
204
208
  - app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb
209
+ - app/views/solid_stack_web/shared/_flash.html.erb
205
210
  - app/views/solid_stack_web/stats/index.html.erb
206
211
  - config/importmap.rb
207
212
  - config/routes.rb