solid_stack_web 0.2.0 → 0.4.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +104 -22
  3. data/app/assets/stylesheets/solid_stack_web/_02_layout.css +8 -0
  4. data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
  5. data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +64 -12
  6. data/app/controllers/solid_stack_web/application_controller.rb +1 -1
  7. data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
  8. data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +10 -4
  9. data/app/controllers/solid_stack_web/history_controller.rb +42 -0
  10. data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
  11. data/app/controllers/solid_stack_web/queues/pauses_controller.rb +13 -0
  12. data/app/controllers/solid_stack_web/queues_controller.rb +12 -7
  13. data/app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb +18 -0
  14. data/app/controllers/solid_stack_web/recurring_tasks_controller.rb +7 -0
  15. data/app/controllers/solid_stack_web/scheduled_jobs_controller.rb +52 -0
  16. data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
  17. data/app/helpers/solid_stack_web/application_helper.rb +64 -0
  18. data/app/javascript/solid_stack_web/application.js +5 -1
  19. data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
  20. data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
  21. data/app/models/solid_stack_web/alert_webhook.rb +67 -0
  22. data/app/models/solid_stack_web/cable_stats.rb +10 -0
  23. data/app/models/solid_stack_web/cache_stats.rb +10 -0
  24. data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
  25. data/app/models/solid_stack_web/queue_stats.rb +34 -0
  26. data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
  27. data/app/views/layouts/solid_stack_web/application.html.erb +6 -0
  28. data/app/views/solid_stack_web/dashboard/index.html.erb +37 -2
  29. data/app/views/solid_stack_web/history/index.html.erb +76 -0
  30. data/app/views/solid_stack_web/jobs/index.html.erb +26 -3
  31. data/app/views/solid_stack_web/processes/index.html.erb +3 -0
  32. data/app/views/solid_stack_web/queues/index.html.erb +10 -5
  33. data/app/views/solid_stack_web/queues/show.html.erb +67 -0
  34. data/app/views/solid_stack_web/recurring_tasks/index.html.erb +67 -0
  35. data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +9 -0
  36. data/app/views/solid_stack_web/stats/index.html.erb +48 -0
  37. data/config/importmap.rb +4 -0
  38. data/config/routes.rb +15 -5
  39. data/lib/solid_stack_web/version.rb +1 -1
  40. data/lib/solid_stack_web.rb +37 -1
  41. metadata +22 -2
@@ -1,5 +1,69 @@
1
1
  module SolidStackWeb
2
2
  module ApplicationHelper
