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
@@ -0,0 +1,25 @@
1
+ <div class="card-head">
2
+ <div class="card-title">Workers</div>
3
+ <div class="mono mute" style="font-size:11px"><%= @busy %> / <%= @threads %> busy</div>
4
+ </div>
5
+ <% if @processes.empty? -%>
6
+ <div class="empty"><b>No worker processes</b>Start one with <code>cosmo -C config/cosmo.yml</code>.</div>
7
+ <% else -%>
8
+ <div class="cells">
9
+ <% @cells.each do |stream| -%>
10
+ <i<% if stream %> style="background:<%= hue(stream) %>" title="<%= h(stream) %>"<% end %>></i>
11
+ <% end -%>
12
+ </div>
13
+ <% if @hidden.positive? -%><div class="small-mono">+<%= @hidden %> more threads</div><% end -%>
14
+ <div class="procs">
15
+ <% @processes.each do |process| -%>
16
+ <div class="proc">
17
+ <div class="ellip" title="<%= h(process[:cmdline]) %>"><%= h(process[:hostname]) %> · pid <%= h(process[:pid]) %></div>
18
+ <span class="mute"><%= h(process[:busy]) %>/<%= h(process[:concurrency]) %></span>
19
+ <span class="faint"><%= process[:rss] ? format_bytes(process[:rss].to_i * 1024) : '—' %></span>
20
+ <span style="color:<%= { 'running' => 'var(--ok)', 'quiet' => 'var(--warn)', 'stopping' => 'var(--err)' }.fetch(process[:state].to_s, 'var(--mute)') %>"><%= h(process[:state]) %></span>
21
+ </div>
22
+ <% end -%>
23
+ </div>
24
+ <a class="small-mono" href="<%= url_for('/processes') %>">All <%= @total %> process(es) &rarr;</a>
25
+ <% end -%>
@@ -0,0 +1,28 @@
1
+ <div hx-get="<%= url_for('/_kpis') %>" hx-trigger="load, cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="innerHTML">
2
+ <div class="kpis"><div class="kpi"><div class="loading">Loading statistics&hellip;</div></div></div>
3
+ </div>
4
+
5
+ <div class="row-wrap">
6
+ <% if @metrics -%>
7
+ <section class="card card-pad grow2" id="throughput" hx-get="<%= url_for('/_throughput') %>" hx-trigger="load" hx-swap="outerHTML">
8
+ <div class="card-head"><div class="card-title">Throughput</div></div>
9
+ <div class="loading">Loading metrics&hellip;</div>
10
+ </section>
11
+ <% end -%>
12
+ <section class="card card-pad <%= @metrics ? 'grow1' : 'grow2' %>"
13
+ hx-get="<%= url_for('/_workers') %>" hx-trigger="load, cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="innerHTML">
14
+ <div class="card-head"><div class="card-title">Workers</div></div>
15
+ <div class="loading">Loading workers&hellip;</div>
16
+ </section>
17
+ </div>
18
+
19
+ <div class="row-wrap">
20
+ <section class="card grow2" hx-get="<%= url_for('/jobs/_queues', compact: 1) %>" hx-trigger="load, cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="innerHTML">
21
+ <div class="card-head bar"><div class="card-title">Queues</div></div>
22
+ <div class="loading">Loading queues&hellip;</div>
23
+ </section>
24
+ <section class="card grow1" hx-get="<%= url_for('/_running') %>" hx-trigger="load, cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="innerHTML">
25
+ <div class="card-head bar ruled"><div class="card-title">Running now</div></div>
26
+ <div class="loading">Loading jobs&hellip;</div>
27
+ </section>
28
+ </div>
@@ -0,0 +1,67 @@
1
+ <% states = { 'running' => 'var(--ok)', 'quiet' => 'var(--warn)', 'stopping' => 'var(--err)' } -%>
2
+ <% kinds = { 'jobs' => 'Jobs', 'streams' => 'Streams', 'jobs+streams' => 'Jobs & Streams', 'none' => 'No subscriptions' } -%>
3
+ <section class="card" id="processes"
4
+ <% if @polling -%>
5
+ hx-get="<%= url_for('/processes', @query.merge(page: @page)) %>" hx-trigger="cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="outerHTML"
6
+ <% end -%>>
7
+ <div class="card-head bar ruled">
8
+ <div class="card-title"><%= @total %> process(es) alive</div>
9
+ <% if @polling -%>
10
+ <button type="button" class="btn quiet" hx-get="<%= url_for('/processes', @query.merge(page: @page, poll: 0)) %>"
11
+ hx-target="#processes" hx-swap="outerHTML" hx-indicator="#global-spinner">⏸ Stop polling</button>
12
+ <% else -%>
13
+ <button type="button" class="btn" hx-get="<%= url_for('/processes', @query.except(:poll).merge(page: @page)) %>"
14
+ hx-target="#processes" hx-swap="outerHTML" hx-indicator="#global-spinner">▶ Live poll</button>
15
+ <% end -%>
16
+ </div>
17
+
18
+ <% if @groups.empty? -%>
19
+ <div class="empty"><b>No worker processes</b>Start one with <code>cosmo -C config/cosmo.yml</code>.</div>
20
+ <% else -%>
21
+ <div class="scroll"><div style="min-width:1040px">
22
+ <div class="thead t-procs">
23
+ <span>Process</span><span>Command</span><span>Subscriptions</span><span class="r">Busy</span><span>State</span>
24
+ <span class="r">RTT</span><span class="r">Reconn.</span><span class="r">Memory</span><span class="r">Uptime</span><span class="r">Last beat</span>
25
+ </div>
26
+ <% @groups.each do |kind, group| -%>
27
+ <div class="trow group t-procs"><span class="full"><%= h(kinds.fetch(kind, kind)) %> (<%= group.size %>)</span></div>
28
+ <% group.each do |process| -%>
29
+ <% nats = Hash(process[:nats]) -%>
30
+ <% cmdline = process[:cmdline].to_s -%>
31
+ <div class="trow hover t-procs">
32
+ <div class="stack">
33
+ <span class="strong ellip"><%= h(process[:hostname]) %>:<%= h(process[:pid]) %></span>
34
+ <span class="small-mono ellip"><%= h(nats[:ip] || 'unknown IP') %><% if nats[:name] -%> · <%= h(nats[:name]) %><% end -%></span>
35
+ </div>
36
+ <div class="stack">
37
+ <% if cmdline.length > 32 -%>
38
+ <details>
39
+ <summary class="mono ellip" style="font-size:11.5px" title="<%= h(cmdline) %>"><%= h(cmdline) %></summary>
40
+ <pre class="code-block"><%= h(cmdline) %></pre>
41
+ </details>
42
+ <% else -%>
43
+ <span class="mono" style="font-size:11.5px"><%= h(cmdline) %></span>
44
+ <% end -%>
45
+ <span class="small-mono">cosmo <%= h(process[:version]) %> · <%= h(process[:ruby]) %><% if process[:http_port] -%> · http :<%= h(process[:http_port]) %><% end -%></span>
46
+ </div>
47
+ <div class="stack">
48
+ <% Hash(process[:subscriptions]).each do |type, names| -%>
49
+ <% next if Array(names).empty? -%>
50
+ <span class="mono ellip" style="font-size:11.5px" title="<%= h(Array(names).join(', ')) %>"><% if kind == 'jobs+streams' -%><span class="faint"><%= h(type) %>:</span> <% end -%><%= h(Array(names).join(', ')) %></span>
51
+ <% end -%>
52
+ </div>
53
+ <span class="mono r"><%= h(process[:busy]) %>/<%= h(process[:concurrency]) %></span>
54
+ <span class="pill" style="--tone:<%= states.fetch(process[:state].to_s, 'var(--acc)') %>"><%= h(process[:state]) %></span>
55
+ <span class="mono mute r"><%= nats[:rtt] ? "#{h(nats[:rtt])}ms" : '—' %></span>
56
+ <span class="mono mute r"><%= h(nats[:reconnects].to_i) %></span>
57
+ <span class="mono mute r"><%= process[:rss] ? format_bytes(process[:rss].to_i * 1024) : '—' %></span>
58
+ <span class="mono mute r"><%= elapsed(process[:started_at]) %></span>
59
+ <span class="mono mute r"><%= elapsed(process[:beat_at]) %> ago</span>
60
+ </div>
61
+ <% end -%>
62
+ <% end -%>
63
+ </div></div>
64
+ <% end -%>
65
+
66
+ <%= render('shared/_pager', { path: '/processes', query: @query, page: @page, total_pages: @total_pages, target: '#processes' }) %>
67
+ </section>
@@ -0,0 +1 @@
1
+ <%= @table %>
@@ -0,0 +1,22 @@
1
+ <% if @total_pages.to_i > 1 -%>
2
+ <% link = ->(page) { url_for(@path, Hash(@query).merge(page: page)) } -%>
3
+ <nav class="pager">
4
+ <% if @page > 1 -%>
5
+ <a class="btn quiet" hx-get="<%= link.(@page - 1) %>" hx-target="<%= @target %>" hx-swap="outerHTML" hx-indicator="#global-spinner">&larr; Prev</a>
6
+ <% else -%>
7
+ <span class="btn quiet disabled">&larr; Prev</span>
8
+ <% end -%>
9
+ <% pages(@page, @total_pages).each do |p| -%>
10
+ <% if p == :gap -%>
11
+ <span class="gap">&hellip;</span>
12
+ <% else -%>
13
+ <a class="btn<%= ' cur' if p == @page %>" hx-get="<%= link.(p) %>" hx-target="<%= @target %>" hx-swap="outerHTML" hx-indicator="#global-spinner"><%= p %></a>
14
+ <% end -%>
15
+ <% end -%>
16
+ <% if @page < @total_pages -%>
17
+ <a class="btn quiet" hx-get="<%= link.(@page + 1) %>" hx-target="<%= @target %>" hx-swap="outerHTML" hx-indicator="#global-spinner">Next &rarr;</a>
18
+ <% else -%>
19
+ <span class="btn quiet disabled">Next &rarr;</span>
20
+ <% end -%>
21
+ </nav>
22
+ <% end -%>
@@ -0,0 +1,7 @@
1
+ <section class="card" data-reload="<%= @retry %>">
2
+ <div class="empty">
3
+ <b>NATS is unreachable</b>
4
+ <span>Nothing was fetched, so this page shows no data. It tries again every <%= @retry %> seconds.</span>
5
+ <a class="btn" href="<%= h(@request.fullpath) %>">Retry now</a>
6
+ </div>
7
+ </section>
@@ -1,92 +1,34 @@
1
- <div class="cards-container">
2
- <article class="stat-card">
3
- <h3><%= format_numbers(@state.messages) %></h3>
4
- <p>Messages</p>
5
- </article>
6
- <article class="stat-card">
7
- <h3><%= format_bytes(@state.bytes) %></h3>
8
- <p>Size</p>
9
- </article>
10
- <article class="stat-card">
11
- <h3><%= @state.consumer_count %></h3>
12
- <p>Consumers</p>
13
- </article>
14
- <article class="stat-card">
15
- <h3><%= format_numbers(@state.first_seq) + " &rarr; " + format_numbers(@state.last_seq) %></h3>
16
- <p>Sequence Range</p>
17
- </article>
1
+ <div>
2
+ <a class="small-mono" href="<%= url_for('/streams') %>">&larr; Back to streams</a>
18
3
  </div>
