Module: RoundhouseUi::ApplicationHelper

Defined in:
app/helpers/roundhouse_ui/application_helper.rb

Constant Summary collapse

FACET_OVERRIDES =

Override keys that name a facet, and the FilterQuery field each one sets. Anything not listed here is transport (page, op) and passes straight through.

{ class: :klass, error: :error, queue: :queue, tag: :tag }.freeze
LIKE_PATHS =

Where "find more like this" goes, per set.

{ "dead" => :dead_set_path, "retry" => :retries_path,
"scheduled" => :scheduled_path }.freeze

Instance Method Summary collapse

Instance Method Details

#attempt_ladder(count, max = 25) ⇒ Object

Retry attempt as a ladder rather than a number. "retry 21" and "retry 3" are the same shape of text; twenty-one filled rungs going red is not. The count stays beside it, because a ladder answers "how bad" and not "how many".



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 222

def attempt_ladder(count, max = 25)
  filled = count.to_i.clamp(0, max)
  rungs = Array.new(max) do |i|
    state = if i >= filled then nil
    elsif filled > (max * 0.6) then "is-hot"
    else "is-on"
    end
    (:i, "", class: state)
  end
  safe_join([
    (:span, safe_join(rungs), class: "rh-ladder",
                title: "attempt #{filled} of #{max}", aria: { hidden: true }),
    (:span, "#{filled}/#{max}", class: "rh-sub rh-ladder-n")
  ])
end

#countdown(at, since: nil, label: nil) ⇒ Object

How far through the wait a scheduled or retrying job is, as a ring. A job about to fire should not look like one parked for six hours.

since is when the wait started; without it the ring cannot know the fraction and renders empty rather than guessing.



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 243

def countdown(at, since: nil, label: nil)
  return (:span, "β€”", class: "rh-sub") if at.nil?

  remaining = at.to_f - Time.now.to_f
  turn = if since && (total = at.to_f - since.to_f) > 0
    (1.0 - (remaining / total)).clamp(0.0, 1.0)
  else
    remaining <= 0 ? 1.0 : 0.0
  end
  safe_join([
    (:span, "", class: "rh-ring", style: "--rh-turn:#{turn.round(3)}turn",
                aria: { hidden: true }),
    (:span, label || job_time(at, overdue: "now"), class: "rh-sub")
  ])
end

#duration(seconds) ⇒ Object

Compact duration. Raw seconds stop being readable somewhere around a minute and are actively useless by four figures β€” "10058.0s" is nearly three hours and nobody reads it as that. Two units is all anyone acts on. See #31. Both delegate to RoundhouseUi so views and lib/ cannot drift apart again.



216
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 216

def duration(seconds) = RoundhouseUi.duration(seconds)

#duration_ms(ms) ⇒ Object



217
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 217

def duration_ms(ms) = RoundhouseUi.duration_ms(ms)

#empty_state(query, tag, all_clear:, noun: "jobs") ⇒ Object

What an empty table says. Three cases, not two: the search was refused, the surviving filter matched nothing, or the set really is empty. Only the third gets the πŸŽ‰ β€” it used to get it in all three.



174
175
176
177
178
179
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 174

def empty_state(query, tag, all_clear:, noun: "jobs")
  return "Nothing matched β€” the search above was not understood." if @filter&.invalid?
  return "No #{noun} #{filter_description(query, tag)}." if filtered_view?(query, tag)

  all_clear
end

#filter_params(overrides = {}) ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 37

def filter_params(overrides = {})
  return overrides.compact unless respond_to?(:active_filters)

  # A facet override edits the QUERY, it does not add a parameter. The filter
  # now travels as one `?q=` string, so `filter_url(class: nil)` had to stop
  # meaning "send class=nil alongside q" β€” which would have left the class
  # sitting inside q, untouched, while the link claimed to clear it. Every Γ—
  # in the console is one of these calls.
  query = @filter || FilterQuery.none
  transport = {}
  overrides.each do |key, value|
    # `q:` replaces the whole query β€” the one override that is not a facet edit.
    # Left in transport it would have emitted a second q beside the real one.
    next query = FilterQuery.parse(value) if key == :q

    field = FACET_OVERRIDES[key]
    field ? query = query.merge(field => value) : transport[key] = value
  end

  { q: query.to_s.presence }.compact.merge(transport.compact)
end

#filter_placeholder(keys = FilterQuery::KEYS, noun: "text") ⇒ Object

The placeholder, BUILT from the facets the page honours rather than written out per page. Five pages had five hand-written strings β€” "search class, jid, error, or arg value…", "filter by job class, error, or squad…", "filter queues by name…" β€” none of which mentioned that class= was a thing you could type. A hand-written hint next to a grammar is a hint that goes stale the day the grammar changes, and it went stale the day the grammar shipped.



119
120
121
122
123
124
125
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 119

def filter_placeholder(keys = FilterQuery::KEYS, noun: "text")
  facets = keys.reject { |k| k == "text" }
  facets -= [ "tag" ] unless RoundhouseUi.job_tags
  return "search #{noun}…" if facets.empty?

  "#{facets.map { |k| "#{k}=" }.join(" ")} or just type to search #{noun}"
