cosmonats 0.7.0 → 0.9.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +219 -113
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +17 -17
  5. data/lib/cosmo/api/cron/entry.rb +10 -11
  6. data/lib/cosmo/api/cron.rb +6 -3
  7. data/lib/cosmo/api/job.rb +1 -1
  8. data/lib/cosmo/api/kv.rb +3 -3
  9. data/lib/cosmo/api/registry.rb +1 -1
  10. data/lib/cosmo/api/stats/busy.rb +6 -1
  11. data/lib/cosmo/api/stats/metrics.rb +109 -0
  12. data/lib/cosmo/api/stats/processes.rb +14 -4
  13. data/lib/cosmo/api/stats/totals.rb +4 -12
  14. data/lib/cosmo/api/stats.rb +18 -2
  15. data/lib/cosmo/api/stream.rb +7 -2
  16. data/lib/cosmo/batch/counters.rb +19 -0
  17. data/lib/cosmo/batch.rb +6 -6
  18. data/lib/cosmo/cli.rb +21 -5
  19. data/lib/cosmo/client.rb +37 -14
  20. data/lib/cosmo/config/cosmo.yml +58 -0
  21. data/lib/cosmo/config/settings.rb +20 -0
  22. data/lib/cosmo/config.rb +144 -19
  23. data/lib/cosmo/heartbeat.rb +11 -0
  24. data/lib/cosmo/job/data.rb +17 -12
  25. data/lib/cosmo/job/enqueuer.rb +53 -0
  26. data/lib/cosmo/job/limit.rb +2 -2
  27. data/lib/cosmo/job/processor.rb +32 -58
  28. data/lib/cosmo/job/requeue.rb +20 -0
  29. data/lib/cosmo/job.rb +8 -22
  30. data/lib/cosmo/logger.rb +15 -2
  31. data/lib/cosmo/middleware/busy.rb +15 -0
  32. data/lib/cosmo/middleware/chain.rb +137 -0
  33. data/lib/cosmo/middleware/limit.rb +50 -0
  34. data/lib/cosmo/middleware/metrics.rb +39 -0
  35. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  36. data/lib/cosmo/middleware/totals.rb +29 -0
  37. data/lib/cosmo/middleware.rb +7 -0
  38. data/lib/cosmo/processor.rb +11 -5
  39. data/lib/cosmo/publisher.rb +1 -13
  40. data/lib/cosmo/railtie.rb +2 -2
  41. data/lib/cosmo/services.rb +69 -0
  42. data/lib/cosmo/stream/processor.rb +7 -1
  43. data/lib/cosmo/utils/hash.rb +7 -8
  44. data/lib/cosmo/utils.rb +0 -1
  45. data/lib/cosmo/version.rb +1 -1
  46. data/lib/cosmo/web/assets/app.css +271 -571
  47. data/lib/cosmo/web/assets/app.js +158 -0
  48. data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
  49. data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
  50. data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
  51. data/lib/cosmo/web/assets/logo.svg +10 -0
  52. data/lib/cosmo/web/cache.rb +75 -0
  53. data/lib/cosmo/web/chart.rb +122 -0
  54. data/lib/cosmo/web/circuit.rb +55 -0
  55. data/lib/cosmo/web/context.rb +0 -4
  56. data/lib/cosmo/web/controllers/application.rb +16 -0
  57. data/lib/cosmo/web/controllers/batches.rb +1 -0
  58. data/lib/cosmo/web/controllers/crons.rb +17 -2
  59. data/lib/cosmo/web/controllers/jobs.rb +59 -54
  60. data/lib/cosmo/web/controllers/metrics.rb +49 -0
  61. data/lib/cosmo/web/controllers/overview.rb +70 -0
  62. data/lib/cosmo/web/controllers/processes.rb +33 -0
  63. data/lib/cosmo/web/controllers/streams.rb +26 -28
  64. data/lib/cosmo/web/helpers/application.rb +67 -0
  65. data/lib/cosmo/web/renderer.rb +19 -6
  66. data/lib/cosmo/web/throughput.rb +104 -0
  67. data/lib/cosmo/web/views/batches/_table.erb +42 -43
  68. data/lib/cosmo/web/views/batches/index.erb +3 -10
  69. data/lib/cosmo/web/views/crons/_table.erb +32 -53
  70. data/lib/cosmo/web/views/crons/index.erb +2 -9
  71. data/lib/cosmo/web/views/jobs/_args.erb +5 -0
  72. data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
  73. data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
  74. data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
  75. data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
  76. data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
  77. data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
  78. data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
  79. data/lib/cosmo/web/views/jobs/index.erb +4 -17
  80. data/lib/cosmo/web/views/layout.erb +73 -22
  81. data/lib/cosmo/web/views/metrics/_chart.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  83. data/lib/cosmo/web/views/metrics/_table.erb +25 -0
  84. data/lib/cosmo/web/views/metrics/index.erb +9 -0
  85. data/lib/cosmo/web/views/overview/_badges.erb +3 -0
  86. data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
  87. data/lib/cosmo/web/views/overview/_running.erb +18 -0
  88. data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
  89. data/lib/cosmo/web/views/overview/_workers.erb +25 -0
  90. data/lib/cosmo/web/views/overview/index.erb +28 -0
  91. data/lib/cosmo/web/views/processes/_table.erb +67 -0
  92. data/lib/cosmo/web/views/processes/index.erb +1 -0
  93. data/lib/cosmo/web/views/shared/_pager.erb +22 -0
  94. data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
  95. data/lib/cosmo/web/views/streams/_info.erb +28 -86
  96. data/lib/cosmo/web/views/streams/_list.erb +7 -0
  97. data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
  98. data/lib/cosmo/web/views/streams/_stream.erb +52 -0
  99. data/lib/cosmo/web/views/streams/index.erb +3 -11
  100. data/lib/cosmo/web/views/streams/info.erb +3 -11
  101. data/lib/cosmo/web.rb +67 -33
  102. data/lib/cosmo.rb +38 -0
  103. data/sig/cosmo/api/counter.rbs +2 -3
  104. data/sig/cosmo/api/cron/entry.rbs +2 -1
  105. data/sig/cosmo/api/stats/busy.rbs +3 -1
  106. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  107. data/sig/cosmo/api/stats/processes.rbs +3 -0
  108. data/sig/cosmo/api/stats/totals.rbs +3 -2
  109. data/sig/cosmo/api/stats.rbs +4 -0
  110. data/sig/cosmo/api/stream.rbs +2 -0
  111. data/sig/cosmo/batch/counters.rbs +10 -0
  112. data/sig/cosmo/batch.rbs +3 -4
  113. data/sig/cosmo/cli.rbs +2 -0
  114. data/sig/cosmo/client.rbs +4 -1
  115. data/sig/cosmo/config/settings.rbs +31 -0
  116. data/sig/cosmo/config.rbs +59 -5
  117. data/sig/cosmo/heartbeat.rbs +2 -0
  118. data/sig/cosmo/job/data.rbs +5 -3
  119. data/sig/cosmo/job/enqueuer.rbs +11 -0
  120. data/sig/cosmo/job/processor.rbs +3 -5
  121. data/sig/cosmo/job/requeue.rbs +9 -0
  122. data/sig/cosmo/job.rbs +0 -2
  123. data/sig/cosmo/logger.rbs +6 -0
  124. data/sig/cosmo/middleware/chain.rbs +51 -0
  125. data/sig/cosmo/middleware/sentry.rbs +14 -0
  126. data/sig/cosmo/middleware.rbs +35 -0
  127. data/sig/cosmo/processor.rbs +2 -0
  128. data/sig/cosmo/publisher.rbs +1 -9
  129. data/sig/cosmo/services.rbs +18 -0
  130. data/sig/cosmo/stream/processor.rbs +2 -0
  131. data/sig/cosmo/utils/hash.rbs +2 -3
  132. data/sig/cosmo.rbs +11 -0
  133. metadata +60 -20
  134. data/lib/cosmo/sentry/auto.rb +0 -6
  135. data/lib/cosmo/utils/overrides.rb +0 -206
  136. data/lib/cosmo/utils/warnings.rb +0 -17
  137. data/lib/cosmo/web/controllers/actions.rb +0 -16
  138. data/lib/cosmo/web/views/actions/index.erb +0 -7
  139. data/lib/cosmo/web/views/jobs/_processes.erb +0 -131
  140. data/lib/cosmo/web/views/jobs/_stats.erb +0 -73
  141. data/lib/cosmo/web/views/jobs/_tabs.erb +0 -8
  142. data/lib/cosmo/web/views/jobs/busy.erb +0 -18
  143. data/lib/cosmo/web/views/jobs/dead.erb +0 -18
  144. data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
  145. data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
  146. data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
  147. data/lib/cosmo/web/views/streams/_table.erb +0 -25