3
+ def format_duration(seconds)
4
+ return "—" if seconds.nil?
5
+ return "#{(seconds * 1000).round}ms" if seconds < 1
6
+ s = seconds.to_i
7
+ return "#{sprintf("%g", seconds.round(1))}s" if s < 60
8
+ return "#{s / 60}m #{s % 60}s" if s < 3600
9
+
10
+ "#{s / 3600}h #{(s % 3600) / 60}m"
11
+ end
12
+
13
+ def throughput_sparkline_svg(sparkline)
14
+ build_sparkline_svg(sparkline, aria_label: "Throughput over the last 12 hours") do |count, i|
15
+ hours_ago = SolidStackWeb::ThroughputSparkline::HOURS - i
16
+ if hours_ago == 1
17
+ "#{count} #{count == 1 ? "job" : "jobs"} in the last hour"
18
+ else
19
+ "#{count} #{count == 1 ? "job" : "jobs"} (#{hours_ago}h–#{hours_ago - 1}h ago)"
20
+ end
21
+ end
22
+ end
23
+
24
+ def queue_depth_sparkline_svg(sparkline)
25
+ build_sparkline_svg(sparkline, css_class: "sqw-sparkline sqw-sparkline--sm",
26
+ aria_label: "Queue depth over the last 12 hours") do |count, i|
27
+ hours_ago = SolidStackWeb::QueueDepthSparkline::HOURS - 1 - i
28
+ jobs_word = count == 1 ? "job" : "jobs"
29
+ hours_ago.zero? ? "#{count} ready #{jobs_word} now" : "#{count} ready #{jobs_word} #{hours_ago}h ago"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def build_sparkline_svg(sparkline, css_class: "sqw-sparkline", aria_label: nil, &tooltip_text)
36
+ buckets = sparkline.buckets
37
+ peak = [sparkline.max.to_f, 1.0].max
38
+ h = 40
39
+ bar_w = 8
40
+ gap = 2
41
+ total_w = buckets.size * (bar_w + gap) - gap
42
+
43
+ bars = buckets.each_with_index.map do |count, i|
44
+ x = i * (bar_w + gap)
45
+ bar_h = [(count / peak * (h - 4)).round, 2].max
46
+ y = h - bar_h
47
+ opacity = count.zero? ? "0.18" : "1"
48
+ tip = tooltip_text.call(count, i)
49
+ attrs = %( x="#{x}" y="#{y}" width="#{bar_w}" height="#{bar_h}" rx="1") +
50
+ %( fill="currentColor" opacity="#{opacity}") +
51
+ %( data-sparkline-tooltip-target="bar") +
52
+ %( data-tip="#{ERB::Util.html_escape(tip)}") +
53
+ %( data-action="mouseenter->sparkline-tooltip#show mouseleave->sparkline-tooltip#hide")
54
+ "<rect#{attrs}></rect>"
55
+ end.join
56
+
57
+ content_tag(:svg, bars.html_safe,
58
+ viewBox: "0 0 #{total_w} #{h}",
59
+ preserveAspectRatio: "none",
60
+ class: css_class,
61
+ role: "img",
62
+ "aria-label": aria_label)
63
+ end
64
+
65
+ public
66
+
3
67
  def inline_styles
4
68
  dir = SolidStackWeb::Engine.root.join("app/assets/stylesheets/solid_stack_web")
5
69
  css = dir.glob("_*.css").sort.map(&:read).join("\n")
@@ -1,6 +1,10 @@
1
1
  import "@hotwired/turbo"
2
2
  import { Application } from "@hotwired/stimulus"
3
+ import RefreshController from "solid_stack_web/refresh_controller"
3
4
  import SelectionController from "solid_stack_web/selection_controller"
5
+ import SparklineTooltipController from "solid_stack_web/sparkline_tooltip_controller"
4
6
 
5
7
  const application = Application.start()
