lepus-saga 0.3.0 → 0.3.2

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.
@@ -29,6 +29,9 @@ module Lepus
29
29
  QUERY_MAX_ROWS = 50_000
30
30
  MAX_SERIES_BUCKETS = 1_000
31
31
  GROUPABLE = %i[saga_name status partition current_step].freeze
32
+ SORTS = {started_at: :started_at, duration: :duration_ms, finished_at: :finished_at}.freeze
33
+ NULLABLE_SORTS = %i[duration_ms finished_at].freeze
34
+ DIRECTIONS = %i[desc asc].freeze
32
35
  SERIES_KEYS = ["started", *ExecutionBehaviour::TERMINAL_STATUSES].freeze
33
36
  LATENCY_STATUSES = %w[succeeded failed].freeze
34
37
  PERCENTILES = {p50: 50, p95: 95, p99: 99}.freeze
@@ -87,7 +90,8 @@ module Lepus
87
90
  raise NotImplementedError
88
91
  end
89
92
 
90
- # Executions of a resource, most recently created first.
93
+ # Executions of a resource, most recently started first (ties by id,
94
+ # descending).
91
95
  def executions_for_resource(resource_type, resource_id)
92
96
  raise NotImplementedError
93
97
  end
@@ -112,6 +116,16 @@ module Lepus
112
116
  raise NotImplementedError
113
117
  end
114
118
 
119
+ # Names of the steps each of `executions` ran forward (attempts in the
120
+ # execute direction, whatever their status), as a Hash keyed by
121
+ # execution id, every id present. Stores override it to read them in
122
+ # one query.
123
+ def executed_steps(executions)
124
+ executions.to_h do |execution|
125
+ [execution.id, step_attempts(execution).select { |attempt| attempt.direction == "execute" }.map(&:step_name).uniq]
126
+ end
127
+ end
128
+
115
129
  # Number of attempts of `execution` for this step, direction and epoch,
116
130
  # whatever their status.
117
131
  def count_step_attempts(execution, step_name:, direction:, epoch:)
@@ -139,23 +153,38 @@ module Lepus
139
153
  # Whether the optional query methods below are implemented.
140
154
  def queries? = false
141
155
 
142
- # Executions matching the filters, newest first by id (UUIDv7, so by
143
- # creation time), at most `limit` of them. `before_id` returns the
144
- # page after the execution with that id. Every filter is optional:
145
- # `saga_name`, `status` and `current_step` take a value or a list;
146
- # `partition` matches the partition column, and executions without one
147
- # whose definition runs in that partition; `resource` is a
148
- # case-insensitive substring of resource_type or resource_id; `from`
149
- # (inclusive) and `to` (exclusive) bound created_at, `progress_from`
150
- # and `progress_to` bound last_progress_at the same way (in-progress
151
- # executions with `progress_to: Time.now.utc - 15 * 60` are the
152
- # stalled ones). `error_class` and `error_step` take a value or a list
153
- # and keep executions with a failed attempt of that error class and at
154
- # that step (either filter alone matches on its own attribute);
156
+ # Executions matching the filters, at most `limit` of them, in the
157
+ # order of `sort` and `direction`:
158
+ #
159
+ # - `sort: :started_at` (the default), `:duration` (duration_ms) or
160
+ # `:finished_at`, `direction: :desc` (the default) or `:asc`. The id
161
+ # breaks ties in the same direction. Executions without a value
162
+ # (in-progress ones have no duration_ms or finished_at) come last in
163
+ # either direction, by id. Ids need not follow time: rows written
164
+ # with ids that are not time-ordered (e.g. random UUIDv4) still list
165
+ # in time order.
166
+ # - `after` is the id of the last execution of the previous page: the
167
+ # page holds the executions strictly after it, by (sort value, id),
168
+ # in the same order. An `after` that names no execution returns an
169
+ # empty page.
170
+ #
171
+ # Every filter is optional: `saga_name`, `status` and `current_step`
172
+ # take a value or a list; `partition` matches the partition column,
173
+ # and executions without one whose definition runs in that partition;
174
+ # `resource` is a case-insensitive substring of resource_type or
175
+ # resource_id; `from` (inclusive) and `to` (exclusive) bound
176
+ # started_at, `progress_from` and `progress_to` bound
177
+ # last_progress_at and `finished_from` and `finished_to` bound
178
+ # finished_at the same way (in-progress executions with
179
+ # `progress_to: Time.now.utc - 15 * 60` are the stalled ones).
180
+ # `error_class` and `error_step` take a value or a list and keep
181
+ # executions with a failed attempt of that error class and at that
182
+ # step (either filter alone matches on its own attribute);
155
183
  # `error_from` (inclusive) and `error_to` (exclusive) bound that
