solid_web_ui 0.3.0 → 0.4.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/solid_web_ui.js +20 -0
  3. data/app/assets/stylesheets/solid_web_ui.css +194 -1
  4. data/app/controllers/solid_web_ui/cable/application_controller.rb +4 -0
  5. data/app/controllers/solid_web_ui/cable/channels_controller.rb +16 -2
  6. data/app/controllers/solid_web_ui/cable/messages_controller.rb +4 -0
  7. data/app/controllers/solid_web_ui/cache/entries_controller.rb +182 -1
  8. data/app/controllers/solid_web_ui/queue/jobs_controller.rb +7 -0
  9. data/app/helpers/solid_web_ui/cable/application_helper.rb +7 -2
  10. data/app/helpers/solid_web_ui/cache/application_helper.rb +29 -2
  11. data/app/helpers/solid_web_ui/queue/application_helper.rb +28 -0
  12. data/app/views/layouts/solid_web_ui.html.erb +12 -0
  13. data/app/views/solid_web_ui/cable/channels/index.html.erb +2 -2
  14. data/app/views/solid_web_ui/cable/channels/show.html.erb +35 -0
  15. data/app/views/solid_web_ui/cable/messages/show.html.erb +21 -0
  16. data/app/views/solid_web_ui/cache/entries/_form.html.erb +47 -0
  17. data/app/views/solid_web_ui/cache/entries/edit.html.erb +9 -0
  18. data/app/views/solid_web_ui/cache/entries/index.html.erb +17 -3
  19. data/app/views/solid_web_ui/cache/entries/new.html.erb +8 -0
  20. data/app/views/solid_web_ui/cache/entries/show.html.erb +50 -0
  21. data/app/views/solid_web_ui/queue/jobs/index.html.erb +2 -2
  22. data/app/views/solid_web_ui/queue/jobs/show.html.erb +62 -0
  23. data/lib/solid_web_ui/cable/routes.rb +2 -0
  24. data/lib/solid_web_ui/cache/routes.rb +8 -0
  25. data/lib/solid_web_ui/cache.rb +3 -0
  26. data/lib/solid_web_ui/queue/routes.rb +1 -0
  27. data/lib/solid_web_ui/theme.rb +4 -1
  28. data/lib/solid_web_ui/version.rb +1 -1
  29. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c473be31f5f3992b41cfca6aaf1855045104194afa6507947110feb311d374ee
4
- data.tar.gz: '059154707b3a45e39a94904824c7dca137170549f0f7a8bf52432c51aa11a898'
3
+ metadata.gz: 9036bf52798c82718cce437f3ba58c1ca43f5f325d4a185bc7fb942a1dadfed7
4
+ data.tar.gz: fc33a3c5f65913a9aa9f59e5d3e1578a8ec89ba38a76cea55b4e79b38c980bea
5
5
  SHA512:
6
- metadata.gz: a1a32a7e4292847fb8fa0ecd9df72d8e5c1b8211ac91eed54043373956015e7435a46720e4857d274fa8ba8ca6adeb5e5d1af576b95e3950cb037d20083c099d
7
- data.tar.gz: d0b391dde5552ab3152edb019427c6caf0fbb77e545fc013580fe0f3491dd9f24ec71dd5399a26140c34dc9029663b25e86248a316a6be211dac63acada112a8
6
+ metadata.gz: ce886a4c5e21b2aa6fe722281338fb07619fb6302b9577cd2e736ec6381b14d797191715a54f25c149dfc31b212fda51fc5937c90f20c4df86240d0526446333
7
+ data.tar.gz: 855442dad92195d237732c456662472fe03b9daca0e07a72cff4f334913a0f0249582465f193adf5fa40ec650d0b6feb8688e99593443c5eb19ab755e23c6ccf
@@ -123,3 +123,23 @@
123
123
  document.addEventListener("turbo:load", bindAll);
124
124
  window.setInterval(tick, TICK_MS);
125
125
  })();
126
+
127
+ // Clickable table rows: a row carrying data-swui-row-href navigates to that URL.
128
+ // A single delegated listener (bound once, survives frame morphs). Clicks on an
129
+ // inner link/button keep their own behaviour, and modified/middle clicks are left
130
+ // to the browser so "open in new tab" still works via the row's ID link.
131
+ (function () {
132
+ "use strict";
133
+
134
+ document.addEventListener("click", function (event) {
135
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey ||
136
+ event.ctrlKey || event.shiftKey || event.altKey) return;
137
+ if (event.target.closest("a, button, input, label, select, textarea")) return;
138
+
139
+ var row = event.target.closest("tr[data-swui-row-href]");
140
+ if (!row) return;
141
+
142
+ var href = row.getAttribute("data-swui-row-href");
143
+ if (href) window.location.assign(href);
144
+ });
145
+ })();
@@ -2,9 +2,30 @@
2
2
  @layer properties;
3
3
  @layer theme, components, utilities;
