roundhouse_ui 0.9.1 → 0.11.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -46
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
  4. data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
  5. data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
  6. data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
  7. data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
  15. data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
  16. data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
  17. data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
  18. data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
  23. data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
  24. data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
  25. data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
  26. data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
  30. data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
  31. data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
  32. data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
  34. data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
  35. data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
  36. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  37. data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
  38. data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
  39. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
  40. data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
  41. data/config/routes.rb +9 -0
  42. data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
  43. data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
  44. data/lib/roundhouse_ui/demo.rb +130 -0
  45. data/lib/roundhouse_ui/error_groups.rb +25 -4
  46. data/lib/roundhouse_ui/filter_query.rb +504 -0
  47. data/lib/roundhouse_ui/health.rb +43 -4
  48. data/lib/roundhouse_ui/history.rb +54 -0
  49. data/lib/roundhouse_ui/icons.rb +104 -0
  50. data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
  51. data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
  52. data/lib/roundhouse_ui/observability.rb +55 -7
  53. data/lib/roundhouse_ui/pause.rb +6 -11
  54. data/lib/roundhouse_ui/queue_summary.rb +11 -0
  55. data/lib/roundhouse_ui/recurring.rb +137 -0
  56. data/lib/roundhouse_ui/runbooks.rb +75 -0
  57. data/lib/roundhouse_ui/snapshots.rb +13 -2
  58. data/lib/roundhouse_ui/tags.rb +112 -0
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +235 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +32 -7
@@ -0,0 +1,104 @@
1
+ module RoundhouseUi
2
+ # Icons as inline SVG. No font, no request, no CSP change, and identical on
3
+ # every platform — which the Unicode glyphs these replace were not: `⛁` and
4
+ # `☷` have no glyph at all on older Windows, `⚙`/`☾`/`⚠` get substituted for
5
+ # emoji on some platforms, and `▦` and `▤` are indistinguishable at 16px.
6
+ #
7
+ # Hosts that already ship an icon font can use it instead:
8
+ #
9
+ # RoundhouseUi.icons = :font_awesome
10
+ # RoundhouseUi.icons = { dashboard: "fa-solid fa-gauge-high", ... }
11
+ #
12
+ # Roundhouse never loads a font itself in either case. It emits class names and
13
+ # the host's existing pipeline supplies the glyphs, which is what keeps the
14
+ # self-contained CSP intact.
15
+ module Icons
16
+ VIEWBOX = "0 0 24 24".freeze
17
+ # Geometric on purpose: an operations console is not the place for
18
+ # personality, and a 1.9 stroke stays legible at 15px.
19
+ ATTRS = %(viewBox="#{VIEWBOX}" width="15" height="15" fill="none" stroke="currentColor" ) +
20
+ %(stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false")
21
+
22
+ PATHS = {
23
+ dashboard: '<rect x="3" y="3" width="7.5" height="7.5" rx="1.4"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.4"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.4"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.4"/>',
24
+ queues: '<path d="M4 7h16M4 12h16M4 17h10"/>',
25
+ retries: '<path d="M20 11a8 8 0 1 0-2.5 6.9"/><path d="M20 5v6h-6"/>',
26
+ dead: '<circle cx="12" cy="12" r="8.4"/><path d="M9 9l6 6M15 9l-6 6"/>',
27
+ errors: '<path d="M12 4.2 2.8 20h18.4L12 4.2Z"/><path d="M12 10v4"/><circle cx="12" cy="17" r=".6" fill="currentColor"/>',
28
+ busy: '<circle cx="12" cy="12" r="8.4"/><path d="M12 3.6a8.4 8.4 0 0 1 0 16.8Z" fill="currentColor" stroke="none"/>',
29
+ metrics: '<path d="M4 20V13M9.3 20V8M14.7 20v-6M20 20V4"/>',
30
+ snapshots: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
31
+ audit: '<path d="M4 6h16M4 11h16M4 16h9"/><path d="M15.5 18.5l1.8 1.8 3.2-3.6"/>',
32
+ settings: '<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2.1"/><circle cx="7" cy="17" r="2.1"/>',
33
+ scheduled: '<circle cx="12" cy="12" r="8.4"/><path d="M12 7.6V12l3.2 2"/>',
34
+ workers: '<rect x="6.5" y="6.5" width="11" height="11" rx="1.6"/><path d="M10 3v3M14 3v3M10 18v3M14 18v3M3 10h3M3 14h3M18 10h3M18 14h3"/>',
35
+ capsules: '<rect x="3.5" y="3.5" width="7" height="7" rx="1.3"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.3"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.3"/><path d="M17 14v6M14 17h6"/>',
36
+ redis: '<path d="M3.6 7.5 12 4l8.4 3.5L12 11 3.6 7.5Z"/><path d="M3.6 12 12 15.5 20.4 12"/><path d="M3.6 16.5 12 20l8.4-3.5"/>',
37
+ enqueue: '<path d="M12 5v14M5 12h14"/>',
38
+ width: '<path d="M3 12h18"/><path d="M7 8l-4 4 4 4M17 8l4 4-4 4"/>',
39
+ theme: '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z"/>',
40
+ pause: '<path d="M9.5 5v14M14.5 5v14"/>',
41
+ delete: '<path d="M5 7h14"/><path d="M9 7V4.6h6V7"/><path d="M6.5 7l1 12.4h9L18 7"/>',
42
+ ok: '<path d="M4.5 12.5l5 5L20 6.5"/>',
43
+ warn: '<path d="M12 4.2 2.8 20h18.4L12 4.2Z"/><path d="M12 10v4"/><circle cx="12" cy="17" r=".6" fill="currentColor"/>',
44
+ stalled: '<circle cx="12" cy="12" r="8.4"/><path d="M8.5 12h7"/>',
45
+ search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.4 15.4 21 21"/>',
46
+ # A funnel, for "find more like this". Deliberately not the magnifier: the
47
+ # search box is one control and this is another, and giving them the same
48
+ # glyph is what made the row control ambiguous. A funnel also cannot be
49
+ # misread as "duplicate", which two stacked shapes would be with Edit
50
+ # sitting directly beside it.
51
+ filter: '<path d="M4 5h16l-6.2 7.3v5.9L10.2 21v-8.7L4 5Z"/>',
52
+ runbook: '<path d="M5 4h9l5 5v11H5Z"/><path d="M14 4v5h5"/><path d="M8.5 13h7M8.5 16.5h4.5"/>',
53
+ # Default trace glyph: an external-link arrow. Roundhouse ships no vendor
54
+ # logo — see Observability adapters, which may supply their own mark.
55
+ trace_out: '<path d="M14 4h6v6"/><path d="M20 4 11 13"/><path d="M18 14v4.5A1.5 1.5 0 0 1 16.5 20h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10"/>'
56
+ }.freeze
57
+
58
+ # For hosts that already load FontAwesome Free. Names chosen from the free
59
+ # set so nothing here needs a Pro licence.
60
+ FONT_AWESOME = {
61
+ dashboard: "fa-solid fa-gauge-high", queues: "fa-solid fa-layer-group",
62
+ retries: "fa-solid fa-rotate-right", dead: "fa-solid fa-circle-xmark",
63
+ errors: "fa-solid fa-triangle-exclamation", busy: "fa-solid fa-circle-half-stroke",
64
+ metrics: "fa-solid fa-chart-simple", snapshots: "fa-solid fa-database",
65
+ audit: "fa-solid fa-list-check", settings: "fa-solid fa-sliders",
66
+ scheduled: "fa-regular fa-clock", workers: "fa-solid fa-microchip",
67
+ capsules: "fa-solid fa-table-cells-large", redis: "fa-solid fa-cubes-stacked",
68
+ enqueue: "fa-solid fa-plus", width: "fa-solid fa-arrows-left-right",
69
+ theme: "fa-solid fa-moon", pause: "fa-solid fa-pause",
70
+ delete: "fa-solid fa-trash-can", ok: "fa-solid fa-check",
71
+ warn: "fa-solid fa-triangle-exclamation", stalled: "fa-solid fa-minus",
72
+ search: "fa-solid fa-magnifying-glass", runbook: "fa-regular fa-file-lines",
73
+ filter: "fa-solid fa-filter",
74
+ trace_out: "fa-solid fa-arrow-up-right-from-square"
75
+ }.freeze
76
+
77
+ module_function
78
+
79
+ # An icon's markup, or nil when the name is unknown — an unknown name draws
80
+ # nothing rather than raising, so a typo in a host's mapping cannot take a
81
+ # page down.
82
+ def markup(name, config = RoundhouseUi.icons)
83
+ key = name.to_s.to_sym
84
+ case config
85
+ when :font_awesome then class_name(FONT_AWESOME[key])
86
+ when Hash then class_name(config[key] || config[name.to_s])
87
+ else svg(key)
88
+ end
89
+ end
90
+
91
+ def svg(key)
92
+ path = PATHS[key]
93
+ return nil unless path
94
+
95
+ %(<svg #{ATTRS}>#{path}</svg>)
96
+ end
97
+
98
+ # Host-supplied, so it is never marked html_safe — the caller escapes it into
99
+ # a class attribute.
100
+ def class_name(value)
101
+ value.presence
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,30 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800.5 196.2" role="img" aria-hidden="true" focusable="false" class="rh-mark-dd rh-mark-on-dark"><g><g><path fill="#FFFFFF" d="M167.2,98.4l-54.9,10c-1.4,1.8-4.7,4.8-6.3,5.6c-6.9,3.5-11.6,2.5-15.7,1.4c-2.6-0.7-4.1-1-6.3-2l-13.4,1.8
2
+ l8.1,67.9l94.1-17L167.2,98.4z M83.4,176.7l-0.8-7.4L98,145.9l17.4,5.1l15-25.1l18,11.9l13.7-28.7l4.9,52.2L83.4,176.7z M79.5,110
3
+ c-6.8-4.4-13.8-10.6-16.9-14.1c-0.5-0.4-0.4-2.1-0.4-2.1c2.8,2.2,14.3,10.4,26.4,14.1c4.3,1.3,10.9,1.8,16.6-1.4
4
+ c4.4-2.5,9.6-6.8,12.7-11.2l0.6,1c0,0.7-1.5,3.9-2.2,5.2c1.3,0.8,2.3,1,3.8,1.4l10.1-1.6c3.6-5.8,6.2-15.2,3.4-24.2
5
+ c-1.6-5.1-9.8-15.4-10.4-15.9c-2.1-2,0.3-9.6-3.7-18c-4.3-8.8-15.6-18-20.5-22.1c1.4,1,10.3,4.6,14.4,9.6c0.4-0.5,0.6-3.2,0.9-3.9
6
+ c-3.6-4.6-3.8-12.9-3.8-15.1c0-4.1-2.1-8.7-2.1-8.7s3.6,2.8,4.5,7.7c1.1,5.7,3.4,10.3,6.5,14.1c5.8,7.2,11,10.9,13.7,8.2
7
+ c3.2-3.2-3.2-17.5-11.3-25.5c-9.5-9.3-12-8.1-17.5-6.1c-4.4,1.5-6.8,14.2-18.4,13.9c-2-0.2-7-0.4-9.5-0.3c1.3-1.8,2.4-3.2,2.4-3.2
8
+ s-3.9,1.6-7.2,3.6l-0.3-0.4c1.1-2.4,2.3-3.8,2.3-3.8s-3.1,1.9-5.9,4.1c0.5-2.8,2.5-4.6,2.5-4.6s-3.9,0.7-8.9,6.2
9
+ c-5.7,1.5-7,2.6-11.6,4.6c-7.4-1.6-10.8-4.2-14.1-9c-2.5-3.6-7-4.2-11.6-2.3c-6.7,2.8-15.1,6.5-15.1,6.5s2.8-0.1,5.6,0
10
+ c-3.9,1.5-7.7,3.5-7.7,3.5s1.8-0.1,4.1,0c-1.6,1.3-2.4,1.9-3.9,2.9c-3.6,2.6-6.5,5.6-6.5,5.6s2.4-1.1,4.6-1.7
11
+ c-1.5,3.5-4.6,6.1-4,10.4c0.5,3.9,5.3,12,11.6,17c0.5,0.4,9,8.3,15.4,5.1c6.4-3.2,8.9-6.1,10-10.5c1.2-5.1,0.5-8.9-2.1-19.9
12
+ c-0.9-3.6-3.1-11.1-4.2-14.6l0.2-0.2c2.1,4.4,7.4,16.1,9.6,23.9c3.4,12.2,2.3,18.4,0.8,20.7c-4.7,6.8-16.6,7.8-22,4
13
+ c-0.8,13.1,2.1,18.9,3.1,21.8c-0.5,3.3,1.7,9.6,1.7,9.6s0.2-2.8,1.2-4.3c0.3,3.3,1.9,7.3,1.9,7.3s-0.1-2.4,0.7-4.5
14
+ c1.1,1.8,1.9,2.2,2.9,3.6c1,3.6,3,6.2,3,6.2s-0.3-1.9-0.2-3.9c5,4.8,5.8,11.8,6.3,17.1c1.4,14.7-23.2,26.4-28,35.6
15
+ c-3.6,5.4-5.8,14.1,0.3,19.2c14.8,12.3,9.1,15.7,16.5,21.1c10.2,7.4,22.9,4.1,27.2-1.9c6-8.4,4.5-16.3,2.2-23.7
16
+ c-1.8-5.8-6.5-15.4-12.4-19.1c-6-3.8-11.9-4.5-16.8-4l0.5-0.5c7.1-1.4,14.6-0.6,20,2.8c6.1,3.9,11.7,10.7,14.6,21
17
+ c3.3-0.5,3.8-0.7,6.8-1.1L65,111.6L79.5,110z M113.8,43.3c6.3,2.9,5.5,8.5,5.6,11.8c0.1,0.9,0,1.6-0.1,2c-0.9-0.5-2.2-0.8-4.4-0.7
18
+ c-0.6,0-1.3,0.1-1.9,0.2c-2.3-1.2-3.7-3.7-5-6.3c-0.1-0.2-0.2-0.5-0.3-0.7c0-0.1-0.1-0.2-0.1-0.3c0,0,0-0.1,0-0.1
19
+ c-0.7-2.2-0.2-2.7,0.1-3.3s1.4-1.3-0.2-1.8c-0.1,0-0.3-0.1-0.5-0.1C108.1,42.6,111.4,42.2,113.8,43.3z M106,79.9
20
+ c1.2-0.9,6.8-2.7,12-3.4c2.7-0.3,6.6-0.5,7.4,0c1.6,0.9,1.6,3.7,0.5,6.3c-1.6,3.8-3.9,7.9-6.6,8.2c-4.3,0.6-8.3-1.7-12.9-5.2
21
+ C104.8,84.6,103.8,81.6,106,79.9z M65.6,51.4c6.8-4.6,15.5-2.8,14-1.4c-2.8,2.7,0.9,1.9,1.3,6.8c0.3,3.6-0.9,5.6-1.9,6.7
22
+ c-2.1,0.3-4.7,0.8-7.8,1.8c-1.8,0.6-3.4,1.2-4.8,1.9c-0.4-0.2-0.8-0.5-1.2-0.9C60.5,62.1,60.7,54.8,65.6,51.4z"/></g><g><path fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" d="M249.2,141.8H211v-88h38.2c27.5,0,41.3,13.9,41.3,41.6C290.5,126.3,276.8,141.8,249.2,141.8z M227.4,127.6
23
+ h19.4c18.3,0,27.4-10.7,27.4-32.2c0-18.3-9.1-27.4-27.4-27.4h-19.4V127.6L227.4,127.6z"/><polygon fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" points="307.6,141.8 290.9,141.8 328.3,53.8 345.9,53.8 384.1,141.8 366.5,141.8 355.4,117.8 327.2,117.8
24
+ 332.8,103.7 351.1,103.7 336.7,70.6 "/><polygon fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" points="374.8,53.8 441.7,53.8 441.7,68 416.5,68 416.5,141.8 400.1,141.8 400.1,68 374.8,68 "/><polygon fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" points="450.1,141.8 433.3,141.8 470.8,53.8 488.3,53.8 526.5,141.8 509,141.8 497.9,117.8 469.6,117.8
25
+ 475.2,103.7 493.5,103.7 479.1,70.6 "/><path fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" d="M575.6,141.8h-38.2v-88h38.2c27.5,0,41.3,13.9,41.3,41.6C616.9,126.3,603.1,141.8,575.6,141.8z M553.7,127.6
26
+ h19.4c18.3,0,27.4-10.7,27.4-32.2c0-18.3-9.2-27.4-27.4-27.4h-19.4V127.6z"/><path fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" d="M628,97.9c0-29.8,14.8-44.7,44.3-44.7c29,0,43.6,14.9,43.6,44.7c0,29.7-14.5,44.5-43.6,44.5
27
+ C644,142.4,629.3,127.6,628,97.9z M672.2,128.2c17.7,0,26.6-10.2,26.6-30.7c0-20.2-8.9-30.2-26.6-30.2
28
+ c-18.2,0-27.3,10.1-27.3,30.2C644.9,118,654,128.2,672.2,128.2z"/><path fill="#FFFFFF" fill-rule="evenodd" clip-rule="evenodd" d="M783.9,106.2v20.6c-3.8,1-7.1,1.5-10.1,1.5c-20,0-30-10.6-30-31.7c0-19.5,10.6-29.2,31.7-29.2
29
+ c8.8,0,17,1.6,24.7,4.9V57.5c-7.6-2.9-16.2-4.3-25.9-4.3c-31.6,0-47.5,14.4-47.5,43.4c0,30.6,15.5,45.8,46.7,45.8
30
+ c10.7,0,19.6-1.6,26.7-4.7v-46h-26.4l-5.5,14.5H783.9z"/></g></g></svg>
@@ -0,0 +1,41 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800.5 203.19" role="img" aria-hidden="true" focusable="false" class="rh-mark-dd rh-mark-on-light"><g><g><path fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" d="M260.87,144.65h-37.4v-86.1h37.4c26.94,0,40.43,13.57,40.43,40.7C301.29,129.51,287.81,144.65,260.87,144.65z
2
+ M239.45,130.79h19c17.9,0,26.84-10.51,26.84-31.55c0-17.91-8.95-26.87-26.84-26.87h-19L239.45,130.79L239.45,130.79z"/><polygon fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" points="318.04,144.65 301.62,144.65 338.25,58.55 355.44,58.55 392.85,144.65 375.66,144.65 364.8,121.17
3
+ 337.17,121.17 342.66,107.32 360.58,107.32 346.46,74.98 "/><polygon fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" points="383.82,58.55 449.28,58.55 449.28,72.39 424.55,72.39 424.55,144.65 408.57,144.65 408.57,72.39
4
+ 383.82,72.39 "/><polygon fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" points="457.5,144.65 441.08,144.65 477.71,58.55 494.9,58.55 532.31,144.65 515.1,144.65 504.24,121.17
5
+ 476.61,121.17 482.1,107.32 500.02,107.32 485.91,74.98 "/><path fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" d="M580.32,144.65h-37.4v-86.1h37.4c26.96,0,40.43,13.57,40.43,40.7C620.75,129.51,607.28,144.65,580.32,144.65z
6
+ M558.91,130.79h19c17.89,0,26.86-10.51,26.86-31.55c0-17.91-8.96-26.87-26.86-26.87h-19V130.79z"/><path fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" d="M631.58,101.72c0-29.2,14.45-43.79,43.33-43.79c28.44,0,42.64,14.59,42.64,43.79
7
+ c0,29.03-14.21,43.55-42.64,43.55C647.31,145.27,632.87,130.75,631.58,101.72z M674.91,131.39c17.36,0,26.05-10.01,26.05-30.05
8
+ c0-19.72-8.69-29.59-26.05-29.59c-17.82,0-26.73,9.87-26.73,29.59C648.18,121.38,657.09,131.39,674.91,131.39z"/><path fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" d="M784.26,109.81v20.16c-3.69,0.96-6.99,1.44-9.9,1.44c-19.55,0-29.31-10.34-29.31-31.01
9
+ c0-19.09,10.36-28.62,31.07-28.62c8.65,0,16.69,1.61,24.13,4.82V62.14c-7.44-2.8-15.89-4.21-25.34-4.21
10
+ c-30.97,0-46.46,14.15-46.46,42.47c0,29.9,15.22,44.87,45.67,44.87c10.47,0,19.17-1.52,26.13-4.58V95.64h-25.82l-5.4,14.16
11
+ L784.26,109.81L784.26,109.81z"/></g><g><g><path fill="#632CA6" fill-rule="evenodd" clip-rule="evenodd" d="M158.87,144.16L142,133.04l-14.07,23.5l-16.36-4.78l-14.41,21.99l0.74,6.92l78.33-14.43l-4.55-48.94
12
+ L158.87,144.16z M85.82,123.07l12.57-1.73c2.03,0.91,3.45,1.26,5.89,1.88c3.8,0.99,8.19,1.94,14.7-1.34
13
+ c1.51-0.75,4.67-3.64,5.94-5.28l51.49-9.34l5.25,63.57l-88.21,15.9L85.82,123.07z M181.46,100.16l-5.08,0.97L166.62,0.25
14
+ L0.25,19.54l20.5,166.33l19.47-2.83c-1.55-2.22-3.98-4.91-8.11-8.35c-5.74-4.76-3.71-12.86-0.32-17.97
15
+ c4.47-8.63,27.54-19.61,26.23-33.41c-0.47-5.02-1.27-11.55-5.93-16.03c-0.17,1.86,0.14,3.65,0.14,3.65s-1.91-2.44-2.87-5.77
16
+ c-0.95-1.28-1.69-1.68-2.7-3.39c-0.72,1.97-0.62,4.26-0.62,4.26s-1.56-3.7-1.82-6.82c-0.93,1.4-1.16,4.05-1.16,4.05
17
+ s-2.03-5.83-1.57-8.97c-0.93-2.73-3.68-8.15-2.9-20.47c5.08,3.56,16.26,2.71,20.61-3.71c1.45-2.13,2.44-7.93-0.72-19.36
18
+ c-2.03-7.33-7.05-18.25-9.01-22.4l-0.23,0.17c1.03,3.34,3.16,10.33,3.98,13.73c2.47,10.29,3.13,13.87,1.97,18.61
19
+ c-0.99,4.12-3.35,6.82-9.35,9.84c-6,3.03-13.96-4.34-14.47-4.74c-5.83-4.64-10.34-12.22-10.84-15.9
20
+ c-0.52-4.03,2.32-6.45,3.76-9.74c-2.05,0.59-4.34,1.63-4.34,1.63s2.73-2.83,6.1-5.27c1.4-0.92,2.21-1.51,3.68-2.73
21
+ c-2.13-0.03-3.86,0.02-3.86,0.02s3.55-1.92,7.23-3.31c-2.69-0.12-5.27-0.02-5.27-0.02S35.75,27.1,42,24.5
22
+ c4.3-1.76,8.5-1.24,10.86,2.17c3.1,4.47,6.35,6.9,13.25,8.41c4.24-1.88,5.52-2.84,10.84-4.29c4.68-5.15,8.36-5.82,8.36-5.82
23
+ s-1.82,1.67-2.31,4.3c2.66-2.09,5.57-3.84,5.57-3.84s-1.13,1.39-2.18,3.6l0.24,0.36c3.1-1.86,6.74-3.32,6.74-3.32
24
+ s-1.04,1.32-2.26,3.02c2.34-0.02,7.08,0.1,8.91,0.31c10.86,0.24,13.11-11.6,17.28-13.08c5.22-1.86,7.55-2.99,16.44,5.74
25
+ c7.63,7.5,13.59,20.91,10.63,23.92c-2.48,2.49-7.38-0.97-12.8-7.74c-2.87-3.58-5.03-7.81-6.05-13.19
26
+ c-0.86-4.54-4.19-7.17-4.19-7.17s1.93,4.31,1.93,8.11c0,2.08,0.26,9.84,3.59,14.19c-0.33,0.64-0.48,3.15-0.85,3.63
27
+ c-3.87-4.68-12.19-8.03-13.54-9.02c4.59,3.76,15.14,12.4,19.19,20.68c3.83,7.83,1.57,15.01,3.51,16.87
28
+ c0.55,0.53,8.24,10.11,9.72,14.93c2.58,8.39,0.15,17.21-3.22,22.68l-9.43,1.47c-1.38-0.38-2.31-0.58-3.55-1.29
29
+ c0.68-1.21,2.04-4.22,2.05-4.84l-0.53-0.93c-2.94,4.16-7.85,8.2-11.94,10.52c-5.35,3.03-11.51,2.56-15.52,1.32
30
+ c-11.39-3.51-22.16-11.21-24.75-13.23c0,0-0.08,1.61,0.41,1.98c2.87,3.24,9.45,9.1,15.81,13.18l-13.55,1.49l6.41,49.89
31
+ c-2.84,0.41-3.28,0.61-6.39,1.05c-2.74-9.68-7.98-16.01-13.71-19.69c-5.05-3.25-12.02-3.98-18.7-2.66l-0.43,0.5
32
+ c4.64-0.48,10.12,0.19,15.74,3.75c5.52,3.49,9.97,12.51,11.61,17.94c2.1,6.94,3.55,14.36-2.1,22.23
33
+ c-4.02,5.59-15.74,8.68-25.22,2c2.53,4.07,5.95,7.4,10.55,8.02c6.84,0.93,13.33-0.26,17.79-4.84c3.81-3.92,5.84-12.12,5.3-20.75
34
+ l6.03-0.87l2.18,15.49l99.88-12.03L181.46,100.16z M120.69,58.08c-0.28,0.64-0.72,1.05-0.06,3.12l0.04,0.12l0.1,0.27l0.27,0.62
35
+ c1.19,2.42,2.49,4.71,4.66,5.88c0.56-0.09,1.15-0.16,1.75-0.19c2.04-0.09,3.33,0.23,4.15,0.68c0.07-0.41,0.09-1,0.04-1.88
36
+ c-0.16-3.07,0.61-8.29-5.29-11.04c-2.23-1.03-5.35-0.72-6.39,0.58c0.19,0.02,0.36,0.06,0.49,0.11
37
+ C122.04,56.89,120.98,57.43,120.69,58.08 M137.23,86.73c-0.77-0.43-4.39-0.26-6.93,0.04c-4.84,0.57-10.07,2.25-11.22,3.14
38
+ c-2.08,1.61-1.14,4.42,0.4,5.57c4.32,3.22,8.1,5.39,12.09,4.86c2.45-0.32,4.61-4.2,6.14-7.73
39
+ C138.77,90.19,138.77,87.58,137.23,86.73 M94.36,61.88c1.37-1.3-6.8-3-13.14,1.32c-4.67,3.19-4.82,10.03-0.35,13.9
40
+ c0.45,0.38,0.82,0.66,1.16,0.88c1.31-0.62,2.8-1.24,4.51-1.79c2.9-0.94,5.3-1.43,7.28-1.68c0.95-1.06,2.05-2.92,1.77-6.29
41
+ C95.22,63.63,91.75,64.36,94.36,61.88"/></g></g></g></svg>
@@ -5,9 +5,16 @@ module RoundhouseUi
5
5
  # never depends on Datadog (or anything) — it asks the configured adapter for
6
6
  # a URL and renders a link only if one comes back.
7
7
  #
8
- # RoundhouseUi.observability = RoundhouseUi::Observability::DatadogAdapter.new(service: "trainual")
8
+ # RoundhouseUi.observability = RoundhouseUi::Observability::DatadogAdapter.new(service: "sidekiq")
9
9
  #
10
- # Write your own (Honeycomb, Sentry, …) by duck-typing job_url/queue_url/label.
10
+ # `service:` is the service your *Sidekiq spans* carry, which is frequently not
11
+ # your app name — apps commonly set `c.tracing.instrument :sidekiq, service_name:
12
+ # "sidekiq"`, and dd-trace has no default of its own. Passing the app name when
13
+ # your spans say something else silently matches nothing. Omit it if unsure; the
14
+ # term is left out entirely when nil.
15
+ #
16
+ # Write your own (Honeycomb, Sentry, …) by duck-typing job_url and label.
17
+ # error_url, icon and wordmark? are optional; queue_url is unused by the UI.
11
18
  module Observability
12
19
  # Default: no links anywhere.
13
20
  class NullAdapter
@@ -26,22 +33,63 @@ module RoundhouseUi
26
33
 
27
34
  def label = "Datadog"
28
35
 
36
+ # Datadog's official horizontal lockup, from datadoghq.com/about/resources
37
+ # (Logo_Assets.zip). Their mark, their trademark, shipped unmodified in
38
+ # geometry and aspect ratio.
39
+ #
40
+ # Both variants ship, because Datadog's white lockup is different artwork
41
+ # rather than the purple one recoloured: a different viewBox (196.2 tall
42
+ # against 203.19) and a different fill-rule mix. Forcing white onto the
43
+ # purple paths renders Bits as a solid tile with the dog knocked out of
44
+ # him. The only edit is moving their <style> block's declarations onto the
45
+ # elements that carried each class — per element, since the white asset
46
+ # mixes nonzero and evenodd — because an inline <style> inside the SVG
47
+ # would need a CSP nonce an adapter cannot see.
48
+ #
49
+ # The lockup rather than the icon-only mark, because Bits is drawn for
50
+ # large use: at the 12-16px a table row allows he renders as a smudge,
51
+ # while the wordmark stays legible to about 11px.
52
+ MARKS_DIR = File.expand_path("marks", __dir__)
53
+ MARK_ON_LIGHT = File.read(File.join(MARKS_DIR, "datadog-lockup.svg")).freeze
54
+ MARK_ON_DARK = File.read(File.join(MARKS_DIR, "datadog-lockup-white.svg")).freeze
55
+
56
+ # Both go in the markup and CSS picks one, rather than the UI guessing the
57
+ # ground it will be painted on. A viewer on the system default has stamped
58
+ # no theme at all, so there is nothing server-side to branch on.
59
+ def icon = MARK_ON_LIGHT + MARK_ON_DARK
60
+
61
+ # The mark already spells "Datadog", so the UI must not print the word
62
+ # again beside it. Datadog's usage rules also forbid encasing the logo in
63
+ # a box, which is why this affordance stays borderless where its sibling
64
+ # controls do not.
65
+ def wordmark? = true
66
+
67
+ # Tag names come from dd-trace-rb's Sidekiq integration: sidekiq.job.id and
68
+ # sidekiq.job.queue (Datadog::Tracing::Contrib::Sidekiq::Ext). There is no
69
+ # `sidekiq.jid` or `sidekiq.queue` facet — querying those matches nothing.
70
+ #
71
+ # operation_name pins the job span: a JID also appears on the enqueueing
72
+ # push span, and the two can sit under different service names.
73
+ #
74
+ # Values are quoted because ":" is a Datadog query operator — an unquoted
75
+ # namespaced class is not merely unmatched, it fails to parse.
29
76
  def job_url(klass:, jid:, queue: nil)
30
- terms = [ "@sidekiq.jid:#{jid}" ]
77
+ terms = [ %(@sidekiq.job.id:"#{jid}"), "operation_name:sidekiq.job" ]
31
78
  terms << "service:#{@service}" if @service
32
79
  terms << @extra_query if @extra_query
33
80
  traces_url(terms)
34
81
  end
35
82
 
36
83
  def queue_url(name)
37
- traces_url([ "@sidekiq.queue:#{name}" ])
84
+ traces_url([ %(@sidekiq.job.queue:"#{name}") ])
38
85
  end
39
86
 
40
87
  # Grouped Errors rows have no single JID, so link to a class-wide search.
41
- # The exact facet depends on your Datadog tagging; tune via `extra_query`
42
- # if `resource_name` isn't how your Sidekiq spans are tagged.
88
+ # dd-trace sets span.resource to the job class — unwrapped, for ActiveJob —
89
+ # so callers must pass the real class, not the adapter's wrapper. Tune via
90
+ # `extra_query` if your spans are tagged differently.
43
91
  def error_url(klass:, error: nil)
44
- terms = [ "resource_name:#{klass}" ]
92
+ terms = [ %(resource_name:"#{klass}") ]
45
93
  terms << "service:#{@service}" if @service
46
94
  terms << @extra_query if @extra_query
47
95
  traces_url(terms)
@@ -9,10 +9,8 @@ module RoundhouseUi
9
9
  # consumed without stopping the worker process.
10
10
  #
11
11
  # When Sidekiq Pro is loaded we defer to *its* registry instead (see .native?).
12
- # Pro reopens Sidekiq::Queue with pause!/unpause!/paused? and prepends pause
13
- # support onto Sidekiq::BasicFetch, so pausing is already enforced with no
14
- # Roundhouse fetcher installed — and Pro's key ("paused") is not ours
15
- # ("roundhouse:paused"), so writing our own set there would do nothing.
12
+ # Under Pro we defer to its own pause API (see .native?), so pausing is enforced
13
+ # with no Roundhouse fetcher installed. Pro's key is not ours.
16
14
  module Pause
17
15
  KEY = "roundhouse:paused"
18
16
  FETCH_FLAG = "roundhouse:fetch_alive" # liveness beacon set by the fetcher
@@ -28,10 +26,8 @@ module RoundhouseUi
28
26
  defined?(::Sidekiq::Queue) && ::Sidekiq::Queue.method_defined?(:pause!)
29
27
  end
30
28
 
31
- # Under Pro, go through Sidekiq::Queue#pause! rather than writing PRO_KEY
32
- # ourselves: Pro's fetchers read that set once at startup and afterwards only
33
- # update on the "pro:config" pubsub message that pause! publishes. A bare
34
- # SADD would leave running workers pulling the queue until they restarted.
29
+ # Under Pro, always go through Sidekiq::Queue#pause! — writing PRO_KEY directly
30
+ # does not reach already-running workers.
35
31
  def pause!(queue)
36
32
  return ::Sidekiq::Queue.new(queue.to_s).pause! if native?
37
33
 
@@ -82,9 +78,8 @@ module RoundhouseUi
82
78
  # True when a RoundhouseUi::Fetch has reported in recently — i.e. pausing
83
79
  # will take effect. When false, the UI warns instead of pretending.
84
80
  #
85
- # Under Pro no beacon is needed: Pro prepends pause support onto
86
- # Sidekiq::BasicFetch (and SuperFetch honors it too), so any Pro worker
87
- # enforces pauses whether or not our fetcher is installed.
81
+ # Under Pro no beacon is needed: any Pro worker enforces pauses whether or not
82
+ # our fetcher is installed.
88
83
  def fetch_installed?
89
84
  return true if native?
90
85
 
@@ -0,0 +1,11 @@
1
+ module RoundhouseUi
2
+ # One row of the Queues page: name, depth, and how long the oldest waiting job
3
+ # has been waiting. A plain value object rather than a backend's own Queue,
4
+ # because the backends compute these in one batch — Sidekiq in a single
5
+ # pipeline, Solid Queue in a single grouped query — and handing back live
6
+ # objects invites the caller to re-read per row and undo that.
7
+ #
8
+ # `latency` is seconds. Actions (pause, purge, snapshot) still go through
9
+ # backend.queue(name); this carries no behaviour.
10
+ QueueSummary = Struct.new(:name, :size, :latency, keyword_init: true)
11
+ end
@@ -0,0 +1,137 @@
1
+ module RoundhouseUi
2
+ # Periodic work, normalised across the four things that schedule it.
3
+ #
4
+ # There is no shared API: sidekiq-cron, sidekiq-scheduler, Sidekiq Enterprise's
5
+ # periodic loops and Solid Queue's recurring tasks each expose something
6
+ # different. So this detects what is loaded and adapts, the same way the
7
+ # backend port does — nothing to configure, and the nav item hides when nothing
8
+ # is present. See #62.
9
+ #
10
+ # Reading only. A UI that lets someone silently change a production schedule is
11
+ # a different risk conversation, and the schedule belongs in the code that
12
+ # defines it.
13
+ module Recurring
14
+ Task = Struct.new(:name, :schedule, :klass, :queue, :last_run, :enabled, :source,
15
+ keyword_init: true) do
16
+ def enabled? = enabled != false
17
+
18
+ # The interesting question is not "what is the crontab" — it is "this says
19
+ # hourly and has not run in three days". That needs the schedule's expected
20
+ # interval, which needs a cron parser.
21
+ #
22
+ # Fugit ships with both sidekiq-cron and sidekiq-scheduler, so it is present
23
+ # wherever this feature is. Where it is not, staleness is simply unknown
24
+ # rather than guessed.
25
+ def expected_interval
26
+ return nil unless defined?(::Fugit)
27
+
28
+ parsed = ::Fugit.parse(schedule.to_s)
29
+ return nil unless parsed.respond_to?(:next_time)
30
+
31
+ a = parsed.next_time(Time.now)
32
+ b = parsed.next_time(a.to_t + 1)
33
+ (b.to_t - a.to_t).to_f
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ # Overdue by more than one whole interval. One interval of slack on purpose:
39
+ # a job due at :00 and running at :00:07 is not late, and a view that says it
40
+ # is gets ignored.
41
+ def overdue?
42
+ interval = expected_interval
43
+ return false if interval.nil? || last_run.nil? || !enabled?
44
+
45
+ Time.now - last_run > interval * 2
46
+ end
47
+
48
+ def status
49
+ return :paused unless enabled?
50
+ return :unknown if last_run.nil?
51
+
52
+ overdue? ? :overdue : :ok
53
+ end
54
+ end
55
+ module_function
56
+
57
+ # Every source that is loaded, merged. An app can genuinely run two — a
58
+ # sidekiq-cron schedule inherited from an old migration alongside Solid
59
+ # Queue's own — and hiding one of them would be worse than showing both.
60
+ # Overdue first, then by name — the order is information, same rule as
61
+ # everywhere else, and a stable tiebreak so it does not shuffle.
62
+ def tasks
63
+ SOURCES.flat_map { |name, reader| detected?(name) ? safely(name, &reader) : [] }
64
+ .sort_by { |t| [ t.overdue? ? 0 : 1, t.name.to_s ] }
65
+ end
66
+
67
+ def any? = SOURCES.keys.any? { |n| detected?(n) }
68
+
69
+ def detected?(name)
70
+ case name
71
+ when :sidekiq_cron then defined?(::Sidekiq::Cron::Job)
72
+ when :sidekiq_scheduler then defined?(::SidekiqScheduler::Schedule) || defined?(::Sidekiq::Scheduler)
73
+ when :sidekiq_ent then defined?(::Sidekiq::Periodic::LoopSet)
74
+ when :solid_queue then defined?(::SolidQueue::RecurringTask)
75
+ end
76
+ end
77
+
78
+ # A broken or half-configured scheduler must not take the page down, and it
79
+ # must not hide the schedulers that ARE working.
80
+ def safely(name)
81
+ result = yield
82
+ # NOT Array(): a Struct responds to to_a, so Array(task) splats a single
83
+ # task into its seven field values instead of wrapping it.
84
+ result.is_a?(Array) ? result : [ result ].compact
85
+ rescue StandardError => e
86
+ Rails.logger&.warn("[roundhouse] recurring source #{name} failed: #{e.message}") if defined?(Rails)
87
+ []
88
+ end
89
+
90
+ SOURCES = {
91
+ sidekiq_cron: lambda do
92
+ ::Sidekiq::Cron::Job.all.map do |j|
93
+ Task.new(name: j.name, schedule: j.cron, klass: j.klass, queue: j.queue,
94
+ last_run: j.last_enqueue_time, enabled: j.status != "disabled",
95
+ source: "sidekiq-cron")
96
+ end
97
+ end,
98
+
99
+ sidekiq_scheduler: lambda do
100
+ (::Sidekiq.schedule || {}).map do |name, cfg|
101
+ Task.new(name: name,
102
+ schedule: cfg["cron"] || cfg["every"] || cfg["interval"] || cfg[:cron],
103
+ klass: cfg["class"] || cfg[:class], queue: cfg["queue"] || cfg[:queue],
104
+ last_run: scheduler_last_run(name),
105
+ enabled: cfg["enabled"] != false,
106
+ source: "sidekiq-scheduler")
107
+ end
108
+ end,
109
+
110
+ sidekiq_ent: lambda do
111
+ ::Sidekiq::Periodic::LoopSet.new.map do |loop_|
112
+ Task.new(name: loop_.klass, schedule: loop_.schedule, klass: loop_.klass,
113
+ queue: loop_.options&.dig("queue"),
114
+ last_run: (Time.at(loop_.last_enqueue_time.to_i) if loop_.last_enqueue_time),
115
+ enabled: true, source: "sidekiq-ent")
116
+ end
117
+ end,
118
+
119
+ solid_queue: lambda do
120
+ ::SolidQueue::RecurringTask.all.map do |t|
121
+ Task.new(name: t.key, schedule: t.schedule, klass: t.class_name, queue: t.queue_name,
122
+ last_run: t.recurring_executions.maximum(:run_at),
123
+ enabled: true, source: "solid-queue")
124
+ end
125
+ end
126
+ }.freeze
127
+
128
+ def scheduler_last_run(name)
129
+ return nil unless defined?(::SidekiqScheduler::RedisManager)
130
+
131
+ raw = ::SidekiqScheduler::RedisManager.get_job_last_time(name)
132
+ raw && Time.parse(raw.to_s)
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,75 @@
1
+ module RoundhouseUi
2
+ # Resolves a runbook for a job class at read time, the same way Tags resolves
3
+ # owners (ADR 0002) — no middleware, no storage, applies retroactively to jobs
4
+ # already in the sets.
5
+ #
6
+ # RoundhouseUi.job_runbooks = RoundhouseUi::Runbooks.from_constant(:RUNBOOK)
7
+ # RoundhouseUi.job_runbooks = { "Billing::SyncWorker" => "https://wiki/…" }
8
+ # RoundhouseUi.job_runbooks = ->(klass:, item:) { "https://wiki/#{klass}" }
9
+ #
10
+ # Whoever wrote the job knows what to do when it fails; the person paged at
11
+ # 3am usually does not. This is the cheapest possible bridge between them.
12
+ module Runbooks
13
+ module_function
14
+
15
+ # A runbook URL for one job, or nil. Resolvers always see the real job
16
+ # class — the ActiveJob wrapper is unwrapped first, so a runbook declared on
17
+ # a mailer is found when that mailer fails.
18
+ #
19
+ # Only http(s) URLs are returned. A runbook lands in an href, so `javascript:`
20
+ # and `data:` are refused rather than escaped: a host that misconfigures this
21
+ # should get no link, not a link that runs.
22
+ def for(klass, item = nil, cache: nil)
23
+ source = RoundhouseUi.job_runbooks
24
+ return nil unless source
25
+
26
+ effective = RoundhouseUi.unwrapped_class(klass, item)
27
+ return nil unless effective.present?
28
+ return resolve(source, effective, item) unless cache
29
+
30
+ # `item` is withheld once we are caching by class — the same rule Tags
31
+ # follows, and for the same reason: a resolver that reads the payload but
32
+ # is cached per class would hand every job of that class the *first* job's
33
+ # runbook. Withholding makes that misuse resolve to nil deterministically
34
+ # instead of returning something plausible and wrong. A runbook describes a
35
+ # job class, so reading the payload is a misuse rather than a use case.
36
+ cache.key?(effective) ? cache[effective] : cache[effective] = resolve(source, effective, nil)
37
+ end
38
+
39
+ # The class-constant convention, matching Tags.from_constant. Inherited
40
+ # constants count, so a base class can carry the runbook for a whole family.
41
+ def from_constant(const_name = :RUNBOOK)
42
+ lambda do |klass:, item:|
43
+ _ = item
44
+ k = RoundhouseUi.job_class(klass)
45
+ k.const_get(const_name) if k&.const_defined?(const_name)
46
+ end
47
+ end
48
+
49
+ # -- internals ----------------------------------------------------------
50
+
51
+ def resolve(source, klass, item)
52
+ raw = if source.respond_to?(:call)
53
+ source.call(klass: klass, item: item)
54
+ elsif source.respond_to?(:[])
55
+ source[klass]
56
+ end
57
+ safe_url(raw)
58
+ rescue StandardError => e
59
+ warn_once("job_runbooks resolver failed for #{klass}: #{e.message}")
60
+ nil
61
+ end
62
+
63
+ def safe_url(raw)
64
+ url = raw.to_s.strip
65
+ # Capped like theme values are: this is interpolated into markup on every
66
+ # matching row, and a runaway string from a misconfigured resolver should
67
+ # not become the page.
68
+ return nil if url.empty? || url.length > 2_000
69
+
70
+ url if url.match?(%r{\Ahttps?://\S+\z}i)
71
+ end
72
+
73
+ def warn_once(message) = RoundhouseUi.warn_once(message)
74
+ end
75
+ end
@@ -47,8 +47,19 @@ module RoundhouseUi
47
47
  return 0 unless raw
48
48
 
49
49
  data = JSON.parse(raw)
50
- key = "queue:#{data["queue"]}"
51
- data["jobs"].each { |payload| Sidekiq.redis { |conn| conn.call("RPUSH", key, payload) } }
50
+ queue = data["queue"]
51
+
52
+ Sidekiq.redis do |conn|
53
+ # Re-register the queue, not just its jobs. Sidekiq::Queue#clear removes
54
+ # the name from the `queues` set as well as deleting the list, and that
55
+ # set is what Sidekiq::Queue.all reads — so a restore that only pushes the
56
+ # payloads back leaves a queue Roundhouse cannot see. The operator purges
57
+ # production, restores, and every page says nothing came back. Sidekiq's
58
+ # own client does this SADD on every push.
59
+ conn.call("SADD", "queues", queue)
60
+ data["jobs"].each { |payload| conn.call("RPUSH", "queue:#{queue}", payload) }
61
+ end
62
+
52
63
  data["jobs"].size
53
64
  end
54
65