156
184
  # failed attempt's started_at, the window #failure_summary counts.
157
- def list_executions(saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
158
- progress_from: nil, progress_to: nil, error_class: nil, error_step: nil, error_from: nil, error_to: nil, before_id: nil, limit: 50)
185
+ def list_executions(sort: :started_at, direction: :desc, after: nil, limit: 50, saga_name: nil, status: nil, current_step: nil,
186
+ partition: nil, resource: nil, from: nil, to: nil, progress_from: nil, progress_to: nil, finished_from: nil, finished_to: nil,
187
+ error_class: nil, error_step: nil, error_from: nil, error_to: nil)
159
188
  raise NotImplementedError
160
189
  end
161
190
 
@@ -165,11 +194,37 @@ module Lepus
165
194
  # resolves executions without one to their definition's partition
166
195
  # (nil for an unknown saga).
167
196
  def count_executions(group_by:, saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
168
- progress_from: nil, progress_to: nil, error_class: nil, error_step: nil, error_from: nil, error_to: nil)
197
+ progress_from: nil, progress_to: nil, finished_from: nil, finished_to: nil, error_class: nil, error_step: nil, error_from: nil,
198
+ error_to: nil)
169
199
  raise NotImplementedError
170
200
  end
171
201
 
172
- # Executions started (by created_at) and finished in each terminal
202
+ # Executions that finished between `from` (inclusive) and `to`
203
+ # (exclusive), latest finish first (ties by id), at most `per_saga`
204
+ # of each saga when given and at most `limit` in all. `saga_name`
205
+ # takes a value or a list. Returns `{executions: [...], counts:
206
+ # {[saga_name, status] => n}}`, the counts covering every execution
207
+ # that finished in the window, so a caller can tell how many the
208
+ # limits left out. `listed_from` (at most `from`) widens the listed
209
+ # executions, not the counts, back to that time, so a caller polling
210
+ # with some overlap can catch late writes without counting them
211
+ # twice. Built on #list_executions and #count_executions.
212
+ def finished_executions(from:, to:, listed_from: nil, saga_name: nil, per_saga: nil, limit: 100)
213
+ counts = count_executions(group_by: %i[saga_name status], saga_name: saga_name, finished_from: from, finished_to: to)
214
+ window = {saga_name: saga_name, finished_from: [listed_from, from].compact.min, finished_to: to}
215
+ names = (per_saga && listed_from.nil?) ? counts.keys.map(&:first).uniq : nil
216
+ executions = if per_saga
217
+ (names || saga_names_finished(**window)).sort.flat_map do |name|
218
+ list_executions(**window, saga_name: name, sort: :finished_at, limit: [per_saga, limit].min)
219
+ end
220
+ else
221
+ list_executions(**window, sort: :finished_at, limit: limit)
222
+ end
223
+ ordered = executions.sort_by { |execution| [execution.finished_at.to_r, execution.id] }.reverse
224
+ {executions: ordered.first(limit), counts: counts}
225
+ end
226
+
227
+ # Executions started (by started_at) and finished in each terminal
173
228
  # status (by finished_at) per `bucket` seconds between `from` and
