lepus 0.2.2 → 0.3.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Gemfile.lock +3 -3
  4. data/README.md +104 -9
  5. data/bin/web-demo +592 -0
  6. data/docs/brand/BRAND.md +67 -0
  7. data/docs/brand/apple-touch-icon.png +0 -0
  8. data/docs/brand/favicon-16.png +0 -0
  9. data/docs/brand/favicon-32.png +0 -0
  10. data/docs/brand/favicon.ico +0 -0
  11. data/docs/brand/favicon.svg +1 -0
  12. data/docs/brand/icon-512.png +0 -0
  13. data/docs/brand/lockup-lepus-mono.svg +1 -0
  14. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  15. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  16. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  17. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga.svg +1 -0
  20. data/docs/brand/lockup-lepus.svg +1 -0
  21. data/docs/brand/mark-mono.svg +1 -0
  22. data/docs/brand/mark-on-dark.svg +1 -0
  23. data/docs/brand/mark-on-light.svg +1 -0
  24. data/docs/brand/mark-small-mono.svg +1 -0
  25. data/docs/brand/mark-small-on-dark.svg +1 -0
  26. data/docs/brand/mark-small-on-light.svg +1 -0
  27. data/docs/brand/mark-small.svg +1 -0
  28. data/docs/brand/mark.svg +1 -0
  29. data/docs/configuration.md +1 -0
  30. data/docs/consumers.md +39 -5
  31. data/docs/getting-started.md +1 -1
  32. data/docs/images/lepus-web.png +0 -0
  33. data/docs/middleware.md +1 -1
  34. data/docs/producers.md +41 -0
  35. data/docs/testing.md +67 -48
  36. data/docs/web.md +39 -13
  37. data/gemfiles/Gemfile.rails-5.2.lock +2 -2
  38. data/gemfiles/Gemfile.rails-6.1.lock +4 -4
  39. data/gemfiles/Gemfile.rails-7.2.lock +1 -1
  40. data/gemfiles/Gemfile.rails-8.0.lock +1 -1
  41. data/lib/lepus/configuration.rb +6 -0
  42. data/lib/lepus/consumer.rb +34 -0
  43. data/lib/lepus/consumers/config.rb +8 -7
  44. data/lib/lepus/consumers/stats.rb +15 -2
  45. data/lib/lepus/delay.rb +180 -0
  46. data/lib/lepus/primitive/string.rb +2 -12
  47. data/lib/lepus/producer.rb +6 -1
  48. data/lib/lepus/producers/definition.rb +3 -1
  49. data/lib/lepus/publisher.rb +29 -3
  50. data/lib/lepus/testing/rspec_matchers.rb +20 -1
  51. data/lib/lepus/testing.rb +2 -0
  52. data/lib/lepus/version.rb +1 -1
  53. data/lib/lepus/web/api.rb +45 -0
  54. data/lib/lepus/web/app.rb +19 -16
  55. data/lib/lepus/web/management_api.rb +125 -2
  56. data/lib/lepus/web/topology.rb +341 -0
  57. data/lib/lepus/web.rb +17 -18
  58. data/lib/lepus.rb +1 -0
  59. data/web/assets/app.css +862 -0
  60. data/web/assets/apple-touch-icon.png +0 -0
  61. data/web/assets/favicon-32.png +0 -0
  62. data/web/assets/favicon.svg +1 -0
  63. data/web/assets/fonts/overpass/OFL.txt +93 -0
  64. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  65. data/web/assets/js/api.js +27 -0
  66. data/web/assets/js/dom.js +95 -0
  67. data/web/assets/js/format.js +63 -0
  68. data/web/assets/js/live.js +74 -0
  69. data/web/assets/js/main.js +156 -0
  70. data/web/assets/js/map.js +606 -0
  71. data/web/assets/js/model.js +262 -0
  72. data/web/assets/js/rates.js +161 -0
  73. data/web/assets/js/theme.js +8 -0
  74. data/web/assets/js/views/connections.js +61 -0
  75. data/web/assets/js/views/overview.js +229 -0
  76. data/web/assets/js/views/processes.js +101 -0
  77. data/web/assets/js/views/queues.js +456 -0
  78. data/web/index.html +58 -153
  79. data/web/sw.js +14 -143
  80. metadata +47 -12
  81. data/test_offline.html +0 -189
  82. data/web/assets/css/styles.css +0 -635
  83. data/web/assets/js/app.js +0 -6
  84. data/web/assets/js/bootstrap.js +0 -20
  85. data/web/assets/js/controllers/connection_controller.js +0 -44
  86. data/web/assets/js/controllers/dashboard_controller.js +0 -499
  87. data/web/assets/js/controllers/queue_controller.js +0 -17
  88. data/web/assets/js/controllers/theme_controller.js +0 -31
  89. data/web/assets/js/offline-manager.js +0 -233
  90. data/web/assets/js/service-worker-manager.js +0 -65