4
4
  @layer utilities {
5
+ .visible {
6
+ visibility: visible;
7
+ }
5
8
  .static {
6
9
  position: static;
7
10
  }
11
+ .container {
12
+ width: 100%;
13
+ @media (width >= 40rem) {
14
+ max-width: 40rem;
15
+ }
16
+ @media (width >= 48rem) {
17
+ max-width: 48rem;
18
+ }
19
+ @media (width >= 64rem) {
20
+ max-width: 64rem;
21
+ }
22
+ @media (width >= 80rem) {
23
+ max-width: 80rem;
24
+ }
25
+ @media (width >= 96rem) {
26
+ max-width: 96rem;
27
+ }
28
+ }
8
29
  .block {
9
30
  display: block;
10
31
  }
@@ -14,6 +35,9 @@
14
35
  .hidden {
15
36
  display: none;
16
37
  }
38
+ .table {
39
+ display: table;
40
+ }
17
41
  .filter {
18
42
  filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
19
43
  }
@@ -29,8 +53,17 @@
29
53
  .solid-web-ui *, .solid-web-ui *::before, .solid-web-ui *::after {
30
54
  box-sizing: border-box;
31
55
  }
56
+ .solid-web-ui[data-color-scheme="light"] {
57
+ color-scheme: light;
58
+ }
59
+ .solid-web-ui[data-color-scheme="dark"] {
60
+ color-scheme: dark;
61
+ }
62
+ .solid-web-ui[data-color-scheme="auto"] {
63
+ color-scheme: light dark;
64
+ }
32
65
  .solid-web-ui .swui-page {
33
- max-width: 72rem;
66
+ max-width: var(--swui-page-max-width, 72rem);
34
67
  margin-inline: auto;
35
68
  padding: 1.5rem;
36
69
  }
@@ -224,6 +257,15 @@
224
257
  .solid-web-ui .swui-table tr:last-child td {
225
258
  border-bottom: 0;
226
259
  }
260
+ .solid-web-ui .swui-table tbody tr[data-swui-row-href] {
261
+ cursor: pointer;
262
+ }
263
+ .solid-web-ui .swui-table tbody tr[data-swui-row-href]:hover {
264
+ background: var(--swui-color-primary);
265
+ @supports (color: color-mix(in lab, red, red)) {
266
+ background: color-mix(in srgb, var(--swui-color-primary) 8%, transparent);
267
+ }
268
+ }
227
269
  .solid-web-ui .swui-table__empty td {
228
270
  text-align: center;
229
271
  color: var(--swui-color-muted);
@@ -280,6 +322,157 @@
280
322
  font-weight: 600;
281
323
  margin: 1.5rem 0 0.75rem;
282
324
  }
325
+ .solid-web-ui .swui-back {
326
+ display: inline-block;
327
+ margin-bottom: 1rem;
328
+ color: var(--swui-color-muted);
329
+ text-decoration: none;
330
+ font-size: 0.85rem;
331
+ }
332
+ .solid-web-ui .swui-back:hover {
333
+ color: var(--swui-color-text);
334
+ }
335
+ .solid-web-ui .swui-detail {
336
+ display: grid;
337
+ grid-template-columns: minmax(8rem, 14rem) 1fr;
338
+ gap: 0;
339
+ margin: 0 0 1.5rem;
340
+ background: var(--swui-color-surface);
341
+ border: 1px solid var(--swui-color-border);
342
+ border-radius: var(--swui-radius);
343
+ overflow: hidden;
344
+ font-size: 0.9rem;
345
+ }
346
+ .solid-web-ui .swui-detail dt, .solid-web-ui .swui-detail dd {
347
+ padding: 0.6rem 0.85rem;
348
+ border-bottom: 1px solid var(--swui-color-border);
349
+ margin: 0;
350
+ }
351
+ .solid-web-ui .swui-detail dt {
352
+ font-size: 0.75rem;
353
+ text-transform: uppercase;
354
+ letter-spacing: 0.03em;
355
+ color: var(--swui-color-muted);
356
+ background: var(--swui-color-muted);
357
+ @supports (color: color-mix(in lab, red, red)) {
358
+ background: color-mix(in srgb, var(--swui-color-muted) 6%, transparent);
359
+ }
360
+ }
361
+ .solid-web-ui .swui-detail > dt:last-of-type, .solid-web-ui .swui-detail > dd:last-of-type {
362
+ border-bottom: 0;
363
+ }
364
+ .solid-web-ui .swui-detail__hint {
365
+ text-transform: none;
366
+ letter-spacing: 0;
367
+ opacity: 0.8;
368
+ }
369
+ .solid-web-ui .swui-flash-region {
370
+ max-width: var(--swui-page-max-width, 72rem);
371
+ margin-inline: auto;
372
+ padding: 1.5rem 1.5rem 0;
373
+ }
374
+ .solid-web-ui .swui-flash {
375
+ padding: 0.6rem 0.85rem;
376
+ margin-bottom: 1rem;
377
+ border: 1px solid var(--swui-color-border);
378
+ border-radius: var(--swui-radius);
379
+ font-size: 0.9rem;
380
+ }
381
+ .solid-web-ui .swui-flash--notice {
382
+ border-color: var(--swui-color-success);
383
+ background: var(--swui-color-success);
384
+ @supports (color: color-mix(in lab, red, red)) {
385
+ background: color-mix(in srgb, var(--swui-color-success) 14%, transparent);
386
+ }
387
+ }
388
+ .solid-web-ui .swui-flash--alert {
389
+ border-color: var(--swui-color-danger);
390
+ background: var(--swui-color-danger);
391
+ @supports (color: color-mix(in lab, red, red)) {
392
+ background: color-mix(in srgb, var(--swui-color-danger) 14%, transparent);
393
+ }
394
+ }
395
+ .solid-web-ui .swui-actions {
396
+ display: flex;
397
+ flex-wrap: wrap;
398
+ gap: 0.5rem;
399
+ margin-bottom: 1rem;
400
+ }
401
+ .solid-web-ui .swui-form {
402
+ max-width: 48rem;
403
+ margin-bottom: 1.5rem;
404
+ }
405
+ .solid-web-ui .swui-field {
406
+ margin-bottom: 1rem;
407
+ }
408
+ .solid-web-ui .swui-fieldset {
409
+ margin: 0 0 1rem;
410
+ padding: 1rem 1.1rem 0.25rem;
411
+ border: 1px solid var(--swui-color-border);
412
+ border-radius: var(--swui-radius);
413
+ }
414
+ .solid-web-ui .swui-fieldset legend {
415
+ padding: 0 0.4rem;
416
+ }
417
+ .solid-web-ui .swui-label {
418
+ display: block;
419
+ margin-bottom: 0.35rem;
420
+ font-size: 0.8rem;
421
+ font-weight: 600;
422
+ text-transform: uppercase;
423
+ letter-spacing: 0.03em;
424
+ color: var(--swui-color-muted);
425
+ }
426
+ .solid-web-ui .swui-input, .solid-web-ui .swui-textarea {
427
+ width: 100%;
428
+ padding: 0.5rem 0.65rem;
429
+ border: 1px solid var(--swui-color-border);
430
+ border-radius: var(--swui-radius);
431
+ background: var(--swui-color-surface);
432
+ color: var(--swui-color-text);
433
+ font: inherit;
434
+ font-size: 0.9rem;
435
+ }
436
+ .solid-web-ui .swui-textarea {
437
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
438
+ line-height: 1.45;
439
+ resize: vertical;
440
+ }
441
+ .solid-web-ui .swui-input:focus, .solid-web-ui .swui-textarea:focus {
442
+ outline: none;
443
+ border-color: var(--swui-color-primary);
444
+ }
445
+ .solid-web-ui .swui-input:disabled {
446
+ opacity: 0.6;
447
+ cursor: not-allowed;
448
+ }
449
+ .solid-web-ui .swui-hint {
450
+ margin: 0.35rem 0 0;
451
+ font-size: 0.8rem;
452
+ color: var(--swui-color-muted);
453
+ }
454
+ .solid-web-ui .swui-form__actions {
455
+ display: flex;
456
+ gap: 0.5rem;
457
+ margin-top: 1.25rem;
458
+ }
459
+ .solid-web-ui .swui-codeblock {
460
+ margin: 0;
461
+ padding: 0.75rem;
462
+ max-height: 24rem;
463
+ overflow: auto;
464
+ background: var(--swui-color-muted);
465
+ @supports (color: color-mix(in lab, red, red)) {
466
+ background: color-mix(in srgb, var(--swui-color-muted) 8%, transparent);
467
+ }
468
+ border: 1px solid var(--swui-color-border);
469
+ border-radius: var(--swui-radius);
470
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
471
+ font-size: 0.8rem;
472
+ line-height: 1.45;
473
+ white-space: pre;
474
+ color: var(--swui-color-text);
475
+ }
283
476
  }