19
4
 
20
- <%= render("streams/_pause_banner", { name: @name, paused: @paused }) %>
21
-
22
- <div class="nav">
23
- <a href="<%= url_for('/streams') %>"
24
- hx-get="<%= url_for('/streams') %>"
25
- hx-target="#content"
26
- hx-push-url="true"
27
- class="btn">Back to Streams</a>
5
+ <div class="kpis">
6
+ <div class="kpi" style="--hue:var(--c1)"><div class="kpi-label"><i></i>Messages</div><div class="kpi-val"><b><%= format_numbers(@state.messages) %></b></div></div>
7
+ <div class="kpi" style="--hue:var(--c2)"><div class="kpi-label"><i></i>Size</div><div class="kpi-val"><b><%= format_bytes(@state.bytes) %></b></div></div>
8
+ <div class="kpi" style="--hue:var(--c3)"><div class="kpi-label"><i></i>Consumers</div><div class="kpi-val"><b><%= @state.consumer_count %></b></div></div>
9
+ <div class="kpi" style="--hue:var(--c4)"><div class="kpi-label"><i></i>Sequence range</div><div class="kpi-val"><b style="font-size:22px"><%= format_numbers(@state.first_seq) %> → <%= format_numbers(@state.last_seq) %></b></div></div>
28
10
  </div>