@@ -1,116 +1,36 @@
1
- <div id="enqueued-poller">
2
- <div class="nav">
3
- <% @stream_names.each do |stream_name| -%>
4
- <button hx-get="<%= url_for('/jobs/enqueued', { stream_name: }) %>"
5
- hx-target="#content"
6
- hx-swap="innerHTML"
7
- hx-push-url="true"
8
- hx-indicator="#global-spinner"
9
- class="btn <%= "btn-primary" if @stream_name.to_s == stream_name.to_s %>">
10
- <%= h(stream_name) %>
11
- </button>
1
+ <div class="subbar">
2
+ <div class="seg">
3
+ <% @stream_names.each do |name| -%>
4
+ <a class="<%= 'on' if @stream_name.to_s == name.to_s %>" href="<%= url_for('/jobs/enqueued', stream_name: u(name)) %>"
5
+ hx-get="<%= url_for('/jobs/enqueued', stream_name: u(name)) %>" hx-target="#jobs-panel" hx-swap="outerHTML"
6
+ hx-push-url="true" hx-indicator="#global-spinner"><%= h(name) %></a>
12
7
  <% end -%>
13
8
  </div>
14
-
15
- <div class="pending">
16
- <span></span>
17
- <span class="text-muted"><%= @total.to_i %> message(s) pending</span>
18
- </div>
19
-
20
- <% if @jobs.nil? -%>
21
- <div class="alert alert-success">No streams found</div>
22
- <% elsif @jobs.empty? -%>
23
- <div class="alert alert-success">Stream <code><%= h(@stream_name) %></code> is empty</div>
24
- <% else -%>
25
- <div class="table-container">
26
- <table>
27
- <thead>
28
- <tr>
29
- <th>Job</th>
30
- <th>JID[Args]</th>
31
- <th>Stream</th>
32
- <th>Seq</th>
33
- <th>Actions</th>
34
- </tr>
35
- </thead>
36
- <tbody>
37
- <% @jobs.each do |job| -%>
38
- <tr>
39
- <td>
40
- <div class="job-class">
41
- <%= h(job.data[:class].to_s) %>
42
- </div>
43
- </td>
44
- <td>
45
- <div class="job-id">
46
- <details>
47
- <summary>
48
- <code><%= h(job.data[:jid].to_s) %></code>
49
- </summary>
50
- <code><%= h((JSON.pretty_generate(job.data[:args]) rescue job.data[:args].to_s)) %></code>
51
- </details>
52
- </div>
53
- </td>
54
- <td>
55
- <code><%= h(job.stream) %></code>
56
- </td>
57
- <td>
58
- <code><%= job.seq %></code>
59
- </td>
60
- <td>
61
- <a hx-delete="<%= url_for("/jobs/enqueued/#{job.seq}", { stream_name: @stream_name, page: @page, limit: @limit }) %>"
62
- hx-target="#enqueued-poller"
63
- hx-swap="outerHTML"
64
- hx-indicator="#global-spinner"
65
- hx-confirm="Remove message #<%= job.seq %> from <%= h(@stream_name) %>?"
66
- title="Remove message"
67
- class="btn btn-danger btn-sm">&#x2715;</a>
68
- </td>
69
- </tr>
70
- <% end -%>
71
- </tbody>
72
- </table>
73
- </div>
74
- <% end -%>
75
-
76
- <% if @total_pages.to_i > 1 -%>
77
- <div class="pagination">
78
- <% if @page > 1 -%>
79
- <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
80
- hx-target="#enqueued-poller"
81
- hx-swap="outerHTML"
82
- hx-push-url="true"
83
- hx-indicator="#global-spinner"
84
- class="btn">&#x2190; Prev</a>
85
- <% else -%>
86
- <span class="btn btn-disabled">&#x2190; Prev</span>
87
- <% end -%>
88
-
89
- <% pages(@page, @total_pages).each do |p| -%>
90
- <% if p == :gap -%>
91
- <span class="pagination-gap">&hellip;</span>
92
- <% elsif p == @page -%>
93
- <span class="btn btn-primary btn-disabled"><%= p %></span>
94
- <% else -%>
95
- <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: p, limit: @limit) %>"
96
- hx-target="#enqueued-poller"
97
- hx-swap="outerHTML"
98
- hx-push-url="true"
99
- hx-indicator="#global-spinner"
100
- class="btn"><%= p %></a>
101
- <% end -%>
102
- <% end -%>
103
-
104
- <% if @page < @total_pages -%>
105
- <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
106
- hx-target="#enqueued-poller"
107
- hx-swap="outerHTML"
108
- hx-push-url="true"
109
- hx-indicator="#global-spinner"
110
- class="btn">Next &#x2192;</a>
111
- <% else -%>
112
- <span class="btn btn-disabled">Next &#x2192;</span>
113
- <% end -%>
114
- </div>
115
- <% end -%>
9
+ <span class="spacer"></span>
10
+ <span class="small-mono"><%= format_numbers(@total.to_i) %> message(s) pending</span>
116
11
  </div>
