cosmonats 0.1.3 → 0.2.0

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