cosmonats 0.8.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -11
  3. data/lib/cosmo/api/counter.rb +1 -1
  4. data/lib/cosmo/api/cron/entry.rb +10 -11
  5. data/lib/cosmo/api/cron.rb +3 -3
  6. data/lib/cosmo/api/job.rb +1 -1
  7. data/lib/cosmo/api/kv.rb +3 -3
  8. data/lib/cosmo/api/stats/busy.rb +5 -0
  9. data/lib/cosmo/api/stats/metrics.rb +3 -3
  10. data/lib/cosmo/api/stats.rb +15 -0
  11. data/lib/cosmo/api/stream.rb +7 -2
  12. data/lib/cosmo/client.rb +10 -13
  13. data/lib/cosmo/job/data.rb +1 -1
  14. data/lib/cosmo/job/processor.rb +5 -6
  15. data/lib/cosmo/utils.rb +0 -1
  16. data/lib/cosmo/version.rb +1 -1
  17. data/lib/cosmo/web/assets/app.css +271 -629
  18. data/lib/cosmo/web/assets/app.js +158 -0
  19. data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
  20. data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
  21. data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
  22. data/lib/cosmo/web/assets/logo.svg +10 -0
  23. data/lib/cosmo/web/cache.rb +75 -0
  24. data/lib/cosmo/web/chart.rb +62 -38
  25. data/lib/cosmo/web/circuit.rb +55 -0
  26. data/lib/cosmo/web/context.rb +0 -4
  27. data/lib/cosmo/web/controllers/application.rb +16 -0
  28. data/lib/cosmo/web/controllers/batches.rb +1 -0
  29. data/lib/cosmo/web/controllers/crons.rb +9 -2
  30. data/lib/cosmo/web/controllers/jobs.rb +50 -59
  31. data/lib/cosmo/web/controllers/metrics.rb +1 -0
  32. data/lib/cosmo/web/controllers/overview.rb +70 -0
  33. data/lib/cosmo/web/controllers/processes.rb +33 -0
  34. data/lib/cosmo/web/controllers/streams.rb +26 -28
  35. data/lib/cosmo/web/helpers/application.rb +57 -0
  36. data/lib/cosmo/web/renderer.rb +19 -6
  37. data/lib/cosmo/web/throughput.rb +104 -0
  38. data/lib/cosmo/web/views/batches/_table.erb +42 -54
  39. data/lib/cosmo/web/views/batches/index.erb +3 -10
  40. data/lib/cosmo/web/views/crons/_table.erb +32 -53
  41. data/lib/cosmo/web/views/crons/index.erb +2 -9
  42. data/lib/cosmo/web/views/jobs/_args.erb +5 -0
  43. data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
  44. data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
  45. data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
  46. data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
  47. data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
  48. data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
  49. data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
  50. data/lib/cosmo/web/views/jobs/index.erb +4 -17
  51. data/lib/cosmo/web/views/layout.erb +73 -22
  52. data/lib/cosmo/web/views/metrics/_chart.erb +37 -32
  53. data/lib/cosmo/web/views/metrics/_panel.erb +3 -3
  54. data/lib/cosmo/web/views/metrics/_table.erb +24 -40
  55. data/lib/cosmo/web/views/metrics/index.erb +8 -16
  56. data/lib/cosmo/web/views/overview/_badges.erb +3 -0
  57. data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
  58. data/lib/cosmo/web/views/overview/_running.erb +18 -0
  59. data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
  60. data/lib/cosmo/web/views/overview/_workers.erb +25 -0
  61. data/lib/cosmo/web/views/overview/index.erb +28 -0
  62. data/lib/cosmo/web/views/processes/_table.erb +67 -0
  63. data/lib/cosmo/web/views/processes/index.erb +1 -0
  64. data/lib/cosmo/web/views/shared/_pager.erb +22 -0
  65. data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
  66. data/lib/cosmo/web/views/streams/_info.erb +28 -86
  67. data/lib/cosmo/web/views/streams/_list.erb +7 -0
  68. data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
  69. data/lib/cosmo/web/views/streams/_stream.erb +52 -0
  70. data/lib/cosmo/web/views/streams/index.erb +3 -11
  71. data/lib/cosmo/web/views/streams/info.erb +3 -11
  72. data/lib/cosmo/web.rb +66 -35
  73. data/sig/cosmo/api/cron/entry.rbs +2 -1
  74. data/sig/cosmo/api/stats/busy.rbs +2 -0
  75. data/sig/cosmo/api/stats/metrics.rbs +1 -1
  76. data/sig/cosmo/api/stats.rbs +4 -0
  77. data/sig/cosmo/api/stream.rbs +2 -0
  78. data/sig/cosmo/client.rbs +1 -0
  79. metadata +31 -18
  80. data/lib/cosmo/utils/overrides.rb +0 -206
  81. data/lib/cosmo/utils/warnings.rb +0 -17
  82. data/lib/cosmo/web/controllers/actions.rb +0 -16
  83. data/lib/cosmo/web/views/actions/index.erb +0 -7
  84. data/lib/cosmo/web/views/jobs/_processes.erb +0 -145
  85. data/lib/cosmo/web/views/jobs/_stats.erb +0 -77
  86. data/lib/cosmo/web/views/jobs/_tabs.erb +0 -14
  87. data/lib/cosmo/web/views/jobs/busy.erb +0 -18
  88. data/lib/cosmo/web/views/jobs/dead.erb +0 -18
  89. data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
  90. data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
  91. data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
  92. 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', (@processes_query unless @processes_query.empty?)) %>"
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', '/metrics') %>">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>
@@ -1,36 +1,41 @@
1
- <div class="metrics-chart">
2
- <div class="metrics-chart-header">
3
- <span><code><%= h(@job) %></code> <span class="text-muted">last <%= @days %> days</span></span>
4
- <span class="metrics-legend">
5
- <span class="metrics-legend-runs">runs per day</span>
6
- <span class="metrics-legend-exec">avg execution (ms)</span>
7
- </span>
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>
8
6
  </div>