29
11
 
30
- <h2>Configuration</h2>
31
- <div class="table-container">
32
- <table>
33
- <tbody>
34
- <tr>
35
- <th>Name</th>
36
- <td>
37
- <code><%= h(@name) %></code>
38
- </td>
39
- </tr>
40
- <tr>
41
- <th>Retention</th>
42
- <td>
43
- <code><%= h(@config.retention.to_s) %></code>
44
- </td>
45
- </tr>
46
- <tr>
47
- <th>Storage</th>
48
- <td>
49
- <code><%= h(@config.storage.to_s) %></code>
50
- </td>
51
- </tr>
52
- <tr>
53
- <th>Discard Policy</th>
54
- <td>
55
- <code><%= h(@config.discard.to_s) %></code>
56
- </td>
57
- </tr>
58
- <tr>
59
- <th>Max Consumers</th>
60
- <td>
61
- <code><%= @config.max_consumers || "Unlimited" %></code>
62
- </td>
63
- </tr>
64
- <tr>
65
- <th>Max Messages</th>
66
- <td>
67
- <code><%= @config.max_msgs || "Unlimited" %></code>
68
- </td>
69
- </tr>
70
- <tr>
71
- <th>Max Bytes</th>
72
- <td>
73
- <code><%= @config.max_bytes&.positive? ? format_bytes(@config.max_bytes) : "Unlimited" %></code>
74
- </td>
75
- </tr>
76
- <tr>
77
- <th>Max Age</th>
78
- <td>
79
- <code><%= @config.max_age&.positive? ? "#{@config.max_age}ns" : "Unlimited" %></code>
80
- </td>
81
- </tr>
82
- </tbody>
83
- </table>
84
- </div>
12
+ <%= render('streams/_pause_banner', { name: @name, paused: @paused }) %>
85
13
 