end

#filter_url(overrides = {}) ⇒ Object



59
60
61
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 59

def filter_url(overrides = {})
  url_for(filter_params(overrides).merge(only_path: true))
end

#filter_vocabularyObject

What the bar can complete. Keys always; tag values from the host's DECLARED vocabulary and queue names from the set the page already knows about β€” both free, no extra scan.

class= and error= values are deliberately absent: enumerating them means reading every entry in the set on every render, and the whole reason browse reads only one page is that a 50k dead set must stay cheap to open. Type them; the funnel on each row fills them in for you.



135
136
137
138
139
140
141
142
143
144
145
146
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 135

def filter_vocabulary
  vocab = {}
  declared = RoundhouseUi::Tags.filters
  if declared.present?
    pairs = declared.flat_map { |key, values| Array(values).map { |v| "#{key}:#{v}" } }
    vocab["tag"] = pairs if pairs.any?
  end
  names = Array(@queues).map { |q| q.respond_to?(:name) ? q.name.to_s : q.to_s }.reject(&:empty?)
  names = [ @name.to_s ] if names.empty? && @name.present?
  vocab["queue"] = names.uniq.sort if names.any?
  vocab
end

#filtered_view?(query, tag) ⇒ Boolean

Is the page showing a SUBSET of the set? A different question from any_filter?, which asks whether a filter may authorise a bulk action β€” and answers no for a refused query, deliberately and correctly.

Reusing the authorisation predicate for display made a refusal render as "nothing is filtered". Every typo now refuses, where once only a 500-character query did, so clas=Foo in the box produced the heading "Dead set Β· 24 jobs" and the cell "Dead set is empty πŸŽ‰" β€” above twenty-four dead jobs, with a tick of congratulation. Found by looking at the running page; no test asked. Asks the FILTER, never the bulk gate. Routing display through any_filter? produced the same bug twice: a refused query rendered as "nothing filtered" (heading "Dead set Β· 24 jobs" over "Dead set is empty πŸŽ‰"), and then a degraded one did too β€” tag=garbage queue=ai drops the tag, applies the queue, and still claimed the whole set. any_filter? answers "may this authorise a bulk action" and correctly says no in both cases; it is not a display predicate and is no longer used as one.

Returns:

  • (Boolean)


164
165
166
167
168
169
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 164

def filtered_view?(query, tag)
  return true if @filter&.invalid? # selects nothing, which is a subset
  return @filter.any? if @filter   # whatever SURVIVED parsing, dropped facets and all

  any_filter?(query, tag)
end

"Find more like this": the same class, and where the set records one, the same error β€” the pair the Errors page already treats as one issue, so a row here and a row there mean the same thing.

Emitted as exact class=/error= facets in ?q= β€” the same string you could type. No %, because this button reveals "delete all matching".



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 73

def find_like_link(set, job)
  helper = LIKE_PATHS[set.to_s]
  klass = job_display_class(job)
  return nil if helper.nil? || klass.blank?

  error = job.item["error_class"].presence
  label = error ? "Find more #{klass} failing with #{error}" : "Find more #{klass}"
  # Built through from_params, not build: build does not validate, so a class
  # name carrying a quote would produce a link whose own q= the next request
  # refuses β€” a control that visibly does nothing. No representable filter,
  # no button.
  # Braced: from_params takes a keyword now, so a bare `class:` would be read as
  # one and leave the positional hash empty.
  filter = FilterQuery.from_params({ class: klass, error: error })
  return nil if filter.invalid?

  query = { q: filter.to_s }

  # No size modifier: it takes the page scale, like every other control in the
  # Actions column it sits in. Asking for --sm here was picking a scale step by
  # hand next to 30px siblings β€” the scale stopped arbitrary pixels, it could
  # not stop me choosing the wrong one of the two it offers.
  link_to icon(:filter), send(helper, **query),
          class: "rh-btn rh-btn--icon", title: label,
          aria: { label: label }
end

#icon(name, extra_class: nil) ⇒ Object

An icon by name. Inline SVG by default; a class name when the host has its own icon font. Only our own SVG constants are marked html_safe β€” a host-supplied class name goes through content_tag and is escaped.



262
263
264
265
266
267
268
269
270
271
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 262

def icon(name, extra_class: nil)
  markup = RoundhouseUi::Icons.markup(name)
  return "".html_safe unless markup

  if markup.start_with?("<svg")
    (:span, markup.html_safe, class: [ "rh-ico", extra_class ].compact.join(" "))
  else
    (:i, "", class: [ markup, "rh-ico", extra_class ].compact.join(" "), aria: { hidden: true })
  end
end

#job_args_preview(entry, length: 70) ⇒ Object

A one-line, redacted preview of a job's arguments. Sidekiq Web shows args in its queue listing and we did not, which made a queue of one class indistinguishable row to row β€” the whole question being "which of these is the one I care about".