data/lib/lepus/web/app.rb CHANGED
@@ -3,35 +3,38 @@
3
3
  module Lepus
4
4
  module Web
5
5
  class App
6
+ CONTENT_SECURITY_POLICY = [
7
+ "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:",
8
+ "font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'",
9
+ "form-action 'self'", "frame-ancestors 'self'"
10
+ ].join("; ").freeze
11
+ REVALIDATE = "no-cache"
12
+
6
13
  def self.build
7
14
  root = Web.assets_path
8
15
 
9
16
  Rack::Builder.new do
10
17
  use Rack::Static,
11
18
  urls: ["/assets", "/sw.js"],
12
- root: root.to_s
19
+ root: root.to_s,
20
+ header_rules: [[:all, {"cache-control" => REVALIDATE}], [%w[woff2], {Rack::CONTENT_TYPE => "font/woff2"}]]
13
21
 
14
22
  map "/api" do
15
23
  run Lepus::Web::API.new
16
24
  end
17
25
 
18
- run lambda { |env|
19
- req = Rack::Request.new(env)
20
- path = req.path_info
21
-
22
- if path == "/" || path == "/index.html"
23
- [200, {"content-type" => "text/html"}, [Web.render_index(env)]]
24
- else
25
- file_path = root.join(path.sub(%r{^/}, ""))
26
- if File.file?(file_path)
27
- [200, {"content-type" => Web.mime_for(file_path)}, [File.binread(file_path)]]
28
- else
29
- [200, {"content-type" => "text/html"}, [Web.render_index(env)]]
30
- end
31
- end
32
- }
26
+ run ->(env) { App.index(env) }
33
27
  end
34
28
  end
29
+
30
+ def self.index(env)
31
+ headers = {
32
+ "content-type" => "text/html",
33
+ "cache-control" => REVALIDATE,
34
+ "content-security-policy" => CONTENT_SECURITY_POLICY
35
+ }
36
+ [200, headers, [Web.render_index(env)]]
37
+ end
35
38
  end
36
39
  end
37
40
  end
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require "time"
6
7
 
7
8
  module Lepus
8
9
  module Web
@@ -10,12 +11,18 @@ module Lepus
10
11
  # Fetches queue and connection statistics.
11
12
  class ManagementAPI
12
13
  DEFAULT_PORT = 15672
14
+ OVERVIEW_RATES = {
15
+ publish: "publish",
16
+ deliver: "deliver_get",
17
+ ack: "ack",
18
+ redeliver: "redeliver"
19
+ }.freeze
13
20
 
14
21
  attr_reader :base_url, :vhost
15
22
 
16
- def initialize(base_url: nil, vhost: "/")
23
+ def initialize(base_url: nil, vhost: nil)
17
24
  @base_url = base_url || derive_management_url
18
- @vhost = vhost
25
+ @vhost = vhost || derive_vhost
19
26
  end
20
27
 
21
28
  # Fetch all queues for the configured vhost
@@ -47,6 +54,36 @@ module Lepus
47
54
  data.map { |c| normalize_connection(c) }
48
55
  end
49
56
 