174
229
  # `to`. Returns `{rows: [[bucket_start, {"started" => n, "completed" =>
175
230
  # n, ...}], ...], truncated:}` with every bucket of the window, bucket
@@ -226,6 +281,22 @@ module Lepus
226
281
  attributes.each { |name, value| record.public_send(:"#{name}=", value) }
227
282
  end
228
283
 
284
+ # The column and direction of a #list_executions order, and whether
285
+ # rows without a value can match: never for finished_at when a
286
+ # finished_at bound is among the filters.
287
+ def list_order(sort, direction, filters = {})
288
+ column = SORTS[sort.to_s.to_sym] || raise(ArgumentError, "Cannot sort executions by #{sort.inspect}")
289
+ descending = direction.to_s.to_sym
290
+ raise ArgumentError, "Unknown sort direction #{direction.inspect}" unless DIRECTIONS.include?(descending)
291
+
292
+ bounded = column == :finished_at && (filters[:finished_from] || filters[:finished_to])
293
+ [column, descending == :desc, NULLABLE_SORTS.include?(column) && !bounded]
294
+ end
295
+
296
+ def saga_names_finished(**window)
297
+ count_executions(group_by: :saga_name, **window).keys
298
+ end
299
+
229
300
  def filter_values(value)
230
301
  values = Array(value).map(&:to_s)
231
302
  values unless values.empty?
@@ -131,7 +131,7 @@ module Lepus
131
131
 
132
132
  def executions_for_resource(resource_type, resource_id)
133
133
  where(@executions, Execution) { |row| row[:resource_type] == resource_type && row[:resource_id] == resource_id }
134
- .sort_by { |execution| [execution.created_at, execution.id] }.reverse
134
+ .sort_by { |execution| [execution.started_at, execution.id] }.reverse
135
135
  end
136
136
 
137
137
  def create_step_attempt(execution, attributes)
@@ -177,10 +177,15 @@ module Lepus
177
177
 
178
178
  def queries? = true
179
179
 
180
- def list_executions(before_id: nil, limit: 50, **filters)
180
+ def list_executions(sort: :started_at, direction: :desc, after: nil, limit: 50, **filters)
181
+ column, descending, = list_order(sort, direction, filters)
182
+ cursor = after && find_execution(after)
183
+ return [] if after && !cursor
184
+
185
+ compare = list_comparator(column, descending)
181
186
  executions = matching_executions(**filters)
182
- executions = executions.select { |execution| execution.id < before_id } if before_id
183
- executions.sort_by(&:id).reverse.first(limit)
187
+ executions = executions.select { |execution| compare.call(execution, cursor).positive? } if cursor
188
+ executions.sort(&compare).first(limit)
184
189
  end
185
190
 
186
191
  def count_executions(group_by:, **filters)
@@ -192,7 +197,7 @@ module Lepus
192
197
  window = from...to
193
198
  executions = matching_executions(saga_name: saga_name)
194
199
  started, started_truncated = take_rows(
195
- executions.map(&:created_at).select { |time| window.cover?(time) }.sort.reverse, max_rows
200
+ executions.map(&:started_at).select { |time| window.cover?(time) }.sort.reverse, max_rows
196
201
  )
197
202
  finished, finished_truncated = take_rows(
198
203
  executions.select { |execution| execution.terminal? && window.cover?(execution.finished_at) }
@@ -229,7 +234,8 @@ module Lepus
229
234
  private
230
235
 
231
236
  def matching_executions(saga_name: nil, status: nil, current_step: nil, partition: nil, resource: nil, from: nil, to: nil,
232
- progress_from: nil, progress_to: nil, error_class: nil, error_step: nil, error_from: nil, error_to: nil)
237
+ progress_from: nil, progress_to: nil, finished_from: nil, finished_to: nil, error_class: nil, error_step: nil, error_from: nil,
238
+ error_to: nil)
233
239
  sagas = filter_values(saga_name)
234
240
  statuses = filter_values(status)
