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
@@ -7,109 +7,114 @@ module Cosmo
7
7
  module Controllers
8
8
  class Jobs < Application
9
9
  def index
10
- content_for :title, "Jobs"
11
- ok render("jobs/index", layout: true)
10
+ tab("busy")
12
11
  end
13
12
 
14
13
  def busy
15
- return _busy if hx_request?
16
-
17
- content_for :title, "Busy Jobs"
18
- ok render("jobs/busy", layout: true)
14
+ tab("busy")
19
15
  end
20
16
 
21
17
  def enqueued
22
- return _enqueued if hx_request?
23
-
24
- content_for :title, "Enqueued Jobs"
25
- stream_name, _stream_names = streams
26
- ok render("jobs/enqueued", { stream_name: }, layout: true)
18
+ tab("enqueued")
27
19
  end
28
20
 
29
21
  def scheduled
30
- return _scheduled if hx_request?
22
+ return not_found unless Config.scheduled.enabled
31
23
 
32
- content_for :title, "Scheduled Jobs"
33
- ok render("jobs/scheduled", layout: true)
24
+ tab("scheduled")
34
25
  end
35
26
 
36
27
  def dead
37
- return _dead if hx_request?
28
+ return not_found unless Config.dead.enabled
29
+
30
+ tab("dead")
31
+ end
38
32
 
39
- content_for :title, "Dead Jobs"
40
- ok render("jobs/dead", layout: true)
33
+ def _queues
34
+ ok render("jobs/_queues", { queues: API::Stats.queues, compact: params["compact"].to_s == "1" })
41
35
  end
42
36
 
43
37
  def retry
38
+ return not_found unless Config.dead.enabled
39
+
44
40
  seq = path.split("/").last.to_i
45
- stream = API::Stream.new("dead")
46
- stream.retry(seq)
47
- ok
41
+ API::Stream.new("dead").retry(seq)
42
+ ok flash("Dead job ##{seq} sent back to its queue")
48
43
  end
49
44
 
50
45
  def delete
46
+ return not_found unless Config.dead.enabled
47
+
51
48
  seq = path.split("/").last.to_i
52
- stream = API::Stream.new("dead")
53
- stream.delete(seq)
54
- ok
49
+ API::Stream.new("dead").delete(seq)
50
+ ok flash("Dead job ##{seq} deleted")
55
51
  end
56
52
 
57
53
  def delete_enqueued
58
54
  seq = path.split("/").last.to_i
59
55
  stream_name, = streams
60
56
  API::Stream.new(stream_name).delete(seq)
61
- _enqueued
57
+ ok panel("enqueued") + flash("Message ##{seq} removed from #{stream_name}")
58
+ end
59
+
60
+ private
61
+
62
+ def tab(name)
63
+ return ok(panel(name)) if hx_request?
64
+
65
+ content_for :title, "Jobs"
66
+ content_for :subtitle, "Queues, in-flight work and everything waiting its turn."
67
+ ok render("jobs/index", { panel: panel(name) }, layout: true)
62
68
  end
63
69
 
64
- def _scheduled
65
- stream = API::Stream.new("scheduled")
66
- jobs = stream.messages(page: params["page"], limit: params["limit"])
67
- ok render("jobs/_scheduled", { jobs: jobs, total: stream.total })
70
+ def panel(name)
71
+ render("jobs/_panel", send("#{name}_locals").merge(tab: name, counts: counts))
68
72
  end
69
73
 
70
- def _dead
71
- stream = API::Stream.new("dead")
72
- jobs = stream.messages(page: params["page"], limit: params["limit"])
73
- ok render("jobs/_dead", { jobs: jobs, total: stream.total })
74
+ def counts
75
+ counts = { busy: API::Stats.busy, enqueued: API::Stats.enqueued }
76
+ counts[:scheduled] = API::Stats.scheduled if Config.scheduled.enabled
77
+ counts[:dead] = API::Stats.dead if Config.dead.enabled
78
+ counts
74
79
  end
75
80
 
76
- def _busy
81
+ def busy_locals
77
82
  busy = API::Stats::Busy.instance
78
83
  total = busy.size
79
84
  page, limit, total_pages = paginate(total, API::Stats::Busy::LIMIT)
80
85
  polling = params["poll"].to_s != "0"
