cosmonats 0.1.4 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -7
  3. data/lib/cosmo/api/busy.rb +66 -0
  4. data/lib/cosmo/api/counter.rb +70 -0
  5. data/lib/cosmo/api/job.rb +46 -0
  6. data/lib/cosmo/api/kv.rb +63 -0
  7. data/lib/cosmo/api/stats.rb +44 -0
  8. data/lib/cosmo/api/stream.rb +110 -0
  9. data/lib/cosmo/api.rb +11 -0
  10. data/lib/cosmo/cli.rb +6 -4
  11. data/lib/cosmo/client.rb +33 -2
  12. data/lib/cosmo/config.rb +8 -6
  13. data/lib/cosmo/defaults.yml +31 -30
  14. data/lib/cosmo/job/processor.rb +52 -18
  15. data/lib/cosmo/job.rb +1 -1
  16. data/lib/cosmo/logger.rb +4 -0
  17. data/lib/cosmo/processor.rb +1 -1
  18. data/lib/cosmo/utils/overrides.rb +15 -0
  19. data/lib/cosmo/utils/warnings.rb +17 -0
  20. data/lib/cosmo/utils.rb +14 -0
  21. data/lib/cosmo/version.rb +1 -1
  22. data/lib/cosmo/web/assets/app.css +431 -0
  23. data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
  24. data/lib/cosmo/web/context.rb +28 -0
  25. data/lib/cosmo/web/controllers/actions.rb +16 -0
  26. data/lib/cosmo/web/controllers/application.rb +43 -0
  27. data/lib/cosmo/web/controllers/jobs.rb +97 -0
  28. data/lib/cosmo/web/controllers/streams.rb +44 -0
  29. data/lib/cosmo/web/helpers/application.rb +76 -0
  30. data/lib/cosmo/web/renderer.rb +58 -0
  31. data/lib/cosmo/web/views/actions/index.erb +7 -0
  32. data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
  33. data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
  34. data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
  35. data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
  36. data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
  37. data/lib/cosmo/web/views/jobs/busy.erb +16 -0
  38. data/lib/cosmo/web/views/jobs/dead.erb +17 -0
  39. data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
  40. data/lib/cosmo/web/views/jobs/index.erb +12 -0
  41. data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
  42. data/lib/cosmo/web/views/layout.erb +33 -0
  43. data/lib/cosmo/web/views/streams/_info.erb +89 -0
  44. data/lib/cosmo/web/views/streams/_table.erb +42 -0
  45. data/lib/cosmo/web/views/streams/index.erb +11 -0
  46. data/lib/cosmo/web/views/streams/info.erb +11 -0
  47. data/lib/cosmo/web.rb +66 -0
  48. data/lib/cosmo.rb +2 -7
  49. data/sig/cosmo/api/busy.rbs +35 -0
  50. data/sig/cosmo/api/counter.rbs +34 -0
  51. data/sig/cosmo/api/job.rbs +31 -0
  52. data/sig/cosmo/api/kv.rbs +30 -0
  53. data/sig/cosmo/api/stats.rbs +21 -0
  54. data/sig/cosmo/api/stream.rbs +44 -0
  55. data/sig/cosmo/client.rbs +13 -3
  56. metadata +58 -2
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+
5
+ module Cosmo
6
+ class Web
7
+ module Controllers
8
+ class Actions < Application
9
+ def index
10
+ content_for :title, "Actions"
11
+ ok render("actions/index", layout: true)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Web
5
+ module Controllers
6
+ class Application
7
+ include Renderer
8
+
9
+ def initialize(request)
10
+ @request = request
11
+ end
12
+
13
+ def content_for(name, content)
14
+ @content_for ||= {}
15
+ @content_for[name] = content
16
+ end
17
+
18
+ def render(template, locals = nil, layout: false)
19
+ defaults = { request: @request }
20
+ locals = Hash(locals).merge(defaults)
21
+ view = erb(template, locals)
22
+ return view unless layout
23
+
24
+ @content_for ||= {}
25
+ @content_for[:view] = view
26
+ erb("layout", defaults, @content_for)
27
+ end
28
+
29
+ def params
30
+ @request.params
31
+ end
32
+
33
+ def path
34
+ @request.path
35
+ end
36
+
37
+ def hx_request?
38
+ @request.get_header("HTTP_HX_REQUEST") == "true"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+
5
+ module Cosmo
6
+ class Web
7
+ module Controllers
8
+ class Jobs < Application
9
+ def index
10
+ content_for :title, "Jobs"
11
+ ok render("jobs/index", layout: true)
12
+ end
13
+
14
+ def busy
15
+ return _busy if hx_request?
16
+
17
+ content_for :title, "Busy Jobs"
18
+ ok render("jobs/busy", layout: true)
19
+ end
20
+
21
+ def enqueued
22
+ return _enqueued if hx_request?
23
+
24
+ content_for :title, "Enqueued Jobs"
25
+ stream_name, _stream_names = streams
26
+ ok render("jobs/enqueued", { stream_name: }, layout: true)
27
+ end
28
+
29
+ def scheduled
30
+ return _scheduled if hx_request?
31
+
32
+ content_for :title, "Scheduled Jobs"
33
+ ok render("jobs/scheduled", layout: true)
34
+ end
35
+
36
+ def dead
37
+ return _dead if hx_request?
38
+
39
+ content_for :title, "Dead Jobs"
40
+ ok render("jobs/dead", layout: true)
41
+ end
42
+
43
+ def retry
44
+ seq = path.split("/").last.to_i
45
+ stream = API::Stream.new("dead")
46
+ stream.retry(seq)
47
+ ok
48
+ end
49
+
50
+ def delete
51
+ seq = path.split("/").last.to_i
52
+ stream = API::Stream.new("dead")
53
+ stream.delete(seq)
54
+ ok
55
+ end
56
+
57
+ def _scheduled
58
+ stream = API::Stream.new("scheduled")
59
+ jobs = stream.messages(page: params["page"], limit: params["limit"])
60
+ ok render("jobs/_scheduled", { jobs: jobs, total: stream.total })
61
+ end
62
+
63
+ def _dead
64
+ stream = API::Stream.new("dead")
65
+ jobs = stream.messages(page: params["page"], limit: params["limit"])
66
+ ok render("jobs/_dead", { jobs: jobs, total: stream.total })
67
+ end
68
+
69
+ def _busy
70
+ limit = (limit || 25).to_i
71
+ jobs = API::Busy.instance.list(limit:)
72
+ ok render("jobs/_busy", { jobs: jobs, total: API::Busy.instance.size })
73
+ end
74
+
75
+ def _enqueued
76
+ stream_name, stream_names = streams
77
+ stream = API::Stream.new(stream_name)
78
+ jobs = stream.messages(page: params["page"], limit: params["limit"])
79
+
80
+ ok render("jobs/_enqueued", { jobs:, total: stream.total, stream_name:, stream_names: })
81
+ end
82
+
83
+ def _stats
84
+ ok render("jobs/_stats", API::Stats.summary)
85
+ end
86
+
87
+ private
88
+
89
+ def streams
90
+ stream_names = API::Stream.jobs.map(&:name)
91
+ stream_name = params.fetch("stream_name", stream_names.first)
92
+ [stream_name, stream_names]
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+
5
+ module Cosmo
6
+ class Web
7
+ module Controllers
8
+ class Streams < Application
9
+ def index
10
+ return _table if hx_request?
11
+
12
+ content_for :title, "Streams"
13
+ ok render("streams/index", layout: true)
14
+ end
15
+
16
+ def info
17
+ name = Rack::Utils.unescape(@request.params["name"])
18
+ return _info if hx_request?
19
+
20
+ content_for :title, "Streams"
21
+ ok render("streams/info", { name: name }, layout: true)
22
+ end
23
+
24
+ def _table
25
+ streams = API::Stream.all.map do |stream|
26
+ state, config = stream.info.values
27
+ { name: stream.name, messages: state.messages, bytes: state.bytes,
28
+ first_seq: state.first_seq, last_seq: state.last_seq,
29
+ consumer_count: state.consumer_count,
30
+ subjects: config.subjects }
31
+ end
32
+
33
+ ok render("streams/_table", { streams: streams })
34
+ end
35
+
36
+ def _info
37
+ name = Rack::Utils.unescape(@request.params["name"])
38
+ state = API::Stream.new(name).info.merge(name:)
39
+ ok render("streams/_info", state)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/renderer"
4
+
5
+ module Cosmo
6
+ class Web
7
+ module Helpers
8
+ module Application
9
+ include Renderer
10
+
11
+ def format_bytes(bytes)
12
+ b = bytes.to_i
13
+ return "0 B" if b.zero?
14
+
15
+ sizes = %w[B KB MB GB TB]
16
+ i = [(Math.log(b) / Math.log(1024)).floor, sizes.size - 1].min
17
+ "#{(b / (1024.0**i)).round(2)} #{sizes[i]}"
18
+ end
19
+
20
+ def format_numbers(num)
21
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
22
+ end
23
+
24
+ def format_timestamp(value)
25
+ return "N/A" unless value
26
+
27
+ Time.at(value.to_f).strftime("%Y-%m-%d %H:%M:%S")
28
+ rescue StandardError
29
+ value.to_s
30
+ end
31
+
32
+ def elapsed(value)
33
+ elapsed = Time.now.to_i - value.to_i
34
+
35
+ if elapsed < 60
36
+ "#{elapsed}s"
37
+ elsif elapsed < 3600
38
+ "#{elapsed / 60}m #{elapsed % 60}s"
39
+ else
40
+ "#{elapsed / 3600}h #{(elapsed % 3600) / 60}m"
41
+ end
42
+ end
43
+
44
+ def time_until(value)
45
+ return "N/A" unless value
46
+
47
+ diff = value.to_f - Time.now.to_f
48
+ return "Ready" if diff <= 0
49
+ return "#{diff.to_i}s" if diff < 60
50
+ return "#{(diff / 60).to_i}m" if diff < 3_600
51
+ return "#{(diff / 3_600).to_i}h" if diff < 86_400
52
+
53
+ "#{(diff / 86_400).to_i}d"
54
+ end
55
+
56
+ def h(value)
57
+ Rack::Utils.escape_html(value.to_s)
58
+ end
59
+
60
+ def u(value)
61
+ Rack::Utils.escape(value.to_s)
62
+ end
63
+
64
+ def current_page?(path)
65
+ request_path = @request.path_info
66
+ request_path = "/" if request_path.empty?
67
+ request_path == url_for(path)
68
+ end
69
+
70
+ def referrer?(path)
71
+ URI(@request.referrer).path == path
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Web
5
+ module Renderer
6
+ ASSETS_ROOT = File.expand_path("assets", __dir__).freeze
7
+ VIEWS_ROOT = File.expand_path("views", __dir__).freeze
8
+
9
+ def redirect_to(url, status = 302)
10
+ [status, { "location" => url_for(url) }, []]
11
+ end
12
+
13
+ def serve(filename, content_type, headers = nil)
14
+ path = File.join(ASSETS_ROOT, filename)
15
+ return not_found unless File.exist?(path)
16
+
17
+ respond_with 200, body: File.read(path), headers: { "content-type" => content_type }.merge(headers || {})
18
+ end
19
+
20
+ def ok(body = "")
21
+ headers = { "content-type" => "text/html; charset=utf-8" }
22
+ respond_with 200, body:, headers:
23
+ end
24
+
25
+ def no_content
26
+ respond_with 204, body: ""
27
+ end
28
+
29
+ def not_found
30
+ body = "<div class='alert alert-danger'>404 — Not Found</div>"
31
+ headers = { "content-type" => "text/html; charset=utf-8" }
32
+ respond_with 404, body:, headers:
33
+ end
34
+
35
+ # Prepend the mount prefix to an internal route.
36
+ # url_for("/jobs") # => "/admin/cosmo/jobs" (mounted)
37
+ # url_for("/jobs") # => "/jobs" (standalone)
38
+ def url_for(path, params = nil)
39
+ url = "#{@request.script_name}#{path}"
40
+ url = "#{url}?#{params.to_a.map { _1.join("=") }.join("&")}" if params
41
+ url
42
+ end
43
+
44
+ private
45
+
46
+ def respond_with(status, headers: nil, body: "")
47
+ [status, Hash(headers), [body]]
48
+ end
49
+
50
+ def erb(path, locals, content_for = nil)
51
+ path = File.join(VIEWS_ROOT, "#{path}.erb")
52
+ erb = ERB.new(File.read(path), trim_mode: "-")
53
+ context = Context.new(locals, content_for)
54
+ erb.result(context.binding)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,7 @@
1
+ <section>
2
+ <header></header>
3
+
4
+ <div>
5
+ <div class="alert alert-info">None</div>
6
+ </div>
7
+ </section>
@@ -0,0 +1,50 @@
1
+ <div class="pending">
2
+ <span></span>
3
+ <span class="text-muted"><%= @total %> job(s) running</span>
4
+ </div>
5
+
6
+ <% if @jobs.empty? -%>
7
+ <div class="alert alert-success">No jobs are currently running</div>
8
+ <% else -%>
9
+ <div class="table-container">
10
+ <table>
11
+ <thead>
12
+ <tr>
13
+ <th>Job</th>
14
+ <th>JID[Args]</th>
15
+ <th>Stream</th>
16
+ <th>Worker</th>
17
+ <th>Running for</th>
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ <% @jobs.each do |job| -%>
22
+ <tr>
23
+ <td>
24
+ <div class="job-class">
25
+ <%= h(job.dig(:data, :class).to_s) %>
26
+ </div>
27
+ </td>
28
+ <td>
29
+ <div class="job-id">
30
+ <details>
31
+ <summary>
32
+ <code><%= h(job.dig(:data, :jid)) %></code>
33
+ </summary>
34
+ <code><%= h(JSON.pretty_generate(job.dig(:data, :args))) %></code>
35
+ </details>
36
+ </div>
37
+ </td>
38
+ <td>
39
+ <code><%= h(job[:stream].to_s) %></code>
40
+ </td>
41
+ <td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
42
+ <td>
43
+ <%= elapsed(job[:started_at]) %>
44
+ </td>
45
+ </tr>
46
+ <% end -%>
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+ <% end -%>
@@ -0,0 +1,65 @@
1
+ <% if @jobs.empty? -%>
2
+ <div class="alert alert-success">No dead jobs found</div>
3
+ <% else -%>
4
+ <div class="table-container">
5
+ <table>
6
+ <thead>
7
+ <tr>
8
+ <th>Job</th>
9
+ <th>JID[Args]</th>
10
+ <th>Stream</th>
11
+ <th>Error</th>
12
+ <th>Failed</th>
13
+ <th>Actions</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% @jobs.each do |job| -%>
18
+ <tr class="dead-row" id="dead-row-<%= job.seq %>">
19
+ <td>
20
+ <div class="job-class">
21
+ <%= h(job.data[:class].to_s) %>
22
+ </div>
23
+ </td>
24
+ <td>
25
+ <div class="job-id">
26
+ <details>
27
+ <summary>
28
+ <code><%= h(job.data[:jid].to_s) %></code>
29
+ </summary>
30
+ <code><%= h((JSON.pretty_generate(job.data[:args]) rescue job.data[:args].to_s)) %></code>
31
+ </details>
32
+ </div>
33
+ </td>
34
+ <td>
35
+ <code><%= h(job.stream) %></code>
36
+ </td>
37
+ <td style="max-width: 400px;">
38
+ <% if job.data[:error] -%>
39
+ <div class="error-message">
40
+ <strong><%= h(job.data[:error].to_s.split(":").first || "Error") %></strong>
41
+ <p><%= h(job.data[:error]) %></p>
42
+ </div>
43
+ <% end -%>
44
+ </td>
45
+ <td style="white-space: nowrap;">
46
+ <time><%= h(job.timestamp) %></time>
47
+ </td>
48
+ <td>
49
+ <a hx-patch="<%= url_for("/jobs/retry/#{job.seq}") %>"
50
+ hx-target="#dead-row-<%= job.seq %>"
51
+ hx-swap="innerHTML"
52
+ class="btn btn-primary"
53
+ style="width: 100%; text-align: center; margin-bottom: 3px">&#x21BB; Retry</a>
54
+ <a hx-delete="<%= url_for("/jobs/delete/#{job.seq}") %>"
55
+ hx-target="#dead-row-<%= job.seq %>"
56
+ hx-swap="innerHTML"
57
+ class="btn btn-danger"
58
+ style="width: 100%; text-align: center; margin-bottom: 3px">&#x2715; Delete</a>
59
+ </td>
60
+ </tr>
61
+ <% end -%>
62
+ </tbody>
63
+ </table>
64
+ </div>
65
+ <% end -%>
@@ -0,0 +1,60 @@
1
+ <div class="nav">
2
+ <% @stream_names.each do |stream_name| -%>
3
+ <button hx-get="<%= url_for('/jobs/enqueued', { stream_name: }) %>"
4
+ hx-target="#content"
5
+ hx-swap="innerHTML"
6
+ hx-push-url="true"
7
+ class="btn <%= "btn-primary" if @stream_name.to_s == stream_name.to_s %>">
8
+ <%= h(stream_name) %>
9
+ </button>
10
+ <% end -%>
11
+ </div>
12
+
13
+ <div class="pending">
14
+ <span></span>
15
+ <span class="text-muted"><%= @total %> message(s) pending</span>
16
+ </div>
17
+
18
+ <% if @jobs.empty? -%>
19
+ <div class="alert alert-success">Stream <code><%= h(@stream_name) %></code> is empty</div>
20
+ <% else -%>
21
+ <div class="table-container">
22
+ <table>
23
+ <thead>
24
+ <tr>
25
+ <th>Job</th>
26
+ <th>JID[Args]</th>
27
+ <th>Stream</th>
28
+ <th>Seq</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% @jobs.each do |job| -%>
33
+ <tr>
34
+ <td>
35
+ <div class="job-class">
36
+ <%= h(job.data[:class].to_s) %>
37
+ </div>
38
+ </td>
39
+ <td>
40
+ <div class="job-id">
41
+ <details>
42
+ <summary>
43
+ <code><%= h(job.data[:jid].to_s) %></code>
44
+ </summary>
45
+ <code><%= h((JSON.pretty_generate(job.data[:args]) rescue job.data[:args].to_s)) %></code>
46
+ </details>
47
+ </div>
48
+ </td>
49
+ <td>
50
+ <code><%= h(job.stream) %></code>
51
+ </td>
52
+ <td>
53
+ <code><%= job.seq %></code>
54
+ </td>
55
+ </tr>
56
+ <% end -%>
57
+ </tbody>
58
+ </table>
59
+ </div>
60
+ <% end -%>
@@ -0,0 +1,49 @@
1
+ <div class="pending">
2
+ <span></span>
3
+ <span class="text-muted"><%= @total %> job(s) scheduled</span>
4
+ </div>
5
+
6
+ <% if @jobs.empty? -%>
7
+ <div class="alert alert-success">No scheduled jobs found</div>
8
+ <% else -%>
9
+ <div class="table-container">
10
+ <table>
11
+ <thead>
12
+ <tr>
13
+ <th>Job</th>
14
+ <th>JID[Args]</th>
15
+ <th>Stream</th>
16
+ <th>When</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @jobs.each do |job| -%>
21
+ <tr>
22
+ <td>
23
+ <div class="job-class">
24
+ <%= h(job.data[:class].to_s) %>
25
+ </div>
26
+ </td>
27
+ <td>
28
+ <div class="job-id">
29
+ <details>
30
+ <summary>
31
+ <code><%= h(job.data[:jid].to_s) %></code>
32
+ </summary>
33
+ <code><%= h((JSON.pretty_generate(job.data[:args]) rescue job.data[:args].to_s)) %></code>
34
+ </details>
35
+ </div>
36
+ </td>
37
+ <td>
38
+ <code><%= h(job.x_stream) %></code>
39
+ </td>
40
+ <td style="white-space: nowrap;">
41
+ <div class="time-badge"><%= h(time_until(job.execute_at)) %></div>
42
+ <time><%= h(format_timestamp(job.execute_at)) %></time>
43
+ </td>
44
+ </tr>
45
+ <% end -%>
46
+ </tbody>
47
+ </table>
48
+ </div>
49
+ <% end -%>
@@ -0,0 +1,69 @@
1
+ <div class="cards-container">
2
+ <article class="stat-card">
3
+ <div class="row">
4
+ <p>&#x1F4AA; Processed</p>
5
+ <h4><%= format_numbers(@processed) %></h4>
6
+ </div>
7
+ <div class="row">
8
+ <p>&#x1F625; Failed</p>
9
+ <h4><%= format_numbers(@failed) %></h4>
10
+ </div>
11
+ <div class="row">
12
+ <p>&#x1F504; Retries</p>
13
+ <h4><%= format_numbers(@retries) %></h4>
14
+ </div>
15
+ </article>
16
+ <article class="stat-card">
17
+ <h3><%= format_numbers(@busy) %></h3>
18
+ <p>
19
+ <a href="<%= url_for('/jobs/busy') %>"
20
+ hx-get="<%= url_for('/jobs/busy') %>"
21
+ hx-target="#content"
22
+ hx-push-url="true">&#x23F3; Busy</a>
23
+ </p>
24
+ </article>
25
+ <article class="stat-card">
26
+ <h3><%= format_numbers(@enqueued) %></h3>
27
+ <p>
28
+ <a href="<%= url_for('/jobs/enqueued') %>"
29
+ hx-get="<%= url_for('/jobs/enqueued') %>"
30
+ hx-target="#content"
31
+ hx-push-url="true"
32
+ class="<%= 'active' if referrer?('/jobs/enqueued') %>">&#x1F4EC; Enqueued</a>
33
+ </p>
34
+ </article>
35
+ <article class="stat-card">
36
+ <h3><%= format_numbers(@scheduled) %></h3>
37
+ <p>
38
+ <a href="<%= url_for('/jobs/scheduled') %>"
39
+ hx-get="<%= url_for('/jobs/scheduled') %>"
40
+ hx-target="#content"
41
+ hx-push-url="true"
42
+ class="<%= 'active' if referrer?('/jobs/scheduled') %>">&#x23F0; Scheduled</a>
43
+ </p>
44
+ </article>
45
+ <article class="stat-card">
46
+ <h3><%= format_numbers(@dead) %></h3>
47
+ <p>
48
+ <a href="<%= url_for('/jobs/dead') %>"
49
+ hx-get="<%= url_for('/jobs/dead') %>"
50
+ hx-target="#content"
51
+ hx-push-url="true"
52
+ class="<%= 'active' if referrer?('/jobs/dead') %>">&#x1F480; Dead</a>
53
+ </p>
54
+ </article>
55
+ </div>
56
+
57
+ <script>
58
+ (function () {
59
+ const links = document.querySelectorAll('.cards-container a');
60
+
61
+ links.forEach(link => {
62
+ link.addEventListener('click', () => {
63
+ links.forEach(item => item.classList.remove('active'));
64
+
65
+ link.classList.add('active');
66
+ });
67
+ });
68
+ })();
69
+ </script>
@@ -0,0 +1,16 @@
1
+ <section>
2
+ <header></header>
3
+
4
+ <div hx-get="<%= url_for('/jobs/_stats') %>"
5
+ hx-trigger="load, every 5s"
6
+ hx-swap="innerHTML">
7
+ <div class="alert alert-info">Loading statistics…</div>
8
+ </div>
9
+
10
+ <div id="content"
11
+ hx-get="<%= url_for('/jobs/_busy') %>"
12
+ hx-trigger="load, every 5s"
13
+ hx-swap="innerHTML">
14
+ <div class="alert alert-info">Loading busy jobs&hellip;</div>
15
+ </div>
16
+ </section>
@@ -0,0 +1,17 @@
1
+ <section>
2
+ <header></header>
3
+
4
+ <div hx-get="<%= url_for('/jobs/_stats') %>"
5
+ hx-trigger="load, every 5s"
6
+ hx-swap="innerHTML">
7
+ <div class="alert alert-info">Loading statistics…</div>
8
+ </div>
9
+
10
+ <div id="content">
11
+ <div hx-get="<%= url_for('/jobs/_dead') %>"
12
+ hx-trigger="load, every 5s"
13
+ hx-swap="innerHTML">
14
+ <div class="alert alert-info">Loading dead jobs…</div>
15
+ </div>
16
+ </div>
17
+ </section>