12
+ <% if @jobs.nil? -%>
13
+ <div class="empty"><b>No job streams</b>Run <code>cosmo --setup</code> to create them.</div>
14
+ <% elsif @jobs.empty? -%>
15
+ <div class="empty"><b>Queue is empty</b><code><%= h(@stream_name) %></code> has nothing waiting.</div>
16
+ <% else -%>
17
+ <div class="scroll"><div style="min-width:860px">
18
+ <div class="thead t-jobs actions"><span>JID</span><span>Class</span><span>Args</span><span>Seq</span><span>Status</span><span class="r">Enqueued</span><span></span></div>
19
+ <% @jobs.each do |job| -%>
20
+ <div class="trow hover t-jobs actions">
21
+ <span class="mono mute ellip" style="font-size:11.5px" title="<%= h(job.data[:jid]) %>"><%= h(job.data[:jid]) %></span>
22
+ <span class="strong ellip"><%= h(job.data[:class]) %></span>
23
+ <%= render('jobs/_args', { args: job.data[:args] }) %>
24
+ <span class="mono mute" style="font-size:12px">#<%= job.seq %></span>
25
+ <span class="pill" style="--tone:var(--mute)">enqueued</span>
26
+ <span class="mono mute r" style="font-size:11.5px"><%= (at = job.stored_at) ? "#{elapsed(at)} ago" : '—' %></span>
27
+ <div class="btns">
28
+ <button type="button" class="btn danger" title="Remove message"
29
+ hx-delete="<%= url_for("/jobs/enqueued/#{job.seq}", { stream_name: u(@stream_name), page: @page, limit: @limit }) %>"
30
+ hx-target="#jobs-panel" hx-swap="outerHTML" hx-indicator="#global-spinner"
31
+ hx-confirm="Remove message #<%= job.seq %> from <%= h(@stream_name) %>?">✕</button>
32
+ </div>
33
+ </div>
34
+ <% end -%>
35
+ </div></div>
36
+ <% end -%>
@@ -0,0 +1,29 @@
1
+ <% tabs = { busy: 'Busy', enqueued: 'Enqueued', scheduled: 'Scheduled', dead: 'Dead' }.slice(*@counts.keys) -%>
2
+ <% tab_path = "/jobs/#{@tab}" -%>
3
+ <section class="card" id="jobs-panel"
4
+ <% if @polling -%>
5
+ hx-get="<%= url_for(tab_path, @query.merge(page: @page)) %>" hx-trigger="cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="outerHTML"
6
+ <% end -%>>
7
+ <div class="tabs">
8
+ <% tabs.each do |key, label| -%>
9
+ <a class="<%= 'on' if key.to_s == @tab %>" href="<%= url_for("/jobs/#{key}") %>"
10
+ hx-get="<%= url_for("/jobs/#{key}") %>" hx-target="#jobs-panel" hx-swap="outerHTML" hx-push-url="true"
11
+ hx-indicator="#global-spinner"><%= label %><span><%= compact_number(@counts[key]) %></span></a>
12
+ <% end -%>
13
+ <div class="tools">
14
+ <% if @tab == 'busy' -%>
15
+ <% if @polling -%>
16
+ <button type="button" class="btn quiet" hx-get="<%= url_for(tab_path, @query.merge(page: @page, poll: 0)) %>"
17
+ hx-target="#jobs-panel" hx-swap="outerHTML" hx-indicator="#global-spinner">⏸ Stop polling</button>
18
+ <% else -%>
19
+ <button type="button" class="btn" hx-get="<%= url_for(tab_path, @query.except(:poll).merge(page: @page)) %>"
20
+ hx-target="#jobs-panel" hx-swap="outerHTML" hx-indicator="#global-spinner">▶ Live poll</button>
21
+ <% end -%>
22
+ <% end -%>
23
+ </div>
24
+ </div>
25
+
26
+ <%= render("jobs/_#{@tab}", { jobs: @jobs, total: @total, page: @page, limit: @limit, stream_name: @stream_name, stream_names: @stream_names }) %>
27
+
28
+ <%= render('shared/_pager', { path: tab_path, query: @query, page: @page, total_pages: @total_pages, target: '#jobs-panel' }) %>
29
+ </section>
@@ -0,0 +1,18 @@
1
+ <% q = @queue -%>
2
+ <div class="trow hover <%= @compact ? 't-queues' : 't-queues-full' %>">
3
+ <div class="qname">
4
+ <span class="dot" style="background:<%= q[:paused] ? 'var(--warn)' : hue(q[:name]) %>"></span>
5
+ <a class="mono ellip" style="color:var(--ink)" href="<%= url_for('/jobs/enqueued', stream_name: u(q[:name])) %>"><%= h(q[:name]) %></a>
6
+ </div>
7
+ <div class="r"><%= format_numbers(q[:size]) %></div>
8
+ <div class="r mute"><%= format_numbers(q[:busy]) %></div>
9
+ <div class="r" style="color:<%= q[:retries].positive? ? 'var(--warn)' : 'var(--mute)' %>"><%= format_numbers(q[:retries]) %></div>
10
+ <% if @compact -%>
11
+ <div class="r"><% if q[:paused] -%><span class="paused-tag">paused</span><% end -%></div>
12
+ <% else -%>
13
+ <span class="pill" style="--tone:<%= q[:paused] ? 'var(--warn)' : 'var(--ok)' %>"><%= q[:paused] ? 'paused' : 'active' %></span>
14
+ <button type="button" class="btn<%= ' quiet' unless q[:paused] %>" style="justify-self:end"
15
+ hx-patch="<%= url_for(q[:paused] ? '/streams/unpause' : '/streams/pause', name: u(q[:name]), view: 'queue') %>"
16
+ hx-target="closest .trow" hx-swap="outerHTML" hx-indicator="#global-spinner"><%= q[:paused] ? '▶ Resume' : '⏸ Pause' %></button>
17
+ <% end -%>
18
+ </div>
@@ -0,0 +1,20 @@
1
+ <% grid = @compact ? 't-queues' : 't-queues-full' -%>
2
+ <% if @compact -%>
3
+ <div class="card-head bar">
4
+ <div class="card-title">Queues</div>
5
+ <a class="small-mono" href="<%= url_for('/jobs') %>">All queues &rarr;</a>
6
+ </div>
7
+ <% end -%>
8
+ <% if @queues.empty? -%>
9
+ <div class="empty"><b>No job streams</b>Run <code>cosmo --setup</code> to create them.</div>
10
+ <% else -%>
11
+ <div class="scroll"><div style="min-width:<%= @compact ? 520 : 680 %>px">
12
+ <div class="thead<%= ' top' if @compact %> <%= grid %>">
13
+ <span>Queue</span><span class="r">Size</span><span class="r">Busy</span><span class="r">Retries</span>
14
+ <% if @compact -%><span></span><% else -%><span>Status</span><span></span><% end -%>
15
+ </div>
16
+ <% @queues.each do |queue| -%>
17
+ <%= render('jobs/_queue_row', { queue:, compact: @compact }) %>
18
+ <% end -%>
19
+ </div></div>
20
+ <% end -%>
@@ -1,54 +1,20 @@
1
- <div id="scheduled-poller"
2
- hx-get="<%= url_for('/jobs/_scheduled') %>"
3
- hx-trigger="every <%= poll_interval %>s"
4
- hx-swap="outerHTML">
5
- <div class="pending">
6
- <span></span>
7
- <span class="text-muted"><%= @total %> job(s) scheduled</span>
8
- </div>
9
-
10
- <% if @jobs.empty? -%>
11
- <div class="alert alert-success">No scheduled jobs found</div>
12
- <% else -%>
13
- <div class="table-container">
14
- <table>
15
- <thead>
16
- <tr>
17
- <th>Job</th>
18
- <th>JID[Args]</th>
19
- <th>Stream</th>
20
- <th>When</th>
21
- </tr>
22
- </thead>
23
- <tbody>
24
- <% @jobs.each do |job| -%>
25
- <tr>
26
- <td>
27
- <div class="job-class">
28
- <%= h(job.data[:class].to_s) %>
29
- </div>
30
- </td>
31
- <td>
32
- <div class="job-id">
33
- <details>
34
- <summary>
35
- <code><%= h(job.data[:jid].to_s) %></code>
36
- </summary>
37
- <code><%= h((JSON.pretty_generate(job.data[:args]) rescue job.data[:args].to_s)) %></code>
38
- </details>
39
- </div>
40
- </td>
41
- <td>
42
- <code><%= h(job.x_stream) %></code>
43
- </td>
44
- <td style="white-space: nowrap;">
45
- <div class="time-badge"><%= h(time_until(job.execute_at)) %></div>
46
- <time><%= h(format_timestamp(job.execute_at)) %></time>
47
- </td>
48
- </tr>
49
- <% end -%>
50
- </tbody>
51
- </table>
52
- </div>
53
- <% end -%>
54
- </div>
1
+ <% if @jobs.empty? -%>
2
+ <div class="empty"><b>Nothing scheduled</b>No delayed jobs are waiting for their time.</div>
3
+ <% else -%>
4
+ <div class="scroll"><div style="min-width:820px">
5
+ <div class="thead t-jobs plain"><span>JID</span><span>Class</span><span>Args</span><span>Queue</span><span>Status</span><span class="r">When</span></div>
6
+ <% @jobs.each do |job| -%>
7
+ <div class="trow hover t-jobs plain">
8
+ <span class="mono mute ellip" style="font-size:11.5px" title="<%= h(job.data[:jid]) %>"><%= h(job.data[:jid]) %></span>
9
+ <span class="strong ellip"><%= h(job.data[:class]) %></span>
10
+ <%= render('jobs/_args', { args: job.data[:args] }) %>
11
+ <span class="mono ellip" style="font-size:12px"><%= h(job.x_stream) %></span>
12
+ <span class="pill" style="--tone:var(--warn)">scheduled</span>
13
+ <div class="stack r" style="text-align:right">
14
+ <span class="mono" style="font-size:12px"><%= h(time_until(job.execute_at)) %></span>
15
+ <span class="small-mono"><%= h(format_timestamp(job.execute_at)) %></span>
16
+ </div>
17
+ </div>
18
+ <% end -%>
19
+ </div></div>
20
+ <% end -%>
@@ -1,18 +1,5 @@
1
- <section>
2
- <header><%= render('jobs/_tabs') %></header>
3
-
4
- <div hx-get="<%= url_for('/jobs/_stats') %>"
5
- hx-trigger="load, every <%= poll_interval %>s"
6
- hx-swap="innerHTML">
7
- <div class="alert alert-info">Loading statistics…</div>
8
- </div>
9
-
10
- <div id="content">
11
- <div id="processes-poller"
12
- hx-get="<%= url_for('/jobs/_processes') %>"
13
- hx-trigger="load"
14
- hx-swap="outerHTML">
15
- <div class="alert alert-info">Loading processes&hellip;</div>
16
- </div>
17
- </div>
1
+ <section class="card" hx-get="<%= url_for('/jobs/_queues') %>" hx-trigger="load, cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="innerHTML">
2
+ <div class="loading">Loading queues&hellip;</div>
18
3
  </section>