9
7
  <% if @chart.empty? -%>
10
- <div class="alert alert-info">No runs of <code><%= h(@job) %></code> in this period.</div>
8
+ <div class="empty"><b>No runs</b><code><%= h(@job) %></code> didn't run in this period.</div>
11
9
  <% else -%>
12
- <svg viewBox="0 0 <%= Cosmo::Web::Chart::WIDTH %> <%= Cosmo::Web::Chart::HEIGHT %>" role="img"
13
- aria-label="Runs per day and average execution time of <%= h(@job) %>">
14
- <% @chart.ticks.each do |tick| -%>
15
- <line class="chart-grid" x1="<%= Cosmo::Web::Chart::LEFT %>" x2="<%= Cosmo::Web::Chart::RIGHT %>"
16
- y1="<%= tick[:y] %>" y2="<%= tick[:y] %>"/>
17
- <text class="chart-axis" x="<%= Cosmo::Web::Chart::LEFT - 6 %>" y="<%= tick[:y] + 4 %>"
18
- text-anchor="end"><%= format_numbers(tick[:count]) %></text>
19
- <text class="chart-axis chart-axis-exec" x="<%= Cosmo::Web::Chart::RIGHT + 6 %>" y="<%= tick[:y] + 4 %>"
20
- text-anchor="start"><%= format_numbers(tick[:exec_ms]) %></text>
21
- <% end -%>
22
- <% @chart.bars.each do |bar| -%>
23
- <rect class="chart-bar" x="<%= bar[:x] %>" y="<%= bar[:y] %>" width="<%= bar[:width] %>"
24
- height="<%= bar[:height] %>"><title><%= h(bar[:title]) %></title></rect>
25
- <% end -%>
26
- <polyline class="chart-line" points="<%= @chart.line %>"/>
27
- <% @chart.points.each do |point| -%>
28
- <circle class="chart-point" cx="<%= point[:x] %>" cy="<%= point[:y] %>" r="4"><title><%= h(point[:title]) %></title></circle>
29
- <% end -%>
30
- <% @chart.labels.each do |label| -%>
31
- <text class="chart-axis" x="<%= label[:x] %>" y="<%= Cosmo::Web::Chart::BOTTOM + 18 %>"
32
- text-anchor="middle"><%= label[:text] %></text>
33
- <% end -%>
34
- </svg>
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>
35
40
  <% end -%>