235
241
  steps = filter_values(current_step)
@@ -241,16 +247,35 @@ module Lepus
241
247
  (sagas.nil? || sagas.include?(row[:saga_name])) &&
242
248
  (statuses.nil? || statuses.include?(row[:status])) &&
243
249
  (steps.nil? || steps.include?(row[:current_step])) &&
244
- (from.nil? || row[:created_at] >= from) &&
245
- (to.nil? || row[:created_at] < to) &&
246
- (progress_from.nil? || row[:last_progress_at] >= progress_from) &&
247
- (progress_to.nil? || row[:last_progress_at] < progress_to) &&
250
+ within?(row[:started_at], from, to) &&
251
+ within?(row[:last_progress_at], progress_from, progress_to) &&
252
+ within?(row[:finished_at], finished_from, finished_to) &&
248
253
  (failing.nil? || failing.include?(row[:id])) &&
249
254
  (partition_sagas.nil? || in_partition?(row, partition.to_s, partition_sagas)) &&
250
255
  (needle.nil? || row.values_at(:resource_type, :resource_id).any? { |value| value.to_s.downcase.include?(needle) })
251
256
  end
252
257
  end
253
258
 
259
+ # Orders by the column in the given direction with nulls last either
260
+ # way, then by id in the same direction.
261
+ def list_comparator(column, descending)
262
+ lambda do |left, right|
263
+ a = left[column]
264
+ b = right[column]
265
+ next(a.nil? ? 1 : -1) if a.nil? != b.nil?
266
+
267
+ order = (a.nil? ? 0 : a <=> b).nonzero? || left.id <=> right.id
268
+ descending ? -order : order
269
+ end
270
+ end
271
+
272
+ def within?(value, from, to)
273
+ return true if from.nil? && to.nil?
274
+ return false if value.nil?
275
+
276
+ (from.nil? || value >= from) && (to.nil? || value < to)
277
+ end
278
+
254
279
  def failing_execution_ids(error_classes, steps, from, to)
255
280
  return if [error_classes, steps, from, to].all?(&:nil?)
256
281
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lepus
4
4
  module Saga
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
@@ -25,6 +25,7 @@ module Lepus
25
25
  ["GET", %r{\A/executions\z}, :executions],
26
26
  ["GET", %r{\A/executions/(?<id>[^/]+)\z}, :execution],
27
27
  ["GET", %r{\A/moving\z}, :moving],
28
+ ["GET", %r{\A/recent\z}, :recent],
28
29
  ["GET", %r{\A/series\z}, :series],
29
30
  ["GET", %r{\A/insights/failures\z}, :failures],
30
31
  ["GET", %r{\A/insights/latency\z}, :latency],
@@ -40,6 +41,8 @@ module Lepus
40
41
  IN_PROGRESS = ExecutionBehaviour::IN_PROGRESS_STATUSES
41
42
  STOPPED_STATUSES = %w[failed compensation_failed].freeze
42
43
  RELATED_LIMIT = 20
44
+ SORTS = {"started_at" => :started_at, "duration" => :duration}.freeze
45
+ DIRECTIONS = %w[desc asc].freeze
43
46
 
44
47
  def initialize(config)
45
48
  @config = config
@@ -104,7 +107,7 @@ module Lepus
104
107
  totals: totals.filter_map { |(saga, status), count| [status, count] if saga == name }.to_h
105
108
  )
106
109
  end
107
- {from: Serializer.time(from), to: Serializer.time(to), stalled_after: config.stalled_after, sagas: sagas}
110
+ {now: Serializer.time(Time.now.utc), from: Serializer.time(from), to: Serializer.time(to), stalled_after: config.stalled_after, sagas: sagas}
108
111
  end
109
112
 
110
113
  def stations(name, counts, stalled, failures)
@@ -121,12 +124,18 @@ module Lepus
121
124
  stations
122
125
  end
123
126
 