86
- <h2>Subjects</h2>
87
- <div class="subjects" style="margin-top: var(--space-2x);">
88
- <% Array(@config.subjects).each do |sub| -%>
89
- <div class="subject-tag" style="padding: var(--space) var(--space-2x); font-size: 1rem;"><%= h(sub) %></div>
14
+ <section class="card">
15
+ <div class="card-head bar ruled"><div class="card-title">Configuration</div></div>
16
+ <% rows = {
17
+ 'Name' => @name,
18
+ 'Retention' => @config.retention,
19
+ 'Storage' => @config.storage,
20
+ 'Replicas' => @config.num_replicas || 1,
21
+ 'Discard policy' => @config.discard,
22
+ 'Max consumers' => @config.max_consumers.to_i.positive? ? @config.max_consumers : 'Unlimited',
23
+ 'Max messages' => @config.max_msgs.to_i.positive? ? format_numbers(@config.max_msgs) : 'Unlimited',
24
+ 'Max bytes' => @config.max_bytes.to_i.positive? ? format_bytes(@config.max_bytes) : 'Unlimited',
25
+ 'Max age' => @config.max_age.to_i.positive? ? "#{format_span(@config.max_age.to_i / 1_000_000_000)} (#{@config.max_age}ns)" : 'Unlimited'
26
+ } -%>
27
+ <% rows.each do |label, value| -%>
28
+ <div class="trow t-kv"><span class="eyebrow"><%= label %></span><code><%= h(value) %></code></div>
90
29
  <% end -%>
91
- </div>
92
-
30
+ <div class="trow t-kv">
31
+ <span class="eyebrow">Subjects</span>
32
+ <div class="subjects"><% Array(@config.subjects).each do |subject| -%><span class="subject"><%= h(subject) %></span><% end -%></div>
33
+ </div>
34
+ </section>
@@ -0,0 +1,7 @@
1
+ <% if @streams.empty? -%>
2
+ <section class="card"><div class="empty"><b>No streams</b>Run <code>cosmo --setup</code> to create them.</div></section>
3
+ <% else -%>
4
+ <% @streams.each do |stream| -%>
5
+ <%= render('streams/_stream', { stream: }) %>
6
+ <% end -%>
7
+ <% end -%>
@@ -1,17 +1,9 @@
1
- <% if @paused -%>
2
- <div id="pause-banner-<%= h(@name) %>" class="alert alert-warning" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-2x);">
3
- <span>&#9208; <strong>Stream is paused</strong> — consumers are not fetching new messages.</span>
4
- <a hx-patch="<%= url_for('/streams/unpause', { name: u(@name), banner: 1 }) %>"
5
- hx-target="#pause-banner-<%= h(@name) %>"
6
- hx-swap="outerHTML"
7
- class="btn btn-success">&#9654; Resume Stream</a>
1
+ <section class="card banner" style="<%= 'border-color:color-mix(in oklch,var(--warn) 45%,var(--line))' if @paused %>">
2
+ <div class="stack">
3
+ <span class="strong"><%= @paused ? '⏸ Stream is paused' : '▶ Stream is active' %></span>
4
+ <span class="mute"><%= @paused ? 'Consumers are not fetching new messages.' : 'Consumers are fetching new messages.' %></span>
8
5
  </div>
