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/bin/web-demo ADDED
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Serves the dashboard on a simulated broker that keeps moving, for working on
5
+ # the UI without RabbitMQ or workers.
6
+ #
7
+ # bin/web-demo # http://127.0.0.1:9393/
8
+ # PORT=4000 HOST=0.0.0.0 bin/web-demo
9
+ # HEAVY=1 bin/web-demo # ~70 queues on 12 exchanges, 20 workers, deep backlogs
10
+ # MOUNT=/lepus bin/web-demo # served under a sub-path, as `mount Lepus::Web => "/lepus"` does
11
+
12
+ require "bundler/setup"
13
+ require "logger"
14
+ require "stringio"
15
+ require "securerandom"
16
+ require "webrick"
17
+ require "lepus"
18
+ require "lepus/web"
19
+
20
+ module WebDemo
21
+ CYCLE = 180
22
+ HISTORY = 3600
23
+ VERSION_LABEL = "Lepus (#{Lepus::VERSION})"
24
+ RATES = %i[publish deliver ack redeliver].freeze
25
+
26
+ UNUSED_AFTER = 45
27
+
28
+ Exchange = Struct.new(:name, :type, keyword_init: true)
29
+
30
+ # A queue Lepus::Delay declares: "<queue>.delay.<ms>" for a queue delay
31
+ # (published through the default exchange, or by a consumer's delay!) or
32
+ # "<exchange>.delayed.<ms>" behind the "<exchange>.delayed" headers
33
+ # exchange. Each message waits ms, then goes to the target line. When
34
+ # nothing has used it for a while it expires, as x-expires does.
35
+ class DelayQueue
36
+ attr_reader :ms, :target, :exchange, :source, :held, :in_rate, :out_rate
37
+
38
+ def initialize(ms:, target:, exchange: nil, source: :producer, rate: 0.0, share: 0.0, window: nil)
39
+ @ms = ms
40
+ @target = target
41
+ @exchange = exchange
42
+ @source = source
43
+ @rate = rate
44
+ @share = share
45
+ @window = window
46
+ @held = 0.0
47
+ @in_rate = 0.0
48
+ @out_rate = 0.0
49
+ @used_at = nil
50
+ end
51
+
52
+ def name
53
+ exchange ? Lepus::Delay.exchange_queue_name_for(exchange, ms) : Lepus::Delay.queue_delay_name_for(target.name, ms)
54
+ end
55
+
56
+ def arguments
57
+ exchange ? Lepus::Delay.queue_args_for_exchange(exchange, ms) : Lepus::Delay.queue_args_for_queue(target.name, ms)
58
+ end
59
+
60
+ def present?(second)
61
+ !@used_at.nil? && (@held >= 0.5 || second - @used_at < UNUSED_AFTER)
62
+ end
63
+
64
+ def step(second, dt, rng, failing:)
65
+ wave = 1 + 0.25 * Math.sin(second / 37.0 + ms % 11) + (rng.rand - 0.5) * 0.2
66
+ open = @window.nil? || @window.cover?(second % CYCLE)
67
+ @in_rate = if !open
68
+ 0.0
69
+ elsif source == :consumer
70
+ target.rate(:deliver) * (failing ? @share * 6 : @share) * wave
71
+ else
72
+ @rate * wave
73
+ end
74
+ released = @held * [dt * 1000.0 / ms, 1].min
75
+ @held = [@held + @in_rate * dt - released, 0.0].max
76
+ @out_rate = released / dt
77
+ @used_at = second if @in_rate.positive?
78
+ target.counters[:delayed] += @in_rate * dt if source == :consumer
79
+ end
80
+ end
81
+
82
+ # One main queue with its consumer, and the counters the simulation moves.
83
+ class Line
84
+ attr_reader :name, :exchange, :keys, :consumer, :worker, :base, :retry_ttl, :error_queue, :threads
85
+ attr_accessor :ready, :unacked, :retrying, :errors, :rates, :counters, :backlog_rate, :channels
86
+
87
+ def initialize(name:, exchange:, keys: [], consumer: nil, worker: nil, base: 1.0, retry_ttl: 5000, error_queue: true, threads: 2, ready: 0, idle: false, capacity: 1.15, drain: 0.4)
88
+ @name = name
89
+ @exchange = exchange
90
+ @keys = keys
91
+ @consumer = consumer
92
+ @worker = worker
93
+ @base = idle ? 0.0 : base
94
+ @retry_ttl = retry_ttl
95
+ @error_queue = error_queue
96
+ @threads = threads
97
+ @capacity = capacity
98
+ @drain = drain
99
+ @ready = ready.to_f
100
+ @unacked = 0
101
+ @retrying = 0.0
102
+ @errors = 0.0
103
+ @backlog_rate = 0.0
104
+ @channels = consumer ? threads : 0
105
+ @rates = Hash.new(0.0)
106
+ @counters = Hash.new(0.0)
107
+ @phase = name.sum % 17
108
+ @period = 40 + name.sum % 50
109
+ end
110
+
111
+ def retry?
112
+ retry_ttl ? true : false
113
+ end
114
+
115
+ def step(t, dt, rng, slow:, failing:, released: 0.0)
116
+ wave = 1 + 0.3 * Math.sin(t / @period.to_f + @phase) + (rng.rand - 0.5) * 0.16
117
+ publish = @base * wave + released
118
+ returning = retry? ? @retrying * [dt * 1000.0 / retry_ttl, 1].min : 0.0
119
+ @retrying -= returning
120
+ capacity = channels.zero? ? 0.0 : (@base + released) * capacity_share(slow)
121
+ available = @ready + (publish + returning / dt) * dt
122
+ deliver = [capacity * dt, available].min / dt
123
+ reject_share = failing ? 0.3 : 0.004
124
+ rejected = deliver * reject_share
125
+ error_share = failing ? 0.35 : 0.02
126
+ to_error = error_queue ? rejected * error_share : 0.0
127
+ to_retry = retry? ? rejected - to_error : 0.0
128
+ previous = @ready
129
+ @ready = [@ready + (publish + returning / dt - deliver) * dt, 0.0].max
130
+ @retrying += to_retry * dt
131
+ @errors += to_error * dt
132
+ @backlog_rate = @backlog_rate * 0.6 + ((@ready - previous) / dt) * 0.4
133
+ @unacked = [(deliver * 0.2).round, channels * 10].min
134
+ @rates = {publish: publish + returning / dt, deliver: deliver, ack: deliver - rejected, redeliver: returning / dt, retry_in: to_retry, error_in: to_error, released: released}
135
+ @counters[:publish] += publish * dt
136
+ @counters[:processed] += (deliver - rejected) * dt
137
+ @counters[:rejected] += to_retry * dt
138
+ @counters[:errored] += to_error * dt
139
+ end
140
+
141
+ def rate(key)
142
+ @rates.fetch(key, 0.0)
143
+ end
144
+
145
+ private
146
+
147
+ def capacity_share(slow)
148
+ return 0.1 if slow
149
+
150
+ @capacity + ((@ready > 0) ? @drain : 0)
151
+ end
152
+ end
153
+
154
+ # The broker and the workers reading it, advanced once a second.
155
+ class World
156
+ attr_reader :exchanges, :lines, :delays, :supervisors, :workers, :history, :started_at, :second
157
+
158
+ def initialize(heavy: false, seed: 7)
159
+ @rng = Random.new(seed)
160
+ @mutex = Mutex.new
161
+ @history = []
162
+ @exchanges = []
163
+ @lines = []
164
+ @delays = []
165
+ heavy ? build_heavy : build_normal
166
+ build_processes
167
+ @started_at = Time.now - HISTORY
168
+ HISTORY.times { |second| advance(second, 1.0, record_at: @started_at + second) }
169
+ @clock = HISTORY
170
+ end
171
+
172
+ def start
173
+ Thread.new do
174
+ loop do
175
+ sleep 1
176
+ synchronize do
177
+ advance(@clock, 1.0, record_at: Time.now)
178
+ @clock += 1
179
+ end
180
+ end
181
+ end
182
+ self
183
+ end
184
+
185
+ def synchronize(&block)
186
+ @mutex.synchronize(&block)
187
+ end
188
+
189
+ private
190
+
191
+ def build_normal
192
+ add_exchange("orders", :topic)
193
+ add_exchange("invoices", :direct)
194
+ add_exchange("emails", :fanout)
195
+ add_exchange("webhooks", :topic)
196
+ add_exchange("notifications", :fanout)
197
+ add_exchange("audit", :headers)
198
+
199
+ add_line("orders.fulfillment", "orders", %w[order.paid], "FulfillmentConsumer", "default", 42, threads: 5)
200
+ add_line("orders.billing", "orders", %w[order.created order.paid order.cancelled], "BillingConsumer", "billing", 18)
201
+ add_line("orders.analytics", "orders", %w[order.#], "OrderAnalyticsConsumer", "default", 64, retry_ttl: nil, error_queue: false, threads: 3)
202
+ add_line("invoices.render", "invoices", %w[invoice.render], "InvoiceRenderConsumer", "billing", 60, ready: 40)
203
+ add_line("invoices.deliver", "invoices", %w[invoice.deliver], "InvoiceDeliveryConsumer", "billing", 9)
204
+ add_line("emails.transactional", "emails", [], "TransactionalEmailConsumer", "mailers", 30, threads: 4)
205
+ add_line("emails.digest", "emails", [], "DigestEmailConsumer", "mailers", 2.5, retry_ttl: 30_000)
206
+ add_line("webhooks.outbound", "webhooks", %w[webhook.#], "WebhookDeliveryConsumer", "webhooks", 22, threads: 6)
207
+ add_line("notifications.push", "notifications", [], "PushNotificationConsumer", "mailers", 15)
208
+ add_line("notifications.sms", "notifications", [], nil, nil, 0, idle: true)
209
+ delay_exchange("orders", "orders.billing", 5000, rate: 1.2)
210
+ delay_exchange("orders", "orders.billing", 300_000, rate: 0.4)
211
+ delay_queue("emails.digest", 60_000, rate: 2.0)
212
+ delay_queue("emails.digest", 3_600_000, rate: 0.06)
213
+ delay_queue("invoices.deliver", 10_000, rate: 1.5, window: 0...50)
214
+ delay_queue("webhooks.outbound", 30_000, source: :consumer, share: 0.03)
215
+ @scripted = {slow: "invoices.render", failing: "webhooks.outbound"}
216
+ end
217
+
218
+ DOMAINS = {
219
+ "orders" => %w[created paid cancelled refunded fulfillment billing analytics],
220
+ "invoices" => %w[render deliver remind void archive],
221
+ "emails" => %w[transactional digest bounce receipt welcome],
222
+ "webhooks" => %w[outbound retries partner audit],
223
+ "payments" => %w[authorize capture refund dispute payout settle],
224
+ "shipments" => %w[label dispatch track deliver return],
225
+ "reports" => %w[daily weekly monthly export],
226
+ "exports" => %w[csv pdf archive],
227
+ "search" => %w[index reindex suggest purge],
228
+ "inventory" => %w[reserve release restock count audit],
229
+ "users" => %w[signup verify login profile delete],
230
+ "notifications" => %w[push sms email slack teams digest inbox web mobile desktop pager voice chat badge feed alerts]
231
+ }.freeze
232
+
233
+ BACKLOGS = {
234
+ "reports.monthly" => {ready: 1_200_000, capacity: 0.97, drain: 0.0},
235
+ "search.reindex" => {ready: 250_000, capacity: 0.99, drain: 0.0},
236
+ "exports.pdf" => {ready: 4_800, capacity: 1.0, drain: 0.0}
237
+ }.freeze
238
+
239
+ def build_heavy
240
+ workers = (1..20).map { |n| "pool-#{n}" }
241
+ DOMAINS.each_with_index do |(domain, actions), index|
242
+ fanout = domain == "notifications"
243
+ type = index.even? ? :topic : :direct
244
+ add_exchange(domain, fanout ? :fanout : type)
245
+ actions.each_with_index do |action, position|
246
+ name = "#{domain}.#{action}"
247
+ keys = fanout ? [] : ["#{domain}.#{action}"]
248
+ keys << "#{domain}.#{action}.urgent" if position.zero?
249
+ keys << "#{domain}.all" if position == 1
250
+ base = (10**(@rng.rand * 3.1)).round(1)
251
+ consumer = "#{domain.capitalize.chomp("s")}#{action.split("_").map(&:capitalize).join}Consumer"
252
+ idle = (index + position) % 9 == 4
253
+ add_line(name, domain, keys, idle ? nil : consumer, workers[(index * 3 + position) % workers.size], base,
254
+ idle: idle, threads: 1 + position % 5, **BACKLOGS.fetch(name, {}))
255
+ end
256
+ end
257
+ delay_queue("payments.capture", 1000, rate: 40)
258
+ delay_queue("payments.capture", 15_000, rate: 12)
259
+ delay_queue("payments.capture", 120_000, rate: 6)
260
+ delay_exchange("payments", "payments.settle", 600_000, rate: 0.8)
261
+ delay_exchange("payments", "payments.payout", 86_400_000, rate: 0.02)
262
+ delay_queue("notifications.digest", 900_000, rate: 1.1)
263
+ delay_queue("users.verify", 300_000, rate: 0.3, window: 0...90)
264
+ delay_queue("shipments.track", 60_000, source: :consumer, share: 0.05)
265
+ delay_queue("webhooks.outbound", 30_000, source: :consumer, share: 0.03)
266
+ @scripted = {slow: "invoices.render", failing: "webhooks.outbound"}
267
+ end
268
+
269
+ def add_exchange(name, type)
270
+ @exchanges << Exchange.new(name: name, type: type.to_s)
271
+ end
272
+
273
+ def add_line(name, exchange, keys, consumer, worker, base, **options)
274
+ @lines << Line.new(name: name, exchange: exchange, keys: keys, consumer: consumer, worker: worker, base: base, **options)
275
+ end
276
+
277
+ def delay_queue(target, ms, **options)
278
+ @delays << DelayQueue.new(ms: ms, target: line(target), **options)
279
+ end
280
+
281
+ def delay_exchange(exchange, target, ms, **options)
282
+ delayed = Lepus::Delay.exchange_name_for(exchange)
283
+ add_exchange(delayed, :headers) unless @exchanges.any? { |candidate| candidate.name == delayed }
284
+ @delays << DelayQueue.new(ms: ms, target: line(target), exchange: exchange, **options)
285
+ end
286
+
287
+ public
288
+
289
+ def line(name)
290
+ @lines.find { |candidate| candidate.name == name }
291
+ end
292
+
293
+ def live_delays
294
+ @delays.select { |delay| delay.present?(@second) }
295
+ end
296
+
297
+ def consumer_delays(line)
298
+ @delays.select { |delay| delay.source == :consumer && delay.target == line }.map(&:ms)
299
+ end
300
+
301
+ private
302
+
303
+ def build_processes
304
+ names = @lines.map(&:worker).compact.uniq
305
+ apps = (names.size > 6) ? %w[orders-service billing-service mailer-service platform-service] : %w[orders-service mailer-service]
306
+ hosts = apps.each_with_index.map { |_app, index| "app-#{index + 1}.internal" }
307
+ @supervisors = apps.each_with_index.map do |app, index|
308
+ {id: SecureRandom.uuid, name: "supervisor-#{index + 1}", pid: 4100 + index * 100, hostname: hosts[index], kind: "Supervisor", application: app}
309
+ end
310
+ @workers = names.each_with_index.map do |worker, index|
311
+ supervisor = @supervisors[index % @supervisors.size]
312
+ {
313
+ id: SecureRandom.uuid, name: worker, pid: supervisor[:pid] + 10 + index, hostname: supervisor[:hostname], kind: "Worker",
314
+ supervisor_id: supervisor[:id], application: supervisor[:application], rss: 120 + @rng.rand(260), late: names.size > 6 && index == 5
315
+ }
316
+ end
317
+ end
318
+
319
+ def advance(second, dt, record_at:)
320
+ @second = second
321
+ phase = second % CYCLE
322
+ totals = Hash.new(0.0)
323
+ failing = ->(line) { line.name == @scripted[:failing] && phase >= 90 && phase < 130 }
324
+ @delays.each { |delay| delay.step(second, dt, @rng, failing: failing.call(delay.target)) }
325
+ released = @delays.group_by(&:target).transform_values { |list| list.sum(&:out_rate) }
326
+ @lines.each do |line|
327
+ slow = line.name == @scripted[:slow] && phase >= 20 && phase < 60
328
+ line.errors *= 0.3 if phase.zero?
329
+ line.step(second, dt, @rng, slow: slow, failing: failing.call(line), released: released.fetch(line, 0.0))
330
+ RATES.each { |key| totals[key] += line.rate(key) }
331
+ end
332
+ @delays.each { |delay| totals[:publish] += delay.in_rate }
333
+ @workers&.each { |worker| worker[:rss] = (worker[:rss] + @rng.rand(-3..3)).clamp(120, 400) }
334
+ @history << {at: record_at, counts: totals}
335
+ @history.shift while @history.size > HISTORY
336
+ end
337
+ end
338
+
339
+ # Answers like Lepus::Web::ManagementAPI, from the simulated broker.
340
+ class ManagementAPI
341
+ def initialize(world)
342
+ @world = world
343
+ end
344
+
345
+ def queues
346
+ @world.synchronize do
347
+ @world.lines.flat_map { |line| queue_rows(line) } + @world.live_delays.map { |delay| delay_row(delay) }
348
+ end
349
+ end
350
+
351
+ def queue(name)
352
+ queues.find { |q| q[:name] == name }
353
+ end
354
+
355
+ def exchanges
356
+ @world.synchronize do
357
+ @world.exchanges.map do |exchange|
358
+ lines = @world.lines.select { |line| line.exchange == exchange.name }
359
+ direct = lines.map { |line| line.rate(:publish) - line.rate(:redeliver) - line.rate(:released) }
360
+ out = direct.sum
361
+ incoming = (exchange.type == "fanout") ? (direct.max || 0) : out
362
+ fed = @world.delays.select { |delay| delay.exchange && Lepus::Delay.exchange_name_for(delay.exchange) == exchange.name }
363
+ incoming = out = fed.sum(&:in_rate) unless fed.empty?
364
+ released = @world.delays.select { |delay| delay.exchange == exchange.name }.sum(&:out_rate)
365
+ incoming += released
366
+ out += released
367
+ {
368
+ name: exchange.name, type: exchange.type, durable: true, auto_delete: false,
369
+ message_stats: {publish_in: 0, publish_in_rate: incoming.round(2), publish_out: 0, publish_out_rate: out.round(2)}
370
+ }
371
+ end
372
+ end
373
+ end
374
+
375
+ def bindings
376
+ @world.synchronize do
377
+ rows = @world.lines.flat_map do |line|
378
+ keys = line.keys.empty? ? [""] : line.keys
379
+ keys.map { |key| binding(line.exchange, line.name, key) }
380
+ end
381
+ rows + @world.live_delays.select(&:exchange).map do |delay|
382
+ binding(Lepus::Delay.exchange_name_for(delay.exchange), delay.name, "", "x-match" => "all", Lepus::Delay::HEADER => delay.ms)
383
+ end
384
+ end
385
+ end
386
+
387
+ def consumers
388
+ @world.synchronize do
389
+ @world.lines.select(&:consumer).flat_map do |line|
390
+ (1..line.channels).map do |n|
391
+ {
392
+ queue: line.name, consumer_tag: "#{line.consumer}-#{n}", connection_name: "#{VERSION_LABEL} #{line.worker}",
393
+ channel: "#{line.worker} (#{n})", prefetch_count: 10, ack_required: true, active: true
394
+ }
395
+ end
396
+ end
397
+ end
398
+ end
399
+
400
+ def connections
401
+ @world.synchronize do
402
+ workers = @world.workers.map do |worker|
403
+ connection("#{VERSION_LABEL} #{worker[:name]}", @world.lines.count { |line| line.worker == worker[:name] } * 2)
404
+ end
405
+ producers = @world.supervisors.map { |supervisor| connection("#{supervisor[:application]} publisher", 1) }
406
+ workers + producers + [connection("#{VERSION_LABEL} (web-aggregator)", 1)]
407
+ end
408
+ end
409
+
410
+ def overview(age: nil, increment: 10)
411
+ @world.synchronize do
412
+ history = @world.history
413
+ now = history.last[:counts]
414
+ {
415
+ rabbitmq_version: "4.1.2",
416
+ cluster_name: "rabbit@demo",
417
+ rates: {publish: now[:publish].round(2), deliver: now[:deliver].round(2), ack: now[:ack].round(2), redeliver: now[:redeliver].round(2)},
418
+ queue_totals: {
419
+ messages: @world.lines.sum { |line| line.ready + line.unacked }.round,
420
+ messages_ready: @world.lines.sum(&:ready).round,
421
+ messages_unacknowledged: @world.lines.sum(&:unacked)
422
+ },
423
+ object_totals: {queues: queues_count + @world.live_delays.size, exchanges: @world.exchanges.size},
424
+ history: age ? buckets(history.last(age), increment) : []
425
+ }
426
+ end
427
+ end
428
+
429
+ private
430
+
431
+ def queues_count
432
+ @world.lines.sum { |line| 1 + (line.retry? ? 1 : 0) + (line.error_queue ? 1 : 0) }
433
+ end
434
+
435
+ def buckets(rows, increment)
436
+ rows.each_slice(increment).map do |slice|
437
+ totals = RATES.map { |key| [key, (slice.sum { |row| row[:counts][key] } / slice.size).round(2)] }.to_h
438
+ totals.merge(t: slice.last[:at].utc.iso8601)
439
+ end
440
+ end
441
+
442
+ def queue_rows(line)
443
+ main_args = {}
444
+ main_args = {"x-dead-letter-exchange" => "", "x-dead-letter-routing-key" => "#{line.name}.retry"} if line.retry?
445
+ rows = [queue_row(line.name, line.ready, line.unacked, line.channels, main_args,
446
+ publish: line.rate(:publish), deliver: line.rate(:deliver), ack: line.rate(:ack), redeliver: line.rate(:redeliver), backlog: line.backlog_rate)]
447
+ if line.retry?
448
+ rows << queue_row("#{line.name}.retry", line.retrying, 0, 0,
449
+ {"x-dead-letter-exchange" => "", "x-dead-letter-routing-key" => line.name, "x-message-ttl" => line.retry_ttl},
450
+ publish: line.rate(:retry_in))
451
+ end
452
+ rows << queue_row("#{line.name}.error", line.errors, 0, 0, {}, publish: line.rate(:error_in)) if line.error_queue
453
+ rows
454
+ end
455
+
456
+ def delay_row(delay)
457
+ queue_row(delay.name, delay.held, 0, 0, delay.arguments, publish: delay.in_rate)
458
+ end
459
+
460
+ def queue_row(name, ready, unacked, consumers, arguments, publish: 0.0, deliver: 0.0, ack: 0.0, redeliver: 0.0, backlog: 0.0)
461
+ ready = ready.round
462
+ {
463
+ name: name, type: "classic", messages: ready + unacked, messages_ready: ready, messages_unacknowledged: unacked,
464
+ consumers: consumers, memory: 20_000 + ready * 600, state: "running", durable: true, exclusive: false, auto_delete: false,
465
+ arguments: arguments, idle_since: nil, consumer_utilisation: consumers.zero? ? nil : 0.9,
466
+ messages_rate: backlog.round(2), messages_ready_rate: backlog.round(2),
467
+ message_stats: {
468
+ publish: 0, publish_rate: publish.round(2), deliver_get: 0, deliver_get_rate: deliver.round(2),
469
+ ack: 0, ack_rate: ack.round(2), redeliver: 0, redeliver_rate: redeliver.round(2)
470
+ }
471
+ }
472
+ end
473
+
474
+ def binding(source, destination, key, arguments = {})
475
+ {source: source, destination: destination, destination_type: "queue", routing_key: key, arguments: arguments}
476
+ end
477
+
478
+ def connection(name, channels)
479
+ {
480
+ name: "10.0.0.#{name.sum % 200 + 10}:#{40_000 + name.sum} -> 10.0.0.2:5672", state: "running", user: "app", vhost: "/",
481
+ channels: channels, connected_at: ((@world.started_at.to_f + (name.sum % 900)) * 1000).round,
482
+ client_properties: {connection_name: name}
483
+ }
484
+ end
485
+ end
486
+
487
+ # Answers like Lepus::Web::Aggregator, with a heartbeat per simulated process.
488
+ class Aggregator
489
+ def initialize(world)
490
+ @world = world
491
+ end
492
+
493
+ def start
494
+ end
495
+
496
+ def stop
497
+ end
498
+
499
+ def running?
500
+ true
501
+ end
502
+
503
+ def all_processes
504
+ @world.synchronize do
505
+ supervisors = @world.supervisors.map { |supervisor| process(supervisor, 0, consumers: []) }
506
+ supervisors + @world.workers.map { |worker| worker_process(worker) }
507
+ end
508
+ end
509
+
510
+ def find(id)
511
+ all_processes.find { |process| process[:id] == id }
512
+ end
513
+
514
+ def count
515
+ all_processes.size
516
+ end
517
+
518
+ private
519
+
520
+ def worker_process(worker)
521
+ lines = @world.lines.select { |line| line.worker == worker[:name] && line.consumer }
522
+ consumers = lines.map do |line|
523
+ {
524
+ class_name: line.consumer, exchange: line.exchange, queue: line.name, route: line.keys.first,
525
+ retry_queue: (line.retry? ? "#{line.name}.retry" : nil), error_queue: (line.error_queue ? "#{line.name}.error" : nil),
526
+ threads: line.threads, processed: line.counters[:processed].round, rejected: line.counters[:rejected].round,
527
+ errored: line.counters[:errored].round, delayed: line.counters[:delayed].round,
528
+ delays: @world.consumer_delays(line).then { |delays| delays.empty? ? nil : delays }
529
+ }
530
+ end
531
+ process(worker, worker[:rss] * 1024 * 1024, consumers: consumers, connections: 1 + lines.size / 4, late: worker[:late])
532
+ end
533
+
534
+ def process(data, rss, consumers:, connections: 1, late: false)
535
+ age = late ? rand(150..159) : rand(0..25)
536
+ {
537
+ id: data[:id], name: data[:name], pid: data[:pid], hostname: data[:hostname], kind: data[:kind],
538
+ supervisor_id: data[:supervisor_id], application: data[:application],
539
+ last_heartbeat_at: (Time.now - age).utc.iso8601(6),
540
+ rss_memory: rss, connections: connections, consumers: consumers
541
+ }
542
+ end
543
+ end
544
+
545
+ # Just enough of a Rack handler on WEBrick, which ships with Ruby.
546
+ class Servlet < WEBrick::HTTPServlet::AbstractServlet
547
+ def initialize(server, app)
548
+ super(server)
549
+ @app = app
550
+ end
551
+
552
+ def service(request, response)
553
+ env = {
554
+ "REQUEST_METHOD" => request.request_method, "SCRIPT_NAME" => "", "PATH_INFO" => request.path,
555
+ "QUERY_STRING" => request.query_string.to_s, "SERVER_NAME" => request.host.to_s, "SERVER_PORT" => request.port.to_s,
556
+ "SERVER_PROTOCOL" => "HTTP/1.1", "rack.url_scheme" => "http", "rack.input" => StringIO.new(request.body.to_s),
557
+ "rack.errors" => $stderr
558
+ }
559
+ request.each { |name, value| env["HTTP_#{name.upcase.tr("-", "_")}"] = value }
560
+ status, headers, body = @app.call(env)
561
+ response.status = status
562
+ headers.each { |name, value| response[name] = value }
563
+ response.body = +""
564
+ body.each { |chunk| response.body << chunk }
565
+ body.close if body.respond_to?(:close)
566
+ end
567
+ end
568
+
569
+ def self.install(heavy:)
570
+ world = World.new(heavy: heavy)
571
+ Lepus::Web.aggregator = Aggregator.new(world)
572
+ Lepus::Web.management_api = ManagementAPI.new(world)
573
+ world
574
+ end
575
+ end
576
+
577
+ if $PROGRAM_NAME == __FILE__
578
+ $stdout.sync = true
579
+ Lepus.logger = Logger.new(File::NULL)
580
+ heavy = ENV["HEAVY"] == "1"
581
+ world = WebDemo.install(heavy: heavy).start
582
+ mount = ENV.fetch("MOUNT", "").chomp("/")
583
+ app = mount.empty? ? Lepus::Web : Rack::URLMap.new(mount => Lepus::Web)
584
+ port = Integer(ENV.fetch("PORT", "9393"))
585
+ host = ENV.fetch("HOST", "127.0.0.1")
586
+ server = WEBrick::HTTPServer.new(Port: port, BindAddress: host, Logger: WEBrick::Log.new(File::NULL), AccessLog: [])
587
+ server.mount("/", WebDemo::Servlet, app)
588
+ trap("INT") { server.shutdown }
589
+ trap("TERM") { server.shutdown }
590
+ puts "Lepus dashboard demo#{" (heavy)" if heavy}: #{world.lines.size} queues on http://#{host}:#{port}#{mount}/"
591
+ server.start
592
+ end
@@ -0,0 +1,67 @@
1
+ # Lepus brand kit
2
+
3
+ ## The mark
4
+
5
+ A capital L drawn as a single route. The upright is the hare's ear; the foot curls back into a rounded body, and the amber dot inside it is a message in transit. One line, one stop, one message: the mark reads as "L", as a hare, and as a route carrying work.
6
+
7
+ ## Colours
8
+
9
+ | Role | Light background | Dark background |
10
+ |-------|------------------|-----------------|
11
+ | Ink | `#1a2233` | `#e3e9f3` |
12
+ | Amber | `#f2a30f` | `#f2a30f` |
13
+ | Tile (app icons) | `#1a2233` | `#1a2233` |
14
+
15
+ Amber is reserved for the message dot and the rule under "SAGA". Do not recolour the line in amber or put the dot in ink (except in the mono files).
16
+
17
+ ## Clear space and minimum size
18
+
19
+ - Clear space: keep at least the height of the mark's body (the rounded loop, about one third of the mark's height) empty on every side.
20
+ - `mark*.svg`: 48 px and up.
21
+ - `mark-small*.svg` / `favicon.svg`: 16 to 48 px. Its line is heavier and every edge sits on the 16, 32, 48 and 64 px pixel grids, so it stays sharp at favicon sizes. Do not use it large.
22
+ - `lockup-lepus*`: 120 px wide minimum. `lockup-lepus-saga*`: 180 px wide minimum (below that, "SAGA" gets too small; use `lockup-lepus` or the mark alone).
23
+
24
+ ## Which file where
25
+
26
+ | Use | File |
27
+ |-----|------|
28
+ | README header, Lepus | `lockup-lepus-on-light.svg` + `lockup-lepus-on-dark.svg` in a `<picture>` (below) |
29
+ | README header, Lepus saga | `lockup-lepus-saga-on-light.svg` + `lockup-lepus-saga-on-dark.svg` |
30
+ | Web page header that follows the OS theme | `lockup-lepus.svg`, `lockup-lepus-saga.svg`, `mark.svg` (ink switches with `prefers-color-scheme`) |
31
+ | Inline in an app that sets its own text colour | `*-mono.svg` (everything is `currentColor`) |
32
+ | Fixed background you control | `*-on-light.svg` / `*-on-dark.svg` |
33
+ | Browser tab | `favicon.svg` (adaptive), with `favicon.ico` (16/32/48) as fallback |
34
+ | iOS home screen | `apple-touch-icon.png` (180, solid tile) |
35
+ | Web manifest / PWA | `icon-512.png` (solid tile, mark inside the maskable safe zone) |
36
+ | Legacy PNG favicons | `favicon-16.png`, `favicon-32.png` (transparent, light-background ink) |
37
+
38
+ All wordmark text is outlined Overpass (SIL Open Font License), so no font is needed to render it.
39
+
40
+ ### README
41
+
42
+ GitHub follows the viewer's GitHub theme only through `<picture>`, so use the light/dark pair rather than the adaptive file:
43
+
44
+ ```html
45
+ <p align="center">
46
+ <picture>
47
+ <source media="(prefers-color-scheme: dark)" srcset="docs/brand/lockup-lepus-on-dark.svg">
48
+ <img alt="Lepus" src="docs/brand/lockup-lepus-on-light.svg" width="278">
49
+ </picture>
50
+ </p>
51
+ ```
52
+
53
+ For Lepus saga, swap in `lockup-lepus-saga-on-dark.svg` / `lockup-lepus-saga-on-light.svg` and `width="359"`.
54
+
55
+ ### HTML head
56
+
57
+ ```html
58
+ <link rel="icon" href="/favicon.ico" sizes="48x48">
59
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
60
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
61
+ ```
62
+
63
+ ## Don'ts
64
+
65
+ - Don't redraw the wordmark in live text or another typeface.
66
+ - Don't stretch, rotate, outline or add effects to the mark.
67
+ - Don't put the ink mark on mid-tone or busy backgrounds; use the tile or the mono file instead.
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Lepus"><title>Lepus</title><style>.i{stroke:#1a2233}.f{fill:#1a2233}@media (prefers-color-scheme:dark){.i{stroke:#e3e9f3}.f{fill:#e3e9f3}}</style><path class="i" d="M12 6 V40 A16 16 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V18" fill="none" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40" cy="46" r="5" fill="#f2a30f"/></svg>
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6.00 -64.40 259.67 84.00" width="278" height="90" role="img" aria-label="Lepus"><title>Lepus</title><g transform="translate(-13.03 -60.26) scale(1.2414)"><path d="M14 5 V42 A14 14 0 0 0 28 56 H42 A10 10 0 0 0 42 36 H28 V15" fill="none" stroke="currentColor" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="40.5" cy="46" r="4" fill="currentColor"/></g><path fill="currentColor" d="M80.05 0V-44.8H89.26V-8.91H110.81V0H80.05ZM128.16 0.77Q123.38 0.77 119.92 -1.31Q116.46 -3.39 114.61 -7.24Q112.76 -11.08 112.76 -16.38Q112.76 -21.64 114.65 -25.48Q116.54 -29.31 120 -31.39Q123.45 -33.47 128.16 -33.47Q132.23 -33.47 135.52 -31.8Q138.81 -30.12 140.75 -26.68Q142.69 -23.24 142.69 -17.97Q142.69 -17.1 142.66 -15.84Q142.62 -14.57 142.45 -13.31H121.38Q121.44 -11.31 122.29 -9.82Q123.13 -8.34 124.64 -7.52Q126.14 -6.71 128.19 -6.71Q130.6 -6.71 132.3 -7.49Q134 -8.28 135.56 -10.07L140.62 -4.94Q138.41 -2.35 135.33 -0.79Q132.26 0.77 128.16 0.77ZM121.47 -20.02H134.24Q134.04 -21.93 133.27 -23.27Q132.5 -24.6 131.17 -25.3Q129.85 -26 127.96 -26Q125.38 -26 123.59 -24.46Q121.8 -22.92 121.47 -20.02ZM148.64 12.99V-32.7H157.43V-30.41Q159.85 -33.47 164.23 -33.47Q168.44 -33.47 171.47 -31.4Q174.51 -29.34 176.15 -25.5Q177.78 -21.66 177.78 -16.33Q177.78 -11.21 176.07 -7.37Q174.35 -3.53 171.28 -1.38Q168.2 0.77 164.12 0.77Q161.97 0.77 160.28 0Q158.6 -0.76 157.43 -2.25V9.04L148.64 12.99ZM162.75 -7.69Q165.73 -7.69 167.35 -9.96Q168.96 -12.24 168.96 -16.33Q168.96 -20.51 167.35 -22.77Q165.74 -25.02 162.75 -25.02Q161 -25.02 159.72 -24.46Q158.44 -23.9 157.43 -22.71V-10.11Q158 -9.03 159.47 -8.36Q160.94 -7.69 162.75 -7.69ZM195.02 0.77Q189.74 0.77 186.71 -2.57Q183.68 -5.91 183.68 -11.78V-32.7H192.47V-15.4Q192.47 -11.26 193.74 -9.48Q195.02 -7.69 197.92 -7.69Q201.03 -7.69 202.46 -9.53Q203.89 -11.37 203.89 -15.27V-32.7H212.68V0H203.89V-3.03Q202.42 -1.17 200.15 -0.2Q197.87 0.77 195.02 0.77ZM231.57 0.77Q227.63 0.77 224.02 -0.91Q220.41 -2.59 218.35 -5.4L224 -10.18Q225.63 -8.5 227.77 -7.49Q229.9 -6.48 231.85 -6.48Q234.11 -6.48 235.33 -7.2Q236.55 -7.92 236.55 -9.25Q236.55 -10.07 235.97 -10.74Q235.39 -11.4 233.84 -12.14Q232.3 -12.88 229.42 -13.93Q223.86 -15.94 221.58 -18.28Q219.31 -20.63 219.31 -24.27Q219.31 -28.39 222.64 -30.93Q225.98 -33.47 231.34 -33.47Q234.98 -33.47 238.08 -32.09Q241.17 -30.71 243.42 -28.08L237.74 -23.4Q234.87 -26.23 231.17 -26.23Q229.41 -26.23 228.31 -25.58Q227.22 -24.94 227.22 -23.91Q227.22 -22.87 228.53 -22.03Q229.85 -21.2 233.5 -20.06Q237.33 -18.91 239.75 -17.4Q242.17 -15.9 243.31 -13.9Q244.44 -11.9 244.44 -9.28Q244.44 -4.58 241.01 -1.9Q237.57 0.77 231.57 0.77Z"/></svg>