127
+ # A page of executions in the order of `sort` (started_at or
128
+ # duration) and `dir`; `next` is the `after` of the following page.
124
129
  def executions(request)
125
130
  limit = integer(request, "limit", default: 50, range: 1..200)
126
- rows = store.list_executions(**filters(request), before_id: param(request, "before"), limit: limit + 1)
131
+ sort = choice(request, "sort", SORTS.keys, default: "started_at")
132
+ direction = choice(request, "dir", DIRECTIONS, default: "desc")
133
+ rows = store.list_executions(**filters(request), sort: SORTS[sort], direction: direction.to_sym,
134
+ after: param(request, "after"), limit: limit + 1)
127
135
  {
136
+ sort: sort, dir: direction,
128
137
  executions: rows.first(limit).map { |execution| Serializer.execution(execution) },
129
- next_before: (rows[limit - 1].id if rows.size > limit)
138
+ next: (rows[limit - 1].id if rows.size > limit)
130
139
  }
131
140
  end
132
141
 
@@ -143,7 +152,40 @@ module Lepus
143
152
  rows = store.list_executions(saga_name: saga, current_step: step, status: IN_PROGRESS, progress_from: since, limit: per_station)
144
153
  {saga_name: saga, step_name: step, count: count, executions: rows.map { |execution| Serializer.execution(execution) }}
145
154
  end
146
- {stalled_after: config.stalled_after, stations: stations, omitted: counts.size - busiest.size}
155
+ {now: Serializer.time(Time.now.utc), stalled_after: config.stalled_after, stations: stations, omitted: counts.size - busiest.size}
156
+ end
157
+
158
+ # Executions that finished since `since` (the `now` of an earlier
159
+ # response), per saga line: the latest `per_saga` of them with the
160
+ # steps they passed, and counts by status of every one, so a live
161
+ # view can finish or replay them without losing any. `replay_from`
162
+ # (before `since`) also lists, without counting, the ones that
163
+ # finished from then on, for writes that landed after the last poll.
164
+ def recent(request)
165
+ since = time(request, "since") || raise(ArgumentError, "since must be an ISO 8601 time")
166
+ now = Time.now.utc
167
+ raise ArgumentError, "since must be before now" unless since < now
168
+
169
+ replay_from = time(request, "replay_from")
170
+ replay_from = nil if replay_from && replay_from >= since
171
+ per_saga = integer(request, "per_saga", default: 8, range: 1..50)
172
+ limit = integer(request, "limit", default: 100, range: 1..500)
173
+ result = store.finished_executions(
174
+ from: since, to: now, listed_from: replay_from, saga_name: list(request, "saga"), per_saga: per_saga, limit: limit
175
+ )
176
+ steps = store.executed_steps(result[:executions].reject { |execution| execution.status == "completed" })
177
+ by_saga = result[:executions].group_by(&:saga_name)
178
+ counts = result[:counts].group_by { |(saga, _status), _count| saga }
179
+ lines = (by_saga.keys | counts.keys).sort.map do |saga|
180
+ line_counts = counts.fetch(saga, [])
181
+ {
182
+ saga_name: saga,
183
+ total: line_counts.sum(&:last),
184
+ counts: line_counts.to_h { |(_saga, status), count| [status, count] },
185
+ executions: by_saga.fetch(saga, []).map { |execution| Serializer.finished(execution, steps.fetch(execution.id, [])) }
186
+ }
187
+ end
188
+ {now: Serializer.time(now), since: Serializer.time(since), lines: lines}
147
189
  end
148
190
 
149
191
  def execution(_request, id:)
@@ -288,6 +330,13 @@ module Lepus
288
330
  presence(value.strip) if value.is_a?(String)
289
331
  end
290
332
 
333
+ def choice(request, name, values, default:)
334
+ value = param(request, name) || default
335
+ raise ArgumentError, "#{name} must be one of #{values.join(", ")}" unless values.include?(value)
336
+
337
+ value
338
+ end
339
+
291
340
  def flag?(request, name)