6
- application.register("selection", SelectionController)
8
+ application.register("refresh", RefreshController)
9
+ application.register("selection", SelectionController)
10
+ application.register("sparkline-tooltip", SparklineTooltipController)
@@ -0,0 +1,52 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { interval: { type: Number, default: 5000 } }
5
+
6
+ initialize() {
7
+ this._onVisibilityChange = this._onVisibilityChange.bind(this)
8
+ }
9
+
10
+ connect() {
11
+ document.addEventListener("visibilitychange", this._onVisibilityChange)
12
+ this._schedule()
13
+ }
14
+
15
+ disconnect() {
16
+ clearTimeout(this._timer)
17
+ document.removeEventListener("visibilitychange", this._onVisibilityChange)
18
+ }
19
+
20
+ _schedule() {
21
+ this._timer = setTimeout(() => this._reload(), this.intervalValue)
22
+ }
23
+
24
+ async _reload() {
25
+ clearTimeout(this._timer)
26
+ const hasSelection = this.element.querySelector("input[type='checkbox']:checked")
27
+ if (!document.hidden && !hasSelection) {
28
+ try {
29
+ const response = await fetch(window.location.href, {
30
+ headers: { "Turbo-Frame": this.element.id, Accept: "text/html" }
31
+ })
32
+ if (response.ok) {
33
+ const html = await response.text()
34
+ const doc = new DOMParser().parseFromString(html, "text/html")
35
+ const frame = doc.querySelector(`turbo-frame#${this.element.id}`)
36
+ if (frame && this.element.isConnected) this.element.innerHTML = frame.innerHTML
37
+ }
38
+ } catch {
39
+ // network error — skip this tick
40
+ }
41
+ }
42
+ if (this.element.isConnected) this._schedule()
43
+ }
44
+
45
+ _onVisibilityChange() {
46
+ if (document.hidden) {
47
+ clearTimeout(this._timer)
48
+ } else if (!this.element.querySelector("input[type='checkbox']:checked")) {
49
+ this._reload()
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["bar", "tip"]
5
+
6
+ connect() {
7
+ this._tip = this.tipTarget
8
+ }
9
+
10
+ show({ currentTarget }) {
11
+ const label = currentTarget.dataset.tip
12
+ if (!label) return
13
+ const rect = currentTarget.getBoundingClientRect()
14
+ this._tip.textContent = label
15
+ this._tip.style.left = `${rect.left + rect.width / 2}px`
16
+ this._tip.style.top = `${rect.top - 6}px`
17
+ this._tip.hidden = false
18
+ }
19
+
20
+ hide() {
21
+ this._tip.hidden = true
22
+ }
23
+ }
@@ -0,0 +1,67 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module SolidStackWeb
5
+ class AlertWebhook
6
+ COOLDOWN_CACHE_KEY = "solid_stack_web/alert_webhook/cooldown"
7
+
8
+ def self.check(queue_stats)
9
+ new(queue_stats).check
10
+ end
11
+
12
+ def initialize(queue_stats)
13
+ @queue_stats = queue_stats
14
+ end
15
+
16
+ def check
17
+ return unless SolidStackWeb.alert_webhook_url
18
+ return if on_cooldown?
19
+
20
+ alerts = build_alerts
21
+ return if alerts.empty?
22
+
23
+ deliver(alerts)
24
+ set_cooldown
25
+ end
26
+
27
+ private
28
+
29
+ def build_alerts
30
+ alerts = []
31
+
32
+ if (threshold = SolidStackWeb.alert_failure_threshold)
33
+ count = @queue_stats[:failed]
34
+ alerts << { type: "failed_jobs", count: count, threshold: threshold } if count >= threshold
35
+ end
36
+
37
+ SolidStackWeb.alert_queue_thresholds.each do |queue_name, threshold|
38
+ count = ::SolidQueue::ReadyExecution.where(queue_name: queue_name.to_s).count
39
+ alerts << { type: "queue_depth", queue: queue_name.to_s, count: count, threshold: threshold } if count >= threshold
40
+ end
41
+
42
+ alerts
43
+ end
44
+
45
+ def deliver(alerts)
46
+ payload = { alerts: alerts, generated_at: Time.current.iso8601 }
47
+ uri = URI(SolidStackWeb.alert_webhook_url)
48
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
49
+ open_timeout: 5, read_timeout: 5) do |http|
50
+ request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
51
+ request.body = payload.to_json
52
+ http.request(request)
53
+ end
54
+ rescue StandardError
55
+ # webhook delivery failures must not affect the response
56
+ end
57
+
58
+ def on_cooldown?
59
+ Rails.cache.read(COOLDOWN_CACHE_KEY).present?
60
+ end
61
+
62
+ def set_cooldown
63
+ Rails.cache.write(COOLDOWN_CACHE_KEY, Time.current.iso8601,
64
+ expires_in: SolidStackWeb.alert_webhook_cooldown)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,10 @@
1
+ module SolidStackWeb
2
+ class CableStats
3
+ def to_h
4
+ {
5
+ messages: ::SolidCable::Message.count,
6
+ channels: ::SolidCable::Message.distinct.count(:channel)
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ module SolidStackWeb
2
+ class CacheStats
3
+ def to_h
4
+ {
5
+ entries: ::SolidCache::Entry.count,
6
+ byte_size: ::SolidCache::Entry.sum(:byte_size)
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,30 @@
1
+ module SolidStackWeb
2
+ class QueueDepthSparkline
3
+ HOURS = 12
4
+
5
+ def initialize(queue_name)
6
+ @queue_name = queue_name
7
+ end
8
+
9
+ def buckets
10
+ @buckets ||= begin
11
+ now = Time.current
12
+ origin = now - HOURS.hours
13
+
14
+ jobs = ::SolidQueue::Job
15
+ .where(queue_name: @queue_name)
16
+ .where("created_at <= ? AND (finished_at IS NULL OR finished_at >= ?)", now, origin)
17
+ .pluck(:created_at, :finished_at)
18
+
19
+ HOURS.times.map do |i|
20
+ snapshot = origin + (i + 1).hours
21
+ jobs.count { |created_at, finished_at| created_at <= snapshot && (finished_at.nil? || finished_at > snapshot) }
22
+ end
23
+ end
24
+ end
25
+
26
+ def max
27
+ buckets.max || 0
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ module SolidStackWeb
2
+ class QueueStats
3
+ def to_h
4
+ finished_24h = finished_since(24.hours.ago)
5
+ stats = {
6
+ ready: ::SolidQueue::ReadyExecution.count,
7
+ scheduled: ::SolidQueue::ScheduledExecution.count,
8
+ claimed: ::SolidQueue::ClaimedExecution.count,
9
+ blocked: ::SolidQueue::BlockedExecution.count,
10
+ failed: ::SolidQueue::FailedExecution.count,
11
+ done_1h: finished_since(1.hour.ago).count,
12
+ done_24h: finished_24h.count,
13
+ processes_healthy: ::SolidQueue::Process.where("last_heartbeat_at > ?", 5.minutes.ago).count,
14
+ processes_stale: ::SolidQueue::Process.where("last_heartbeat_at <= ? OR last_heartbeat_at IS NULL", 5.minutes.ago).count
15
+ }
16
+ add_slow_jobs(stats, finished_24h)
17
+ stats
18
+ end
19
+
20
+ private
21
+
22
+ def finished_since(time)
23
+ ::SolidQueue::Job.where.not(finished_at: nil).where("finished_at >= ?", time)
24
+ end
25
+
26
+ def add_slow_jobs(stats, finished_24h)
27
+ threshold = SolidStackWeb.slow_job_threshold
28
+ return unless threshold
29
+
30
+ stats[:slow_jobs] = finished_24h.select(:created_at, :finished_at)
31
+ .count { |j| (j.finished_at - j.created_at) > threshold }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ module SolidStackWeb
2
+ class ThroughputSparkline
3
+ HOURS = 12
4
+
5
+ def buckets
6
+ @buckets ||= begin
7
+ now = Time.current
8
+ origin = now - HOURS.hours
9
+ times = ::SolidQueue::Job.where(finished_at: origin..now).pluck(:finished_at)
10
+
11
+ HOURS.times.map do |i|
12
+ from = origin + i.hours
13
+ to = origin + (i + 1).hours
14
+ times.count { |t| t >= from && t < to }
15
+ end
16
+ end
17
+ end
18
+
19
+ def max
20
+ buckets.max || 0
21
+ end
22
+ end
23
+ end
@@ -33,6 +33,12 @@
33
33
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "failed_jobs"}" %>
34
34
  <%= link_to "Queues", queues_path,
35
35
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "queues"}" %>
36
+ <%= link_to "Recurring", recurring_tasks_path,
37
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "recurring_tasks"}" %>
38
+ <%= link_to "Stats", stats_path,
39
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "stats"}" %>
40
+ <%= link_to "History", history_path,
41
+ class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "history"}" %>
36
42
  <%= link_to "Processes", processes_path,
