cosmonats 0.1.4 → 0.3.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +129 -67
  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 +123 -0
  9. data/lib/cosmo/api.rb +11 -0
  10. data/lib/cosmo/cli.rb +8 -5
  11. data/lib/cosmo/client.rb +58 -3
  12. data/lib/cosmo/config.rb +13 -38
  13. data/lib/cosmo/engine.rb +1 -1
  14. data/lib/cosmo/job/processor.rb +66 -57
  15. data/lib/cosmo/job.rb +1 -1
  16. data/lib/cosmo/logger.rb +8 -1
  17. data/lib/cosmo/processor.rb +110 -2
  18. data/lib/cosmo/stream/processor.rb +23 -59
  19. data/lib/cosmo/stream.rb +2 -2
  20. data/lib/cosmo/utils/hash.rb +3 -27
  21. data/lib/cosmo/utils/overrides.rb +15 -0
  22. data/lib/cosmo/utils/ttl_cache.rb +44 -0
  23. data/lib/cosmo/utils/warnings.rb +17 -0
  24. data/lib/cosmo/utils.rb +15 -0
  25. data/lib/cosmo/version.rb +1 -1
  26. data/lib/cosmo/web/assets/app.css +477 -0
  27. data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
  28. data/lib/cosmo/web/context.rb +28 -0
  29. data/lib/cosmo/web/controllers/actions.rb +16 -0
  30. data/lib/cosmo/web/controllers/application.rb +43 -0
  31. data/lib/cosmo/web/controllers/jobs.rb +97 -0
  32. data/lib/cosmo/web/controllers/streams.rb +70 -0
  33. data/lib/cosmo/web/helpers/application.rb +87 -0
  34. data/lib/cosmo/web/renderer.rb +58 -0
  35. data/lib/cosmo/web/views/actions/index.erb +7 -0
  36. data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
  37. data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
  38. data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
  39. data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
  40. data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
  41. data/lib/cosmo/web/views/jobs/busy.erb +16 -0
  42. data/lib/cosmo/web/views/jobs/dead.erb +17 -0
  43. data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
  44. data/lib/cosmo/web/views/jobs/index.erb +12 -0
  45. data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
  46. data/lib/cosmo/web/views/layout.erb +33 -0
  47. data/lib/cosmo/web/views/streams/_info.erb +92 -0
  48. data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
  49. data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
  50. data/lib/cosmo/web/views/streams/_table.erb +25 -0
  51. data/lib/cosmo/web/views/streams/index.erb +11 -0
  52. data/lib/cosmo/web/views/streams/info.erb +11 -0
  53. data/lib/cosmo/web.rb +68 -0
  54. data/lib/cosmo.rb +2 -7
  55. data/sig/cosmo/api/busy.rbs +35 -0
  56. data/sig/cosmo/api/counter.rbs +34 -0
  57. data/sig/cosmo/api/job.rbs +31 -0
  58. data/sig/cosmo/api/kv.rbs +30 -0
  59. data/sig/cosmo/api/stats.rbs +21 -0
  60. data/sig/cosmo/api/stream.rbs +50 -0
  61. data/sig/cosmo/client.rbs +21 -3
  62. data/sig/cosmo/config.rbs +3 -15
  63. data/sig/cosmo/job/processor.rbs +16 -8
  64. data/sig/cosmo/processor.rbs +26 -0
  65. data/sig/cosmo/stream/processor.rbs +4 -10
  66. data/sig/cosmo/utils/hash.rbs +0 -8
  67. data/sig/cosmo/utils/ttl_cache.rbs +20 -0
  68. metadata +62 -3
  69. data/lib/cosmo/defaults.yml +0 -69