9
- <% else -%>
10
- <div id="pause-banner-<%= h(@name) %>" class="alert alert-success" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-2x);">
11
- <span>&#9654; <strong>Stream is active</strong> — consumers are fetching new messages.</span>
12
- <a hx-patch="<%= url_for('/streams/pause', { name: u(@name), banner: 1 }) %>"
13
- hx-target="#pause-banner-<%= h(@name) %>"
14
- hx-swap="outerHTML"
15
- class="btn btn-warning">&#9208; Pause Stream</a>
16
- </div>
17
- <% end -%>
6
+ <button type="button" class="btn<%= ' quiet' unless @paused %>"
7
+ hx-patch="<%= url_for(@paused ? '/streams/unpause' : '/streams/pause', { name: u(@name), view: 'banner' }) %>"
8
+ hx-target="closest section" hx-swap="outerHTML" hx-indicator="#global-spinner"><%= @paused ? '▶ Resume stream' : '⏸ Pause stream' %></button>
9
+ </section>
@@ -0,0 +1,52 @@
1
+ <% s = @stream -%>
2
+ <% state = s[:state] -%>
3
+ <% config = s[:config] -%>
4
+ <% max_age = config.max_age.to_i -%>
5
+ <section class="card">
6
+ <div class="stream-head">
7
+ <div class="stack" style="gap:6px">
8
+ <a class="stream-name" style="--hue:<%= s[:paused] ? 'var(--warn)' : hue(s[:name]) %>"
9
+ href="<%= url_for('/streams/info', name: u(s[:name])) %>"><i></i><%= h(s[:name]) %></a>
10
+ <div class="mono mute ellip" style="font-size:11.5px" title="<%= h(Array(config.subjects).join(', ')) %>"><%= h(Array(config.subjects).join(' · ')) %></div>
11
+ </div>
12
+ <div class="stat"><span class="eyebrow">Messages</span><b><%= compact_number(state.messages) %></b></div>
13
+ <div class="stat"><span class="eyebrow">Size</span><b><%= format_bytes(state.bytes) %></b></div>
14
+ <div class="stat"><span class="eyebrow">Retention</span><b><%= h(config.retention) %><%= " · #{format_span(max_age / 1_000_000_000)}" if max_age.positive? %></b></div>
15
+ <div class="stat"><span class="eyebrow">Replicas</span><b>R<%= h(config.num_replicas || 1) %></b></div>
16
+ <div class="stat"><span class="eyebrow">Sequence</span><b class="ellip"><%= compact_number(state.first_seq) %> → <%= compact_number(state.last_seq) %></b></div>
17
+ </div>
18
+ <div class="stream-tools">
19
+ <% if s[:paused] -%><span class="paused-tag">paused · consumers are not fetching</span><% end -%>
20
+ <span class="spacer"></span>
21
+ <a class="btn quiet" href="<%= url_for('/streams/info', name: u(s[:name])) %>">Details</a>
22
+ <button type="button" class="btn<%= ' quiet' unless s[:paused] %>"
23
+ hx-patch="<%= url_for(s[:paused] ? '/streams/unpause' : '/streams/pause', name: u(s[:name])) %>"
24
+ hx-target="closest section" hx-swap="outerHTML" hx-indicator="#global-spinner"><%= s[:paused] ? '▶ Resume' : '⏸ Pause' %></button>
25
+ </div>
26
+ <% if s[:consumers].empty? -%>
27
+ <div class="thead top tint"><span>No consumers</span></div>
28
+ <% else -%>
29
+ <div class="scroll"><div style="min-width:760px">
30
+ <div class="thead top tint t-cons"><span>Consumer</span><span class="r">Pending</span><span class="r">Ack pending</span><span class="r">Ack wait</span><span class="r">Redeliv.</span><span>Lag</span></div>
31
+ <% s[:consumers].each do |consumer| -%>
32
+ <% cfg = Hash(consumer['config']) -%>
33
+ <% pending = consumer['num_pending'].to_i -%>
34
+ <% redelivered = consumer['num_redelivered'].to_i -%>
35
+ <% ratio = pending.fdiv([state.messages.to_i, 1].max).clamp(0, 1) -%>
36
+ <% tone = ratio > 0.7 ? 'var(--err)' : ratio > 0.3 ? 'var(--warn)' : 'var(--ok)' -%>
37
+ <% filters = Array(cfg['filter_subjects']).concat(Array(cfg['filter_subject'])).reject(&:empty?) -%>
38
+ <div class="trow t-cons">
39
+ <div class="stack">
40
+ <span class="strong ellip"><%= h(consumer['name']) %></span>
41
+ <span class="small-mono ellip" title="<%= h(filters.join(', ')) %>">deliver: <%= h(cfg['deliver_policy']) %><%= " · #{h(filters.join(', '))}" if filters.any? %></span>
42
+ </div>
43
+ <span class="r"><%= format_numbers(pending) %></span>
44
+ <span class="r mute"><%= format_numbers(consumer['num_ack_pending'].to_i) %></span>
45
+ <span class="r mute"><%= format_span(cfg['ack_wait'].to_i / 1_000_000_000) %></span>
46
+ <span class="r" style="color:<%= redelivered > 100 ? 'var(--warn)' : 'var(--mute)' %>"><%= format_numbers(redelivered) %></span>
47
+ <div class="lag"><div class="lag-track"><i style="width:<%= [ratio * 100, 2].max.round(1) %>%;background:<%= tone %>"></i></div><span style="color:<%= tone %>"><%= compact_number(pending) %></span></div>
48
+ </div>
49
+ <% end -%>
50
+ </div></div>
51
+ <% end -%>
52
+ </section>
@@ -1,11 +1,3 @@
1
- <section>
2
- <header></header>
3
-
4
- <div id="content">
5
- <div hx-get="<%= url_for('/streams/_table') %>"
6
- hx-trigger="load, every <%= poll_interval %>s"
7
- hx-swap="innerHTML">
8
- <div class="alert alert-info">Loading streams…</div>
9
- </div>
10
- </div>
11
- </section>
1
+ <div class="view" hx-get="<%= url_for('/streams') %>" hx-trigger="load, cosmo:poll" data-poll="<%= poll_interval %>" hx-swap="innerHTML">
2
+ <section class="card"><div class="loading">Loading streams&hellip;</div></section>
3
+ </div>
@@ -1,11 +1,3 @@
1
- <section>
2
- <header></header>
3
-
4
- <div id="content">
5
- <div hx-get="<%= url_for("/streams/_info", { name: u(@name) }) %>"
6
- hx-trigger="load"
7
- hx-swap="innerHTML">
8
- <div class="alert alert-info">Loading stream info…</div>
9
- </div>
10
- </div>
11
- </section>
1
+ <div class="view" hx-get="<%= url_for('/streams/info', { name: u(@name) }) %>" hx-trigger="load" hx-swap="innerHTML">
2
+ <section class="card"><div class="loading">Loading stream info&hellip;</div></section>
3
+ </div>
data/lib/cosmo/web.rb CHANGED
@@ -3,68 +3,102 @@
3
3
  require "rack"