37
43
  class: "sqw-subnav__link#{" sqw-subnav__link--active" if controller_name == "processes"}" %>
38
44
  </div>
@@ -1,3 +1,5 @@
1
+ <%= turbo_frame_tag "sqw-dashboard", target: "_top",
2
+ data: { controller: "refresh", refresh_interval_value: SolidStackWeb.dashboard_refresh_interval } do %>
1
3
  <div class="sqw-page-header">
2
4
  <h1 class="sqw-page-title">Overview</h1>
3
5
  </div>
@@ -29,10 +31,42 @@
29
31
  <span class="sqw-inline-stat__label">Failed</span>
30
32
  <span class="sqw-inline-stat__value"><%= @queue_stats[:failed] %></span>
31
33
  </a>
34
+ <a href="<%= history_path(period: "1h") %>" class="sqw-inline-stat sqw-inline-stat--neutral">
35
+ <span class="sqw-inline-stat__label">Done (1h)</span>
36
+ <span class="sqw-inline-stat__value"><%= @queue_stats[:done_1h] %></span>
37
+ </a>
38
+ <a href="<%= history_path(period: "24h") %>" class="sqw-inline-stat sqw-inline-stat--neutral">
39
+ <span class="sqw-inline-stat__label">Done (24h)</span>
40
+ <span class="sqw-inline-stat__value"><%= @queue_stats[:done_24h] %></span>
41
+ </a>
42
+ <% if @queue_stats.key?(:slow_jobs) %>
43
+ <a href="<%= stats_path %>" class="sqw-inline-stat sqw-inline-stat--<%= @queue_stats[:slow_jobs] > 0 ? "failed" : "neutral" %>">
44
+ <span class="sqw-inline-stat__label">Slow (24h)</span>
45
+ <span class="sqw-inline-stat__value"><%= @queue_stats[:slow_jobs] %></span>
46
+ </a>
47
+ <% end %>
32
48
  <a href="<%= processes_path %>" class="sqw-inline-stat sqw-inline-stat--neutral">