57
+ # Fetch the bindings of the configured vhost, without the default exchange's implicit ones
58
+ # @return [Array<Hash>] array of binding data
59
+ def bindings
60
+ data = get("/api/bindings/#{encode_vhost}")
61
+ return [] unless data.is_a?(Array)
62
+
63
+ data
64
+ .reject { |b| b["source"].to_s.empty? }
65
+ .map { |b| normalize_binding(b) }
66
+ end
67
+
68
+ # Fetch the consumers subscribed to queues of the configured vhost
69
+ # @return [Array<Hash>] array of consumer data
70
+ def consumers
71
+ data = get("/api/consumers/#{encode_vhost}")
72
+ return [] unless data.is_a?(Array)
73
+
74
+ data.map { |c| normalize_consumer(c) }
75
+ end
76
+
77
+ # Fetch the broker overview, with message rate history when asked
78
+ # @param age [Integer, nil] seconds of history to include
79
+ # @param increment [Integer] seconds between history samples
80
+ # @return [Hash] overview data
81
+ def overview(age: nil, increment: 10)
82
+ path = "/api/overview"
83
+ path += "?msg_rates_age=#{Integer(age)}&msg_rates_incr=#{Integer(increment)}" if age
84
+ normalize_overview(get(path) || {})
85
+ end
86
+
50
87
  # Fetch a specific queue
51
88
  # @param name [String] queue name
52
89
  # @return [Hash, nil] queue data or nil if not found
@@ -65,6 +102,18 @@ module Lepus
65
102
 
66
103
  class NotFoundError < Error; end
67
104
 
105
+ UNREACHABLE = [ConnectionError, SocketError, SystemCallError, IOError, Timeout::Error].freeze
106
+
107
+ # What the dashboard may show about a failure: never the broker's
108
+ # host names, ports or response bodies, which stay in the log.
109
+ def self.client_message(error)
110
+ case error
111
+ when AuthenticationError then "The RabbitMQ management API rejected the credentials"
112
+ when *UNREACHABLE then "The RabbitMQ management API could not be reached"
113
+ else "The RabbitMQ management API returned an error"
114
+ end
115
+ end
116
+
68
117
  private
69
118
 
70
119
  def parse_rabbitmq_uri
@@ -80,6 +129,11 @@ module Lepus
80
129
  "http://#{uri.host}:#{DEFAULT_PORT}"
81
130
  end
82
131
 
132
+ def derive_vhost
133
+ path = parse_rabbitmq_uri&.path.to_s.delete_prefix("/")
134
+ path.empty? ? "/" : URI::DEFAULT_PARSER.unescape(path)
135
+ end
136
+
83
137
  def credentials
84
138
  uri = parse_rabbitmq_uri
85
139
  return unless uri&.user
@@ -134,6 +188,15 @@ module Lepus
134
188
  messages_unacknowledged: q["messages_unacknowledged"] || 0,
135
189
  consumers: q["consumers"] || 0,
136
190
  memory: q["memory"] || 0,
191
+ state: q["state"],
192
+ durable: q["durable"],
193
+ exclusive: q["exclusive"] || false,
194
+ auto_delete: q["auto_delete"] || false,
195
+ arguments: q["arguments"] || {},
196
+ idle_since: q["idle_since"],
197
+ consumer_utilisation: q["consumer_utilisation"],
198
+ messages_rate: q.dig("messages_details", "rate") || 0.0,
199
+ messages_ready_rate: q.dig("messages_ready_details", "rate") || 0.0,
137
200
  message_stats: normalize_message_stats(q["message_stats"])
138
201
  }
139
202
  end
@@ -153,6 +216,66 @@ module Lepus
153
216
  }
154
217
  end
155
218
 