284
477
  @property --tw-blur {
285
478
  syntax: "*";
@@ -7,6 +7,10 @@ module SolidWebUi::Cable
7
7
 
8
8
  private
9
9
 
10
+ def per_page
11
+ SolidWebUi::Cable.config.per_page
12
+ end
13
+
10
14
  def trimmable_scope
11
15
  SolidCable::Message.where(created_at: ...SolidWebUi::Cable.config.retention.ago)
12
16
  end
@@ -3,11 +3,25 @@
3
3
  module SolidWebUi::Cable
4
4
  class ChannelsController < ApplicationController
5
5
  def index
6
- counts = SolidCable::Message.group(:channel).count
6
+ counts = SolidCable::Message.group(:channel, :channel_hash).count
7
7
  last_seen = SolidCable::Message.group(:channel).maximum(:created_at)
8
8
  @channels = counts
9
- .map { |channel, count| { name: channel, count: count, last: last_seen[channel] } }
9
+ .map { |(channel, hash), count| { name: channel, hash: hash, count: count, last: last_seen[channel] } }
10
10
  .sort_by { |row| -row[:count] }
11
11
  end
12
+
13
+ def show
14
+ scope = SolidCable::Message.where(channel_hash: params[:channel_hash])
15
+ @name = scope.limit(1).pick(:channel)
16
+ raise ActiveRecord::RecordNotFound if @name.nil?
17
+
18
+ @channel_hash = params[:channel_hash]
19
+ @count = scope.count
20
+ @first = scope.minimum(:created_at)
21
+ @last = scope.maximum(:created_at)
22
+
23
+ @paginator = SolidWebUi::Paginator.new(scope.order(id: :desc), page: params[:page], per_page: per_page)
24
+ @messages = @paginator.records
25
+ end
12
26
  end
13
27
  end
@@ -4,6 +4,10 @@ module SolidWebUi::Cable
4
4
  class MessagesController < ApplicationController
5
5
  before_action :ensure_trim_enabled, only: :trim
6
6
 
7
+ def show
8
+ @message = SolidCable::Message.find(params[:id])
9
+ end
10
+
7
11
  def trim
8
12
  deleted = trimmable_scope.delete_all
9
13
  redirect_to root_path, notice: "Trimmed #{deleted} old message(s)."
@@ -2,12 +2,94 @@
2
2
 
3
3
  module SolidWebUi::Cache
4
4
  class EntriesController < ApplicationController
5
- before_action :ensure_clear_enabled, only: :clear
5
+ before_action :ensure_clear_enabled, only: :clear
6
+ before_action :ensure_create_enabled, only: %i[new create]
7
+ before_action :ensure_edit_enabled, only: %i[edit update]
8
+ before_action :ensure_delete_enabled, only: :destroy
9
+ before_action :set_entry, only: %i[show edit update destroy]
6
10
 
7
11
  def index
8
12
  scope = SolidCache::Entry.order(id: :desc)
9
13
  @paginator = SolidWebUi::Paginator.new(scope, page: params[:page], per_page: per_page)
10
14
  @entries = @paginator.records
15
+ # Per-entry expiry, read from each cache envelope's header (no value
16
+ # deserialization). Numeric epoch = has a TTL, :never = decodable but no
17
+ # TTL, :undecodable = couldn't be read.
18
+ @expiry = @entries.to_h { |entry| [ entry.id, entry_expiry(entry) ] }
19
+ end
20
+
21
+ def show
22
+ @decoded = decode_value(@entry.value)
23
+ value = resolved_value(@decoded)
24
+ @value_readable = value != UNREADABLE
25
+ @value_text = value.is_a?(String) ? value : value.inspect if @value_readable
26
+ end
27
+
28
+ def new
29
+ @key = ""
30
+ @value = ""
31
+ @expires_at = ""
32
+ @version = ""
33
+ end
34
+
35
+ def create
36
+ @key = params[:key].to_s
37
+ @value = params[:value].to_s
38
+ @expires_at = params[:expires_at].to_s
39
+ @version = params[:version].to_s
40
+
41
+ if @key.empty?
42
+ return render_new_error("Key can't be blank.")
43
+ elsif SolidCache::Entry.read(@key)
44
+ return render_new_error("An entry with that key already exists.")
45
+ end
46
+
47
+ expires_at = parse_expires_at(@expires_at)
48
+ SolidCache::Entry.write(@key, encode_entry(@value, version: @version.presence, expires_at: expires_at))
49
+ redirect_to entries_path, notice: "Entry created."
50
+ rescue InvalidExpiry
51
+ render_new_error("Couldn't parse the expiry time.")
52
+ end
53
+
54
+ def edit
55
+ decoded = decode_value(@entry.value)
56
+ value = resolved_value(decoded)
57
+ @value_editable = value.is_a?(String)
58
+ @meta_editable = value != UNREADABLE
59
+ @key = scrub_for_display(@entry.key)
60
+ @expires_at = format_expires_at(decoded)
61
+ @version = (decoded.version if decoded.respond_to?(:version)).to_s
62
+
63
+ if @value_editable
64
+ @value = value
65
+ elsif @meta_editable
66
+ @value = value.inspect
67
+ @note = "This entry holds a #{value.class} (not a plain string), so the value is " \
68
+ "read-only — but you can still change its metadata below."
69
+ else
70
+ @value = scrub_for_display(@entry.value)
71
+ @note = "This value couldn't be read as a cache entry (it may use a different cache format " \
72
+ "or reference a class this app can't load), so it can't be edited here."
73
+ end
74
+ end
75
+
76
+ def update
77
+ value = resolved_value(decode_value(@entry.value))
78
+ if value == UNREADABLE
79
+ return redirect_to edit_entry_path(@entry), alert: "This entry can't be edited."
80
+ end
81
+
82
+ value = params[:value].to_s if value.is_a?(String)
83
+ expires_at = parse_expires_at(params[:expires_at])
84
+ SolidCache::Entry.write(@entry.key, encode_entry(value, version: params[:version].presence, expires_at: expires_at))
85
+ redirect_to entry_path(@entry), notice: "Entry updated."
86
+ rescue InvalidExpiry
87
+ redirect_to edit_entry_path(@entry), alert: "Couldn't parse the expiry time."
88
+ end
89
+
90
+ def destroy
91
+ @entry.destroy
92
+ redirect_to entries_path, notice: "Entry deleted."
11
93
  end
12
94
 
13
95
  def clear
@@ -17,8 +99,107 @@ module SolidWebUi::Cache
17
99
 
18
100
  private
19
101
 
102
+ InvalidExpiry = Class.new(StandardError)
103
+
104
+ # Sentinel for a value that can't be read — either the entry didn't decode, or
105
+ # its envelope decoded but resolving the value raised (a LazyEntry resolves on
106
+ # first access and can fail, e.g. a Marshal value for a class this app can't load).
107
+ UNREADABLE = Object.new.freeze
108
+ private_constant :UNREADABLE
109
+
110
+ def set_entry
111
+ @entry = SolidCache::Entry.find(params[:id])
112
+ end
113
+
114
+ def render_new_error(message)
115
+ flash.now[:alert] = message
116
+ render :new, status: :unprocessable_entity
117
+ end
118
+
119
+ # Resolve a decoded entry's value, returning UNREADABLE if it can't be read.
120
+ def resolved_value(decoded)
121
+ return UNREADABLE unless decoded.respond_to?(:value)
122
+
123
+ decoded.value
124
+ rescue StandardError
125
+ UNREADABLE
126
+ end
127
+
128
+ # The cache store whose coder matches how these entries were written. Prefer the
129
+ # host's Rails.cache when it is the Solid Cache store (exact format match), and
130
+ # fall back to a default Solid Cache store otherwise.
131
+ def cache_store
132
+ @cache_store ||=
133
+ if defined?(SolidCache::Store) && Rails.cache.is_a?(SolidCache::Store)
134
+ Rails.cache
135
+ else
136
+ ActiveSupport::Cache.lookup_store(:solid_cache_store)
137
+ end
138
+ end
139
+
140
+ # Expiry of a single entry for the index: epoch Float, :never, or :undecodable.
141
+ def entry_expiry(entry)
142
+ decoded = decode_value(entry.value)
143
+ return :undecodable unless decoded.respond_to?(:expires_at)
144
+
145
+ decoded.expires_at || :never
146
+ end
147
+
148
+ # Raw stored bytes -> ActiveSupport::Cache::Entry (or nil if undecodable).
149
+ def decode_value(raw)
150
+ cache_store.send(:deserialize_entry, raw.to_s)
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ # A value + metadata -> serialized cache-entry bytes.
156
+ def encode_entry(value, version:, expires_at:)
157
+ entry = ActiveSupport::Cache::Entry.new(value, version: version, expires_at: expires_at)
158
+ cache_store.send(:serialize_entry, entry)
159
+ end
160
+
161
+ def cache_time_zone
162
+ ActiveSupport::TimeZone[SolidWebUi::Cache.config.time_zone] || ActiveSupport::TimeZone["UTC"]
163
+ end
164
+
165
+ # Absolute epoch on the decoded entry -> a datetime-local value (YYYY-MM-DDThh:mm:ss)
166
+ # in the dashboard's time zone.
167
+ def format_expires_at(decoded)
168
+ epoch = decoded.expires_at if decoded.respond_to?(:expires_at)
169
+ return "" unless epoch
170
+
171
+ Time.at(epoch).in_time_zone(cache_time_zone).strftime("%Y-%m-%dT%H:%M:%S")
172
+ rescue StandardError
173
+ "" # a corrupt/out-of-range epoch shouldn't break the form
174
+ end
175
+
176
+ # Form string (in the dashboard's time zone) -> a Time, or nil for "no expiry".
177
+ def parse_expires_at(str)
178
+ return nil if str.blank?
179
+
180
+ cache_time_zone.parse(str) || raise(InvalidExpiry)
181
+ rescue ArgumentError, RangeError, TypeError
182
+ raise InvalidExpiry
183
+ end
184
+
185
+ def scrub_for_display(bytes)
186
+ bytes.to_s.dup.force_encoding("UTF-8").scrub("?")
187
+ end
188
+
20
189
  def ensure_clear_enabled
21
190
  head :forbidden unless SolidWebUi::Cache.config.enable_clear
22
191
  end
192
+
193
+ def ensure_create_enabled
194
+ head :forbidden unless SolidWebUi::Cache.config.enable_create
195
+ end
196
+
197
+ def ensure_edit_enabled
198
+ head :forbidden unless SolidWebUi::Cache.config.enable_edit
199
+ end
200
+
201
+ def ensure_delete_enabled
202
+ head :forbidden unless SolidWebUi::Cache.config.enable_delete
203
+ end
23
204
  end
24
205
  end
@@ -20,5 +20,12 @@ module SolidWebUi::Queue
20
20
  @paginator = SolidWebUi::Paginator.new(scope, page: params[:page], per_page: per_page)
21
21
  @jobs = @paginator.records
22
22
  end
23
+
24
+ def show
25
+ @job = SolidQueue::Job
26
+ .includes(:ready_execution, :scheduled_execution, :claimed_execution,
27
+ :blocked_execution, :failed_execution)
28
+ .find(params[:id])
29
+ end
23
30
  end
24
31
  end
@@ -9,8 +9,13 @@ module SolidWebUi::Cable
9
9
  ]