292
341
  %w[1 true].include?(param(request, name))
293
342
  end
@@ -6,7 +6,7 @@ module Lepus
6
6
  module Serializer
7
7
  EXECUTION_FIELDS = %i[
8
8
  id saga_name resource_type resource_id status current_step direction
9
- epoch correlation_id failure_reason resume_count
9
+ epoch correlation_id failure_reason resume_count duration_ms
10
10
  ].freeze
11
11
  EXECUTION_TIMES = %i[started_at finished_at last_progress_at created_at updated_at].freeze
12
12
  ATTEMPT_FIELDS = %i[
@@ -24,6 +24,19 @@ module Lepus
24
24
  data
25
25
  end
26
26
 
27
+ # A finished execution and the steps it passed: `reached` is the
28
+ # furthest step it ran forward and, on the compensation path,
29
+ # `back_to` the last step it compensated (or failed compensating).
30
+ def finished(record, executed)
31
+ steps = Array(Lepus::Saga.definitions[record.saga_name]&.steps).map(&:name)
32
+ reached = executed.max_by { |name| steps.index(name) || -1 } || record.current_step
33
+ {
34
+ id: record.id, saga_name: record.saga_name, status: record.status,
35
+ started_at: time(record.started_at), finished_at: time(record.finished_at), duration_ms: record.duration_ms,
36
+ reached: reached, back_to: (record.current_step if record.compensate_direction?)
37
+ }
38
+ end
39
+
27
40
  def attempt(record)
28
41
  data = ATTEMPT_FIELDS.to_h { |name| [name, record.public_send(name)] }
29
42
  data[:duration_ms] = data[:duration_ms]&.to_f&.round(1)
data/lib/lepus/saga.rb CHANGED
@@ -95,7 +95,7 @@ module Lepus
95
95
  epoch: execution.epoch + 1,
96
96
  status: execution.compensate_direction? ? "compensating" : "running",
97
97
  failure_reason: nil,
98
- finished_at: nil)
98
+ **execution.reopen_attributes)
99
99
  end
100
100
  Instrumentation.saga_event("saga.resumed", execution)
101
101
  StepProducer.publish_step(execution)
data/web/assets/app.css CHANGED
@@ -300,6 +300,21 @@ button, input, select { font: inherit; color: inherit; }
300
300
  .sym-idle { fill: var(--paper); stroke: var(--token); stroke-width: 3; }
301
301
  .sym-stop { fill: var(--stop); }
302
302
  .sym-back { fill: var(--back); }
303
+ .sym-replay { fill: var(--token); fill-opacity: 0.5; stroke: var(--line); stroke-width: 2; stroke-opacity: 0.45; }
304
+
305
+ .m-key {
306
+ display: flex;
307
+ flex-wrap: wrap;
308
+ gap: 4px 18px;
309
+ list-style: none;
310
+ margin: -6px 0 18px;
311
+ padding: 0;
312
+ color: var(--ink-3);
313
+ font-size: 13px;
314
+ font-weight: 550;
315
+ }
316
+ .m-key li { display: inline-flex; align-items: center; gap: 6px; }
317
+ .m-key .sym svg { display: block; width: 14px; height: 14px; overflow: visible; }
303
318
 
304
319
  /* The map */
305
320
 
@@ -366,6 +381,14 @@ button, input, select { font: inherit; color: inherit; }
366
381
  .t-more.is-back { stroke: var(--back); }
367
382
  .t-more-n { fill: var(--token-text); font-size: 11.5px; font-weight: 750; }
368
383
  .t-more-n.is-back { fill: var(--back-text); }
384
+ .t-token.is-replay { fill-opacity: 0.5; stroke-opacity: 0.6; }
385
+ .t-flash { fill: var(--stop); }
386
+ .t-note { fill: var(--raise); stroke: var(--rule); stroke-width: 1.5; }
387
+ .t-note-n { font-size: 11.5px; font-weight: 750; }
388
+ .t-note-pale, .t-note-sep { fill: var(--ink-2); }
389
+ .t-note-done { fill: var(--ink-2); }
390
+ .t-note-back { fill: var(--back-text); }
391
+ .t-note-stop { fill: var(--stop-text); }
369
392
  .g-trail-move { stop-color: var(--token); }
