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
@@ -1,25 +1,134 @@
1
1
  module RoundhouseUi
2
+ # Links out to whatever APM the host configured. Four entry points — job or
3
+ # error, by historical call-site name — and exactly one appearance. They were
4
+ # four hand-rolled copies once, and only some got updated: two still emitted a
5
+ # literal ↗ after the rest moved to SVG. Later the split survived as two
6
+ # different shapes, a text link and a bordered box, which is not "one
7
+ # appearance" however the helper described itself.
2
8
  module ObservabilityHelper
3
- # Renders a deep-link to the configured observability tool for a job, or
4
- # nothing when no adapter is configured (the default).
5
9
  def trace_link(klass:, jid:, queue: nil)
6
- adapter = RoundhouseUi.observability
7
- url = adapter.job_url(klass: klass, jid: jid, queue: queue)
8
- return unless url
10
+ trace_affordance(job_trace_url(klass: klass, jid: jid, queue: queue), as: :mark)
11
+ end
9
12
 
10
- link_to "↗ #{adapter.label}", url, target: "_blank", rel: "noopener", class: "rh-trace"
13
+ def trace_icon(klass:, jid:, queue: nil)
14
+ trace_affordance(job_trace_url(klass: klass, jid: jid, queue: queue), as: :control)
11
15
  end
12
16
 
13
- # Deep-link for a grouped error row (no single JID) — a class-wide search.
14
- # respond_to? keeps older/custom adapters that lack error_url working.
15
17
  def error_trace_link(klass:, error: nil)
18
+ trace_affordance(error_trace_url(klass: klass, error: error), as: :mark)
19
+ end
20
+
21
+ def error_trace_icon(klass:, error: nil)
22
+ trace_affordance(error_trace_url(klass: klass, error: error), as: :control, compact: true)
23
+ end
24
+
25
+ # Says once, above a table, where every row's trace glyph goes. Takes the rows
26
+ # it is describing, because a legend above an empty table explains a control
27
+ # that is not on the page — which is exactly what "Nothing scheduled" under
28
+ # "Traces open in Datadog" looked like.
29
+ #
30
+ # Returns nil unless the adapter has a mark of its own, too: a plain adapter
31
+ # already labels every row, so there would be nothing to hoist.
32
+ def trace_legend(rows)
33
+ return nil if rows.blank?
34
+
35
+ adapter = RoundhouseUi.observability
36
+ return nil unless wordmark?(adapter)
37
+ return nil if adapter.job_url(klass: "Probe", jid: "probe").blank?
38
+
39
+ content_tag(:div, class: "rh-trace-legend") do
40
+ safe_join([ content_tag(:span, "Traces open in"), trace_glyph(adapter) ], " ")
41
+ end
42
+ end
43
+
44
+ # An adapter's own mark, when it supplies one. Three shapes are accepted, in
45
+ # descending order of how much we trust them:
46
+ #
47
+ # :name → one of Roundhouse's shipped icons
48
+ # "<svg …>" → the adapter's markup, rendered as-is
49
+ # nil → the default trace glyph
50
+ #
51
+ # The markup case is not escaped, because escaping renders the tags as text
52
+ # and the feature does not work at all. That is the same trust level as the
53
+ # adapter object itself, which can already run arbitrary code. Anything that
54
+ # is neither markup nor a known name falls back rather than printing stray
55
+ # text into a table.
56
+ def trace_glyph(adapter)
57
+ supplied = adapter.respond_to?(:icon) ? adapter.icon : nil
58
+ return icon(:trace_out) if supplied.blank?
59
+ return icon(supplied) if supplied.is_a?(Symbol)
60
+
61
+ supplied.to_s.strip.start_with?("<") ? supplied.to_s.html_safe : icon(:trace_out)
62
+ end
63
+
64
+ private
65
+
66
+ def job_trace_url(klass:, jid:, queue: nil)
67
+ RoundhouseUi.observability.job_url(klass: klass, jid: jid, queue: queue)
68
+ end
69
+
70
+ def error_trace_url(klass:, error: nil)
71
+ adapter = RoundhouseUi.observability
72
+ return nil unless adapter.respond_to?(:error_url)
73
+
74
+ adapter.error_url(klass: klass, error: error)
75
+ end
76
+
77
+ # Two contexts, one principle: a dense list gets a control, a single item
78
+ # gets the vendor's mark.
79
+ #
80
+ # In a table the mark would repeat on every row — 366 copies of someone
81
+ # else's logo on one Retries page, and the only unboxed thing in a column of
82
+ # bordered buttons. So rows get a Roundhouse glyph shaped exactly like the
83
+ # buttons beside it, and the page says once, above the table, where that
84
+ # glyph goes. On a page showing one job there is nothing to tile, so the
85
+ # mark itself goes inline where it has room to read.
86
+ #
87
+ # This is not the old `labelled` split, which was two arbitrary shapes with
88
+ # no stated rule. The distinction here is repetition, and `trace_legend` is
89
+ # what makes the row glyph legible without restating the brand per row.
90
+ def trace_affordance(url, as:, compact: false)
91
+ return nil if url.blank?
92
+
16
93
  adapter = RoundhouseUi.observability
