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
@@ -7,123 +7,114 @@ module Cosmo
7
7
  module Controllers
8
8
  class Jobs < Application
9
9
  def index
10
- content_for :title, "Jobs"
11
- processes_query = params.slice("page", "limit").transform_values(&:to_i).select { _2.positive? }
12
- ok render("jobs/index", { processes_query: }, layout: true)
10
+ tab("busy")
13
11
  end
14
12
 
15
13
  def busy
16
- return _busy if hx_request?
17
-
18
- content_for :title, "Busy Jobs"
19
- ok render("jobs/busy", layout: true)
14
+ tab("busy")
20
15
  end
21
16
 
22
17
  def enqueued
23
- return _enqueued if hx_request?
24
-
25
- content_for :title, "Enqueued Jobs"
26
- stream_name, _stream_names = streams
27
- ok render("jobs/enqueued", { stream_name: }, layout: true)
18
+ tab("enqueued")
28
19
  end
29
20
 
30
21
  def scheduled
31
22
  return not_found unless Config.scheduled.enabled
32
23
 
33
- return _scheduled if hx_request?
34
-
35
- content_for :title, "Scheduled Jobs"
36
- ok render("jobs/scheduled", layout: true)
24
+ tab("scheduled")
37
25
  end
38
26
 
39
27
  def dead
40
28
  return not_found unless Config.dead.enabled
41
29
 
42
- return _dead if hx_request?
30
+ tab("dead")
31
+ end
43
32
 
44
- content_for :title, "Dead Jobs"
45
- ok render("jobs/dead", layout: true)
33
+ def _queues
34
+ ok render("jobs/_queues", { queues: API::Stats.queues, compact: params["compact"].to_s == "1" })
46
35
  end
47
36
 
48
37
  def retry
49
38
  return not_found unless Config.dead.enabled
50
39
 
51
40
  seq = path.split("/").last.to_i
52
- stream = API::Stream.new("dead")
53
- stream.retry(seq)
54
- ok
41
+ API::Stream.new("dead").retry(seq)
42
+ ok flash("Dead job ##{seq} sent back to its queue")
55
43
  end
56
44
 
57
45
  def delete
58
46
  return not_found unless Config.dead.enabled
59
47
 
60
48
  seq = path.split("/").last.to_i
61
- stream = API::Stream.new("dead")
62
- stream.delete(seq)
63
- ok
49
+ API::Stream.new("dead").delete(seq)
50
+ ok flash("Dead job ##{seq} deleted")
64
51
  end
65
52
 
66
53
  def delete_enqueued
67
54
  seq = path.split("/").last.to_i
68
55
  stream_name, = streams
69
56
  API::Stream.new(stream_name).delete(seq)
70
- _enqueued
57
+ ok panel("enqueued") + flash("Message ##{seq} removed from #{stream_name}")
71
58
  end
72
59
 
73
- def _scheduled
74
- return not_found unless Config.scheduled.enabled
60
+ private
75
61
 
76
- stream = API::Stream.new("scheduled")
77
- jobs = stream.messages(page: params["page"], limit: params["limit"])
78
- ok render("jobs/_scheduled", { jobs: jobs, total: stream.total })
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)
79
68
  end
80
69
 
81
- def _dead
82
- return not_found unless Config.dead.enabled
70
+ def panel(name)
71
+ render("jobs/_panel", send("#{name}_locals").merge(tab: name, counts: counts))
72
+ end
83
73
 
84
- stream = API::Stream.new("dead")
85
- jobs = stream.messages(page: params["page"], limit: params["limit"])
86
- 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
87
79
  end
88
80
 
89
- def _busy
81
+ def busy_locals
90
82
  busy = API::Stats::Busy.instance
91
83
  total = busy.size
92
84
  page, limit, total_pages = paginate(total, API::Stats::Busy::LIMIT)
93
85
  polling = params["poll"].to_s != "0"
94
- ok render("jobs/_busy", { jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
95
- end
96
-
97
- def _processes
98
- processes = API::Stats::Processes.instance
99
- total = processes.size
100
- page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
101
- polling = params["poll"].to_s != "0"
102
- groups = processes.list(page:, limit:).chunk { API::Stats::Processes.kind(_1) }.to_a
103
- ok render("jobs/_processes", { groups:, 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 }
104
87
  end
105
88
 
106
- def _enqueued # rubocop:disable Metrics/AbcSize
89
+ def enqueued_locals # rubocop:disable Metrics/AbcSize
107
90
  stream_name, stream_names = streams
108
91
  limit = (params["limit"] || API::Stream::LIMIT).to_i
109
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?
110
96
 
111
- unless stream_name.to_s.empty?
112
- stream = API::Stream.new(stream_name)
113
- total = stream.total
114
- total_pages = (total.to_f / limit).ceil
115
- page = page.clamp(1, [total_pages, 1].max)
116
- jobs = stream.messages(page:, limit:)
117
- 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
118
103
 
119
- 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)
120
106
  end
121
107
 
122
- def _stats
123
- ok render("jobs/_stats", API::Stats.summary)
108
+ def dead_locals
109
+ stored_locals("dead").merge(polling: false)
124
110
  end
125
111
 
126
- 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
127
118
 
128
119
  def paginate(total, default_limit)
129
120
  limit = (params["limit"] || default_limit).to_i
@@ -13,6 +13,7 @@ module Cosmo
13
13
  return not_found unless Config.metrics.enabled
14
14
 
15
15
  content_for :title, "Metrics"
16
+ content_for :subtitle, "Runs, failures, execution and wait time per job class."
16
17
  ok render("metrics/index", { days:, ranges: RANGES }, layout: true)
17
18
  end
18
19
 
@@ -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
@@ -78,6 +78,63 @@ module Cosmo
78
78
  "#{(diff / 86_400).to_i}d"
79
79
  end
80
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
+
81
138
  def h(value)
82
139
  Rack::Utils.escape_html(value.to_s)
83
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