4
4
  require "json"
5
5
  require "erb"
6
+ require "zlib"
7
+ require "cosmo/web/cache"
8
+ require "cosmo/web/circuit"
6
9
  require "cosmo/web/context"
7
10
  require "cosmo/web/renderer"
8
11
  require "cosmo/web/controllers/application"
12
+ require "cosmo/web/controllers/overview"
9
13
  require "cosmo/web/controllers/jobs"
14
+ require "cosmo/web/controllers/processes"
10
15
  require "cosmo/web/controllers/streams"
11
- require "cosmo/web/controllers/actions"
12
16
  require "cosmo/web/controllers/crons"
13
17
  require "cosmo/web/controllers/batches"
18
+ require "cosmo/web/controllers/metrics"
14
19
 
15
20
  module Cosmo
16
21
  class Web
17
22
  include Renderer
18
23
 
24
+ FONT_HEADERS = { "cache-control" => "public, max-age=31536000, immutable" }.freeze
25
+
19
26
  def self.call(env)
20
27
  new.call(env)
21
28
  end
22
29
 
23
- def call(env) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
30
+ # Serves the UI. An htmx refresh is shared through Cache with concurrent and recent identical ones; any other
31
+ # method than GET changes something, so it empties the cache. While Circuit is open, anything but an asset
32
+ # answers 503 at once instead of waiting on NATS.
33
+ def call(env) # rubocop:disable Metrics/AbcSize
24
34
  @request = Rack::Request.new(env)
35
+ return route if @request.path_info.start_with?("/assets/", "/favicon.ico")
36
+ return guarded.tap { Cache.instance.clear } unless @request.get?
37
+ return guarded unless @request.get_header("HTTP_HX_REQUEST") == "true"
38
+
39
+ status, headers, body = Cache.instance.fetch(@request.fullpath) { guarded }
40
+ [status, headers.dup, body.dup]
41
+ rescue *Circuit::ERRORS
42
+ Circuit.instance.trip!
43
+ unavailable
44
+ end
45
+
46
+ private
47
+
48
+ def route # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
25
49
  path = @request.path_info