17
- return unless adapter.respond_to?(:error_url)
94
+ return trace_control(url, adapter, compact: compact) if as == :control
18
95
 
19
- url = adapter.error_url(klass: klass, error: error)
20
- return unless url
96
+ link_to trace_body(adapter), url, **trace_attrs(adapter),
97
+ class: "rh-trace#{' rh-trace-mark' if wordmark?(adapter)}"
98
+ end
99
+
100
+ # Shaped like whatever it sits beside. In an Actions column that is .rh-btn,
101
+ # so it lands level with Run now and Delete. Inline in a text cell it is
102
+ # .rh-runbook's smaller pill, because a 28px-tall button next to an 11.5px
103
+ # "runbook" link puts three different heights in one table cell.
104
+ #
105
+ # Never carries the vendor mark either way: boxing a logo is against most
106
+ # brand guidelines, Datadog's included.
107
+ def trace_control(url, adapter, compact: false)
108
+ # One class. Borrowing .rh-btn's or .rh-runbook's box was the previous fix
109
+ # for a 2px drift, and it still left two definitions to keep in step — the
110
+ # compact variant came out 2px taller than the runbook pill it sat beside.
111
+ # The control scale (#65) gives every control the same box from the same
112
+ # tokens, so there is nothing to borrow and nothing to synchronise.
113
+ base = compact ? "rh-trace-btn rh-btn--sm" : "rh-trace-btn"
114
+ link_to icon(:trace_out), url, **trace_attrs(adapter), class: base
115
+ end
116
+
117
+ def trace_body(adapter)
118
+ glyph = trace_glyph(adapter)
119
+ return glyph if wordmark?(adapter)
120
+
121
+ safe_join([ glyph, content_tag(:span, adapter.label) ])
122
+ end
123
+
124
+ def trace_attrs(adapter)
125
+ { target: "_blank", rel: "noopener noreferrer",
126
+ title: "Open in #{adapter.label}",
127
+ aria: { label: "Open in #{adapter.label}" } }
128
+ end
21
129
 
22
- link_to "↗ #{adapter.label}", url, target: "_blank", rel: "noopener", class: "rh-trace"
130
+ def wordmark?(adapter)
131
+ adapter.respond_to?(:wordmark?) && adapter.wordmark?
23
132
  end
24
133
  end
25
134
  end
