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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/Gemfile.lock +3 -3
- data/README.md +104 -9
- data/bin/web-demo +592 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/docs/configuration.md +1 -0
- data/docs/consumers.md +39 -5
- data/docs/getting-started.md +1 -1
- data/docs/images/lepus-web.png +0 -0
- data/docs/middleware.md +1 -1
- data/docs/producers.md +41 -0
- data/docs/testing.md +67 -48
- data/docs/web.md +39 -13
- data/gemfiles/Gemfile.rails-5.2.lock +2 -2
- data/gemfiles/Gemfile.rails-6.1.lock +4 -4
- data/gemfiles/Gemfile.rails-7.2.lock +1 -1
- data/gemfiles/Gemfile.rails-8.0.lock +1 -1
- data/lib/lepus/configuration.rb +6 -0
- data/lib/lepus/consumer.rb +34 -0
- data/lib/lepus/consumers/config.rb +8 -7
- data/lib/lepus/consumers/stats.rb +15 -2
- data/lib/lepus/delay.rb +180 -0
- data/lib/lepus/primitive/string.rb +2 -12
- data/lib/lepus/producer.rb +6 -1
- data/lib/lepus/producers/definition.rb +3 -1
- data/lib/lepus/publisher.rb +29 -3
- data/lib/lepus/testing/rspec_matchers.rb +20 -1
- data/lib/lepus/testing.rb +2 -0
- data/lib/lepus/version.rb +1 -1
- data/lib/lepus/web/api.rb +45 -0
- data/lib/lepus/web/app.rb +19 -16
- data/lib/lepus/web/management_api.rb +125 -2
- data/lib/lepus/web/topology.rb +341 -0
- data/lib/lepus/web.rb +17 -18
- data/lib/lepus.rb +1 -0
- data/web/assets/app.css +862 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +27 -0
- data/web/assets/js/dom.js +95 -0
- data/web/assets/js/format.js +63 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +156 -0
- data/web/assets/js/map.js +606 -0
- data/web/assets/js/model.js +262 -0
- data/web/assets/js/rates.js +161 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/views/connections.js +61 -0
- data/web/assets/js/views/overview.js +229 -0
- data/web/assets/js/views/processes.js +101 -0
- data/web/assets/js/views/queues.js +456 -0
- data/web/index.html +58 -153
- data/web/sw.js +14 -143
- metadata +47 -12
- data/test_offline.html +0 -189
- data/web/assets/css/styles.css +0 -635
- data/web/assets/js/app.js +0 -6
- data/web/assets/js/bootstrap.js +0 -20
- data/web/assets/js/controllers/connection_controller.js +0 -44
- data/web/assets/js/controllers/dashboard_controller.js +0 -499
- data/web/assets/js/controllers/queue_controller.js +0 -17
- data/web/assets/js/controllers/theme_controller.js +0 -31
- data/web/assets/js/offline-manager.js +0 -233
- 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
|
|
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
|
-
#
|
|
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"
|