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
-
.actor_resolver ⇒ Object
How the audit log names the person taking an action.
-
.allow_job_editing ⇒ Object
Opt-in: enqueue brand-new jobs and edit/re-enqueue existing ones from the UI.
-
.allow_theme_selection ⇒ Object
Set false where an operator should not be able to recolour a production console.
- .backend ⇒ Object
-
.cancel_enabled ⇒ Object
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.
-
.collect_durations ⇒ Object
Opt-in: record per-class job durations (via RoundhouseUi::DurationCollector server middleware) so the Metrics page can show the slowest job classes.
-
.icons ⇒ Object
How icons render.
-
.job_class_namespaces ⇒ Object
Optional allowlist for constant resolution.
-
.job_runbooks ⇒ Object
Where the runbook for a job class lives.
-
.job_tags ⇒ Object
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.
-
.job_tags_per_job ⇒ Object
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.
- .observability ⇒ Object
-
.pause_enabled ⇒ Object
On OSS Sidekiq, pause is enforced only with RoundhouseUi::Fetch installed as the server's fetch strategy; the UI warns until it is.
-
.poll_interval ⇒ Object
Seconds between dashboard stat polls.
-
.read_only ⇒ Object
When true, destructive actions (purge, retry, delete, …) are disabled.
-
.redact_args ⇒ Object
Argument keys (substring, case-insensitive) to mask when displaying jobs.
-
.show_sidekiq_failures ⇒ Object
Opt-in: fold failures recorded by the
sidekiq-failuresgem (itsfailedsorted set) into the grouped Errors view. - .snapshot_store ⇒ Object
-
.tag_filters ⇒ Object
Optional declared filter vocabulary, so tag filter dropdowns are stable instead of discovered from whatever jobs happen to be visible:.
-
.theme ⇒ Object
Override any of the UI's colour tokens, as pure CSS custom properties:.
-
.themes ⇒ Object
Named palettes a viewer can choose between on the Settings page:.
Class Method Summary collapse
-
.cancelled?(jid) ⇒ Boolean
Cooperative cancellation check for long-running jobs: raise SomeStop if RoundhouseUi.cancelled?(jid).
-
.configure {|_self| ... } ⇒ Object
Configure in an initializer:.
-
.duration(seconds) ⇒ Object
Durations, in one place.
- .duration_ms(ms) ⇒ Object
-
.enqueued_at(item) ⇒ Object
When a job was enqueued, as a Time, or nil if the payload does not say.
-
.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).
- .namespace_allowed?(str) ⇒ Boolean
- .unwrapped_class(klass, item) ⇒ Object
-
.warn_once(message) ⇒ Object
Log a resolver failure at most once per process per distinct message.
Class Attribute Details
.actor_resolver ⇒ Object
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_editing ⇒ Object
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_selection ⇒ Object
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 |
.backend ⇒ Object
193 194 195 |
# File 'lib/roundhouse_ui.rb', line 193 def backend @backend ||= Backends::Sidekiq.new end |
.cancel_enabled ⇒ Object
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_durations ⇒ Object
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 |
.icons ⇒ Object
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_namespaces ⇒ Object
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_runbooks ⇒ Object
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_tags ⇒ Object
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. = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
# equivalent to:
RoundhouseUi. = ->(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 end |
.job_tags_per_job ⇒ Object
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 end |
.observability ⇒ Object
55 56 57 |
# File 'lib/roundhouse_ui.rb', line 55 def observability @observability ||= Observability::NullAdapter.new end |
.pause_enabled ⇒ Object
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_interval ⇒ Object
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_only ⇒ Object
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_args ⇒ Object
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_failures ⇒ Object
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_store ⇒ Object
47 48 49 |
# File 'lib/roundhouse_ui.rb', line 47 def snapshot_store @snapshot_store ||= Snapshots::RedisStore.new end |
.tag_filters ⇒ Object
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 |
.theme ⇒ Object
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 |
.themes ⇒ Object
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)
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
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
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() return unless defined?(Rails) @warned ||= {} return if @warned.key?() @warned.clear if @warned.size >= WARN_MEMO_CAP @warned[] = true Rails.logger&.warn("[roundhouse] #{}") end |