10
10
  end
11
11
 
12
- def readable_channel(channel)
13
- truncate(channel.to_s.dup.force_encoding("UTF-8").scrub("?"), length: 80)
12
+ def readable_channel(channel, length: 80)
13
+ truncate(channel.to_s.dup.force_encoding("UTF-8").scrub("?"), length: length)
14
+ end
15
+
16
+ # Cable payloads are binary; present a scrubbed, truncated preview.
17
+ def readable_payload(payload, length: 120)
18
+ truncate(payload.to_s.dup.force_encoding("UTF-8").scrub("?"), length: length)
14
19
  end
15
20
 
16
21
  def short_time(time)
@@ -10,8 +10,35 @@ module SolidWebUi::Cache
10
10
  end
11
11
 
12
12
  # Cache keys are stored as binary; present them as readable, truncated text.
13
- def readable_key(key)
14
- truncate(key.to_s.dup.force_encoding("UTF-8").scrub("?"), length: 80)
13
+ def readable_key(key, length: 80)
14
+ truncate(key.to_s.dup.force_encoding("UTF-8").scrub("?"), length: length)
15
+ end
16
+
17
+ # Cache values are binary (often a Marshal/compressed blob). Present a scrubbed,
18
+ # truncated preview so the show page never dumps megabytes of unreadable bytes.
19
+ def readable_value(value, length: 2000)
20
+ truncate(value.to_s.dup.force_encoding("UTF-8").scrub("?"), length: length)
21
+ end
22
+
23
+ # Absolute expiry of a cache entry (see EntriesController#entry_expiry).
24
+ def cache_expires_at(expiry)
25
+ case expiry
26
+ when Numeric then short_time(Time.at(expiry))
27
+ when :never then "Never"
28
+ else "—"
29
+ end
30
+ rescue StandardError
31
+ "—" # a corrupt/out-of-range epoch shouldn't break the list
32
+ end
33
+
34
+ # Remaining time until a cache entry expires, e.g. "about 2 hours" / "expired".
35
+ def cache_time_to_expire(expiry)
36
+ return "—" unless expiry.is_a?(Numeric)
37
+
38
+ remaining = expiry - Time.now.to_f
39
+ return "expired" if remaining <= 0
40
+
41
+ distance_of_time_in_words(remaining)
15
42
  end