4
+
5
+ <%= @panel %>
@@ -3,32 +3,83 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
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>
6
+ <title>Cosmonats<%= " · #{h(content_for :title)}" if content_for?(:title) %></title>
7
+ <script>(function(r){var m='auto';try{m=localStorage.getItem('cosmo-theme')||'auto'}catch(e){}r.dataset.themeMode=m;r.dataset.theme=m==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):m})(document.documentElement)</script>
8
+ <link rel="preload" href="<%= url_for('/assets/fonts/geist.woff2') %>" as="font" type="font/woff2" crossorigin>
8
9
  <link rel="stylesheet" href="<%= url_for('/assets/app.css') %>">
10
+ <link rel="icon" href="<%= url_for('/assets/logo.svg') %>" type="image/svg+xml">
11
+ <script src="<%= url_for('/assets/htmx.min.js.gz') %>" defer></script>
12
+ <script src="<%= url_for('/assets/app.js') %>" defer></script>
9
13
  </head>
10
14
  <body>
11
- <header>
12
- <div class="container">
15
+ <% server = nats_server -%>
16
+ <% section = current_section -%>
17
+ <div class="backdrop" data-action="close-menu"></div>
18
+ <div class="app">
19
+ <aside class="sidebar">
20
+ <a class="brand" href="<%= url_for('/') %>">
21
+ <img src="<%= url_for('/assets/logo.svg') %>" alt="">
22
+ <div class="brand-text">
23
+ <div class="brand-name">cosmonats</div>
24
+ <div class="eyebrow">mission control</div>
25
+ </div>
26
+ </a>
13
27
  <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 path_prefix?('/jobs', '/crons', '/batches') %>">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>