36
- </div>
41
+ </section>
@@ -1,9 +1,9 @@
1
- <div id="metrics-panel"
1
+ <div id="metrics-panel" class="view"
2
2
  hx-get="<%= url_for('/metrics/_panel', { days: @days, job: u(@job) }) %>"
3
- hx-trigger="every 30s"
3
+ hx-trigger="cosmo:poll" data-poll="30"
4
4
  hx-swap="outerHTML">
5
5
  <% if @rows.empty? -%>
6
- <div class="alert alert-success">No metrics recorded in this period.</div>
6
+ <section class="card"><div class="empty"><b>No metrics yet</b>Nothing recorded in this period.</div></section>
7
7
  <% else -%>
8
8
  <% if @chart -%>
9
9
  <%= render('metrics/_chart', { job: @job, days: @days, chart: @chart }) %>
@@ -1,41 +1,25 @@
1
1
  <% selectable = @days > 1 -%>
2
- <% if selectable -%>
3
- <p class="metrics-hint">Click a job to chart it.</p>
4
- <% end -%>
5
- <div class="table-container">
6
- <table class="metrics-table">
7
- <thead>
8
- <tr>
9
- <th>Job</th>
10
- <th>Runs</th>
11
- <th>Failed</th>
12
- <th>Avg execution</th>
13
- <th>Avg wait</th>
14
- </tr>
15
- </thead>
16
- <tbody>
17
- <% @rows.each do |row| -%>
18
- <% selected = selectable && row[:job] == @job -%>
19
- <tr<% if selectable -%>
20
- class="selectable<%= ' selected' if selected %>"
21
- hx-get="<%= url_for('/metrics/_panel', { days: @days, job: u(row[:job]) }) %>"
22
- hx-target="#metrics-panel"
23
- hx-swap="outerHTML"
24
- <% if selected -%>aria-selected="true"<% end -%>
25
- <% end -%>>
26
- <td><code><%= h(row[:job]) %></code></td>
27
- <td><%= format_numbers(row[:count]) %></td>
28
- <td>
29
- <% if row[:failed].positive? -%>
30
- <span class="badge badge-danger"><%= format_numbers(row[:failed]) %></span>
31
- <% else -%>
32
- <span class="text-muted">0</span>
33
- <% end -%>
34
- </td>
35
- <td><%= duration(row[:exec_ms]) %></td>
36
- <td><%= duration(row[:wait_ms]) %></td>
37
- </tr>
38
- <% end -%>
39
- </tbody>
40
- </table>
41
- </div>
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>
@@ -1,17 +1,9 @@
1
- <section>
2
- <header><%= render('jobs/_tabs') %></header>
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>
3
6
 
4
- <div class="nav">
5
- <% @ranges.each do |range, name| -%>
6
- <a href="<%= url_for('/metrics', { days: range }) %>"
7
- class="btn <%= 'btn-primary' if @days == range %>"><%= h(name) %></a>
8
- <% end -%>
9
- </div>
10
-
11
- <div id="metrics-panel"
12
- hx-get="<%= url_for('/metrics/_panel', { days: @days }) %>"
13
- hx-trigger="load"
14
- hx-swap="outerHTML">
15
- <div class="alert alert-info">Loading metrics&hellip;</div>
16
- </div>
17
- </section>
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>