Module: RoundhouseUi

Defined in:
lib/roundhouse_ui.rb,
lib/roundhouse_ui/demo.rb,
lib/roundhouse_ui/tags.rb,
lib/roundhouse_ui/audit.rb,
lib/roundhouse_ui/fetch.rb,
lib/roundhouse_ui/icons.rb,
lib/roundhouse_ui/pause.rb,
lib/roundhouse_ui/theme.rb,
lib/roundhouse_ui/engine.rb,
lib/roundhouse_ui/health.rb,
lib/roundhouse_ui/history.rb,
lib/roundhouse_ui/metrics.rb,
lib/roundhouse_ui/version.rb,
lib/roundhouse_ui/runbooks.rb,
lib/roundhouse_ui/recurring.rb,
lib/roundhouse_ui/redaction.rb,
lib/roundhouse_ui/snapshots.rb,
lib/roundhouse_ui/cancellation.rb,
lib/roundhouse_ui/error_groups.rb,
lib/roundhouse_ui/filter_query.rb,
lib/roundhouse_ui/observability.rb,
lib/roundhouse_ui/queue_summary.rb,
lib/roundhouse_ui/backends/sidekiq.rb,
lib/roundhouse_ui/cancel_middleware.rb,
app/helpers/roundhouse_ui/nav_helper.rb,
lib/roundhouse_ui/duration_collector.rb,
app/helpers/roundhouse_ui/tags_helper.rb,
lib/roundhouse_ui/backends/solid_queue.rb,
app/helpers/roundhouse_ui/application_helper.rb,
app/controllers/roundhouse_ui/busy_controller.rb,
app/controllers/roundhouse_ui/dead_controller.rb,
app/controllers/roundhouse_ui/jobs_controller.rb,
app/controllers/roundhouse_ui/audit_controller.rb,
app/controllers/roundhouse_ui/redis_controller.rb,
app/helpers/roundhouse_ui/observability_helper.rb,
app/controllers/roundhouse_ui/assets_controller.rb,
app/controllers/roundhouse_ui/errors_controller.rb,
app/controllers/roundhouse_ui/queues_controller.rb,
app/controllers/roundhouse_ui/metrics_controller.rb,
app/controllers/roundhouse_ui/retries_controller.rb,
app/controllers/roundhouse_ui/workers_controller.rb,
app/controllers/roundhouse_ui/capsules_controller.rb,
app/controllers/roundhouse_ui/settings_controller.rb,
app/controllers/roundhouse_ui/dashboard_controller.rb,
app/controllers/roundhouse_ui/recurring_controller.rb,
app/controllers/roundhouse_ui/scheduled_controller.rb,
app/controllers/roundhouse_ui/snapshots_controller.rb,
app/controllers/roundhouse_ui/application_controller.rb,
app/controllers/concerns/roundhouse_ui/job_set_browsing.rb

Overview

Brand name is "Roundhouse"; the gem and Ruby namespace are RoundhouseUi (matching the published gem name roundhouse_ui).

Defined Under Namespace

Modules: ApplicationHelper, Audit, Backends, Cancellation, Demo, History, Icons, JobSetBrowsing, NavHelper, Observability, ObservabilityHelper, Pause, Recurring, Redaction, Runbooks, Snapshots, Tags, TagsHelper, Theme Classes: ApplicationController, AssetsController, AuditController, BusyController, CancelMiddleware, CapsulesController, DashboardController, Day, DeadController, DurationCollector, Engine, ErrorGroups, ErrorsController, Fetch, FilterQuery, Health, JobsController, Metrics, MetricsController, QueueSummary, QueuesController, RecurringController, RedisController, RetriesController, ScheduledController, SettingsController, SnapshotsController, WorkersController

Constant Summary collapse

JOB_CLASS_NAME =

A well-formed Ruby constant path, and a sane bound on its length. Used to decide what may reach String#safe_constantize — see .job_class.

/\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
MAX_JOB_CLASS_NAME =
200
WARN_MEMO_CAP =

How many distinct warning messages .warn_once remembers before resetting.

200
VERSION =
"0.11.0"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.actor_resolverObject

How the audit log names the person taking an action. Auth is the host's job, so give Roundhouse a callable that pulls the actor from the request:

RoundhouseUi.actor_resolver = ->(controller) { controller.current_user&.email }

Defaults to "anonymous".



65
66
67
# File 'lib/roundhouse_ui.rb', line 65

def actor_resolver
  @actor_resolver
end

.allow_job_editingObject

Opt-in: enqueue brand-new jobs and edit/re-enqueue existing ones from the UI. Off by default — it's a sharp tool (bad edits create unrunnable jobs).



69
70
71
# File 'lib/roundhouse_ui.rb', line 69