16
43
 
17
44
  def short_time(time)
@@ -29,6 +29,34 @@ module SolidWebUi::Queue
29
29
  queue.respond_to?(:human_latency) ? queue.human_latency : queue.latency
30
30
  end
31
31
 
32
+ # Best available execution time for a job. Solid Queue does not persist a job's
33
+ # start time once it finishes (the claimed_execution row is deleted on finish),
34
+ # so:
35
+ # - finished job → finished_at - created_at (total time in the system)
36
+ # - claimed job → now - claimed_execution.created_at (live run time)
37
+ # - anything else → nil (no meaningful duration yet)
38
+ def job_duration(job)
39
+ if job.finished_at?
40
+ human_duration(job.finished_at - job.created_at)
41
+ elsif job.claimed_execution
42
+ human_duration(Time.current - job.claimed_execution.created_at)
43
+ end
44
+ end
45
+
46
+ # Compact, sub-minute-precise duration: "1.2s", "3m 04s", "2h 01m".
47
+ def human_duration(seconds)
48
+ return "—" if seconds.nil?
49
+
50
+ seconds = seconds.to_f
51
+ return format("%.1fs", seconds) if seconds < 60
52
+
53
+ if seconds < 3600
54
+ format("%dm %02ds", seconds / 60, seconds % 60)
55
+ else
56
+ format("%dh %02dm", seconds / 3600, (seconds % 3600) / 60)
57
+ end
58
+ end
59
+
32
60
  def short_time(time)
33
61
  return "—" if time.nil?
34
62
 
@@ -8,6 +8,18 @@
8
8
  <%= solid_web_ui_head_tags %>
9
9
  </head>
10
10
  <body>
11
+ <%# Flash lives in the gem's own layout only. When the dashboards are embedded %>
12
+ <%# in a host layout (config.layout = "your_layout"), the host renders flash, so %>
13
+ <%# rendering it here too would duplicate it. %>
14
+ <% if flash.any? %>
15
+ <div class="solid-web-ui" data-color-scheme="<%= SolidWebUi.config.color_scheme %>">
16
+ <div class="swui-flash-region">
17
+ <% flash.each do |type, message| %>
18
+ <div class="swui-flash swui-flash--<%= type.to_s == "alert" ? "alert" : "notice" %>"><%= message %></div>
19
+ <% end %>
20
+ </div>
21
+ </div>
22
+ <% end %>
11
23
  <%= yield %>
12
24
  </body>
13
25
  </html>
@@ -2,8 +2,8 @@
2
2
  <%= swui_page(title: "Channels", nav: cable_nav(:channels)) do %>
3
3
  <%= swui_table(headers: [ "Channel", "Messages", "Last activity" ], empty_message: "No channels.") do %>
4
4
  <% @channels.each do |row| %>
5
- <tr>
6
- <td><code><%= readable_channel(row[:name]) %></code></td>
5
+ <tr data-swui-row-href="<%= channel_path(row[:hash]) %>">
6
+ <td><%= link_to channel_path(row[:hash]), data: { turbo_frame: "_top" } do %><code><%= readable_channel(row[:name]) %></code><% end %></td>
7
7
  <td><%= number_with_delimiter(row[:count]) %></td>
8
8
  <td><%= short_time(row[:last]) %></td>
9
9
  </tr>
