roundhouse_ui 0.9.0 → 0.10.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.
- checksums.yaml +4 -4
- data/README.md +124 -19
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +73 -6
- data/app/controllers/roundhouse_ui/application_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +6 -1
- data/app/controllers/roundhouse_ui/dead_controller.rb +5 -2
- data/app/controllers/roundhouse_ui/errors_controller.rb +37 -0
- data/app/controllers/roundhouse_ui/queues_controller.rb +2 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +5 -2
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +3 -1
- data/app/helpers/roundhouse_ui/application_helper.rb +50 -0
- data/app/helpers/roundhouse_ui/observability_helper.rb +23 -0
- data/app/helpers/roundhouse_ui/tags_helper.rb +101 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +108 -4
- data/app/views/roundhouse_ui/dashboard/show.html.erb +1 -1
- data/app/views/roundhouse_ui/dead/index.html.erb +41 -27
- data/app/views/roundhouse_ui/errors/index.html.erb +28 -7
- data/app/views/roundhouse_ui/jobs/show.html.erb +1 -0
- data/app/views/roundhouse_ui/queues/index.html.erb +6 -2
- data/app/views/roundhouse_ui/retries/index.html.erb +23 -12
- data/app/views/roundhouse_ui/scheduled/index.html.erb +12 -6
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +30 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +10 -3
- data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
- data/lib/roundhouse_ui/cancellation.rb +43 -0
- data/lib/roundhouse_ui/duration_collector.rb +11 -4
- data/lib/roundhouse_ui/error_groups.rb +12 -1
- data/lib/roundhouse_ui/pause.rb +38 -3
- data/lib/roundhouse_ui/tags.rb +115 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +35 -0
- metadata +5 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module RoundhouseUi
|
|
2
2
|
# Opt-in server middleware that records per-class execution time, so the UI can
|
|
3
3
|
# answer "which job classes are slow?" — something Sidekiq doesn't track. Two
|
|
4
|
-
# cheap Redis writes per job (a counter + a summed-ms float)
|
|
5
|
-
# enable in your Sidekiq server config:
|
|
4
|
+
# cheap Redis writes per job (a counter + a summed-ms float), pipelined into a
|
|
5
|
+
# single round-trip. Off by default; enable in your Sidekiq server config:
|
|
6
6
|
#
|
|
7
7
|
# Sidekiq.configure_server do |config|
|
|
8
8
|
# config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
|
|
@@ -22,9 +22,16 @@ module RoundhouseUi
|
|
|
22
22
|
def record(klass, elapsed_ms)
|
|
23
23
|
return unless klass
|
|
24
24
|
|
|
25
|
+
commands = [
|
|
26
|
+
[ "HINCRBY", KEY, "#{klass}\x00count", 1 ],
|
|
27
|
+
[ "HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms ]
|
|
28
|
+
]
|
|
25
29
|
Sidekiq.redis do |conn|
|
|
26
|
-
conn.
|
|
27
|
-
|
|
30
|
+
if conn.respond_to?(:pipelined) # redis-client and redis-rb 4.5+: one round-trip
|
|
31
|
+
conn.pipelined { |pipe| commands.each { |c| pipe.call(*c) } }
|
|
32
|
+
else
|
|
33
|
+
commands.each { |c| conn.call(*c) }
|
|
34
|
+
end
|
|
28
35
|
end
|
|
29
36
|
rescue => e
|
|
30
37
|
# Metrics collection must never break a job.
|
|
@@ -14,6 +14,7 @@ module RoundhouseUi
|
|
|
14
14
|
def initialize(query: nil, limit: DEFAULT_SCAN_LIMIT)
|
|
15
15
|
@query = query.to_s.strip
|
|
16
16
|
@limit = limit
|
|
17
|
+
@tag_cache = {}
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def call
|
|
@@ -34,12 +35,22 @@ module RoundhouseUi
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
list = groups.values.sort_by { |g| -g[:count] }
|
|
37
|
-
list = list.select { |g|
|
|
38
|
+
list = list.select { |g| matches?(g) } if @query.present?
|
|
38
39
|
Result.new(groups: list, scanned: scanned, truncated: truncated)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
private
|
|
42
43
|
|
|
44
|
+
# Tag values are part of the haystack here for the same reason they are on
|
|
45
|
+
# the job sets: typing a squad name should find that squad's failures. A
|
|
46
|
+
# group's entries all share a class, so the tag is constant for the group
|
|
47
|
+
# and resolves once per group rather than per scanned entry.
|
|
48
|
+
def matches?(group)
|
|
49
|
+
haystack = [ group[:klass], group[:error] ]
|
|
50
|
+
haystack.concat(Tags.for(klass: group[:klass], item: {}, cache: @tag_cache).values) if RoundhouseUi.job_tags
|
|
51
|
+
haystack.join(" ").downcase.include?(@query.downcase)
|
|
52
|
+
end
|
|
53
|
+
|
|
43
54
|
# Sidekiq's native sets, plus the sidekiq-failures `failed` set when opted in
|
|
44
55
|
# and loaded. Its FailureSet is a Sidekiq::JobSet, so it iterates like the rest.
|
|
45
56
|
def sources
|
data/lib/roundhouse_ui/pause.rb
CHANGED
|
@@ -7,30 +7,59 @@ module RoundhouseUi
|
|
|
7
7
|
# Paused queue names live in a Redis set. RoundhouseUi::Fetch consults this set
|
|
8
8
|
# and skips paused queues when pulling work, so a paused queue stops being
|
|
9
9
|
# consumed without stopping the worker process.
|
|
10
|
+
#
|
|
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.
|
|
10
16
|
module Pause
|
|
11
17
|
KEY = "roundhouse:paused"
|
|
12
18
|
FETCH_FLAG = "roundhouse:fetch_alive" # liveness beacon set by the fetcher
|
|
19
|
+
PRO_KEY = "paused" # Sidekiq Pro's own registry
|
|
13
20
|
|
|
14
21
|
module_function
|
|
15
22
|
|
|
23
|
+
# True when Sidekiq Pro's queue-pause API is available. Feature-detected on
|
|
24
|
+
# the method rather than `defined?(Sidekiq::Pro)` so it tracks the actual
|
|
25
|
+
# capability across Pro versions. Cheap (no Redis), so it isn't memoized —
|
|
26
|
+
# loading Pro mid-process would otherwise be missed.
|
|
27
|
+
def native?
|
|
28
|
+
defined?(::Sidekiq::Queue) && ::Sidekiq::Queue.method_defined?(:pause!)
|
|
29
|
+
end
|
|
30
|
+
|
|
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.
|
|
16
35
|
def pause!(queue)
|
|
36
|
+
return ::Sidekiq::Queue.new(queue.to_s).pause! if native?
|
|
37
|
+
|
|
17
38
|
Sidekiq.redis { |conn| conn.call("SADD", KEY, queue.to_s) }
|
|
18
39
|
end
|
|
19
40
|
|
|
20
41
|
def unpause!(queue)
|
|
42
|
+
return ::Sidekiq::Queue.new(queue.to_s).unpause! if native?
|
|
43
|
+
|
|
21
44
|
Sidekiq.redis { |conn| conn.call("SREM", KEY, queue.to_s) }
|
|
22
45
|
end
|
|
23
46
|
|
|
24
47
|
def paused?(queue)
|
|
25
|
-
Sidekiq.redis { |conn| conn.call("SISMEMBER",
|
|
48
|
+
Sidekiq.redis { |conn| conn.call("SISMEMBER", key, queue.to_s) } == 1
|
|
26
49
|
end
|
|
27
50
|
|
|
28
51
|
def paused_queues
|
|
29
|
-
Sidekiq.redis { |conn| conn.call("SMEMBERS",
|
|
52
|
+
Sidekiq.redis { |conn| conn.call("SMEMBERS", key) }.sort
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
def paused_set
|
|
33
|
-
Set.new(Sidekiq.redis { |conn| conn.call("SMEMBERS",
|
|
56
|
+
Set.new(Sidekiq.redis { |conn| conn.call("SMEMBERS", key) })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Which registry reads come from. Reads are plain set lookups in both cases
|
|
60
|
+
# (no pubsub involved), so they can share one implementation.
|
|
61
|
+
def key
|
|
62
|
+
native? ? PRO_KEY : KEY
|
|
34
63
|
end
|
|
35
64
|
|
|
36
65
|
# Given the redis queue keys BasicFetch would poll (e.g. "queue:default"),
|
|
@@ -52,7 +81,13 @@ module RoundhouseUi
|
|
|
52
81
|
|
|
53
82
|
# True when a RoundhouseUi::Fetch has reported in recently — i.e. pausing
|
|
54
83
|
# will take effect. When false, the UI warns instead of pretending.
|
|
84
|
+
#
|
|
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.
|
|
55
88
|
def fetch_installed?
|
|
89
|
+
return true if native?
|
|
90
|
+
|
|
56
91
|
Sidekiq.redis { |conn| conn.call("EXISTS", FETCH_FLAG) } == 1
|
|
57
92
|
end
|
|
58
93
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module RoundhouseUi
|
|
2
|
+
# Resolves host-defined tags for a job at read time (no middleware, no
|
|
3
|
+
# storage — works on every backend and applies retroactively to jobs already
|
|
4
|
+
# in the sets). The host supplies the resolver; see ADR 0002.
|
|
5
|
+
#
|
|
6
|
+
# RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
|
|
7
|
+
# # or any callable:
|
|
8
|
+
# RoundhouseUi.job_tags = ->(klass:, item:) { { squad: :growth } }
|
|
9
|
+
#
|
|
10
|
+
# Contract: resolver output is normalized to string keys/values, masked via
|
|
11
|
+
# Redaction (redact_args patterns apply to tag keys), and a raising resolver
|
|
12
|
+
# yields no tags — tagging must never break a page.
|
|
13
|
+
module Tags
|
|
14
|
+
EMPTY = {}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Tags for one job entry, as a { "key" => "value" } Hash (EMPTY when no
|
|
19
|
+
# resolver is configured, the resolver declines, or it raises).
|
|
20
|
+
#
|
|
21
|
+
# `klass`/`item` come from the backend entry (entry.klass / entry.item);
|
|
22
|
+
# the ActiveJob adapter wrapper is unwrapped here, so resolvers always see
|
|
23
|
+
# the real job class. Pass a Hash as `cache` to memoize per class across a
|
|
24
|
+
# request — unused (and unneeded) in per-job mode.
|
|
25
|
+
def for(klass:, item:, cache: nil)
|
|
26
|
+
resolver = RoundhouseUi.job_tags
|
|
27
|
+
return EMPTY unless resolver
|
|
28
|
+
|
|
29
|
+
effective = effective_klass(klass, item)
|
|
30
|
+
return EMPTY unless effective
|
|
31
|
+
|
|
32
|
+
if RoundhouseUi.job_tags_per_job
|
|
33
|
+
# Per-job mode still memoizes, keyed by jid rather than class: a job's
|
|
34
|
+
# tags cannot change within one request, and the same entry is resolved
|
|
35
|
+
# twice otherwise — once while scanning, once when its badge renders.
|
|
36
|
+
jid = item["jid"] if item.is_a?(Hash)
|
|
37
|
+
return resolve(resolver, effective, item) unless cache && jid
|
|
38
|
+
|
|
39
|
+
cache.key?(jid) ? cache[jid] : cache[jid] = resolve(resolver, effective, item)
|
|
40
|
+
elsif cache
|
|
41
|
+
# Class-cached mode: item is withheld (deliberately — see resolve).
|
|
42
|
+
cache.key?(effective) ? cache[effective] : cache[effective] = resolve(resolver, effective, nil)
|
|
43
|
+
else
|
|
44
|
+
resolve(resolver, effective, nil)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The class-constant convention (Trainual's OWNER pattern) as a resolver:
|
|
49
|
+
#
|
|
50
|
+
# RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
|
|
51
|
+
#
|
|
52
|
+
# Tags every job whose class (or ancestor — inherited constants count, so a
|
|
53
|
+
# base-class OWNER covers subclasses) defines the constant.
|
|
54
|
+
def from_constant(const_name, as: const_name.to_s.downcase)
|
|
55
|
+
lambda do |klass:, item:|
|
|
56
|
+
k = klass.to_s.safe_constantize
|
|
57
|
+
{ as => k.const_get(const_name) } if k&.const_defined?(const_name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The declared filter vocabulary, normalized to { "key" => ["value", ...] },
|
|
62
|
+
# or nil when the host declared none (the filter UI then discovers values
|
|
63
|
+
# from the entries it scans). Both the whole setting and individual values
|
|
64
|
+
# may be callables, so vocabularies can be dynamic.
|
|
65
|
+
def filters
|
|
66
|
+
declared = RoundhouseUi.tag_filters
|
|
67
|
+
declared = declared.call if declared.respond_to?(:call)
|
|
68
|
+
return nil unless declared.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
declared.each_with_object({}) do |(key, values), out|
|
|
71
|
+
values = values.call if values.respond_to?(:call)
|
|
72
|
+
out[key.to_s] = Array(values).map(&:to_s)
|
|
73
|
+
end
|
|
74
|
+
rescue => e
|
|
75
|
+
warn_once("tag_filters failed: #{e.message}")
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Does a resolved tag Hash match a key/value filter? Exact match on the
|
|
80
|
+
# normalized (post-redaction) value — so a redacted tag matches only its
|
|
81
|
+
# mask, and the filter can't be used to probe redacted values.
|
|
82
|
+
def match?(tags, key, value)
|
|
83
|
+
tags[key.to_s] == value.to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -- internals ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# ActiveJob-on-Sidekiq stores the adapter's JobWrapper in item["class"] and
|
|
89
|
+
# the real job class in item["wrapped"]; Solid Queue and raw Sidekiq
|
|
90
|
+
# workers put the real class in klass. Resolvers always get the real one.
|
|
91
|
+
def effective_klass(klass, item)
|
|
92
|
+
wrapped = item["wrapped"] if item.is_a?(Hash)
|
|
93
|
+
(wrapped || klass)&.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `item` is nil in class-cached mode: an args-reading resolver cached by
|
|
97
|
+
# class would poison the cache with first-job-wins values — withholding the
|
|
98
|
+
# payload makes it fail deterministically (rescued → no tags) instead.
|
|
99
|
+
# Hosts that need the payload set RoundhouseUi.job_tags_per_job = true.
|
|
100
|
+
def resolve(resolver, klass, item)
|
|
101
|
+
tags = resolver.call(klass: klass, item: item)
|
|
102
|
+
return EMPTY unless tags.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
normalized = tags.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s }
|
|
105
|
+
Redaction.apply(normalized)
|
|
106
|
+
rescue => e
|
|
107
|
+
warn_once("job_tags resolver failed for #{klass}: #{e.message}")
|
|
108
|
+
EMPTY
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def warn_once(message)
|
|
112
|
+
Rails.logger&.warn("[roundhouse] #{message}") if defined?(Rails)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/roundhouse_ui.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "roundhouse_ui/snapshots"
|
|
|
7
7
|
require "roundhouse_ui/observability"
|
|
8
8
|
require "roundhouse_ui/audit"
|
|
9
9
|
require "roundhouse_ui/redaction"
|
|
10
|
+
require "roundhouse_ui/tags"
|
|
10
11
|
require "roundhouse_ui/cancellation"
|
|
11
12
|
require "roundhouse_ui/cancel_middleware"
|
|
12
13
|
require "roundhouse_ui/metrics"
|
|
@@ -56,6 +57,39 @@ module RoundhouseUi
|
|
|
56
57
|
# e.g. RoundhouseUi.redact_args = %w[password token secret]. Default: none.
|
|
57
58
|
attr_accessor :redact_args
|
|
58
59
|
|
|
60
|
+
# Host-defined job tags, resolved at read time (see ADR 0002): a callable
|
|
61
|
+
# given the job's class name and payload, returning a Hash of tags or nil.
|
|
62
|
+
# `klass` is always the real job class (the ActiveJob adapter wrapper is
|
|
63
|
+
# unwrapped first). For the class-constant convention there's a shorthand:
|
|
64
|
+
#
|
|
65
|
+
# RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
|
|
66
|
+
# # equivalent to:
|
|
67
|
+
# RoundhouseUi.job_tags = ->(klass:, item:) {
|
|
68
|
+
# k = klass.safe_constantize
|
|
69
|
+
# { squad: k.const_get(:OWNER) } if k&.const_defined?(:OWNER)
|
|
70
|
+
# }
|
|
71
|
+
#
|
|
72
|
+
# Tag values render in the UI and pass through redact_args masking (by tag
|
|
73
|
+
# key). Must be cheap: by default it's memoized per class per request and
|
|
74
|
+
# called with item: nil. Default: nil (no tags anywhere).
|
|
75
|
+
attr_accessor :job_tags
|
|
76
|
+
|
|
77
|
+
# Set true when job_tags derives tags from the payload (args, tenant, …):
|
|
78
|
+
# the resolver is then called once per job with the full item, and nothing
|
|
79
|
+
# is cached — a 1,000-entry scan means 1,000 calls, so keep it fast. Leave
|
|
80
|
+
# false for class-derived tags (OWNER-style constants). Default: false.
|
|
81
|
+
attr_accessor :job_tags_per_job
|
|
82
|
+
|
|
83
|
+
# Optional declared filter vocabulary, so tag filter dropdowns are stable
|
|
84
|
+
# instead of discovered from whatever jobs happen to be visible:
|
|
85
|
+
#
|
|
86
|
+
# RoundhouseUi.tag_filters = { squad: %w[core training growth platform ops ai] }
|
|
87
|
+
#
|
|
88
|
+
# Values (or the whole setting) may be callables for dynamic vocabularies.
|
|
89
|
+
# When set, filtering by an undeclared key matches nothing (fail-closed).
|
|
90
|
+
# Default: nil — the filter UI discovers values from the entries it scans.
|
|
91
|
+
attr_accessor :tag_filters
|
|
92
|
+
|
|
59
93
|
# Opt-in: fold failures recorded by the `sidekiq-failures` gem (its `failed`
|
|
60
94
|
# sorted set) into the grouped Errors view. Off by default, and a no-op
|
|
61
95
|
# unless sidekiq-failures is loaded. Jobs with `retry: false` never enter
|
|
@@ -112,4 +146,5 @@ module RoundhouseUi
|
|
|
112
146
|
self.pause_enabled = true
|
|
113
147
|
self.poll_interval = 5
|
|
114
148
|
self.collect_durations = false
|
|
149
|
+
self.job_tags_per_job = false
|
|
115
150
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: roundhouse_ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- R.J. Robinson
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -72,6 +72,7 @@ files:
|
|
|
72
72
|
- app/helpers/roundhouse_ui/application_helper.rb
|
|
73
73
|
- app/helpers/roundhouse_ui/nav_helper.rb
|
|
74
74
|
- app/helpers/roundhouse_ui/observability_helper.rb
|
|
75
|
+
- app/helpers/roundhouse_ui/tags_helper.rb
|
|
75
76
|
- app/views/layouts/roundhouse_ui/application.html.erb
|
|
76
77
|
- app/views/roundhouse_ui/audit/index.html.erb
|
|
77
78
|
- app/views/roundhouse_ui/busy/index.html.erb
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- app/views/roundhouse_ui/retries/index.html.erb
|
|
90
91
|
- app/views/roundhouse_ui/scheduled/index.html.erb
|
|
91
92
|
- app/views/roundhouse_ui/shared/_pager.html.erb
|
|
93
|
+
- app/views/roundhouse_ui/shared/_tag_filter.html.erb
|
|
92
94
|
- app/views/roundhouse_ui/snapshots/index.html.erb
|
|
93
95
|
- app/views/roundhouse_ui/workers/index.html.erb
|
|
94
96
|
- config/routes.rb
|
|
@@ -108,6 +110,7 @@ files:
|
|
|
108
110
|
- lib/roundhouse_ui/pause.rb
|
|
109
111
|
- lib/roundhouse_ui/redaction.rb
|
|
110
112
|
- lib/roundhouse_ui/snapshots.rb
|
|
113
|
+
- lib/roundhouse_ui/tags.rb
|
|
111
114
|
- lib/roundhouse_ui/version.rb
|
|
112
115
|
- lib/tasks/roundhouse_ui_tasks.rake
|
|
113
116
|
homepage: https://github.com/rjrobinson/roundhouse_ui
|