33
- <span class="sqw-inline-stat__label">Processes</span>
34
- <span class="sqw-inline-stat__value"><%= @queue_stats[:processes] %></span>
49
+ <span class="sqw-inline-stat__label">Healthy</span>
50
+ <span class="sqw-inline-stat__value"><%= @queue_stats[:processes_healthy] %></span>
35
51
  </a>
52
+ <% if @queue_stats[:processes_stale] > 0 %>
53
+ <a href="<%= processes_path %>" class="sqw-inline-stat sqw-inline-stat--failed">
54
+ <span class="sqw-inline-stat__label">Stale</span>
55
+ <span class="sqw-inline-stat__value"><%= @queue_stats[:processes_stale] %></span>
56
+ </a>
57
+ <% end %>
58
+ </div>
59
+ <div class="sqw-sparkline-wrap" data-controller="sparkline-tooltip">
60
+ <span class="sqw-sparkline-label">Throughput — last 12 hours</span>
61
+ <div class="sqw-sparkline-positioner">
62
+ <%= throughput_sparkline_svg(@throughput) %>
63
+ <div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
64
+ </div>
65
+ <div class="sqw-sparkline-axis">
66
+ <span>12h ago</span>
67
+ <span>6h ago</span>
68
+ <span>now</span>
69
+ </div>
36
70
  </div>
37
71
  </div>
38
72
 
@@ -70,3 +104,4 @@
70
104
  </div>
71
105
  </div>
72
106
  </div>