@@ -0,0 +1,35 @@
1
+ <% title = readable_channel(@name) %>
2
+ <% content_for :title, "Channel #{title}" %>
3
+ <%= swui_page(title: "Channel", nav: cable_nav(:channels), refresh: false) do %>
4
+ <%= link_to "← Back to channels", channels_path, class: "swui-back" %>
5
+
6
+ <dl class="swui-detail">
7
+ <dt>Channel</dt>
8
+ <dd><code><%= readable_channel(@name, length: 1024) %></code></dd>
9
+
10
+ <dt>Channel hash</dt>
11
+ <dd><code><%= @channel_hash %></code></dd>
12
+
13
+ <dt>Messages</dt>
14
+ <dd><%= number_with_delimiter(@count) %></dd>
15
+
16
+ <dt>First activity</dt>
17
+ <dd><%= short_time(@first) %></dd>
18
+
19
+ <dt>Last activity</dt>
20
+ <dd><%= short_time(@last) %></dd>
21
+ </dl>
22
+
23
+ <h2 class="swui-section-title">Recent messages</h2>
24
+ <%= swui_table(headers: [ "ID", "Payload", "Created" ], empty_message: "No messages.") do %>
25
+ <% @messages.each do |message| %>
26
+ <tr data-swui-row-href="<%= message_path(message) %>">
27
+ <td><%= link_to message.id, message_path(message) %></td>
28
+ <td><code><%= readable_payload(message.payload) %></code></td>
29
+ <td><%= short_time(message.created_at) %></td>
30
+ </tr>
31
+ <% end %>
32
+ <% end %>
33
+
34
+ <%= swui_paginator(paginator: @paginator, page_url: ->(page) { channel_path(@channel_hash, page: page) }) %>
35
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <% content_for :title, "Message ##{@message.id}" %>
2
+ <%= swui_page(title: "Message ##{@message.id}", nav: cable_nav(:channels), refresh: false) do %>
3
+ <%= link_to "← Back to channel", channel_path(@message.channel_hash), class: "swui-back" %>
4
+
5
+ <dl class="swui-detail">
6
+ <dt>ID</dt>
7
+ <dd><%= @message.id %></dd>
8
+
9
+ <dt>Channel</dt>
10
+ <dd><code><%= readable_channel(@message.channel, length: 1024) %></code></dd>
11
+
12
+ <dt>Channel hash</dt>
13
+ <dd><code><%= @message.channel_hash %></code></dd>
14
+
15
+ <dt>Created</dt>
16
+ <dd><%= short_time(@message.created_at) %></dd>
17
+
18
+ <dt>Payload</dt>
19
+ <dd><pre class="swui-codeblock"><%= readable_payload(@message.payload, length: 100_000) %></pre></dd>
20
+ </dl>
21
+ <% end %>
@@ -0,0 +1,47 @@
1
+ <%= form_with url: url, method: method, class: "swui-form" do |f| %>
2
+ <div class="swui-field">
3
+ <label class="swui-label" for="entry_key">Key</label>
4
+ <% if new_record %>
5
+ <%= text_field_tag :key, key, id: "entry_key", class: "swui-input", autocomplete: "off" %>
6
+ <% else %>
7
+ <input id="entry_key" class="swui-input" value="<%= key %>" disabled>
8
+ <p class="swui-hint">The key identifies the entry and can't be changed — delete and recreate to rename.</p>
9
+ <% end %>
10
+ </div>
11
+
12
+ <div class="swui-field">
13
+ <label class="swui-label" for="entry_value">Value</label>
14
+ <% if note %>
15
+ <div class="swui-flash swui-flash--alert"><%= note %></div>
16
+ <% end %>
17
+ <%= text_area_tag :value, value, id: "entry_value", class: "swui-textarea", rows: 12, disabled: !value_editable %>
18
+ <% if value_editable %>
19
+ <p class="swui-hint">Saved through the cache encoder, so <code>Rails.cache</code> reads it back
20
+ as a string.</p>
21
+ <% end %>
22
+ </div>
23
+
24
+ <fieldset class="swui-fieldset">
25
+ <legend class="swui-label">Metadata</legend>
26
+ <div class="swui-field">
27
+ <label class="swui-label" for="entry_expires_at">Expires at</label>
28
+ <%= text_field_tag :expires_at, expires_at, id: "entry_expires_at", class: "swui-input",
29
+ type: "datetime-local", step: 1, disabled: !meta_editable %>
30
+ <p class="swui-hint">Time zone <code><%= SolidWebUi::Cache.config.time_zone %></code>. Leave blank for no expiry.</p>
31
+ </div>
32
+
33
+ <div class="swui-field">
34
+ <label class="swui-label" for="entry_version">Version</label>
35
+ <%= text_field_tag :version, version, id: "entry_version", class: "swui-input",
36
+ autocomplete: "off", disabled: !meta_editable %>
37
+ <p class="swui-hint">Used by <code>Rails.cache</code>'s <code>:version</code> option. Leave blank for none.</p>
38
+ </div>
39
+ </fieldset>
40
+
41
+ <div class="swui-form__actions">
42
+ <% if meta_editable %>
43
+ <%= f.submit(new_record ? "Create entry" : "Save changes", class: "swui-btn") %>
44
+ <% end %>
45
+ <%= link_to "Cancel", cancel_url, class: "swui-btn" %>
46
+ </div>
47
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <% content_for :title, "Edit cache entry ##{@entry.id}" %>
2
+ <%= swui_page(title: "Edit cache entry ##{@entry.id}", nav: cache_nav(:entries), refresh: false) do %>
3
+ <%= link_to "← Back to entry", entry_path(@entry), class: "swui-back" %>
4
+
5
+ <%= render "form", url: entry_path(@entry), method: :patch, new_record: false,
6
+ value_editable: @value_editable, meta_editable: @meta_editable, note: @note,
7
+ cancel_url: entry_path(@entry),
8
+ key: @key, value: @value, expires_at: @expires_at, version: @version %>
9
+ <% end %>
@@ -1,12 +1,26 @@
1
1
  <% content_for :title, "Cache entries" %>
2
2
  <%= swui_page(title: "Cache entries", nav: cache_nav(:entries)) do %>