370
393
  .g-trail-back { stop-color: var(--back); }
371
394
 
@@ -801,7 +824,25 @@ button, input, select { font: inherit; color: inherit; }
801
824
  border-bottom: 1px solid var(--rule);
802
825
  }
803
826
  .rows td { padding: 12px 12px 12px 0; border-bottom: 1px solid var(--rule-soft); vertical-align: middle; }
804
- .rows tr { cursor: pointer; }
827
+ .rows tbody tr { cursor: pointer; }
828
+ .rows th .sort {
829
+ display: inline-flex;
830
+ align-items: center;
831
+ gap: 5px;
832
+ margin: -4px -6px;
833
+ padding: 4px 6px;
834
+ border: 0;
835
+ border-radius: 6px;
836
+ background: none;
837
+ color: inherit;
838
+ font: inherit;
839
+ cursor: pointer;
840
+ }
841
+ .rows th .sort:hover { color: var(--ink); background: var(--rule-soft); }
842
+ .rows th .sort svg { width: 10px; height: 10px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; opacity: 0.45; }
843
+ .rows th[aria-sort="ascending"] .sort, .rows th[aria-sort="descending"] .sort { color: var(--ink); }
844
+ .rows th[aria-sort="ascending"] .sort svg, .rows th[aria-sort="descending"] .sort svg { opacity: 1; }
845
+ .sort-pick { display: none; }
805
846
  .rows tbody tr:hover td { background: var(--rule-soft); }
806
847
  .rows td:first-child, .rows th:first-child { padding-left: 4px; }
807
848
  .rows .r-res a { font-weight: 650; text-decoration: none; overflow-wrap: anywhere; }
@@ -875,6 +916,7 @@ button, input, select { font: inherit; color: inherit; }
875
916
  .h-took { grid-column: 3; padding-left: 6px; padding-top: 0; }
876
917
  .h-error, .h-trace { grid-column: 3; }
877
918
  .rows thead { display: none; }
919
+ .sort-pick { display: inline-block; }
878
920
  .rows, .rows tbody, .rows tr, .rows td { display: block; }
879
921
  .rows tr {
880
922
  display: grid;
@@ -890,6 +932,8 @@ button, input, select { font: inherit; color: inherit; }
890
932
  .rows .r-step { grid-column: 1; font-size: 14px; }
891
933
  .rows .r-when { grid-column: 2; text-align: right; }
892
934
  .rows .r-took { display: none; }
935
+ .rows.is-by-took .r-when { display: none; }
936
+ .rows.is-by-took .r-took { display: block; grid-column: 2; text-align: right; }
893
937
  }
894
938
 
895
939
  @media (min-width: 1700px) {
data/web/assets/js/api.js CHANGED
@@ -41,6 +41,7 @@ export const api = {
41
41
  executions: (params) => request(url("executions", params)),
42
42
  execution: (id) => request(url(`executions/${encodeURIComponent(id)}`)),
43
43
  moving: (params) => request(url("moving", params)),
44
+ recent: (params) => request(url("recent", params)),
44
45
  series: (params) => request(url("series", params)),
45
46
  failures: (params) => request(url("insights/failures", params)),
46
47
  latency: (params) => request(url("insights/latency", params)),
data/web/assets/js/dom.js CHANGED
@@ -9,7 +9,7 @@ function apply(el, attrs) {
9
9
  }
10
10
  }
11
11
 
12
- function append(el, children) {
12
+ export function append(el, children) {
13
13
  for (const child of children.flat(Infinity)) {
14
14
  if (child === null || child === undefined || child === false) continue;
15
15
  el.append(child instanceof Node ? child : document.createTextNode(String(child)));