81
- ok render("jobs/_busy", { jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
82
- end
83
-
84
- def _processes
85
- processes = API::Stats::Processes.instance
86
- total = processes.size
87
- page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
88
- polling = params["poll"].to_s != "0"
89
- ok render("jobs/_processes", { processes: processes.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
86
+ { jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling:, query: { limit:, poll: (0 unless polling) }.compact }
90
87
  end
91
88
 
92
- def _enqueued # rubocop:disable Metrics/AbcSize
89
+ def enqueued_locals # rubocop:disable Metrics/AbcSize
93
90
  stream_name, stream_names = streams
94
91
  limit = (params["limit"] || API::Stream::LIMIT).to_i
95
92
  page = [params["page"].to_i, 1].max
93
+ locals = { stream_name:, stream_names:, page:, limit:, total: 0, total_pages: 0, jobs: nil, polling: false,
94
+ query: { stream_name: Rack::Utils.escape(stream_name.to_s), limit: } }
95
+ return locals if stream_name.to_s.empty?
96
96
 
97
- unless stream_name.to_s.empty?
98
- stream = API::Stream.new(stream_name)
99
- total = stream.total
100
- total_pages = (total.to_f / limit).ceil
101
- page = page.clamp(1, [total_pages, 1].max)
102
- jobs = stream.messages(page:, limit:)
103
- end
97
+ stream = API::Stream.new(stream_name)
98
+ total = stream.total
99
+ total_pages = (total.to_f / limit).ceil
100
+ page = page.clamp(1, [total_pages, 1].max)
101
+ locals.merge(total:, total_pages:, page:, jobs: stream.messages(page:, limit:))
102
+ end
104
103
 
105
- ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
104
+ def scheduled_locals
105
+ stored_locals("scheduled").merge(polling: true)
106
106
  end
107
107
 
108
- def _stats
109
- ok render("jobs/_stats", API::Stats.summary)
108
+ def dead_locals
109
+ stored_locals("dead").merge(polling: false)
110
110
  end
111
111
 
112
- private
112
+ def stored_locals(name)
113
+ stream = API::Stream.new(name)
114
+ total = stream.total
115
+ page, limit, total_pages = paginate(total, API::Stream::LIMIT)
116
+ { jobs: stream.messages(page:, limit:), total:, page:, limit:, total_pages:, query: { limit: } }
117
+ end
113
118
 
114
119
  def paginate(total, default_limit)
115
120
  limit = (params["limit"] || default_limit).to_i
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+ require "cosmo/web/chart"
5
+
6
+ module Cosmo
7
+ class Web
8
+ module Controllers
9
+ class Metrics < Application
10
+ RANGES = { 1 => "Today", 7 => "7 days", 30 => "30 days" }.freeze
11
+
12
+ def index
13
+ return not_found unless Config.metrics.enabled
14
+
15
+ content_for :title, "Metrics"
16
+ content_for :subtitle, "Runs, failures, execution and wait time per job class."
17
+ ok render("metrics/index", { days:, ranges: RANGES }, layout: true)
18
+ end
19
+
20
+ # The chart and the table together, so the charted job stays highlighted across refreshes.
21
+ def _panel
22
+ return not_found unless Config.metrics.enabled
23
+
24
+ rows = metrics.summary(days:)
25
+ job = charted(rows)
26
+ chart = Chart.new(metrics.daily(job, days:)) if job && days > 1
27
+ ok render("metrics/_panel", { rows:, job:, days:, chart: })
28
+ end
29
+
30
+ private
31
+
32
+ # The requested job while it has rows in the period, the busiest one otherwise.
33
+ def charted(rows)
34
+ job = params["job"].to_s
35
+ rows.any? { _1[:job] == job } ? job : rows.first&.dig(:job)
36
+ end
37
+
38
+ def days
39
+ value = params["days"].to_i
40
+ RANGES.key?(value) ? value : 7
41
+ end
42
+
43
+ def metrics
44
+ API::Stats::Metrics.instance
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+ require "cosmo/web/throughput"
5
+
6
+ module Cosmo
7
+ class Web
8
+ module Controllers
9
+ class Overview < Application
10
+ RANGES = [7, 30].freeze
11
+ CELLS = 256
12
+ RUNNING = 9
13
+ PROCESSES = 5
14
+
15
+ def index
16
+ content_for :title, "Overview"
17
+ content_for :subtitle, "Everything beamed through NATS, live."
18
+ ok render("overview/index", { metrics: Config.metrics.enabled }, layout: true)
19
+ end
20
+
21
+ def _kpis
22
+ ok render("overview/_kpis", API::Stats.summary)
23
+ end
24
+
25
+ def _throughput
26
+ return not_found unless Config.metrics.enabled
27
+
28
+ days = RANGES.include?(params["days"].to_i) ? params["days"].to_i : RANGES.first
29
+ chart = Throughput.new(API::Stats::Metrics.instance.daily(days:))
30
+ ok render("overview/_throughput", { chart:, days:, ranges: RANGES })
31
+ end
32
+
33
+ def _workers # rubocop:disable Metrics/AbcSize
34
+ processes = API::Stats::Processes.instance.list(limit: API::Stats::Processes.instance.size.clamp(1, nil))
35
+ busy = API::Stats::Busy.instance.all.group_by { _1[:worker] }
36
+ cells = processes.flat_map { worker_cells(_1, busy) }
37
+ ok render("overview/_workers", { processes: processes.first(PROCESSES), total: processes.size,
38
+ cells: cells.first(CELLS), hidden: [cells.size - CELLS, 0].max,
39
+ busy: cells.count(&:itself), threads: cells.size })
40
+ end
41
+
42
+ def _running
43
+ jobs = API::Stats::Busy.instance.all.sort_by { _1[:started_at].to_i }.first(RUNNING)
44
+ jobs = jobs.map { _1.merge(data: Utils::Json.parse(_1[:data]) || {}) }
45
+ ok render("overview/_running", { jobs:, total: API::Stats::Busy.instance.size })
46
+ end
47
+
48
+ def _badges
49
+ badges = { jobs: API::Stats.enqueued, streams: visible_streams.size }
50
+ badges[:crons] = API::Cron.instance.all.size if Config.scheduled.enabled
51
+ badges[:batches] = API::Batch.kv.keys("*.meta", limit: nil).size
52
+ ok render("overview/_badges", { badges: })
53
+ end
54
+
55
+ private
56
+
57
+ # A process's threads, each the stream of the job it runs or nil while idle.
58
+ def worker_cells(process, busy)
59
+ streams = Array(busy[process[:identity]]).map { _1[:stream] }
60
+ idle = [process[:concurrency].to_i - streams.size, 0].max
61
+ streams + Array.new(idle)
62
+ end
63
+
64
+ def visible_streams
65
+ API::Stream.all.reject { _1.name.start_with?("KV_", "_cosmo") }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/controllers/application"
4
+
5
+ module Cosmo
6
+ class Web
7
+ module Controllers
8
+ class Processes < Application
9
+ def index
10
+ return ok(table) if hx_request?
11
+
12
+ content_for :title, "Processes"
13
+ content_for :subtitle, "Every worker process heartbeating into NATS."
14
+ ok render("processes/index", { table: table }, layout: true)
15
+ end
16
+
17
+ private
18
+
19
+ def table # rubocop:disable Metrics/AbcSize
20
+ processes = API::Stats::Processes.instance
21
+ total = processes.size
22
+ limit = (params["limit"] || API::Stats::Processes::LIMIT).to_i
23
+ total_pages = (total.to_f / limit).ceil
24
+ page = params["page"].to_i.clamp(1, [total_pages, 1].max)
25
+ polling = params["poll"].to_s != "0"
26
+ groups = processes.list(page:, limit:).chunk { API::Stats::Processes.kind(_1) }.to_a
27
+ render("processes/_table", { groups:, total:, page:, limit:, total_pages:, polling:,
28
+ query: { limit:, poll: (0 unless polling) }.compact })
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -7,62 +7,60 @@ module Cosmo
7
7
  module Controllers
8
8
  class Streams < Application
9
9
  def index
10
- return _table if hx_request?
10
+ return ok(list) if hx_request?
11
11
 
12
12
  content_for :title, "Streams"
13
+ content_for :subtitle, "JetStream streams and the consumers reading them."
13
14
  ok render("streams/index", layout: true)
14
15
  end
15
16
 
16
17
  def info
17
- name = Rack::Utils.unescape(@request.params["name"])
18
- return _info if hx_request?
18
+ return ok(render("streams/_info", info_locals(API::Stream.new(name)))) if hx_request?
19
19
 
20
20
  content_for :title, "Streams"
21
- ok render("streams/info", { name: name }, layout: true)
21
+ content_for :subtitle, "Configuration of #{name}."
22
+ ok render("streams/info", { name: }, layout: true)
22
23
  end
23
24
 
24
25
  def pause
25
- name = Rack::Utils.unescape(@request.params["name"])
26
26
  stream = API::Stream.new(name)
27
27
  stream.pause!
28
- return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
29
-
30
- ok render("streams/_stream_row", { stream: row_locals(stream) })
28
+ toggled(stream, "paused")
31
29
  end
32
30
 
33
31
  def unpause
34
- name = Rack::Utils.unescape(@request.params["name"])
35
32
  stream = API::Stream.new(name)
36
33
  stream.unpause!
37
- return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
38
-
39
- ok render("streams/_stream_row", { stream: row_locals(stream) })
34
+ toggled(stream, "resumed")
40
35
  end
41
36
 
42
- def _table
43
- streams = API::Stream.all.reject { |s| s.name.start_with?("KV_") || s.name.start_with?("_cosmo") }.map { row_locals(_1) }
44
- ok render("streams/_table", { streams: streams })
37
+ private
38
+
39
+ def name
40
+ Rack::Utils.unescape(params["name"].to_s)
45
41
  end
46
42
 
47
- def _info
48
- name = Rack::Utils.unescape(@request.params["name"])
49
- stream = API::Stream.new(name)
50
- ok render("streams/_info", stream.info.merge(name:, paused: stream.paused?))
43
+ def list
44
+ streams = API::Stream.all.reject { _1.name.start_with?("KV_", "_cosmo") }.map { card(_1) }
45
+ render("streams/_list", { streams: })
51
46
  end
52
47
 
53
- private
48
+ def toggled(stream, verb)
49
+ body = case params["view"] || (params["banner"] && "banner")
50
+ when "banner" then render("streams/_pause_banner", { name: stream.name, paused: stream.paused? })
51
+ when "queue" then render("jobs/_queue_row", { queue: API::Stats.queue(stream), compact: false })
52
+ else render("streams/_stream", { stream: card(stream) })
53
+ end
54
+ ok body + flash("#{stream.name} #{verb}")
55
+ end
54
56
 
55
- def row_locals(stream)
57
+ def card(stream)
56
58
  state, config = stream.info.values
57
- { name: stream.name, messages: state.messages, bytes: state.bytes,
58
- first_seq: state.first_seq, last_seq: state.last_seq,
59
- consumer_count: state.consumer_count,
60
- subjects: config.subjects,
61
- paused: stream.paused? }
59
+ { name: stream.name, state:, config:, consumers: stream.consumers, paused: stream.paused? }
62
60
  end
63
61
 
64
- def banner_locals(stream)
65
- { name: stream.name, paused: stream.paused? }
62
+ def info_locals(stream)
63
+ stream.info.merge(name: stream.name, paused: stream.paused?)
66
64
  end
67
65
  end
68
66
  end
@@ -42,6 +42,16 @@ module Cosmo
42
42
  value.to_s
43
43
  end
44
44
 
45
+ # @param millis [Numeric, nil]
46
+ # @return [String] e.g. "340 ms", "1.25 s", "2m 3s", or "—" without a value
47
+ def duration(millis)
48
+ return "—" unless millis
49
+ return "#{millis.round} ms" if millis < 1_000
50
+ return "#{(millis / 1_000.0).round(2)} s" if millis < 60_000
51
+
52
+ "#{(millis / 60_000).floor}m #{((millis % 60_000) / 1_000).round}s"
53
+ end
54
+
45
55
  def elapsed(value)
46
56
  elapsed = Time.now.to_i - value.to_i
47
57
 
@@ -68,6 +78,63 @@ module Cosmo
68
78
  "#{(diff / 86_400).to_i}d"
69
79
  end
70
80
 
81
+ # @return [String] e.g. "940", "18.9k", "1.28M"
82
+ def compact_number(num)
83
+ n = num.to_i
84
+ return format("%.2fM", n / 1e6).sub(/\.?0+M\z/, "M") if n >= 1_000_000
85
+ return format("%.1fk", n / 1e3).sub(/\.0k\z/, "k") if n >= 10_000
86
+
87
+ format_numbers(n)
88
+ end
89
+
90
+ # @param seconds [Numeric, nil]
91
+ # @return [String] e.g. "45s", "30m", "7d", or "—" without a value
92
+ def format_span(seconds)
93
+ s = seconds.to_i
94
+ return "—" unless s.positive?
95
+
96
+ { 86_400 => "d", 3_600 => "h", 60 => "m" }.each { |size, unit| return "#{(s / size.to_f).round(1).to_s.delete_suffix(".0")}#{unit}" if s >= size }
97
+ "#{s}s"
98
+ end
99
+
100
+ # A stable chart color per name, one of the palette's five hues: job streams take them in name order, so the
101
+ # first five never share one, anything else by a hash of its name.
102
+ def hue(name)
103
+ @hues ||= API::Stream.jobs.map(&:name).sort.each_with_index.to_h
104
+ index = @hues.fetch(name.to_s) { Zlib.crc32(name.to_s) }
105
+ "var(--c#{(index % 5) + 1})"
106
+ end
107
+
108
+ # @return [Array<Hash>] the sidebar entries, +:key+, +:label+ and +:path+, for the enabled pages
109
+ def nav_items
110
+ items = [%w[overview Overview /], %w[jobs Jobs /jobs], %w[streams Streams /streams]]
111
+ items << %w[crons Crons /crons] if Config.scheduled.enabled
112
+ items << %w[batches Batches /batches]
113
+ items << %w[metrics Metrics /metrics] if Config.metrics.enabled
114
+ items.map { |key, label, path| { key:, label:, path: } }
115
+ end
116
+
117
+ # @return [String] the nav key of the page being rendered
118
+ def current_section
119
+ path = @request.path_info
120
+ return "overview" if path.empty? || path == "/" || path.start_with?("/processes")
121
+
122
+ path.split("/")[1].to_s
123
+ end
124
+
125
+ # @return [Hash] the connected NATS server's +:url+, +:name+ and +:version+, empty while unknown
126
+ def nats_server
127
+ return {} if Web::Circuit.instance.open?
128
+
129
+ connection = Client.instance.nc
130
+ info = connection.server_info || {}
131
+ uri = connection.connected_server
132
+ name = info[:server_name] unless info[:server_name] == info[:server_id]
133
+ { url: uri && "#{uri.host}:#{uri.port}", name:, version: info[:version] }.compact
134
+ rescue StandardError
135
+ {}
136
+ end
137
+
71
138
  def h(value)
72
139
  Rack::Utils.escape_html(value.to_s)
73
140
  end
@@ -6,6 +6,22 @@ module Cosmo
6
6
  ASSETS_ROOT = File.expand_path("assets", __dir__).freeze
7
7
  VIEWS_ROOT = File.expand_path("views", __dir__).freeze
8
8
 
9
+ @templates = {}
10
+ @lock = Mutex.new
11
+
12
+ # Compiles a view into a Context method on its first render, so later renders skip reading and parsing it.
13
+ #
14
+ # @param path [String] the view, relative to VIEWS_ROOT and without +.erb+, e.g. +"jobs/_busy"+
15
+ # @return [String] the name of the Context method that renders it
16
+ def self.template(path)
17
+ @lock.synchronize do
18
+ @templates[path] ||= "template#{@templates.size}".tap do |name|
19
+ source = File.read(File.join(VIEWS_ROOT, "#{path}.erb"))
20
+ ERB.new(source, trim_mode: "-").def_method(Context, name, "#{path}.erb")
21
+ end
22
+ end
23
+ end
24
+
9
25
  def redirect_to(url, status = 302)
10
26
  [status, { "location" => url_for(url) }, []]
11
27
  end
@@ -14,7 +30,7 @@ module Cosmo
14
30
  path = File.join(ASSETS_ROOT, filename)
15
31
  return not_found unless File.exist?(path)
16
32
 
17
- respond_with 200, body: File.read(path), headers: { "content-type" => content_type }.merge(headers || {})
33
+ respond_with 200, body: File.binread(path), headers: { "content-type" => content_type }.merge(headers || {})
18
34
  end
19
35
 
20
36
  def ok(body = "")
@@ -27,7 +43,7 @@ module Cosmo
27
43
  end
28
44
 
29
45
  def not_found
30
- body = "<div class='alert alert-danger'>404 — Not Found</div>"
46
+ body = "<div class=\"empty\"><b>404</b>Not found</div>"
31
47
  headers = { "content-type" => "text/html; charset=utf-8" }
32
48
  respond_with 404, body:, headers:
33
49
  end
@@ -48,10 +64,7 @@ module Cosmo
48
64
  end
49
65
 
50
66
  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)
67
+ Context.new(locals, content_for).public_send(Renderer.template(path))
55
68
  end
56
69
  end
57
70
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Web
5
+ # Geometry for the Overview's throughput chart: an area line of the successful runs per day and a bar per day for
6
+ # the failed ones, drawn into a viewBox the page stretches to its width, axes and hover targets laid out in percent.
7
+ class Throughput
8
+ WIDTH = 1000
9
+ HEIGHT = 220
10
+ PLOT = 214
11
+ FAILED = 54
12
+ BAR = 22
13
+ TICKS = 3
14
+ LABELS = 5
15
+
16
+ # @param series [Array<Hash>] +{ date:, count:, failed:, exec_ms: }+ per day, oldest first
17
+ def initialize(series)
18
+ @series = series
19
+ @top = top(series.map { _1[:count] }.max.to_i)
20
+ @failed_top = [series.map { _1[:failed] }.max.to_i, 1].max
21
+ end
22
+
23
+ # @return [Boolean]
24
+ def empty?
25
+ @series.all? { _1[:count].zero? && _1[:failed].zero? }
26
+ end
27
+
28
+ # @return [String] the path of the runs line
29
+ def line
30
+ @series.each_with_index.map { |day, index| "#{index.zero? ? "M" : "L"}#{x(index)} #{y(day[:count])}" }.join(" ")
31
+ end
32
+
33
+ # @return [String] the path of the area under the runs line
34
+ def area
35
+ "#{line} L#{x(@series.size - 1)} #{HEIGHT} L#{x(0)} #{HEIGHT} Z"
36
+ end
37
+
38
+ # @return [String] the path of the failure bars, one per day with failures
39
+ def failures # rubocop:disable Metrics/AbcSize
40
+ width = ((WIDTH / [@series.size, 1].max.to_f) * 0.42).clamp(2, BAR).round(1)
41
+ @series.each_with_index.filter_map do |day, index|
42
+ next if day[:failed].zero?
43
+
44
+ height = [(day[:failed] / @failed_top.to_f) * FAILED, 1].max.round(1)
45
+ "M#{(x(index) - (width / 2)).round(1)} #{HEIGHT} v#{-height} h#{width} v#{height} Z"
46
+ end.join(" ")
47
+ end
48
+
49
+ # @return [String] the path of the horizontal grid lines
50
+ def grid
51
+ (0..TICKS).map { "M0 #{(HEIGHT - (PLOT * _1 / TICKS.to_f)).round(1)} H#{WIDTH}" }.join(" ")
52
+ end
53
+
54
+ # @return [Array<Integer>] the y axis values, top down
55
+ def ticks
56
+ TICKS.downto(0).map { (@top * _1 / TICKS.to_f).round }
57
+ end
58
+
59
+ # @return [Array<String>] the x axis labels, evenly spread
60
+ def labels
61
+ count = [LABELS, @series.size].min
62
+ return [] if count.zero?
63
+
64
+ (0...count).map { label(@series[count == 1 ? 0 : ((@series.size - 1) * _1 / (count - 1).to_f).round][:date]) }
65
+ end
66
+
67
+ # @return [Array<Hash>] per day the hover column +left+/+width+ and the dot +top+, in percent, with the numbers
68
+ def points # rubocop:disable Metrics/AbcSize
69
+ slot = 100.0 / [@series.size, 1].max
70
+ @series.each_with_index.map do |day, index|
71
+ center = @series.size == 1 ? 50.0 : x(index) / WIDTH * 100
72
+ day.merge(left: (center - (slot / 2)).round(2), width: slot.round(2), center: center,
73
+ top: (y(day[:count]) / HEIGHT * 100).round(2), label: label(day[:date]))
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def x(index)
80
+ return WIDTH / 2.0 if @series.size == 1
81
+
82
+ (index * WIDTH / (@series.size - 1).to_f).round(1)
83
+ end
84
+
85
+ def y(value)
86
+ (HEIGHT - (value / @top.to_f * PLOT)).round(1)
87
+ end
88
+
89
+ # The smallest "nice" axis maximum (1, 2 or 5 times a power of ten per tick) at or above +max+.
90
+ def top(max)
91
+ return TICKS if max.zero?
92
+
93
+ raw = max / TICKS.to_f
94
+ magnitude = 10**Math.log10(raw).floor
95
+ step = [1, 2, 5, 10].map { _1 * magnitude }.find { _1 >= raw }
96
+ step * TICKS
97
+ end
98
+
99
+ def label(date)
100
+ "#{date[4, 2]}/#{date[6, 2]}"
101
+ end
102
+ end
103
+ end
104
+ end