@@ -0,0 +1,70 @@
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 pause
25
+ name = Rack::Utils.unescape(@request.params["name"])
26
+ stream = API::Stream.new(name)
27
+ stream.pause!
28
+ return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
29
+
30
+ ok render("streams/_stream_row", { stream: row_locals(stream) })
31
+ end
32
+
33
+ def unpause
34
+ name = Rack::Utils.unescape(@request.params["name"])
35
+ stream = API::Stream.new(name)
36
+ stream.unpause!
37
+ return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
38
+
39
+ ok render("streams/_stream_row", { stream: row_locals(stream) })
40
+ end
41
+
42
+ def _table
43
+ streams = API::Stream.all.map { row_locals(_1) }
44
+ ok render("streams/_table", { streams: streams })
45
+ end
46
+
47
+ def _info
48
+ name = Rack::Utils.unescape(@request.params["name"])
49
+ stream = API::Stream.new(name)
50
+ ok render("streams/_info", stream.info.merge(name:, paused: stream.paused?))
51
+ end
52
+
53
+ private
54
+
55
+ def row_locals(stream)
56
+ state, config = stream.info.values
57
+ { name: stream.name, messages: state.messages, bytes: state.bytes,
58
+ first_seq: state.first_seq, last_seq: state.last_seq,
59
+ consumer_count: state.consumer_count,
60
+ subjects: config.subjects,
61
+ paused: stream.paused? }
62
+ end
63
+
64
+ def banner_locals(stream)
65
+ { name: stream.name, paused: stream.paused? }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,87 @@
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 render(template, locals = nil)
12
+ defaults = { request: @request }
13
+ locals = Hash(locals).merge(defaults)
14
+ erb(template, locals)
15
+ end
16
+
17
+ def format_bytes(bytes)
18
+ b = bytes.to_i
19
+ return "0 B" if b.zero?
20
+
21
+ sizes = %w[B KB MB GB TB]
22
+ i = [(Math.log(b) / Math.log(1024)).floor, sizes.size - 1].min
23
+ "#{(b / (1024.0**i)).round(2)} #{sizes[i]}"
24
+ end
25
+
26
+ def format_numbers(num)
27
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
28
+ end
29
+
30
+ def format_timestamp(value)
31
+ return "N/A" unless value
32
+
33
+ Time.at(value.to_f).strftime("%Y-%m-%d %H:%M:%S")
34
+ rescue StandardError
35
+ value.to_s
36
+ end
37
+
38
+ def elapsed(value)
39
+ elapsed = Time.now.to_i - value.to_i
40
+
41
+ if elapsed < 60
42
+ "#{elapsed}s"
43
+ elsif elapsed < 3600
44
+ "#{elapsed / 60}m #{elapsed % 60}s"
45
+ else
46
+ "#{elapsed / 3600}h #{(elapsed % 3600) / 60}m"
47
+ end
48
+ end
49
+
50
+ def time_until(value)
51
+ return "N/A" unless value
52
+
53
+ diff = value.to_f - Time.now.to_f
54
+ return "Ready" if diff <= 0
55
+ return "#{diff.to_i}s" if diff < 60
56
+ return "#{(diff / 60).to_i}m" if diff < 3_600
57
+ return "#{(diff / 3_600).to_i}h" if diff < 86_400
58
+
59
+ "#{(diff / 86_400).to_i}d"
60
+ end
61
+
62
+ def h(value)
63
+ Rack::Utils.escape_html(value.to_s)
64
+ end
65
+
66
+ def u(value)
67
+ Rack::Utils.escape(value.to_s)
68
+ end
69
+
70
+ def current_page?(path)
71
+ request_path = @request.path_info
72
+ request_path = "/" if request_path.empty?
73
+ request_path == path
74
+ end
75
+
76
+ def referrer?(path)
77
+ referrer_uri = URI(@request.referrer)
78
+ referrer_path = referrer_uri.path
79
+ script_name = @request.script_name
80
+ referrer_path = referrer_path.delete_prefix(script_name) if script_name && !script_name.empty?
81
+ referrer_path = "/" if referrer_path.empty?
82
+ referrer_path == path
83
+ end
84
+ end
85
+ end
86
+ end
87
+ 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>
@@ -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/_enqueued', stream_name: @stream_name) %>"
12
+ hx-trigger="load"
13
+ hx-swap="innerHTML">
14
+ <div class="alert alert-info">Loading enqueued jobs&hellip;</div>
15
+ </div>
16
+ </section>
@@ -0,0 +1,12 @@
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>
12
+ </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/_scheduled') %>"
12
+ hx-trigger="load, every 5s"
13
+ hx-swap="innerHTML">
14
+ <div class="alert alert-info">Loading scheduled jobs…</div>
15
+ </div>
16
+ </div>
17
+ </section>
@@ -0,0 +1,33 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cosmo<%= " — #{h(content_for :title)}" if content_for?(:title) %></title>
7
+ <script src="<%= url_for('/assets/htmx.min.js.gz') %>" defer></script>
8
+ <link rel="stylesheet" href="<%= url_for('/assets/app.css') %>">
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <div class="container">
13
+ <nav class="nav">
14
+ <a href="<%= url_for('/jobs') %>" class="navbar-brand">🚀 Cosmo</a>
15
+ <ul class="nav-list">
16
+ <li>
17
+ <a href="<%= url_for('/jobs') %>" class="<%= "active" if current_page?('/jobs') %>">Jobs</a>
18
+ </li>
19
+ <li>
20
+ <a href="<%= url_for('/streams') %>" class="<%= "active" if current_page?('/streams') %>">Streams</a>
21
+ </li>
22
+ <li>
23
+ <a href="<%= url_for('/actions') %>" class="<%= "active" if current_page?('/actions') %>">Actions</a>
24
+ </li>
25
+ </ul>
26
+ </nav>
27
+ </div>
28
+ </header>
29
+ <main class="container">
30
+ <%= content_for :view %>
31
+ </main>
32
+ </body>
33
+ </html>