26
50
  method = @request.request_method
27
51
 
28
52
  response = case [method.downcase.to_sym, path]
29
- in [:get, "/"] then redirect_to("/jobs")
30
- in [:get, "/jobs"] then [Controllers::Jobs, :index]
31
- in [:get, "/jobs/scheduled"] then [Controllers::Jobs, :scheduled]
32
- in [:get, "/jobs/dead"] then [Controllers::Jobs, :dead]
33
- in [:get, "/jobs/busy"] then [Controllers::Jobs, :busy]
34
- in [:get, "/jobs/enqueued"] then [Controllers::Jobs, :enqueued]
35
- in [:patch, %r{/jobs/retry/\d+}] then [Controllers::Jobs, :retry]
36
- in [:delete, %r{/jobs/delete/\d+}] then [Controllers::Jobs, :delete]
37
- in [:delete, %r{/jobs/enqueued/\d+}] then [Controllers::Jobs, :delete_enqueued]
38
- in [:get, "/jobs/_stats"] then [Controllers::Jobs, :_stats]
39
- in [:get, "/jobs/_scheduled"] then [Controllers::Jobs, :_scheduled]
40
- in [:get, "/jobs/_dead"] then [Controllers::Jobs, :_dead]
41
- in [:get, "/jobs/_busy"] then [Controllers::Jobs, :_busy]
42
- in [:get, "/jobs/_processes"] then [Controllers::Jobs, :_processes]
43
- in [:get, "/jobs/_enqueued"] then [Controllers::Jobs, :_enqueued]
44
- in [:get, "/streams"] then [Controllers::Streams, :index]
45
- in [:get, "/streams/info"] then [Controllers::Streams, :info]
46
- in [:get, "/streams/_table"] then [Controllers::Streams, :_table]
47
- in [:get, "/streams/_info"] then [Controllers::Streams, :_info]
48
- in [:patch, "/streams/pause"] then [Controllers::Streams, :pause]
49
- in [:patch, "/streams/unpause"] then [Controllers::Streams, :unpause]
50
- in [:get, "/crons"] then [Controllers::Crons, :index]
51
- in [:get, "/crons/_table"] then [Controllers::Crons, :_table]
52
- in [:delete, "/crons/delete"] then [Controllers::Crons, :delete]
53
- in [:post, "/crons/run"] then [Controllers::Crons, :run_now]
54
- in [:get, "/batches"] then [Controllers::Batches, :index]
55
- in [:get, "/batches/_table"] then [Controllers::Batches, :_table]
56
- in [:get, "/actions"] then [Controllers::Actions, :index]
53
+ in [:get, "/" | ""] then [Controllers::Overview, :index]
54
+ in [:get, "/_kpis"] then [Controllers::Overview, :_kpis]
55
+ in [:get, "/_throughput"] then [Controllers::Overview, :_throughput]
56
+ in [:get, "/_workers"] then [Controllers::Overview, :_workers]
57
+ in [:get, "/_running"] then [Controllers::Overview, :_running]
58
+ in [:get, "/_badges"] then [Controllers::Overview, :_badges]
59
+ in [:get, "/jobs"] then [Controllers::Jobs, :index]
60
+ in [:get, "/jobs/busy"] then [Controllers::Jobs, :busy]
61
+ in [:get, "/jobs/enqueued"] then [Controllers::Jobs, :enqueued]
62
+ in [:get, "/jobs/scheduled"] then [Controllers::Jobs, :scheduled]
63
+ in [:get, "/jobs/dead"] then [Controllers::Jobs, :dead]
64
+ in [:get, "/jobs/_queues"] then [Controllers::Jobs, :_queues]
65
+ in [:patch, %r{/jobs/retry/\d+}] then [Controllers::Jobs, :retry]
66
+ in [:delete, %r{/jobs/delete/\d+}] then [Controllers::Jobs, :delete]
67
+ in [:delete, %r{/jobs/enqueued/\d+}] then [Controllers::Jobs, :delete_enqueued]
68
+ in [:get, "/processes"] then [Controllers::Processes, :index]
69
+ in [:get, "/streams"] then [Controllers::Streams, :index]
70
+ in [:get, "/streams/info"] then [Controllers::Streams, :info]
71
+ in [:patch, "/streams/pause"] then [Controllers::Streams, :pause]
72
+ in [:patch, "/streams/unpause"] then [Controllers::Streams, :unpause]
73
+ in [:get, "/crons"] then [Controllers::Crons, :index]
74
+ in [:get, "/crons/_table"] then [Controllers::Crons, :_table]
75
+ in [:delete, "/crons/delete"] then [Controllers::Crons, :delete]
76
+ in [:post, "/crons/run"] then [Controllers::Crons, :run_now]
77
+ in [:get, "/metrics"] then [Controllers::Metrics, :index]
78
+ in [:get, "/metrics/_panel"] then [Controllers::Metrics, :_panel]
79
+ in [:get, "/batches"] then [Controllers::Batches, :index]
80
+ in [:get, "/batches/_table"] then [Controllers::Batches, :_table]
57
81
  in [:get, "/assets/htmx.min.js.gz"] then serve("htmx.2.0.8.min.js.gz",
58
82
  "application/javascript",
59
83
  { "content-encoding" => "gzip" })