107
+ <% end %>
@@ -0,0 +1,76 @@
1
+ <%= turbo_frame_tag "sqw-history-table",
2
+ data: { controller: "refresh", refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
3
+ <div class="sqw-page-header sqw-page-header--split">
4
+ <h1 class="sqw-page-title">Job History</h1>
5
+ <div class="sqw-header-actions">
6
+ <% if @jobs&.any? %>
7
+ <%= link_to "Export CSV", history_path(format: :csv, queue: @queue, q: @search, period: @period),
8
+ class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
9
+ <% end %>
10
+ </div>
11
+ </div>
12
+
13
+ <form class="sqw-filters" action="<%= history_path %>" method="get">
14
+ <% if @queue.present? %>
15
+ <input type="hidden" name="queue" value="<%= @queue %>">
16
+ <% end %>
17
+ <input type="hidden" name="period" value="<%= @period %>">
18
+ <input class="sqw-search-input" type="search" name="q" value="<%= @search %>"
19
+ placeholder="Filter by job class…" autocomplete="off" aria-label="Filter by job class">
20
+ <button type="submit" class="sqw-btn sqw-btn--muted sqw-btn--sm">Search</button>
21
+ <% if @search.present? %>
22
+ <%= link_to "Clear", history_path(queue: @queue, period: @period), class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
23
+ <% end %>
24
+ <div class="sqw-period-filter" role="group" aria-label="Time period">
25
+ <%= link_to "All", history_path(queue: @queue, q: @search),
26
+ class: "sqw-period-btn #{"sqw-period-btn--active" if @period.nil?}" %>
27
+ <%= link_to "1h", history_path(queue: @queue, q: @search, period: "1h"),
28
+ class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "1h"}" %>
29
+ <%= link_to "24h", history_path(queue: @queue, q: @search, period: "24h"),
30
+ class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "24h"}" %>
31
+ <%= link_to "7d", history_path(queue: @queue, q: @search, period: "7d"),
32
+ class: "sqw-period-btn #{"sqw-period-btn--active" if @period == "7d"}" %>
33
+ </div>
34
+ </form>
35
+
36
+ <% if @queue.present? %>
37
+ <p class="sqw-muted" style="font-size: 13px; margin-bottom: 0.75rem;">
38
+ Filtering by queue: <strong><%= @queue %></strong> &mdash;
39
+ <%= link_to "Clear filter", history_path(q: @search, period: @period) %>
40
+ </p>
41
+ <% end %>
42
+
43
+ <% if @jobs.any? %>
44
+ <div class="sqw-detail-card">
45
+ <table class="sqw-table">
46
+ <thead>
47
+ <tr>
48
+ <th>Job Class</th>
49
+ <th>Queue</th>
50
+ <th>Duration</th>
51
+ <th>Finished At</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ <% @jobs.each do |job| %>
56
+ <tr>
57
+ <td class="sqw-monospace"><%= job.class_name %></td>
58
+ <td>
59
+ <%= link_to job.queue_name,
60
+ history_path(queue: job.queue_name, q: @search, period: @period),
61
+ class: "sqw-badge sqw-badge--queue" %>
62
+ </td>
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>
65
+ </tr>
66
+ <% end %>
67
+ </tbody>
68
+ </table>
69
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %>
70
+ </div>
71
+ <% else %>
72
+ <div class="sqw-empty">
73
+ <p>No finished jobs found.</p>
74
+ </div>
75
+ <% end %>
76
+ <% end %>
@@ -3,6 +3,14 @@
3
3
  <div class="sqw-header-actions">