219
+ def normalize_binding(b)
220
+ {
221
+ source: b["source"],
222
+ destination: b["destination"],
223
+ destination_type: b["destination_type"],
224
+ routing_key: b["routing_key"].to_s,
225
+ arguments: b["arguments"] || {}
226
+ }
227
+ end
228
+
229
+ def normalize_consumer(c)
230
+ channel = c["channel_details"] || {}
231
+ {
232
+ queue: c.dig("queue", "name"),
233
+ consumer_tag: c["consumer_tag"],
234
+ connection_name: channel["connection_name"],
235
+ channel: channel["name"],
236
+ prefetch_count: c["prefetch_count"] || 0,
237
+ ack_required: c["ack_required"],
238
+ active: c.fetch("active", true)
239
+ }
240
+ end
241
+
242
+ def normalize_overview(o)
243
+ stats = o["message_stats"] || {}
244
+ totals = o["queue_totals"] || {}
245
+ {
246
+ rabbitmq_version: o["rabbitmq_version"],
247
+ cluster_name: o["cluster_name"],
248
+ rates: OVERVIEW_RATES.transform_values { |key| stats.dig("#{key}_details", "rate") || 0.0 },
249
+ queue_totals: {
250
+ messages: totals["messages"] || 0,
251
+ messages_ready: totals["messages_ready"] || 0,
252
+ messages_unacknowledged: totals["messages_unacknowledged"] || 0
253
+ },
254
+ object_totals: (o["object_totals"] || {}).transform_keys(&:to_sym),
255
+ history: rate_history(stats)
256
+ }
257
+ end
258
+
259
+ # Turns the cumulative samples RabbitMQ keeps per counter into
260
+ # per-second rates, one row per sample interval, oldest first.
261
+ def rate_history(stats)
262
+ series = OVERVIEW_RATES.transform_values do |key|
263
+ (stats.dig("#{key}_details", "samples") || []).map { |s| [s["timestamp"], s["sample"]] }.sort_by(&:first)
264
+ end
265
+ times = series.values.flat_map { |samples| samples.map(&:first) }.uniq.sort
266
+ return [] if times.size < 2
267
+
268
+ counts = series.transform_values(&:to_h)
269
+ times.each_cons(2).map do |from, to|
270
+ seconds = (to - from) / 1000.0
271
+ row = {t: Time.at(to / 1000.0).utc.iso8601}
272
+ counts.each do |name, by_time|
273
+ row[name] = (by_time[from] && by_time[to]) ? [(by_time[to] - by_time[from]) / seconds, 0].max.round(2) : 0.0
274
+ end
275
+ row
276
+ end
277
+ end
278
+
156
279
  def normalize_exchange(e)