3
- <%= swui_table(headers: [ "ID", "Key", "Size", "Created" ], empty_message: "The cache is empty.") do %>
3
+ <% if SolidWebUi::Cache.config.enable_create %>
4
+ <div class="swui-actions">
5
+ <%= link_to "+ New entry", new_entry_path, class: "swui-btn", data: { turbo_frame: "_top" } %>
6
+ </div>
7
+ <% end %>
8
+
9
+ <% headers = [ "ID", "Key", "Size", "Created", "Expires", "Time to expire" ] %>
10
+ <% headers << "" if SolidWebUi::Cache.config.enable_delete %>
11
+ <%= swui_table(headers: headers, empty_message: "The cache is empty.") do %>
4
12
  <% @entries.each do |entry| %>
5
- <tr>
6
- <td><%= entry.id %></td>
13
+ <tr data-swui-row-href="<%= entry_path(entry) %>">
14
+ <td><%= link_to entry.id, entry_path(entry), data: { turbo_frame: "_top" } %></td>
7
15
  <td><code><%= readable_key(entry.key) %></code></td>
8
16
  <td><%= number_to_human_size(entry.byte_size) %></td>
9
17
  <td><%= short_time(entry.created_at) %></td>
18
+ <td><%= cache_expires_at(@expiry[entry.id]) %></td>
19
+ <td><%= cache_time_to_expire(@expiry[entry.id]) %></td>
20
+ <% if SolidWebUi::Cache.config.enable_delete %>
21
+ <td><%= swui_action_button(label: "Delete", url: entry_path(entry), method: :delete,
22
+ danger: true, confirm: "Delete this cache entry?") %></td>
23
+ <% end %>
10
24
  </tr>
11
25
  <% end %>
12
26
  <% end %>
@@ -0,0 +1,8 @@
1
+ <% content_for :title, "New cache entry" %>
2
+ <%= swui_page(title: "New cache entry", nav: cache_nav(:entries), refresh: false) do %>
3
+ <%= link_to "← Back to entries", entries_path, class: "swui-back" %>
4
+
5
+ <%= render "form", url: entries_path, method: :post, new_record: true,
6
+ value_editable: true, meta_editable: true, note: nil, cancel_url: entries_path,
7
+ key: @key, value: @value, expires_at: @expires_at, version: @version %>
8
+ <% end %>
@@ -0,0 +1,50 @@
1
+ <% content_for :title, "Cache entry ##{@entry.id}" %>
2
+ <%= swui_page(title: "Cache entry ##{@entry.id}", nav: cache_nav(:entries), refresh: false) do %>
3
+ <%= link_to "← Back to entries", entries_path, class: "swui-back" %>
4
+
5
+ <dl class="swui-detail">
6
+ <dt>ID</dt>
7
+ <dd><%= @entry.id %></dd>
8
+
9
+ <dt>Key</dt>
10
+ <dd><code><%= readable_key(@entry.key, length: 1024) %></code></dd>
11
+
12
+ <dt>Key hash</dt>
13
+ <dd><code><%= @entry.key_hash %></code></dd>
14
+
15
+ <dt>Size</dt>
16
+ <dd><%= number_to_human_size(@entry.byte_size) %> (<%= number_with_delimiter(@entry.byte_size) %> bytes)</dd>
17
+
18
+ <dt>Created</dt>
19
+ <dd><%= short_time(@entry.created_at) %></dd>
20
+
21
+ <% if @decoded.respond_to?(:expires_at) %>
22
+ <dt>Expires</dt>
23
+ <dd><%= @decoded.expires_at ? short_time(Time.at(@decoded.expires_at)) : "Never" %></dd>
24
+ <% end %>
25
+
26
+ <% if @decoded.respond_to?(:version) %>
27
+ <dt>Version</dt>
28
+ <dd><%= @decoded.version.presence || "—" %></dd>
29
+ <% end %>
30
+
31
+ <dt>Value<% unless @value_readable %> <span class="swui-detail__hint">(raw — unreadable)</span><% end %></dt>
32
+ <dd>
33
+ <% if @value_readable %>
34
+ <pre class="swui-codeblock"><%= @value_text %></pre>
35
+ <% else %>
36
+ <pre class="swui-codeblock"><%= readable_value(@entry.value) %></pre>
37
+ <% end %>
38
+ </dd>
39
+ </dl>
40
+
41
+ <div class="swui-actions">
42
+ <% if SolidWebUi::Cache.config.enable_edit %>
43
+ <%= link_to "Edit", edit_entry_path(@entry), class: "swui-btn", data: { turbo_frame: "_top" } %>
44
+ <% end %>
45
+ <% if SolidWebUi::Cache.config.enable_delete %>
46
+ <%= swui_action_button(label: "Delete", url: entry_path(@entry), method: :delete,
47
+ danger: true, confirm: "Delete this cache entry?") %>
48
+ <% end %>
49
+ </div>
50
+ <% end %>
@@ -9,8 +9,8 @@
9
9
 
10
10
  <%= swui_table(headers: [ "ID", "Job", "Queue", "Status", "Created" ], empty_message: "No jobs.") do %>
11
11
  <% @jobs.each do |job| %>
12
- <tr>
13
- <td><%= job.id %></td>
12
+ <tr data-swui-row-href="<%= job_path(job) %>">
13
+ <td><%= link_to job.id, job_path(job), data: { turbo_frame: "_top" } %></td>
14
14
  <td><%= job.class_name %></td>
15
15
  <td><%= job.queue_name %></td>
16
16
  <td><%= swui_status_badge(label: job_status(job).to_s.humanize, status: job_status(job)) %></td>
