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.
- checksums.yaml +4 -4
- data/README.md +104 -22
- data/app/assets/stylesheets/solid_stack_web/_02_layout.css +8 -0
- data/app/assets/stylesheets/solid_stack_web/_04_table.css +4 -0
- data/app/assets/stylesheets/solid_stack_web/_07_dashboard.css +64 -12
- data/app/controllers/solid_stack_web/application_controller.rb +1 -1
- data/app/controllers/solid_stack_web/dashboard_controller.rb +4 -16
- data/app/controllers/solid_stack_web/failed_jobs/selections_controller.rb +10 -4
- data/app/controllers/solid_stack_web/history_controller.rb +42 -0
- data/app/controllers/solid_stack_web/metrics_controller.rb +15 -0
- data/app/controllers/solid_stack_web/queues/pauses_controller.rb +13 -0
- data/app/controllers/solid_stack_web/queues_controller.rb +12 -7
- data/app/controllers/solid_stack_web/recurring_tasks/runs_controller.rb +18 -0
- data/app/controllers/solid_stack_web/recurring_tasks_controller.rb +7 -0
- data/app/controllers/solid_stack_web/scheduled_jobs_controller.rb +52 -0
- data/app/controllers/solid_stack_web/stats_controller.rb +39 -0
- data/app/helpers/solid_stack_web/application_helper.rb +64 -0
- data/app/javascript/solid_stack_web/application.js +5 -1
- data/app/javascript/solid_stack_web/refresh_controller.js +52 -0
- data/app/javascript/solid_stack_web/sparkline_tooltip_controller.js +23 -0
- data/app/models/solid_stack_web/alert_webhook.rb +67 -0
- data/app/models/solid_stack_web/cable_stats.rb +10 -0
- data/app/models/solid_stack_web/cache_stats.rb +10 -0
- data/app/models/solid_stack_web/queue_depth_sparkline.rb +30 -0
- data/app/models/solid_stack_web/queue_stats.rb +34 -0
- data/app/models/solid_stack_web/throughput_sparkline.rb +23 -0
- data/app/views/layouts/solid_stack_web/application.html.erb +6 -0
- data/app/views/solid_stack_web/dashboard/index.html.erb +37 -2
- data/app/views/solid_stack_web/history/index.html.erb +76 -0
- data/app/views/solid_stack_web/jobs/index.html.erb +26 -3
- data/app/views/solid_stack_web/processes/index.html.erb +3 -0
- data/app/views/solid_stack_web/queues/index.html.erb +10 -5
- data/app/views/solid_stack_web/queues/show.html.erb +67 -0
- data/app/views/solid_stack_web/recurring_tasks/index.html.erb +67 -0
- data/app/views/solid_stack_web/scheduled_jobs/update.turbo_stream.erb +9 -0
- data/app/views/solid_stack_web/stats/index.html.erb +48 -0
- data/config/importmap.rb +4 -0
- data/config/routes.rb +15 -5
- data/lib/solid_stack_web/version.rb +1 -1
- data/lib/solid_stack_web.rb +37 -1
- 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("
|
|
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,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">
|
|
34
|
-
<span class="sqw-inline-stat__value"><%= @queue_stats[:
|
|
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> —
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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 %> › <%= @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 %>
|