def allow_job_editing
  @allow_job_editing
end

.allow_theme_selectionObject

Set false where an operator should not be able to recolour a production console. Settings then hides palette selection and everyone keeps the host's theme. Default: true.



177
178
179
# File 'lib/roundhouse_ui.rb', line 177

def allow_theme_selection
  @allow_theme_selection
end

.backendObject



193
194
195
# File 'lib/roundhouse_ui.rb', line 193

def backend
  @backend ||= Backends::Sidekiq.new
end

.cancel_enabledObject

Cancellation only does something if the host opted into it — either by installing RoundhouseUi::CancelMiddleware as server middleware, or by having long-running jobs poll RoundhouseUi.cancelled?(jid) themselves. With neither, cancel! writes a JID that nothing ever reads, so the Busy page's button would be inert. Default false: the button appears once you say the check exists. Also requires a backend that supports :cancel.



136
137
138
# File 'lib/roundhouse_ui.rb', line 136

def cancel_enabled
  @cancel_enabled
end

.collect_durationsObject

Opt-in: record per-class job durations (via RoundhouseUi::DurationCollector server middleware) so the Metrics page can show the slowest job classes. Default false; reads/writes a single Redis hash. The flag gates the UI; the collection itself is enabled by installing the middleware.



118
119
120
# File 'lib/roundhouse_ui.rb', line 118

def collect_durations
  @collect_durations
end

.iconsObject

How icons render. :svg (default) inlines them — no font, no request, no CSP change. :font_awesome or a Hash of { name => "class names" } emits class names instead, for hosts that already ship an icon font; Roundhouse never loads one itself either way.



162
163
164
# File 'lib/roundhouse_ui.rb', line 162

def icons
  @icons
end

.job_class_namespacesObject

Optional allowlist for constant resolution. When set, only these namespaces may be resolved from a job payload:

c.job_class_namespaces = %w[Workers Jobs Billing]

This is the only control here that actually restricts what can be loaded. Shape checks do not: Ruby rejects a malformed constant path before it attempts any lookup, so a name like "../../etc/passwd" never reaches autoloading in the first place — only well-formed names do, and those are exactly what a shape check permits.

Default nil (no restriction), because most apps do not need it: production Rails sets eager_load = true, so every app constant is already loaded and resolving one executes no new file. Set it where the job payload is not fully trusted and you would rather bound the blast radius anyway.



243
244
245
# File 'lib/roundhouse_ui.rb', line 243

def job_class_namespaces
  @job_class_namespaces
end

.job_runbooksObject

Where the runbook for a job class lives. A callable, or a Hash keyed by class name:

c.job_runbooks = RoundhouseUi::Runbooks.from_constant(:RUNBOOK)
c.job_runbooks = { "Billing::SyncWorker" => "https://wiki/billing" }

Resolved at read time like job_tags, so it applies to jobs already in the sets. Only http(s) URLs are rendered. Default: nil.



172
173
174
# File 'lib/roundhouse_ui.rb', line 172

def job_runbooks
  @job_runbooks
end

.job_tagsObject

Host-defined job tags, resolved at read time (see ADR 0002): a callable given the job's class name and payload, returning a Hash of tags or nil. klass is always the real job class (the ActiveJob adapter wrapper is unwrapped first). For the class-constant convention there's a shorthand:

RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
# equivalent to:
RoundhouseUi.job_tags = ->(klass:, item:) {
k = klass.safe_constantize
{ squad: k.const_get(:OWNER) } if k&.const_defined?(:OWNER)
}

Tag values render in the UI and pass through redact_args masking (by tag key). Must be cheap: by default it's memoized per class per request and called with item: nil. Default: nil (no tags anywhere).



90
91
92
# File 'lib/roundhouse_ui.rb', line 90

def job_tags
  @job_tags
end

.job_tags_per_jobObject

Set true when job_tags derives tags from the payload (args, tenant, …): the resolver is then called once per job with the full item, and nothing is cached — a 1,000-entry scan means 1,000 calls, so keep it fast. Leave false for class-derived tags (OWNER-style constants). Default: false.



96
97
98
# File 'lib/roundhouse_ui.rb', line 96

def job_tags_per_job
  @job_tags_per_job
end

.observabilityObject



55
56
57
# File 'lib/roundhouse_ui.rb', line 55

def observability
  @observability ||= Observability::NullAdapter.new
end

.pause_enabledObject

On OSS Sidekiq, pause is enforced only with RoundhouseUi::Fetch installed as the server's fetch strategy; the UI warns until it is. Sidekiq Pro and Solid Queue enforce it natively with no fetcher, so leave this on there. Default: true.



128
129
130
# File 'lib/roundhouse_ui.rb', line 128

