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/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
|
data/docs/brand/BRAND.md
ADDED
|
@@ -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>
|