157
280
  {
158
281
  name: e["name"],
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "time"
5
+
6
+ module Lepus
7
+ module Web
8
+ # Assembles the broker topology drawn by the dashboard map: exchanges, the
9
+ # bindings that route them to queues, every queue classified by its role
10
+ # (main, retry, error or delay) and the consumers reading each queue,
11
+ # merged from the management API and worker heartbeats.
12
+ #
13
+ # Delay queues are the ones Lepus::Delay declares: "<queue>.delay.<ms>"
14
+ # dead-letters into <queue> and "<exchange>.delayed.<ms>" into
15
+ # <exchange>, each holding its messages for exactly <ms>. Messages
16
+ # waiting in them are counted as delayed on the queue or exchange they
17
+ # are released to.
18
+ class Topology
19
+ CONSUMER_TAG_INDEX = /-\d+\z/
20
+ COUNTERS = %i[processed rejected errored delayed].freeze
21
+
22
+ def initialize(management_api:, aggregator:, show_all_exchanges: false)
23
+ @management_api = management_api
24
+ @aggregator = aggregator
25
+ @show_all_exchanges = show_all_exchanges
26
+ end
27
+
28
+ def to_h
29
+ broker = fetch_broker
30
+ processes = @aggregator&.running? ? @aggregator.all_processes : []
31
+ heartbeat = heartbeat_consumers(processes)
32
+ broker = broker.merge(declared_topology(heartbeat)) unless broker[:management]
33
+ queues = count_delayed(classify_queues(broker[:queues], heartbeat, broker[:bindings]))
34
+ exchanges = exchange_rows(broker[:exchanges], queues)
35
+ bindings = group_bindings(broker[:bindings])
36
+ exchanges, bindings, queues = filter(exchanges, bindings, queues, heartbeat) unless @show_all_exchanges
37
+
38
+ {
39
+ management: broker[:management],
40
+ management_error: broker[:error],
41
+ generated_at: Time.now.utc.iso8601(3),
42
+ exchanges: exchanges,
43
+ bindings: bindings,
44
+ queues: queues,
45
+ consumers: merge_consumers(heartbeat, broker[:consumers])
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def fetch_broker
52
+ empty = {queues: [], exchanges: [], bindings: [], consumers: []}
53
+ return empty.merge(management: false, error: "The RabbitMQ management API is not configured") unless @management_api
54
+
55
+ empty.merge(
56
+ management: true,
57
+ error: nil,
58
+ queues: @management_api.queues,
59
+ exchanges: @management_api.exchanges,
60
+ bindings: @management_api.bindings,
61
+ consumers: @management_api.consumers
62
+ )
63
+ rescue => e
64
+ Lepus.logger.warn("[Web::Topology] Failed to fetch the topology: #{e.message}")
65
+ empty.merge(management: false, error: ManagementAPI.client_message(e))
66
+ end
67
+
68
+ # Without the management API the map still draws what consumers
69
+ # declared, from their heartbeats, with no counts or rates.
70
+ def declared_topology(heartbeat)
71
+ consumers = heartbeat.map(&:last).select { |c| c[:exchange] && c[:queue] }
72
+ {
73
+ exchanges: consumers.map { |c| {name: c[:exchange], type: nil, message_stats: {}} }.uniq,
74
+ bindings: consumers.flat_map do |c|
75
+ Array(c[:route] || "").map { |key| {source: c[:exchange], destination: c[:queue], destination_type: "queue", routing_key: key} }
76
+ end.uniq,
77
+ queues: consumers.map { |c| {name: c[:queue], arguments: {}, message_stats: {}} }.uniq
78
+ }
79
+ end
80
+
81
+ def heartbeat_consumers(processes)
82
+ processes.flat_map do |process|
83
+ (process[:consumers] || []).map { |consumer| [process, consumer] }
84
+ end
85
+ end
86
+
87
+ def classify_queues(raw, heartbeat, bindings)
88
+ queues = raw.reject { |q| q[:exclusive] && q[:name].to_s.start_with?("amq.") }
89
+ by_name = queues.to_h { |q| [q[:name], q] }
90
+ context = {
91
+ by_name: by_name,
92
+ declared: declared_children(heartbeat),
93
+ consumed: heartbeat.map { |(_process, consumer)| consumer[:queue] }.compact.to_set,
94
+ anchored: bindings.select { |b| b[:destination_type] == "queue" && !b[:source].to_s.empty? }.map { |b| b[:destination] }.to_set
95
+ }
96
+ context[:dead_letter_targets] = dead_letter_targets(queues, by_name)
97
+ applications = heartbeat.each_with_object({}) { |(process, c), map| map[c[:queue]] ||= process[:application] }
98
+
99
+ queues.map do |queue|
100
+ delay = delay_of(queue)
101
+ role, parent, parent_exchange = delay ? delay_role(delay) : classify(queue, context)
102
+ queue_row(queue, role, parent, parent_exchange, applications[queue[:name]], delay)
103
+ end
104
+ end
105
+
106
+ # Where a queue declared by Lepus::Delay releases its messages, read
107
+ # from its name and arguments, or nil for any other queue.
108
+ def delay_of(queue)
109
+ args = queue[:arguments] || {}
110
+ ms = args["x-message-ttl"]
111
+ exchange = args["x-dead-letter-exchange"]
112
+ return unless ms.is_a?(Integer) && ms.positive? && exchange
113
+
114
+ if exchange == Delay::DEFAULT_EXCHANGE
115
+ target = args["x-dead-letter-routing-key"].to_s
116
+ {ms: ms, target: target, target_type: "queue"} if queue[:name] == Delay.queue_delay_name_for(target, ms)
117
+ elsif !args.key?("x-dead-letter-routing-key") && queue[:name] == Delay.exchange_queue_name_for(exchange, ms)
118
+ {ms: ms, target: exchange, target_type: "exchange"}
119
+ end
120
+ end
121
+
122
+ def delay_role(delay)
123
+ (delay[:target_type] == "queue") ? ["delay", delay[:target], nil] : ["delay", nil, delay[:target]]
124
+ end
125
+
126
+ def count_delayed(queues)
127
+ waiting = delayed_by_target(queues, "queue")
128
+ queues.each do |queue|
129
+ held = waiting[queue[:name]]
130
+ queue[:delayed] = held ? held[:messages] : 0
131
+ queue[:delays] = held ? held[:delays] : []
132
+ end
133
+ end
134
+
135
+ def delayed_by_target(queues, type)
136
+ queues.select { |q| q[:target_type] == type }.group_by { |q| q[:target] }.transform_values do |list|
137
+ {messages: list.sum { |q| q[:messages] }, delays: list.map { |q| q[:delay_ms] }.uniq.sort}
138
+ end
139
+ end
140
+
141
+ def declared_children(heartbeat)
142
+ heartbeat.each_with_object({}) do |(_process, consumer), map|
143
+ map[consumer[:retry_queue]] = ["retry", consumer[:queue]] if consumer[:retry_queue]
144
+ map[consumer[:error_queue]] = ["error", consumer[:queue]] if consumer[:error_queue]
145
+ end
146
+ end
147
+
148
+ def dead_letter_target(queue, by_name)
149
+ args = queue[:arguments] || {}
150
+ target = args["x-dead-letter-routing-key"]
151
+ target if args["x-dead-letter-exchange"] == "" && target != queue[:name] && by_name.key?(target)
152
+ end
153
+
154
+ # A TTL queue that dead-letters into a queue which dead-letters back,
155
+ # or one named "<queue>.retry", holds messages waiting to be retried.
156
+ # Delay queues never reach here.
157
+ def retry_loop?(queue, by_name)
158
+ target = dead_letter_target(queue, by_name)
159
+ return false if target.nil? || (queue[:arguments] || {})["x-message-ttl"].nil?
160
+
161
+ queue[:name] == "#{target}.retry" || dead_letter_target(by_name[target], by_name) == queue[:name]
162
+ end
163
+
164
+ # Queues another queue dead-letters into, other than through a retry or
165
+ # delay round trip, collect what was given up on: error queues.
166
+ def dead_letter_targets(queues, by_name)
167
+ queues.each_with_object({}) do |queue, map|
168
+ target = dead_letter_target(queue, by_name)
169
+ next unless target
170
+ next if retry_loop?(queue, by_name) || delay_of(queue)
171
+
172
+ map[target] ||= queue[:name]
173
+ end
174
+ end
175
+
176
+ def classify(queue, context)
177
+ name = queue[:name]
178
+
179
+ return [*context[:declared][name], nil] if context[:declared][name]
180
+ return ["main", nil, nil] if context[:consumed].include?(name) || context[:anchored].include?(name)
181
+ return ["retry", dead_letter_target(queue, context[:by_name]), nil] if retry_loop?(queue, context[:by_name])
182
+ return ["error", context[:dead_letter_targets][name], nil] if context[:dead_letter_targets].key?(name)
183
+
184
+ suffix_role(name, context[:by_name])
185
+ end
186
+
187
+ def suffix_role(name, by_name)
188
+ %w[retry error].each do |role|
189
+ base = name.chomp(".#{role}")
190
+ return [role, base, nil] if base != name && by_name.key?(base)
191
+ end
192
+ ["main", nil, nil]
193
+ end
194
+
195
+ def queue_row(queue, role, parent, parent_exchange, application, delay)
196
+ stats = queue[:message_stats] || {}
197
+ args = queue[:arguments] || {}
198
+ {
199
+ name: queue[:name],
200
+ role: role,
201
+ parent: parent,
202
+ parent_exchange: parent_exchange,
203
+ type: queue[:type],
204
+ state: queue[:state],
205
+ messages: queue[:messages] || 0,
206
+ ready: queue[:messages_ready] || 0,
207
+ unacked: queue[:messages_unacknowledged] || 0,
208
+ consumers: queue[:consumers] || 0,
209
+ utilisation: queue[:consumer_utilisation],
210
+ publish_rate: rate(stats[:publish_rate]),
211
+ deliver_rate: rate(stats[:deliver_get_rate]),
212
+ ack_rate: rate(stats[:ack_rate]),
213
+ redeliver_rate: rate(stats[:redeliver_rate]),
214
+ backlog_rate: rate(queue[:messages_ready_rate]),
215
+ ttl: args["x-message-ttl"],
216
+ dead_letter_exchange: args["x-dead-letter-exchange"],
217
+ dead_letter_routing_key: args["x-dead-letter-routing-key"],
218
+ expires: args["x-expires"],
219
+ delay_ms: delay&.dig(:ms),
220
+ target: delay&.dig(:target),
221
+ target_type: delay&.dig(:target_type),
222
+ idle_since: queue[:idle_since],
223
+ application: application
224
+ }
225
+ end
226
+
227
+ # Each exchange with what waits in its delay queues, and the
228
+ # "<exchange>.delayed" headers exchanges that feed them marked with the
229
+ # exchange they release to.
230
+ def exchange_rows(exchanges, queues)
231
+ waiting = delayed_by_target(queues, "exchange")
232
+ names = exchanges.map { |e| e[:name] }.to_set
233
+ exchanges.map do |exchange|
234
+ held = waiting[exchange[:name]]
235
+ releases_to = delay_front_of(exchange, names, waiting)
236
+ exchange_row(exchange).merge(
237
+ role: releases_to ? "delay" : nil,
238
+ releases_to: releases_to,
239
+ delayed: held ? held[:messages] : 0,
240
+ delays: held ? held[:delays] : []
241
+ )
242
+ end
243
+ end
244
+
245
+ def delay_front_of(exchange, names, waiting)
246
+ base = exchange[:name].to_s.delete_suffix(".delayed")
247
+ return if base == exchange[:name] || Delay.exchange_name_for(base) != exchange[:name]
248
+
249
+ base if waiting.key?(base) || (exchange[:type] == "headers" && names.include?(base))
250
+ end
251
+
252
+ def exchange_row(exchange)
253
+ stats = exchange[:message_stats] || {}
254
+ {
255
+ name: exchange[:name],
256
+ type: exchange[:type],
257
+ durable: exchange[:durable],
258
+ publish_in_rate: rate(stats[:publish_in_rate]),
259
+ publish_out_rate: rate(stats[:publish_out_rate])
260
+ }
261
+ end
262
+
263
+ def group_bindings(bindings)
264
+ grouped = bindings.group_by { |b| [b[:source], b[:destination], b[:destination_type]] }
265
+ grouped.map do |(source, destination, type), list|
266
+ {
267
+ source: source,
268
+ destination: destination,
269
+ destination_type: type,
270
+ routing_keys: list.map { |b| b[:routing_key] }.reject(&:empty?).uniq,
271
+ delays: list.filter_map { |b| (b[:arguments] || {})[Delay::HEADER] }.uniq.sort
272
+ }
273
+ end
274
+ end
275
+
276
+ # Keeps what the running consumers declared: their exchanges (and the
277
+ # delayed exchanges in front of them), the queues those route to and
278
+ # the retry, error and delay queues hanging off them. Without consumer
279
+ # heartbeats nothing is known, so everything stays.
280
+ def filter(exchanges, bindings, queues, heartbeat)
281
+ known = heartbeat.map { |(_process, consumer)| consumer[:exchange] }.compact.to_set
282
+ return [exchanges, bindings, queues] if known.empty?
283
+
284
+ kept_exchanges = exchanges.select { |e| known.include?(e[:name]) || known.include?(e[:releases_to]) }
285
+ exchange_names = kept_exchanges.map { |e| e[:name] }.to_set
286
+ kept_bindings = bindings.select { |b| exchange_names.include?(b[:source]) }
287
+ queue_names = kept_bindings.select { |b| b[:destination_type] == "queue" }.map { |b| b[:destination] }.to_set
288
+ heartbeat.each { |(_process, consumer)| queue_names << consumer[:queue] if consumer[:queue] }
289
+ kept_queues = queues.select do |q|
290
+ queue_names.include?(q[:name]) || queue_names.include?(q[:parent]) || exchange_names.include?(q[:parent_exchange])
291
+ end
292
+ [kept_exchanges, kept_bindings, kept_queues]
293
+ end
294
+
295
+ def merge_consumers(heartbeat, subscriptions)
296
+ merged = {}
297
+ heartbeat.each do |(process, consumer)|
298
+ entry = merged[[consumer[:queue], consumer[:class_name]]] ||= consumer_entry(consumer[:queue], consumer[:class_name], nil)
299
+ entry[:reporting] = true
300
+ entry[:application] ||= process[:application]
301
+ entry[:processes] << {id: process[:id], name: process[:name], hostname: process[:hostname], pid: process[:pid]}
302
+ entry[:threads] += consumer[:threads].to_i
303
+ entry[:delays] |= Array(consumer[:delays])
304
+ COUNTERS.each { |counter| entry[counter] += consumer[counter].to_i }
305
+ end
306
+ subscriptions.each do |subscription|
307
+ class_name = subscription[:consumer_tag].to_s.sub(CONSUMER_TAG_INDEX, "")
308
+ key = [subscription[:queue], class_name]
309
+ key = [subscription[:queue], subscription[:connection_name]] unless merged.key?(key) && merged[key][:reporting]
310
+ entry = merged[key] ||= consumer_entry(subscription[:queue], nil, subscription[:connection_name])
311
+ entry[:channels] += 1
312
+ entry[:prefetch] = [entry[:prefetch], subscription[:prefetch_count].to_i].max
313
+ end
314
+ merged.values
315
+ end
316
+
317
+ def consumer_entry(queue, class_name, connection_name)
318
+ {
319
+ queue: queue,
320
+ class_name: class_name,
321
+ connection_name: connection_name,
322
+ application: nil,
323
+ reporting: false,
324
+ processes: [],
325
+ threads: 0,
326
+ channels: 0,
327
+ prefetch: 0,
328
+ processed: 0,
329
+ rejected: 0,
330
+ errored: 0,
331
+ delayed: 0,
332
+ delays: []
333
+ }
334
+ end
335
+
336
+ def rate(value)
337
+ value.to_f.round(2)
338
+ end
339
+ end
340
+ end
341
+ end
data/lib/lepus/web.rb CHANGED
@@ -24,11 +24,13 @@ module Lepus
24
24
  end
25
25
 
26
26
  # Web-specific consumer extensions.
27
- # Tracks whether the last delivery resulted in an error (exception),
28
- # allowing stats to distinguish explicit rejections from error rejections.
27
+ # Tracks whether the last delivery resulted in an error (exception) or was
28
+ # handed back to the queue later with delay!, allowing stats to tell
29
+ # explicit rejections from error rejections and delays from acks.
29
30
  module ConsumerExtensions
30
31
  def process_delivery(delivery_info, metadata, payload)
31
32
  @_last_delivery_errored = false
33
+ @_last_delivery_delayed = false
32
34
  super
33
35
  end
34
36
 
@@ -36,6 +38,17 @@ module Lepus
36
38
  @_last_delivery_errored == true
37
39
  end
38
40
 
41
+ def last_delivery_delayed?
42
+ @_last_delivery_delayed == true
43
+ end
44
+
45
+ protected
46
+
47
+ def delay!(value)
48
+ super.tap { @_last_delivery_delayed = true }
49
+ end
50
+ alias_method :delay, :delay!
51
+
39
52
  private
40
53
 
41
54
  def on_delivery_error
@@ -60,7 +73,7 @@ module Lepus
60
73
 
61
74
  case result
62
75
  when :ack
63
- stats.record_processed
76
+ consumer.last_delivery_delayed? ? stats.record_delayed : stats.record_processed
64
77
  when :reject, :nack, :requeue
65
78
  if consumer.last_delivery_errored?
66
79
  stats.record_errored
@@ -190,21 +203,6 @@ module Lepus
190
203
  "#{script_name}/"
191
204
  end
192
205
 
193
- def self.mime_for(path)
194
- case File.extname(path)
195
- when ".html" then "text/html"
196
- when ".css" then "text/css"
197
- when ".js" then "application/javascript"
198
- when ".png" then "image/png"
199
- when ".jpg", ".jpeg" then "image/jpeg"
200
- when ".svg" then "image/svg+xml"
201
- when ".woff", ".woff2" then "font/woff"
202
- when ".ttf" then "font/ttf"
203
- when ".eot" then "application/vnd.ms-fontobject"
204
- else "application/octet-stream"
205
- end
206
- end
207
-
208
206
  # Make the Web module directly mountable as a Rack application.
209
207
  #
210
208
  # Mounting via `mount Lepus::Web => "/lepus"` in routes.rb does not call
@@ -234,5 +232,6 @@ end
234
232
  require_relative "web/respond_with"
235
233
  require_relative "web/management_api"
236
234
  require_relative "web/aggregator"
235
+ require_relative "web/topology"
237
236
  require_relative "web/api"
238
237
  require_relative "web/app"
data/lib/lepus.rb CHANGED
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "logger"
7
7
  require "pathname"
8
8
  require "securerandom"
9
+ require "set"
9
10
  require "singleton"
10
11
  require "socket"
11
12
  require "time"