def pause_enabled
  @pause_enabled
end

.poll_intervalObject

Seconds between dashboard stat polls. Lower = livelier, but each poll also re-runs the host's auth/routing on the mount, so a busy console can add DB load. Default 5s; raise it if polling shows up in your traces.



123
124
125
# File 'lib/roundhouse_ui.rb', line 123

def poll_interval
  @poll_interval
end

.read_onlyObject

When true, destructive actions (purge, retry, delete, …) are disabled. Mount Roundhouse read-only where operators should only observe.



41
42
43
# File 'lib/roundhouse_ui.rb', line 41

def read_only
  @read_only
end

.redact_argsObject

Argument keys (substring, case-insensitive) to mask when displaying jobs. e.g. RoundhouseUi.redact_args = %w[password token secret]. Default: none.



73
74
75
# File 'lib/roundhouse_ui.rb', line 73

def redact_args
  @redact_args
end

.show_sidekiq_failuresObject

Opt-in: fold failures recorded by the sidekiq-failures gem (its failed sorted set) into the grouped Errors view. Off by default, and a no-op unless sidekiq-failures is loaded. Jobs with retry: false never enter Sidekiq's retry/dead sets, so this is the only way to surface them here.



112
113
114
# File 'lib/roundhouse_ui.rb', line 112

def show_sidekiq_failures
  @show_sidekiq_failures
end

.snapshot_storeObject



47
48
49
# File 'lib/roundhouse_ui.rb', line 47

def snapshot_store
  @snapshot_store ||= Snapshots::RedisStore.new
end

.tag_filtersObject

Optional declared filter vocabulary, so tag filter dropdowns are stable instead of discovered from whatever jobs happen to be visible:

RoundhouseUi.tag_filters = { squad: %w[core training growth platform ops ai] }

Values (or the whole setting) may be callables for dynamic vocabularies. When set, filtering by an undeclared key matches nothing (fail-closed). Default: nil — the filter UI discovers values from the entries it scans.



106
107
108
# File 'lib/roundhouse_ui.rb', line 106

def tag_filters
  @tag_filters
end

.themeObject

Override any of the UI's colour tokens, as pure CSS custom properties:

RoundhouseUi.theme = { accent: "#FF2BD1", accent_2: "#00E5FF" }
RoundhouseUi.theme = { dark: { bg: "#0A0511" }, light: { bg: "#FFF7FB" } }
RoundhouseUi.theme = RoundhouseUi::Theme::PRESETS[:cyberpunk]

Unset tokens keep their shipped values, so a partial theme is fine. Only known tokens are emitted and values are shape-checked — this is interpolated into a stylesheet, where escaping does not make arbitrary input safe. Default: nil.



148
149
150
# File 'lib/roundhouse_ui.rb', line 148

def theme
  @theme
end

.themesObject

Named palettes a viewer can choose between on the Settings page:

RoundhouseUi.themes = { cyberpunk: RoundhouseUi::Theme::PRESETS[:cyberpunk] }

theme sets the default for everyone; this is the menu each person picks from in their own browser. Default: the shipped presets.



156
157
158
# File 'lib/roundhouse_ui.rb', line 156

def themes
  @themes
end

Class Method Details

.cancelled?(jid) ⇒ Boolean

Cooperative cancellation check for long-running jobs:

raise SomeStop if RoundhouseUi.cancelled?(jid)

Returns:

  • (Boolean)


328
329
330
# File 'lib/roundhouse_ui.rb', line 328

def cancelled?(jid)
  Cancellation.cancelled?(jid)
end

.configure {|_self| ... } ⇒ Object

Configure in an initializer:

RoundhouseUi.configure do |c|
c.read_only = !Rails.env.development?
end

Yields:

  • (_self)

Yield Parameters:

  • _self (RoundhouseUi)

    the object that the method was called on



184
185
186
# File 'lib/roundhouse_ui.rb', line 184

def configure
  yield self
end

.duration(seconds) ⇒ Object