@@ -0,0 +1,128 @@
1
+ module RoundhouseUi
2
+ # Renders host-defined job tags (see ADR 0002). Resolution is memoized for the
3
+ # life of the request: a page can render hundreds of rows, and ErrorGroups
4
+ # scans up to DEFAULT_SCAN_LIMIT entries, so the host's resolver must not be
5
+ # called once per row when it only varies by class.
6
+ module TagsHelper
7
+ # The per-request memo handed to Tags.for, shared with JobSetBrowsing so a
8
+ # class (or job) resolved while scanning is not resolved again when its
9
+ # badge renders. Tags.for chooses the key: class name normally, jid in
10
+ # per-job mode.
11
+ def tag_cache
12
+ @rh_tag_cache ||= {}
13
+ end
14
+
15
+ # Tags for one job entry, as a { "key" => "value" } Hash.
16
+ def tags_for(entry)
17
+ return Tags::EMPTY unless RoundhouseUi.job_tags
18
+
19
+ Tags.for(klass: entry.klass, item: entry.item, cache: tag_cache)
20
+ end
21
+
22
+ # Tags for a class name alone — used by grouped Errors, where every entry in
23
+ # a group shares a class, so a class-derived tag is constant for the group.
24
+ # Passes no payload, so a per-job resolver correctly declines rather than
25
+ # attributing one job's tags to the whole group.
26
+ def tags_for_class(klass)
27
+ return Tags::EMPTY unless RoundhouseUi.job_tags
28
+
29
+ Tags.for(klass: klass, item: {}, cache: tag_cache)
30
+ end
31
+
32
+ # Pills for a resolved tag Hash. Renders nothing when there are no tags, so
33
+ # every call site can be unconditional.
34
+ def tag_badges(tags)
35
+ return if tags.blank?
36
+
37
+ safe_join(tags.map { |key, value| tag_badge(key, value) }, " ")
38
+ end
39
+
40
+ # Tags get their own table column rather than an inline badge: class names
41
+ # vary wildly in length, so inline badges land at ragged x-positions and
42
+ # can't be scanned down. Only worth a column when a host configured tags.
43
+ def tag_column?
44
+ RoundhouseUi.job_tags.present?
45
+ end
46
+
47
+ # Header for that column. With the usual single dimension this is the tag's
48
+ # own name ("squad"); with several there's no one right label.
49
+ def tag_column_label
50
+ keys = Tags.filters&.keys
51
+ keys&.one? ? keys.first.titleize : "Tags"
52
+ end
53
+
54
+ # Cell contents. Stacks when a host defines more than one dimension.
55
+ def tag_cell(tags)
56
+ return content_tag(:span, "—", class: "rh-sub") if tags.blank?
57
+
58
+ tag_badges(tags)
59
+ end
60
+
61
+ # Values offered as filter chips where no counts are available. Prefers the
62
+ # host's declared vocabulary, so the chips stay put as you filter; falls back
63
+ # to whatever the visible rows happen to carry, which at least beats nothing
64
+ # but shifts as you page.
65
+ def tag_vocabulary
66
+ declared = Tags.filters
67
+ return declared if declared.present?
68
+
69
+ seen = Hash.new { |h, k| h[k] = [] }
70
+ Array(@jobs).each do |job|
71
+ tags_for(job).each { |key, value| seen[key] << value unless seen[key].include?(value) }
72
+ end
73
+ seen.transform_values(&:sort)
74
+ end
75
+
76
+ # Human description of the active filter set, so a bulk confirm names every
77
+ # constraint that will be applied — not just the text query.
78
+ # Every active constraint must appear here. A confirm that names only the
79
+ # text query while a queue or tag also narrows the set understates what is
80
+ # about to be destroyed.
81
+ # `filter` is the controller's parse, reached through the helper. One source for
82
+ # the prose, the predicate and the pill — a description built from a second
83
+ # reading of the filter is a description that can disagree with what was applied.
84
+ def rh_filter
85
+ controller.respond_to?(:filter, true) ? controller.send(:filter) : RoundhouseUi::FilterQuery.none
86
+ end
87
+
88
+ def filter_description(query, tag, queue = rh_filter.queue)
89
+ parts = []
90
+ parts << "matching “#{query}”" if query.present?
91
+ parts << "tagged #{tag[0]}: #{tag[1]}" if tag
92
+ parts << "in queue #{queue}" if queue.present?
93
+ parts << like_description if rh_filter.klass.present? || rh_filter.error.present?
94
+ parts.join(" and ")
95
+ end
96
+
97
+ # "find more like this", said back to you. The wording has to be exact about
98
+ # what it selected, because the bulk Delete sitting next to it acts on
99
+ # precisely this set and nothing wider.
100
+ def like_description
101
+ # Either half stands on its own. "every Timeout::Error, whatever the class"
102
+ # is a question worth asking, so clearing the class must not take the error
103
+ # with it — which is what it used to do.
104
+ return "failing with #{rh_filter.error}" if rh_filter.klass.blank?
105
+ return "like #{rh_filter.klass}" if rh_filter.error.blank?
106
+
107
+ "like #{rh_filter.klass} failing with #{rh_filter.error}"
108
+ end
109
+
110
+ # Any filter active? Bulk-on-match is filter-gated, so this decides whether
111
+ # the bulk bar renders at all — and which empty-state copy is honest.
112
+ # Delegates to the concern's predicate where it is available, so the button the
113
+ # view offers and the scope the route enforces cannot drift apart. They did:
114
+ # this returned true only for the view, while bulk_all had no gate at all.
115
+ def any_filter?(query, tag, queue = rh_filter.queue)
116
+ return controller.send(:bulk_filter_present?, query, tag) if controller.respond_to?(:bulk_filter_present?, true)
117
+
118
+ query.present? || !tag.nil? || queue.present? ||
119
+ rh_filter.klass.present? || rh_filter.error.present?
120
+ end
121
+
122
+ # Value only — the key is near-constant down a column, so repeating it is
123
+ # noise. It stays in the tooltip for hosts with more than one dimension.
124
+ def tag_badge(key, value)
125
+ content_tag(:span, value, class: "rh-pill rh-pill-tag", title: "#{key}: #{value}")
126
+ end
127
+ end
128
+ end