28
+ <% nav_items.each_with_index do |item, index| -%>
29
+ <a class="nav-item<%= ' active' if item[:key] == section %>" style="--hue:var(--c<%= (index % 5) + 1 %>)"
30
+ href="<%= url_for(item[:path]) %>">
31
+ <span class="bar"></span><span class="n"><%= format('%02d', index + 1) %></span>
32
+ <span class="label"><%= item[:label] %></span><span class="badge" id="badge-<%= item[:key] %>"></span>
33
+ </a>
34
+ <% end -%>
26
35
  </nav>
27
- </div>
28
- </header>
29
- <main class="container">
30
- <%= content_for :view %>
31
- <div id="global-spinner" class="htmx-indicator" aria-hidden="true"></div>
32
- </main>
36
+ <div hx-get="<%= url_for('/_badges') %>" hx-trigger="load, cosmo:poll" data-poll="30" hx-swap="none"></div>
37
+ <div class="side-foot">
38
+ <div class="conn">
39
+ <div class="conn-title"><span class="conn-dot<%= ' down' if server.empty? %>"></span><%= server.empty? ? 'NATS unreachable' : 'NATS connected' %></div>
40
+ <div class="conn-meta">
41
+ <%= h(server[:url] || "unknown server") %><br>
42
+ <% if server[:name] -%><%= h(server[:name]) %> · <% end -%><% if server[:version] -%>v<%= h(server[:version]) %> · <% end -%>R<%= Cosmo::Config.replicas %>
43
+ </div>
44
+ </div>
45
+ <div class="theme-toggle" role="group" aria-label="Theme">
46
+ <button type="button" data-action="theme" data-theme-set="light" title="Light" aria-label="Light theme">
47
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.5" fill="currentColor"/><path d="M12 1.5v2.5M12 20v2.5M1.5 12H4M20 12h2.5M4.6 4.6l1.8 1.8M17.6 17.6l1.8 1.8M4.6 19.4l1.8-1.8M17.6 6.4l1.8-1.8"/></svg>
48
+ </button>
49
+ <button type="button" data-action="theme" data-theme-set="dark" title="Dark" aria-label="Dark theme">
50
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a12 12 0 0 0 11 11z" fill="currentColor" stroke="none"/></svg>
51
+ </button>
52
+ <button type="button" data-action="theme" data-theme-set="auto" title="Auto (follow the system)" aria-label="Automatic theme">
53
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7a5 5 0 0 0 0 10z" fill="currentColor"/><path d="M1.5 12H4M4.6 4.6l1.8 1.8M4.6 19.4l1.8-1.8"/><path d="M13.5 3a9 9 0 0 1 0 18a12 12 0 0 0 0-18z" fill="currentColor" stroke="none"/></svg>
54
+ </button>
55
+ </div>
56
+ <div class="version">cosmonats v<%= Cosmo::VERSION %> · ruby <%= RUBY_VERSION %></div>
57
+ </div>
58
+ </aside>
59
+
60
+ <main class="main">
61
+ <header class="topbar">
62
+ <button type="button" class="icon-btn" data-action="open-menu" aria-label="Open menu"><span></span><span></span><span></span></button>
63
+ <img class="logo-sm" src="<%= url_for('/assets/logo.svg') %>" alt="">
64
+ <div class="crumb"><%= h(server[:name] || server[:url] || "nats") %> <span class="sep">/</span> <span class="cur"><%= h(content_for(:title).to_s.downcase) %></span></div>
65
+ <div class="spacer"></div>
66
+ <div id="global-spinner" class="spinner" aria-hidden="true"></div>
67
+ <div class="live"><span class="live-label">live</span><span class="t" id="clock"></span></div>
68
+ </header>
69
+
70
+ <div class="content">
71
+ <div class="page-head">
72
+ <div class="titles">
73
+ <h1><%= h(content_for :title) %></h1>
74
+ <% if content_for?(:subtitle) -%><p><%= h(content_for :subtitle) %></p><% end -%>
75
+ </div>
76
+ <div class="flash" id="flash"></div>
77
+ </div>
78
+ <div class="view">
79
+ <%= content_for :view %>
80
+ </div>
81
+ </div>
82
+ </main>
83
+ </div>
33
84
  </body>