Durations, in one place. This lived in a view helper, which meant anything in lib/ that wanted to print a duration had to reinvent it — and did, five different ways, including the health signal that reported an hour-old queue as "3616s" (#31). Views reach this through the duration helper.



286
287
288
289
290
291
292
293
294
295
296
# File 'lib/roundhouse_ui.rb', line 286

def duration(seconds)
  return "" if seconds.nil?

  secs = seconds.to_f.abs
  # Sub-minute keeps a decimal: 0.4s and 12s are a real distinction here.
  return "#{secs.round(1)}s" if secs < 60
  return "#{(secs / 60).floor}m #{(secs % 60).round}s" if secs < 3_600
  return "#{(secs / 3_600).floor}h #{((secs % 3_600) / 60).round}m" if secs < 86_400

  "#{(secs / 86_400).floor}d #{((secs % 86_400) / 3_600).round}h"
end

.duration_ms(ms) ⇒ Object



298
299
300
301
302
303
# File 'lib/roundhouse_ui.rb', line 298

def duration_ms(ms)
  return "" if ms.nil?
  return "#{ms.to_f.abs.round}ms" if ms.to_f.abs < 1_000

  duration(ms.to_f / 1_000)
end

.enqueued_at(item) ⇒ Object

When a job was enqueued, as a Time, or nil if the payload does not say.

Two formats exist three orders of magnitude apart: Sidekiq 8 writes integer epoch milliseconds, 6.5 and 7 write float epoch seconds. Reading one as the other does not give a slightly wrong time, it gives 1970 or the year 58000.



219
220
221
222
223
224
225
226
# File 'lib/roundhouse_ui.rb', line 219

def enqueued_at(item)
  raw = item["enqueued_at"] || item["created_at"] if item.is_a?(Hash)
  return nil unless raw

  raw.is_a?(Float) ? Time.at(raw) : Time.at(raw / 1000.0)
rescue StandardError
  nil
end

.job_class(name) ⇒ Object

Resolve a job class name to its Class, for resolvers that read constants off the class (Tags.from_constant, Runbooks.from_constant).

The name comes from the job payload — item["class"] or item["wrapped"] — which is data, not code. Three narrowings, and it is worth being precise about which of them is a security control and which are not:

* The namespace allowlist, when configured, IS one. It runs before the
constant lookup and is the only thing here that decides what may be
resolved.
* The shape and length checks are NOT. They reject names Ruby would
reject anyway, before it attempts a lookup; they are here so the
rejection is explicit and a pathological string is bounded early.
* Requiring a Module back is robustness: a name resolving to an Array
would otherwise be sent `const_defined?`, which it does not answer.

What nothing at this layer can do is stop a chosen name for a class that genuinely exists — reading a constant requires loading the class. Hosts who want no constant resolution at all should supply a resolver that does none: a Hash keyed by class name serves both tags and runbooks and never constantizes.



266
267
268
269
270
271
272
273
# File 'lib/roundhouse_ui.rb', line 266

def job_class(name)
  str = name.to_s
  return nil unless str.length <= MAX_JOB_CLASS_NAME && str.match?(JOB_CLASS_NAME)
  return nil unless namespace_allowed?(str)

  klass = str.safe_constantize
  klass if klass.is_a?(Module)
end

.namespace_allowed?(str) ⇒ Boolean

Returns:

  • (Boolean)


275
276
277
278
279
280
# File 'lib/roundhouse_ui.rb', line 275

def namespace_allowed?(str)
  allowed = job_class_namespaces
  return true if allowed.nil? || Array(allowed).empty?

  Array(allowed).any? { |ns| str == ns.to_s || str.start_with?("#{ns}::") }
end

.unwrapped_class(klass, item) ⇒ Object

ActiveJob-on-Sidekiq stores the adapter's JobWrapper in item and the real job class in item. Solid Queue and raw Sidekiq workers put the real class in klass, and Solid Queue's synthetic item never carries a "wrapped" key — so this is a no-op there and safe to call unconditionally.

Reading item rather than matching on the wrapper's name also covers Sidekiq 7+/8's native Sidekiq::ActiveJob::Wrapper for free.

Use for display, search, grouping and APM links, so all four agree on one string. NOT for re-enqueue: a payload pushed back to Redis must keep item exactly as Sidekiq stored it, or the job is re-created as a raw worker and fails on every attempt.



209
210
211
212
# File 'lib/roundhouse_ui.rb', line 209

def unwrapped_class(klass, item)
  wrapped = item["wrapped"] if item.is_a?(Hash)
  (wrapped || klass)&.to_s
end

.warn_once(message) ⇒ Object

Log a resolver failure at most once per process per distinct message.

It was named warn_once and warned every time. A resolver that raises raises for every entry in a scan, so one broken job_tags lambda wrote a line per job — a thousand identical lines in the log for a single page render, which is how a warning stops being read at all.

Bounded by WARN_MEMO_CAP, because the message carries the class name and the exception's own text: a resolver whose message varies could otherwise grow this without limit. At the cap it resets rather than leaking or going silent.



315
316
317
318
319
320
321
322
323
324
# File 'lib/roundhouse_ui.rb', line 315

def warn_once(message)
  return unless defined?(Rails)

  @warned ||= {}
  return if @warned.key?(message)

  @warned.clear if @warned.size >= WARN_MEMO_CAP
  @warned[message] = true
  Rails.logger&.warn("[roundhouse] #{message}")
end