@@ -0,0 +1,62 @@
1
+ <% content_for :title, "Job ##{@job.id}" %>
2
+ <%= swui_page(title: "Job ##{@job.id}", nav: queue_nav(:jobs), refresh: false) do %>
3
+ <%= link_to "← Back to jobs", jobs_path, class: "swui-back" %>
4
+
5
+ <dl class="swui-detail">
6
+ <dt>ID</dt>
7
+ <dd><%= @job.id %></dd>
8
+
9
+ <dt>Job</dt>
10
+ <dd><%= @job.class_name %></dd>
11
+
12
+ <dt>Queue</dt>
13
+ <dd><%= @job.queue_name %></dd>
14
+
15
+ <dt>Status</dt>
16
+ <dd><%= swui_status_badge(label: job_status(@job).to_s.humanize, status: job_status(@job)) %></dd>
17
+
18
+ <dt>Priority</dt>
19
+ <dd><%= @job.priority %></dd>
20
+
21
+ <dt>Active Job ID</dt>
22
+ <dd><code><%= @job.active_job_id || "—" %></code></dd>
23
+
24
+ <dt>Enqueued</dt>
25
+ <dd><%= short_time(@job.created_at) %></dd>
26
+
27
+ <dt>Scheduled</dt>
28
+ <dd><%= short_time(@job.scheduled_at) %></dd>
29
+
30
+ <dt>Finished</dt>
31
+ <dd><%= short_time(@job.finished_at) %></dd>
32
+
33
+ <dt>Duration<% if @job.finished_at? %> <span class="swui-detail__hint">(enqueue → finish)</span><% end %></dt>
34
+ <dd><%= job_duration(@job) || "—" %></dd>
35
+
36
+ <dt>Arguments</dt>
37
+ <dd><pre class="swui-codeblock"><%= JSON.pretty_generate(@job.arguments) rescue @job.arguments.inspect %></pre></dd>
38
+ </dl>
39
+
40
+ <% if (failed = @job.failed_execution) %>
41
+ <h2 class="swui-section-title">Error</h2>
42
+ <dl class="swui-detail">
43
+ <dt>Exception</dt>
44
+ <dd><%= swui_status_badge(label: failed.exception_class.to_s, status: :failed) %></dd>
45
+
46
+ <dt>Message</dt>
47
+ <dd><%= failed.message %></dd>
48
+
49
+ <dt>Failed at</dt>
50
+ <dd><%= short_time(failed.created_at) %></dd>
51
+
52
+ <dt>Backtrace</dt>
53
+ <dd>
54
+ <% if failed.backtrace.present? %>
55
+ <pre class="swui-codeblock"><%= Array(failed.backtrace).join("\n") %></pre>
56
+ <% else %>
57
+
58
+ <% end %>
59
+ </dd>
60
+ </dl>
61
+ <% end %>
62
+ <% end %>
@@ -3,5 +3,7 @@
3
3
  SolidWebUi::Cable::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
5
  get "channels", to: "channels#index", as: :channels
6
+ get "channels/:channel_hash", to: "channels#show", as: :channel
6
7
  delete "messages/trim", to: "messages#trim", as: :trim_messages
8
+ get "messages/:id", to: "messages#show", as: :message
7
9
  end
@@ -2,6 +2,14 @@
2
2
 
3
3
  SolidWebUi::Cache::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
+
5
6
  get "entries", to: "entries#index", as: :entries
7
+ get "entries/new", to: "entries#new", as: :new_entry
8
+ post "entries", to: "entries#create"
6
9
  delete "entries", to: "entries#clear", as: :clear_entries
10
+
11
+ get "entries/:id", to: "entries#show", as: :entry
12
+ get "entries/:id/edit", to: "entries#edit", as: :edit_entry
13
+ patch "entries/:id", to: "entries#update"
14
+ delete "entries/:id", to: "entries#destroy", as: :delete_entry
7
15
  end
@@ -11,6 +11,9 @@ module SolidWebUi
11
11
  config.page_title = "Solid Cache"
12
12
 
13
13
  setting :enable_clear, default: true
14
+ setting :enable_create, default: true
15
+ setting :enable_edit, default: true
16
+ setting :enable_delete, default: true
14
17
  end
15
18
  end
16
19
 
@@ -8,6 +8,7 @@ SolidWebUi::Queue::Engine.routes.draw do
8
8
  post "queues/:name/resume", to: "queues#resume", as: :resume_queue, constraints: { name: %r{[^/]+} }
9
9
 
10
10
  get "jobs", to: "jobs#index", as: :jobs
11
+ get "jobs/:id", to: "jobs#show", as: :job
11
12
 
12
13
  get "failed", to: "failed_executions#index", as: :failed_executions
13
14
  post "failed/:id/retry", to: "failed_executions#retry", as: :retry_failed_execution
@@ -21,7 +21,10 @@ module SolidWebUi
21
21
  color_warning: [ "--swui-color-warning", "var(--color-warning, #d97706)" ],
22
22
  color_danger: [ "--swui-color-danger", "var(--color-danger, #dc2626)" ],
23
23
  font: [ "--swui-font", "var(--swui-host-font, ui-sans-serif, system-ui, sans-serif)" ],
24
- radius: [ "--swui-radius", "0.5rem" ]
24
+ radius: [ "--swui-radius", "0.5rem" ],
25
+ # Max width of the centered page column. Set to "none" or "100%" to let the
26
+ # dashboards fill their container (useful when embedded in a host layout).
27
+ page_max_width: [ "--swui-page-max-width", "72rem" ]
25
28
  }.freeze
26
29
 
27
30
  # Dark scheme only re-points the surface/text family; brand color stays.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidWebUi
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_web_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Radushev
@@ -151,12 +151,19 @@ files:
151
151
  - app/helpers/solid_web_ui/queue/application_helper.rb
152
152
  - app/views/layouts/solid_web_ui.html.erb
153
153
  - app/views/solid_web_ui/cable/channels/index.html.erb
154
+ - app/views/solid_web_ui/cable/channels/show.html.erb
154
155
  - app/views/solid_web_ui/cable/dashboard/index.html.erb
156
+ - app/views/solid_web_ui/cable/messages/show.html.erb
155
157
  - app/views/solid_web_ui/cache/dashboard/index.html.erb
158
+ - app/views/solid_web_ui/cache/entries/_form.html.erb
159
+ - app/views/solid_web_ui/cache/entries/edit.html.erb
156
160
  - app/views/solid_web_ui/cache/entries/index.html.erb
161
+ - app/views/solid_web_ui/cache/entries/new.html.erb
162
+ - app/views/solid_web_ui/cache/entries/show.html.erb
157
163
  - app/views/solid_web_ui/queue/dashboard/index.html.erb
158
164
  - app/views/solid_web_ui/queue/failed_executions/index.html.erb
159
165
  - app/views/solid_web_ui/queue/jobs/index.html.erb
166
+ - app/views/solid_web_ui/queue/jobs/show.html.erb
160
167
  - app/views/solid_web_ui/queue/processes/index.html.erb
161
168
  - app/views/solid_web_ui/queue/queues/index.html.erb
162
169
  - app/views/solid_web_ui/queue/recurring_tasks/index.html.erb