34
85
  </html>
@@ -0,0 +1,41 @@
1
+ <section class="card card-pad">
2
+ <div class="card-head">
3
+ <div class="card-title"><span class="mono"><%= h(@job) %></span> <span class="mute" style="font-size:13px">last <%= @days %> days</span></div>
4
+ <div class="legend"><i class="bars"></i>runs / day</div>
5
+ <div class="legend"><i class="line"></i>avg execution</div>
6
+ </div>
7
+ <% if @chart.empty? -%>
8
+ <div class="empty"><b>No runs</b><code><%= h(@job) %></code> didn't run in this period.</div>
9
+ <% else -%>
10
+ <div class="chart dual">
11
+ <div class="yaxis"><% @chart.ticks.each do |tick| -%><span><%= compact_number(tick[:count]) %></span><% end -%></div>
12
+ <div class="plot-col">
13
+ <div class="plot">
14
+ <svg viewBox="0 0 <%= Cosmo::Web::Chart::WIDTH %> <%= Cosmo::Web::Chart::HEIGHT %>" preserveAspectRatio="none" aria-hidden="true">
15
+ <defs>
16
+ <linearGradient id="runsFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--c1)" stop-opacity="0.55"/><stop offset="1" stop-color="var(--c1)" stop-opacity="0.18"/></linearGradient>
17
+ </defs>
18
+ <path d="<%= @chart.grid %>" stroke="var(--line)" stroke-width="1" vector-effect="non-scaling-stroke" fill="none"/>
19
+ <path d="<%= @chart.bars %>" fill="url(#runsFill)"/>
20
+ <path d="<%= @chart.line %>" stroke="var(--c4)" stroke-width="1.75" vector-effect="non-scaling-stroke" fill="none" stroke-linejoin="round"/>
21
+ </svg>
22
+ <% @chart.points.each do |point| -%>
23
+ <% if point[:dot] -%><i class="pt" style="left:<%= point[:center] %>%;top:<%= point[:dot] %>%"></i><% end -%>
24
+ <div class="hit band<%= ' flip' if point[:center] > 62 %>" style="left:<%= point[:left] %>%;width:<%= point[:width] %>%">
25
+ <div class="hv-dot" style="top:<%= point[:dot] || point[:top] %>%;border-color:<%= point[:dot] ? 'var(--c4)' : 'var(--c1)' %>"></div>
26
+ <div class="tip">
27
+ <span class="mono mute" style="font-size:10.5px"><%= point[:label] %></span>
28
+ <div><span class="mute">runs</span><span class="num"><%= format_numbers(point[:count]) %></span></div>
29
+ <div><span class="mute">failed</span><span class="num" style="color:var(--err)"><%= format_numbers(point[:failed]) %></span></div>
30
+ <div><span class="mute">avg exec</span><span class="num" style="color:var(--c4)"><%= duration(point[:exec_ms]) %></span></div>
31
+ <div><span class="mute">avg wait</span><span class="num"><%= duration(point[:wait_ms]) %></span></div>
32
+ </div>
33
+ </div>
34
+ <% end -%>
35
+ </div>
36
+ <div class="xaxis abs"><% @chart.labels.each do |label| -%><span style="left:<%= label[:left] %>%"><%= label[:text] %></span><% end -%></div>
37
+ </div>
38
+ <div class="yaxis right"><% @chart.ticks.each do |tick| -%><span><%= duration(tick[:exec_ms]) %></span><% end -%></div>
39
+ </div>
40
+ <% end -%>
41
+ </section>
@@ -0,0 +1,13 @@
1
+ <div id="metrics-panel" class="view"
2
+ hx-get="<%= url_for('/metrics/_panel', { days: @days, job: u(@job) }) %>"
3
+ hx-trigger="cosmo:poll" data-poll="30"
4
+ hx-swap="outerHTML">
5
+ <% if @rows.empty? -%>
6
+ <section class="card"><div class="empty"><b>No metrics yet</b>Nothing recorded in this period.</div></section>
7
+ <% else -%>
8
+ <% if @chart -%>
9
+ <%= render('metrics/_chart', { job: @job, days: @days, chart: @chart }) %>
10
+ <% end -%>
11
+ <%= render('metrics/_table', { rows: @rows, job: @job, days: @days }) %>
12
+ <% end -%>
13
+ </div>
@@ -0,0 +1,25 @@
1
+ <% selectable = @days > 1 -%>
2
+ <section class="card">
3
+ <div class="card-head bar ruled">
4
+ <div class="card-title">Job classes</div>
5
+ <% if selectable -%><span class="small-mono">click a job to chart it</span><% end -%>
6
+ </div>
7
+ <div class="scroll"><div style="min-width:620px">
8
+ <div class="thead t-metrics"><span>Job</span><span class="r">Runs</span><span class="r">Failed</span><span class="r">Avg execution</span><span class="r">Avg wait</span></div>
9
+ <% @rows.each do |row| -%>
10
+ <% selected = selectable && row[:job] == @job -%>
11
+ <div class="trow hover t-metrics<%= ' selectable' if selectable %><%= ' selected' if selected %>"
12
+ <% if selectable -%>
13
+ hx-get="<%= url_for('/metrics/_panel', { days: @days, job: u(row[:job]) }) %>"
14
+ hx-target="#metrics-panel" hx-swap="outerHTML" hx-indicator="#global-spinner"
15
+ <% if selected -%>aria-selected="true"<% end -%>
16
+ <% end -%>>
17
+ <span class="mono ellip"><%= h(row[:job]) %></span>
18
+ <span class="r"><%= format_numbers(row[:count]) %></span>
19
+ <span class="r" style="color:<%= row[:failed].positive? ? 'var(--err)' : 'var(--faint)' %>"><%= format_numbers(row[:failed]) %></span>
20
+ <span class="r mute"><%= duration(row[:exec_ms]) %></span>
21
+ <span class="r mute"><%= duration(row[:wait_ms]) %></span>
22
+ </div>
23
+ <% end -%>
24
+ </div></div>
25
+ </section>
@@ -0,0 +1,9 @@
1
+ <div class="seg" style="align-self:flex-start">
2
+ <% @ranges.each do |range, name| -%>
3
+ <a class="<%= 'on' if @days == range %>" href="<%= url_for('/metrics', { days: range }) %>"><%= h(name) %></a>
4
+ <% end -%>
5
+ </div>
6
+
7
+ <div id="metrics-panel" class="view" hx-get="<%= url_for('/metrics/_panel', { days: @days }) %>" hx-trigger="load" hx-swap="outerHTML">
8
+ <section class="card"><div class="loading">Loading metrics&hellip;</div></section>
9
+ </div>
@@ -0,0 +1,3 @@
1
+ <% @badges.each do |key, count| -%>
2
+ <span class="badge" id="badge-<%= key %>" hx-swap-oob="true"><%= compact_number(count) %></span>
3
+ <% end -%>
@@ -0,0 +1,19 @@
1
+ <% metrics_path = Cosmo::Config.metrics.enabled ? '/metrics' : nil -%>
2
+ <% kpis = [
3
+ ['Processed', @processed, 'all time', 'var(--c1)', metrics_path],
4
+ ['Failed', @failed, 'all time', 'var(--err)', metrics_path],
5
+ ['Busy', @busy, 'running now', 'var(--c3)', '/jobs/busy'],
6
+ ['Enqueued', @enqueued, 'waiting in queues', 'var(--c2)', '/jobs/enqueued'],
7
+ ['Retries', @retries, 'redeliveries', 'var(--warn)', '/jobs']
8
+ ] -%>
9
+ <% kpis << ['Scheduled', @scheduled, 'delayed & cron', 'var(--c5)', '/jobs/scheduled'] if Cosmo::Config.scheduled.enabled -%>
10
+ <% kpis << ['Dead', @dead, 'gave up', 'var(--c4)', '/jobs/dead'] if Cosmo::Config.dead.enabled -%>
11
+ <div class="kpis">
12
+ <% kpis.each do |label, value, sub, color, link| -%>
13
+ <<%= link ? %(a href="#{url_for(link)}") : 'div' %> class="kpi" style="--hue:<%= color %>">
14
+ <div class="kpi-label"><i></i><%= label %></div>
15
+ <div class="kpi-val"><b title="<%= format_numbers(value) %>"><%= compact_number(value) %></b></div>
16
+ <div class="kpi-sub"><%= sub %></div>
17
+ </<%= link ? 'a' : 'div' %>>
18
+ <% end -%>
19
+ </div>
@@ -0,0 +1,18 @@
1
+ <div class="card-head bar ruled">
2
+ <div class="card-title">Running now</div>
3
+ <a class="small-mono" href="<%= url_for('/jobs/busy') %>"><%= @total %> busy &rarr;</a>
4
+ </div>
5
+ <% if @jobs.empty? -%>
6
+ <div class="empty"><b>All quiet</b>No jobs are running right now.</div>
7
+ <% else -%>
8
+ <div class="feed">
9
+ <% @jobs.each do |job| -%>
10
+ <div class="feed-row" title="<%= h(job.dig(:data, :jid)) %> · <%= h(job[:worker]) %>">
11
+ <span class="mono faint"><%= Time.at(job[:started_at].to_i).strftime('%H:%M:%S') %></span>
12
+ <span class="dot" style="background:<%= hue(job[:stream]) %>"></span>
13
+ <span class="ellip"><%= h(job.dig(:data, :class)) %></span>
14
+ <span class="mono mute"><%= h(job[:stream]) %> · <%= elapsed(job[:started_at]) %></span>
15
+ </div>
16
+ <% end -%>
17
+ </div>
18
+ <% end -%>
@@ -0,0 +1,50 @@
1
+ <section class="card card-pad grow2" id="throughput"
2
+ hx-get="<%= url_for('/_throughput', days: @days) %>" hx-trigger="cosmo:poll" data-poll="30" hx-swap="outerHTML">
3
+ <div class="card-head">
4
+ <div class="card-title">Throughput</div>
5
+ <div class="legend"><i></i>succeeded / day</div>
6
+ <div class="legend"><i class="fail"></i>failed</div>
7
+ <div class="seg">
8
+ <% @ranges.each do |range| -%>
9
+ <button type="button" class="<%= 'on' if range == @days %>" hx-get="<%= url_for('/_throughput', days: range) %>"
10
+ hx-target="#throughput" hx-swap="outerHTML" hx-indicator="#global-spinner"><%= range %>d</button>
11
+ <% end -%>
12
+ </div>
13
+ </div>
14
+ <% if @chart.empty? -%>
15
+ <div class="empty"><b>No runs yet</b>Nothing recorded in the last <%= @days %> days.</div>
16
+ <% else -%>
17
+ <div class="chart">
18
+ <div class="yaxis"><% @chart.ticks.each do |tick| -%><span><%= compact_number(tick) %></span><% end -%></div>
19
+ <div class="plot-col">
20
+ <div class="plot">
21
+ <svg viewBox="0 0 <%= Cosmo::Web::Throughput::WIDTH %> <%= Cosmo::Web::Throughput::HEIGHT %>" preserveAspectRatio="none" aria-hidden="true">
22
+ <defs>
23
+ <linearGradient id="thrFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--c2)" stop-opacity="0.22"/><stop offset="1" stop-color="var(--c1)" stop-opacity="0"/></linearGradient>
24
+ <linearGradient id="thrLine" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--c3)"/><stop offset="0.55" stop-color="var(--c1)"/><stop offset="1" stop-color="var(--c2)"/></linearGradient>
25
+ <linearGradient id="thrFail" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--err)" stop-opacity="0.95"/><stop offset="1" stop-color="var(--err)" stop-opacity="0.45"/></linearGradient>
26
+ </defs>
27
+ <path d="<%= @chart.grid %>" stroke="var(--line)" stroke-width="1" vector-effect="non-scaling-stroke" fill="none"/>
28
+ <path d="<%= @chart.area %>" fill="url(#thrFill)"/>
29
+ <path d="<%= @chart.line %>" stroke="url(#thrLine)" stroke-width="1.75" vector-effect="non-scaling-stroke" fill="none" stroke-linejoin="round"/>
30
+ <path d="<%= @chart.failures %>" fill="url(#thrFail)"/>
31
+ </svg>
32
+ <% @chart.points.each do |point| -%>
33
+ <div class="hit<%= ' flip' if point[:center] > 62 %>" style="left:<%= point[:left] %>%;width:<%= point[:width] %>%">
34
+ <div class="hv-line"></div>
35
+ <div class="hv-dot" style="top:<%= point[:top] %>%"></div>
36
+ <div class="tip">
37
+ <span class="mono mute" style="font-size:10.5px"><%= point[:label] %></span>
38
+ <div><span class="mute">succeeded</span><span class="num"><%= format_numbers(point[:count]) %></span></div>
39
+ <div><span class="mute">failed</span><span class="num" style="color:var(--err)"><%= format_numbers(point[:failed]) %></span></div>
40
+ <div><span class="mute">avg exec</span><span class="num"><%= duration(point[:exec_ms]) %></span></div>
41
+ <div><span class="mute">avg wait</span><span class="num"><%= duration(point[:wait_ms]) %></span></div>
42
+ </div>
43
+ </div>
44
+ <% end -%>
45
+ </div>
46
+ <div class="xaxis"><% @chart.labels.each do |label| -%><span><%= label %></span><% end -%></div>
47
+ </div>
48
+ </div>
49
+ <% end -%>
50
+ </section>