Truncated on purpose: the job page is where the full payload belongs, and a queue listing left open on a second monitor is the wrong place for a long arguments dump. Redaction still applies, but it is key-based, so a bare positional secret is not masked β€” same caveat as everywhere else args show.



204
205
206
207
208
209
210
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 204

def job_args_preview(entry, length: 70)
  args = entry.args
  return (:span, "β€”", class: "rh-sub") if args.blank?

  full = RoundhouseUi::Redaction.apply(args).map { |a| a.is_a?(String) ? a : a.inspect }.join(", ")
  (:span, truncate(full, length: length), class: "rh-sub rh-mono", title: full)
end

#job_display_class(entry) ⇒ Object

The class name a row should show: the real job class, not the ActiveJob adapter's wrapper. Resolved here rather than in each template so every surface prints the string the Errors page groups on and the APM link searches for.



7
8
9
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 7

def job_display_class(entry)
  RoundhouseUi.unwrapped_class(entry.klass, entry.item)
end

#job_editing?Boolean

A URL that keeps every active filter, naming only what changes. Pass a key as nil to clear just that one.

Never enumerate filter params by hand in a view. That is what let the pager drop the class filter on page two, and the confirm form drop it between the dry run and the deletion. Editing needs BOTH the host's opt-in and a backend that can push. Six views read the config flag directly; only this reads the capability too.

Returns:

  • (Boolean)


29
30
31
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 29

def job_editing?
  RoundhouseUi.allow_job_editing && RoundhouseUi.backend.supports?(:enqueue)
end

#job_identity(klass, jid, path) ⇒ Object

A job row's identity cell. Class on the first line where the eye lands, jid on a second, dimmer line β€” one long line of class + hex + link is unreadable once the row also carries squad, queue and error.



14
15
16
17
18
19
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 14

def job_identity(klass, jid, path)
  safe_join([
    link_to(klass, path, class: "rh-joblink"),
    (:div, jid, class: "rh-sub rh-mono rh-jid", title: jid)
  ])
end

#job_time(at, overdue: "now (overdue)") ⇒ Object

Relative time answers "is this soon?", the clock time answers "does that land inside the maintenance window?". Operators need both, so show both rather than making them hover or do the arithmetic.



103
104
105
106
107
108
109
110
111
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 103

def job_time(at, overdue: "now (overdue)")
  return (:span, "β€”", class: "rh-sub") if at.nil?

  relative = at > Time.now ? "in #{distance_of_time_in_words(Time.now, at)}" : overdue
  safe_join([
    (:span, relative),
    (:span, at.strftime("%b %-d, %H:%M"), class: "rh-sub rh-mono")
  ], " Β· ")
end

#queue_pill(name, link: false) ⇒ Object

Queues carry meaning at a glance (critical vs low), so render them as a pill rather than grey text lost between two columns. On the job sets the pill filters to that queue; link: is off where there's nothing to filter (the Queues index itself, grouped Errors rows).



288
289
290
291
292
293
294
295
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 288

def queue_pill(name, link: false)
  return (:span, name, class: "rh-pill rh-mono") unless link

  active = (@filter&.queue) == name.to_s
  link_to name, filter_url(page: nil, queue: (active ? nil : name)),
    class: "rh-pill rh-mono rh-pill-link#{' is-on' if active}",
    title: active ? "Clear queue filter" : "Show only #{name}"
end

The runbook for a job, as a link, or nil when the host declared none (#39). Cached per class per request like tags β€” the same page can ask for the same class dozens of times.



276
277
278
279
280
281
282
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 276

def runbook_link(klass, item = nil, label: "Runbook")
  url = RoundhouseUi::Runbooks.for(klass, item, cache: (@rh_runbook_cache ||= {}))
  return nil unless url

  link_to label, url, class: "rh-runbook", target: "_blank", rel: "noopener noreferrer",
    title: "Open the runbook for #{RoundhouseUi.unwrapped_class(klass, item)}"
end

#set_heading(label, showing:, total:, query: nil, tag: nil) ⇒ Object

Set heading that tells the truth under a filter. It used to always print the whole-set size, so "Dead set Β· 19 jobs" sat above four filtered rows.



183
184
185
186
187
188
189
190
191
192
193
# File 'app/helpers/roundhouse_ui/application_helper.rb', line 183

def set_heading(label, showing:, total:, query: nil, tag: nil)
  filtered = filtered_view?(query, tag)
  count = filtered ? "#{number_with_delimiter showing} of #{number_with_delimiter total}" : number_with_delimiter(total)
  # No prose restatement. The pills in the bar say which filters are on, in the
  # same words you would type to reproduce them; the heading's job is the count.
  # Saying it a second time in prose ("tagged squad: platform and failing with
  # KeyError") was one of the four places one filter was rendered, and the one
  # that could not be clicked to change it. filter_description still earns its
  # place on the bulk confirm, where the sentence IS the thing being approved.
  (:h2, "#{label} Β· #{count} jobs", class: "rh-h2")
end