60
- in [:get, "/assets/app.css"] then serve("app.css", "text/css")
61
- in [:get, "/favicon.ico"] then no_content
84
+ in [:get, "/assets/app.css"] then serve("app.css", "text/css")
85
+ in [:get, "/assets/app.js"] then serve("app.js", "application/javascript")
86
+ in [:get, "/assets/logo.svg"] then serve("logo.svg", "image/svg+xml")
87
+ in [:get, "/assets/fonts/geist.woff2"] then serve("fonts/geist.woff2", "font/woff2", FONT_HEADERS)
88
+ in [:get, "/assets/fonts/geist-mono.woff2"] then serve("fonts/geist-mono.woff2", "font/woff2", FONT_HEADERS)
89
+ in [:get, "/favicon.ico"] then redirect_to("/assets/logo.svg", 301)
62
90
  else not_found
63
91
  end
64
92
  handler(response)
65
93
  end
66
94
 
67
- private
95
+ def guarded
96
+ Circuit.instance.open? ? unavailable : route
97
+ end
98
+
99
+ def unavailable
100
+ Controllers::Application.new(@request).unavailable
101
+ end
68
102
 
69
103
  def handler(response)
70
104
  if response[0].is_a?(Class)
data/lib/cosmo.rb CHANGED
@@ -14,6 +14,8 @@ require "cosmo/cli"
14
14
  require "cosmo/heartbeat"
15
15
  require "cosmo/engine"
16
16
  require "cosmo/api"
17
+ require "cosmo/middleware"
18
+ require "cosmo/services"
17
19
  require "cosmo/railtie" if defined?(Rails::Railtie)
18
20
 
19
21
  module Cosmo
@@ -22,18 +24,54 @@ module Cosmo
22
24
  # Optional, requires rack and webrick gems. Loaded only when an HTTP port is configured.
23
25
  autoload :HTTPServer, "cosmo/http_server"
24
26
 
27
+ # Programmatic setup, typically from an initializer:
28
+ #
29
+ # Cosmo.configure do |config|
30
+ # config.logger = Rails.logger
31
+ # config.log_level = :debug
32
+ # config.server_middleware { |c| c.add MyMiddleware }
33
+ # end
34
+ #
35
+ # @yieldparam config [Config] the {Config} singleton
36
+ # @return [void]
37
+ def self.configure
38
+ yield Config.instance
39
+ end
40
+
41
+ # Hands +error+ to every handler in {Config#error_handlers}. A handler that raises is logged and skipped, so error
42
+ # reporting never breaks processing.
43
+ #
44
+ # @param error [Exception]
45
+ # @param context [Hash] where it happened, see {Config#error_handlers}
46
+ # @return [void]
47
+ def self.handle_error(error, context)
48
+ Config.error_handlers.each do |handler|
49
+ handler.call(error, context)
50
+ rescue StandardError => e
51
+ Logger.error "Error handler failed: #{e.class}: #{e.message}"
52
+ end
53
+ end
54
+
25
55
  class Error < StandardError; end
26
56
 
27
57
  class ArgumentError < Error; end
28
58
 
29
59
  class NotImplementedError < Error; end
30
60
 
61
+ class ConfigError < Error; end
62
+
31
63
  class ConfigNotFoundError < Error
32
64
  def initialize(config_file)
33
65
  super("No such file #{config_file}")
34
66
  end
35
67
  end
36
68
 
69
+ class SchedulingDisabledError < Error
70
+ def initialize
71
+ super("Scheduling is turned off (config.scheduled.enabled = false), so delayed jobs and crons are unavailable")
72
+ end
73
+ end
74
+
37
75
  class StreamNotFoundError < Error
38
76
  def initialize(stream_name)
39
77
  super("Missing stream `#{stream_name}`")