4
4
  <%= link_to "Export CSV", jobs_path(format: :csv, status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
5
5
  class: "sqw-btn sqw-btn--muted sqw-btn--sm", data: { turbo: false } %>
6
+ <% if @status == "scheduled" && @executions&.any? %>
7
+ <%= button_to "Run All Now (#{@pagy.count})",
8
+ run_all_now_scheduled_jobs_path(period: @period),
9
+ method: :post,
10
+ class: "sqw-btn sqw-btn--sm",
11
+ data: { turbo_confirm: "Run all #{@pagy.count} scheduled jobs immediately?",
12
+ turbo_frame: "_top" } %>
13
+ <% end %>
6
14
  <% if SolidStackWeb::Job::DISCARDABLE.include?(@status) && @executions&.any? %>
7
15
  <%= button_to "Discard All (#{@pagy.count})",
8
16
  discard_all_jobs_path(status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
@@ -21,7 +29,9 @@
21
29
  <% end %>
22
30
  </div>
23
31
 
24
- <turbo-frame id="sqw-jobs-filter" data-turbo-action="advance">
32
+ <%= turbo_frame_tag "sqw-jobs-filter",
33
+ data: { turbo_action: "advance", controller: "refresh",
34
+ refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
25
35
  <form class="sqw-filters" action="<%= jobs_path %>" method="get">
26
36
  <%= hidden_field_tag :status, @status %>
27
37
  <%= hidden_field_tag :period, @period %>
@@ -111,9 +121,22 @@
111
121
  <td><%= execution.job.priority %></td>
112
122
  <td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
113
123
  <% if @status == "scheduled" %>
114
- <td class="sqw-muted"><%= execution.scheduled_at&.strftime("%b %d %H:%M") %></td>
124
+ <td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= execution.scheduled_at&.strftime("%b %d %H:%M") %></td>
115
125
  <% end %>
116
126
  <td class="sqw-actions">
127
+ <% if @status == "scheduled" %>
128
+ <%= button_to "Run Now", scheduled_job_path(execution),
129
+ method: :patch,
130
+ params: { offset: "now", period: @period },
131
+ class: "sqw-btn sqw-btn--sm",
132
+ data: { turbo_confirm: "Run this job immediately?" } %>
133
+ <% %w[1h 24h 7d].each do |offset| %>
134
+ <%= button_to "+#{offset}", scheduled_job_path(execution),
135
+ method: :patch,
136
+ params: { offset: offset, period: @period },
137
+ class: "sqw-btn sqw-btn--muted sqw-btn--sm" %>
138
+ <% end %>
139
+ <% end %>
117
140
  <% if %w[ready scheduled blocked].include?(@status) %>
118
141
  <%= button_to "Discard", job_path(execution, status: @status, q: @search, queue: @queue, period: @period, priority: @priority),
119
142
  method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
@@ -130,4 +153,4 @@
130
153
  <%= render "empty" %>
131
154
  <% end %>
132
155
  </div>
133
- </turbo-frame>
156
+ <% end %>
@@ -1,3 +1,5 @@
1
+ <%= turbo_frame_tag "sqw-processes", target: "_top",
2
+ data: { controller: "refresh", refresh_interval_value: SolidStackWeb.default_refresh_interval } do %>
1
3
  <div class="sqw-page-header">
2
4
  <h1 class="sqw-page-title">Processes</h1>
3
5
  </div>
@@ -30,3 +32,4 @@
30
32
  <p>No active processes.</p>
31
33
  </div>
32
34
  <% end %>
35
+ <% end %>
@@ -8,6 +8,7 @@
8
8
  <tr>
9
9
  <th>Name</th>
10
10
  <th>Size</th>
11
+ <th>Depth (12h)</th>
11
12
  <th>Status</th>
12
13
  <th></th>
13
14
  </tr>
@@ -15,8 +16,12 @@
15
16
  <tbody>
16
17
  <% @queues.each do |queue| %>
17
18
  <tr>
18
- <td class="sqw-monospace"><%= queue[:name] %></td>
19
- <td><%= queue[:size] %></td>
19
+ <td class="sqw-monospace"><%= link_to queue[:name], queue_path(queue[:name]) %></td>
20
+ <td><%= link_to queue[:size], queue_path(queue[:name]) %></td>
21
+ <td class="sqw-queue-sparkline" data-controller="sparkline-tooltip">
22
+ <%= queue_depth_sparkline_svg(@sparklines[queue[:name]]) %>
23
+ <div class="sqw-sparkline-tip" data-sparkline-tooltip-target="tip" hidden></div>
24
+ </td>
20
25
  <td>
21
26
  <% if queue[:paused] %>
22
27
  <span class="sqw-badge sqw-badge--paused">Paused</span>
@@ -26,10 +31,10 @@
26
31
  </td>
27
32
  <td class="sqw-actions">
28
33
  <% if queue[:paused] %>
29
- <%= button_to "Resume", resume_queue_path(queue[:name]),
34
+ <%= button_to "Resume", queue_pause_path(queue[:name]),
30
35
  method: :delete, class: "sqw-btn sqw-btn--sm" %>
31
36
  <% else %>
32
- <%= button_to "Pause", pause_queue_path(queue[:name]),
37
+ <%= button_to "Pause", queue_pause_path(queue[:name]),
33
38
  method: :post, class: "sqw-btn sqw-btn--sm" %>
34
39
  <% end %>
35
40
  </td>
@@ -41,4 +46,4 @@
41
46
  <div class="sqw-empty">
42
47
  <p>No queues with ready jobs.</p>
43
48
  </div>
44
- <% end %>
49
+ <% end %>
@@ -0,0 +1,67 @@
1
+ <div class="sqw-page-header sqw-page-header--split">
2
+ <div>
3
+ <div class="sqw-breadcrumb">
4
+ <%= link_to "Queues", queues_path %> &rsaquo; <%= @queue_name %>
5
+ </div>
6
+ <div class="sqw-page-title-row">
7
+ <h1 class="sqw-page-title sqw-monospace"><%= @queue_name %></h1>
8
+ <% if @paused %>
9
+ <span class="sqw-badge sqw-badge--paused">Paused</span>
10
+ <% else %>
11
+ <span class="sqw-badge sqw-badge--ready">Running</span>
12
+ <% end %>
13
+ </div>
14
+ </div>
15
+ <div class="sqw-header-actions">
16
+ <% if @paused %>
17
+ <%= button_to "Resume", queue_pause_path(@queue_name),
18
+ method: :delete, class: "sqw-btn sqw-btn--sm" %>
19
+ <% else %>
20
+ <%= button_to "Pause", queue_pause_path(@queue_name),
21
+ method: :post, class: "sqw-btn sqw-btn--sm" %>
22
+ <% end %>
23
+ <% if @executions.any? %>
24
+ <%= button_to "Discard All Ready (#{@pagy.count})",
25
+ discard_all_jobs_path(status: "ready", queue: @queue_name),
26
+ method: :post,
27
+ class: "sqw-btn sqw-btn--danger sqw-btn--sm",
28
+ data: { turbo_confirm: "Discard all #{@pagy.count} ready jobs in #{@queue_name}? This cannot be undone.",
29
+ turbo_frame: "_top" } %>
30
+ <% end %>
31
+ </div>
32
+ </div>
33
+
34
+ <% if @executions.any? %>
35
+ <table class="sqw-table">
36
+ <thead>
37
+ <tr>
38
+ <th>Job Class</th>
39
+ <th>Priority</th>
40
+ <th>Enqueued At</th>
41
+ <th></th>
42
+ </tr>
43
+ </thead>
44
+ <tbody>
45
+ <% @executions.each do |execution| %>
46
+ <tr>
47
+ <td class="sqw-monospace">
48
+ <%= link_to execution.job.class_name, job_path(execution.id, status: "ready"),
49
+ data: { turbo_frame: "_top" } %>
50
+ </td>
51
+ <td><%= execution.job.priority %></td>
52
+ <td class="sqw-muted"><%= execution.created_at.strftime("%b %d %H:%M") %></td>
53
+ <td class="sqw-actions">
54
+ <%= button_to "Discard", job_path(execution, status: "ready", queue: @queue_name),
55
+ method: :delete, class: "sqw-btn sqw-btn--danger sqw-btn--sm",
56
+ data: { turbo_confirm: "Discard this job?" } %>
57
+ </td>
58
+ </tr>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
62
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %>
63
+ <% else %>
64
+ <div class="sqw-empty">
65
+ <p>No ready jobs in <strong><%= @queue_name %></strong>.</p>
66
+ </div>
67
+ <% end %>