karafka-web 1.0.0 → 1.0.1
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/CHANGELOG.md +10 -0
- data/Gemfile.lock +13 -13
- data/lib/karafka/web/management/actions/enable.rb +74 -10
- data/lib/karafka/web/pro/ui/app.rb +13 -0
- data/lib/karafka/web/pro/ui/controllers/base_controller.rb +4 -0
- data/lib/karafka/web/pro/ui/controllers/cluster_controller.rb +66 -4
- data/lib/karafka/web/pro/ui/controllers/consumers/consumers_controller.rb +9 -2
- data/lib/karafka/web/pro/ui/controllers/consumers/controls_controller.rb +6 -0
- data/lib/karafka/web/pro/ui/controllers/consumers/jobs_controller.rb +8 -2
- data/lib/karafka/web/pro/ui/controllers/dlq_controller.rb +6 -0
- data/lib/karafka/web/pro/ui/controllers/explorer/explorer_controller.rb +6 -0
- data/lib/karafka/web/pro/ui/controllers/health_controller.rb +19 -3
- data/lib/karafka/web/pro/ui/controllers/jobs_controller.rb +25 -0
- data/lib/karafka/web/pro/ui/controllers/recurring_tasks_controller.rb +10 -1
- data/lib/karafka/web/pro/ui/controllers/requests/params.rb +67 -0
- data/lib/karafka/web/pro/ui/controllers/routing_controller.rb +23 -1
- data/lib/karafka/web/pro/ui/controllers/scheduled_messages/schedules_controller.rb +6 -0
- data/lib/karafka/web/pro/ui/controllers/topics/configs_controller.rb +6 -1
- data/lib/karafka/web/pro/ui/controllers/topics/distributions_controller.rb +1 -1
- data/lib/karafka/web/pro/ui/controllers/topics/offsets_controller.rb +1 -1
- data/lib/karafka/web/pro/ui/controllers/topics/replications_controller.rb +1 -1
- data/lib/karafka/web/pro/ui/controllers/topics/topics_controller.rb +6 -0
- data/lib/karafka/web/pro/ui/helpers/filtering_helper.rb +173 -0
- data/lib/karafka/web/pro/ui/helpers/topics_filtering_helper.rb +80 -0
- data/lib/karafka/web/pro/ui/lib/filtering.rb +128 -0
- data/lib/karafka/web/pro/ui/lib/search/matchers/base.rb +2 -2
- data/lib/karafka/web/pro/ui/routes/cluster.rb +0 -2
- data/lib/karafka/web/pro/ui/views/cluster/_brokers_table.erb +41 -0
- data/lib/karafka/web/pro/ui/views/cluster/_configs_table.erb +41 -0
- data/lib/karafka/web/pro/ui/views/cluster/_partition.erb +2 -2
- data/lib/karafka/web/pro/ui/views/cluster/index.erb +12 -20
- data/lib/karafka/web/pro/ui/views/cluster/replication.erb +39 -0
- data/lib/karafka/web/pro/ui/views/cluster/show.erb +13 -21
- data/lib/karafka/web/pro/ui/views/consumers/consumers/_consumer_performances_table.erb +56 -0
- data/lib/karafka/web/pro/ui/views/consumers/consumers/_consumers_table.erb +43 -0
- data/lib/karafka/web/pro/ui/views/consumers/consumers/consumer/_subscription_group.erb +10 -2
- data/lib/karafka/web/pro/ui/views/consumers/consumers/index.erb +8 -24
- data/lib/karafka/web/pro/ui/views/consumers/consumers/performance.erb +8 -37
- data/lib/karafka/web/pro/ui/views/consumers/consumers/subscriptions.erb +20 -6
- data/lib/karafka/web/pro/ui/views/consumers/controls/_processes_table.erb +45 -0
- data/lib/karafka/web/pro/ui/views/consumers/controls/index.erb +14 -27
- data/lib/karafka/web/pro/ui/views/consumers/jobs/_jobs_table.erb +48 -0
- data/lib/karafka/web/pro/ui/views/consumers/jobs/pending.erb +8 -29
- data/lib/karafka/web/pro/ui/views/consumers/jobs/running.erb +8 -29
- data/lib/karafka/web/pro/ui/views/dlq/index.erb +9 -1
- data/lib/karafka/web/pro/ui/views/explorer/explorer/index.erb +9 -1
- data/lib/karafka/web/pro/ui/views/health/_changes_table.erb +59 -0
- data/lib/karafka/web/pro/ui/views/health/_cluster_lags_table.erb +43 -0
- data/lib/karafka/web/pro/ui/views/health/_lags_table.erb +60 -0
- data/lib/karafka/web/pro/ui/views/health/_no_data.erb +5 -1
- data/lib/karafka/web/pro/ui/views/health/_offsets_table.erb +63 -0
- data/lib/karafka/web/pro/ui/views/health/_overview_table.erb +83 -0
- data/lib/karafka/web/pro/ui/views/health/_tabs.erb +6 -0
- data/lib/karafka/web/pro/ui/views/health/changes.erb +13 -43
- data/lib/karafka/web/pro/ui/views/health/cluster_lags.erb +11 -25
- data/lib/karafka/web/pro/ui/views/health/lags.erb +13 -44
- data/lib/karafka/web/pro/ui/views/health/offsets.erb +13 -47
- data/lib/karafka/web/pro/ui/views/health/overview.erb +13 -67
- data/lib/karafka/web/pro/ui/views/jobs/_jobs_table.erb +48 -0
- data/lib/karafka/web/pro/ui/views/jobs/pending.erb +8 -30
- data/lib/karafka/web/pro/ui/views/jobs/running.erb +8 -30
- data/lib/karafka/web/pro/ui/views/recurring_tasks/_tasks_table.erb +43 -0
- data/lib/karafka/web/pro/ui/views/recurring_tasks/schedule.erb +8 -24
- data/lib/karafka/web/pro/ui/views/routing/_consumer_group.erb +36 -32
- data/lib/karafka/web/pro/ui/views/routing/_details_table.erb +43 -0
- data/lib/karafka/web/pro/ui/views/routing/index.erb +18 -6
- data/lib/karafka/web/pro/ui/views/routing/show.erb +12 -40
- data/lib/karafka/web/pro/ui/views/scheduled_messages/schedules/index.erb +7 -1
- data/lib/karafka/web/pro/ui/views/shared/_filter_box.erb +61 -0
- data/lib/karafka/web/pro/ui/views/topics/configs/_configs_table.erb +42 -0
- data/lib/karafka/web/pro/ui/views/topics/configs/index.erb +13 -22
- data/lib/karafka/web/pro/ui/views/topics/replications/_partition.erb +2 -2
- data/lib/karafka/web/pro/ui/views/topics/replications/show.erb +12 -2
- data/lib/karafka/web/pro/ui/views/topics/topics/index.erb +9 -1
- data/lib/karafka/web/tracking/consumers/listeners/errors.rb +80 -14
- data/lib/karafka/web/ui/base.rb +4 -0
- data/lib/karafka/web/ui/controllers/base_controller.rb +1 -1
- data/lib/karafka/web/ui/controllers/cluster_controller.rb +21 -22
- data/lib/karafka/web/ui/controllers/consumers_controller.rb +1 -1
- data/lib/karafka/web/ui/controllers/jobs_controller.rb +23 -27
- data/lib/karafka/web/ui/controllers/requests/hookable.rb +5 -0
- data/lib/karafka/web/ui/controllers/routing_controller.rb +15 -1
- data/lib/karafka/web/ui/helpers/application_helper.rb +3 -278
- data/lib/karafka/web/ui/helpers/badges_helper.rb +66 -0
- data/lib/karafka/web/ui/helpers/formatting_helper.rb +77 -0
- data/lib/karafka/web/ui/helpers/partitions_helper.rb +167 -0
- data/lib/karafka/web/ui/helpers/sorting_helper.rb +78 -0
- data/lib/karafka/web/ui/lib/filter.rb +377 -0
- data/lib/karafka/web/ui/lib/hash_flattener.rb +37 -0
- data/lib/karafka/web/ui/lib/sorter.rb +3 -1
- data/lib/karafka/web/ui/models/jobs.rb +3 -2
- data/lib/karafka/web/ui/models/process.rb +12 -0
- data/lib/karafka/web/ui/public/javascripts/application.min.js.gz +0 -0
- data/lib/karafka/web/ui/public/stylesheets/application.css +9 -0
- data/lib/karafka/web/ui/public/stylesheets/application.min.css +4230 -1258
- data/lib/karafka/web/ui/public/stylesheets/application.min.css.br +0 -0
- data/lib/karafka/web/ui/public/stylesheets/application.min.css.gz +0 -0
- data/lib/karafka/web/ui/public/stylesheets/libs/highlight_dark.min.css.gz +0 -0
- data/lib/karafka/web/ui/public/stylesheets/libs/highlight_light.min.css.gz +0 -0
- data/lib/karafka/web/ui/views/cluster/_brokers_table.erb +21 -0
- data/lib/karafka/web/ui/views/cluster/_partition.erb +2 -2
- data/lib/karafka/web/ui/views/cluster/_partitions_table.erb +31 -0
- data/lib/karafka/web/ui/views/cluster/brokers.erb +1 -21
- data/lib/karafka/web/ui/views/cluster/replication.erb +1 -23
- data/lib/karafka/web/ui/views/consumers/_consumers_table.erb +22 -0
- data/lib/karafka/web/ui/views/consumers/index.erb +1 -22
- data/lib/karafka/web/ui/views/errors/_table.erb +28 -0
- data/lib/karafka/web/ui/views/errors/index.erb +1 -28
- data/lib/karafka/web/ui/views/jobs/_jobs_table.erb +22 -0
- data/lib/karafka/web/ui/views/jobs/pending.erb +1 -22
- data/lib/karafka/web/ui/views/jobs/running.erb +1 -22
- data/lib/karafka/web/ui/views/routing/_consumer_group.erb +18 -12
- data/lib/karafka/web/ui/views/routing/_details_table.erb +23 -0
- data/lib/karafka/web/ui/views/routing/show.erb +1 -29
- data/lib/karafka/web/ui/views/shared/_no_filter_results.erb +6 -0
- data/lib/karafka/web/version.rb +1 -1
- data/package-lock.json +98 -61
- metadata +35 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karafka
|
|
4
|
+
module Web
|
|
5
|
+
module Ui
|
|
6
|
+
module Lib
|
|
7
|
+
# Filtering engine for deep in-memory structures. It supports hashes, arrays and hash
|
|
8
|
+
# proxies. It is a companion to the [[Sorter]] and follows the same wiring
|
|
9
|
+
# (a per-controller allow-list plus an in-place `call`), but instead of reordering a
|
|
10
|
+
# structure it removes the elements that do not match a keyword query.
|
|
11
|
+
#
|
|
12
|
+
# Matching is a case-insensitive substring check performed against the allowed attributes
|
|
13
|
+
# of each element (via `public_send` or hash lookup) and against hash keys (so structural
|
|
14
|
+
# labels such as topic or consumer group names are searchable).
|
|
15
|
+
#
|
|
16
|
+
# It uses match-propagation: a container (array/hash) is kept when it matches directly or
|
|
17
|
+
# when any of its descendants match. That way filtering a nested structure by a topic name
|
|
18
|
+
# keeps that topic with all of its children while dropping the branches that have nothing
|
|
19
|
+
# in common with the query.
|
|
20
|
+
#
|
|
21
|
+
# @note It handles filtering in place by mutating appropriate resources and sub-components,
|
|
22
|
+
# exactly like the [[Sorter]]. Because of that it must only be used on structures that
|
|
23
|
+
# are safe to mutate (per-request data), never on shared/live structures like the app
|
|
24
|
+
# routing.
|
|
25
|
+
class Filter
|
|
26
|
+
# Max depth for nested filtering. Matches the sorter so both engines agree on how deep
|
|
27
|
+
# they are willing to dive into a structure.
|
|
28
|
+
MAX_DEPTH = 8
|
|
29
|
+
|
|
30
|
+
private_constant :MAX_DEPTH
|
|
31
|
+
|
|
32
|
+
# @param filter_query [String] keyword based on which we filter or empty string when no
|
|
33
|
+
# filtering is needed
|
|
34
|
+
# @param allowed_attributes [Array<String, #path>, Hash] attributes on which we allow to
|
|
35
|
+
# filter. Since we can filter on method invocations, this needs to be limited and
|
|
36
|
+
# provided on a per controller basis (same contract as the sorter). It can be an array
|
|
37
|
+
# of attribute names or a `{ attribute => label }` hash (in which case only the keys
|
|
38
|
+
# matter here). An entry may also be a key-alias descriptor (any object responding to
|
|
39
|
+
# `name`/`path`), which matches nested hash keys at a path instead of record attributes
|
|
40
|
+
# (used for tree-shaped data whose labels are keys, e.g. the health stats).
|
|
41
|
+
# @param field [String, nil] when provided (and allowed), filtering is scoped to this
|
|
42
|
+
# single attribute (or key alias) instead of matching the query against every allowed
|
|
43
|
+
# attribute. Used by the field-selectable filter on flat record listings.
|
|
44
|
+
def initialize(filter_query, allowed_attributes:, field: nil)
|
|
45
|
+
@query = filter_query.to_s.downcase.strip
|
|
46
|
+
|
|
47
|
+
allowed = allowed_attributes.is_a?(Hash) ? allowed_attributes.keys : allowed_attributes
|
|
48
|
+
|
|
49
|
+
# A declared field is either a plain attribute (matched on records) or a key alias
|
|
50
|
+
# (matched on nested hash keys at a path). Key aliases are duck-typed: they respond to
|
|
51
|
+
# `path` (a `KeyField`), which nothing else in the allow-list does.
|
|
52
|
+
@allowed = []
|
|
53
|
+
@aliases = {}
|
|
54
|
+
|
|
55
|
+
Array(allowed).each do |attribute|
|
|
56
|
+
if attribute.respond_to?(:path)
|
|
57
|
+
@aliases[attribute.name.to_s] = attribute.path
|
|
58
|
+
else
|
|
59
|
+
# Normalize to strings so symbol keys from controllers and the string field coming
|
|
60
|
+
# from the request params compare cleanly
|
|
61
|
+
@allowed << attribute.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
field = field.to_s
|
|
66
|
+
|
|
67
|
+
# When the selected field is a key alias, filtering prunes hash keys at its path instead
|
|
68
|
+
# of matching record attributes
|
|
69
|
+
if @aliases.key?(field)
|
|
70
|
+
@alias_path = @aliases[field]
|
|
71
|
+
else
|
|
72
|
+
@alias_path = nil
|
|
73
|
+
@field = @allowed.include?(field) ? field : nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Things we have already seen and filtered. Prevents crashing (and infinite loops) on
|
|
77
|
+
# circular dependencies when the same resources are present in different parts of the
|
|
78
|
+
# tree. We cache the match result so a second visit returns the same answer.
|
|
79
|
+
@seen = {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Filters the structure in place and returns it.
|
|
83
|
+
#
|
|
84
|
+
# @param resource [Hash, Array, Lib::HashProxy] structure we want to filter
|
|
85
|
+
# @return [Hash, Array, Lib::HashProxy] the same structure with non-matching elements
|
|
86
|
+
# removed
|
|
87
|
+
def call(resource)
|
|
88
|
+
# Skip if there is nothing to filter on
|
|
89
|
+
return resource if @query.empty?
|
|
90
|
+
# Skip if there are no criteria to match against. Just like the sorter ignores a
|
|
91
|
+
# disallowed field, we do not want to prune anything with nothing to match on.
|
|
92
|
+
return resource if @allowed.empty? && @aliases.empty?
|
|
93
|
+
|
|
94
|
+
if @alias_path
|
|
95
|
+
# Field scoped to a key alias: prune the hash keys at the alias path
|
|
96
|
+
keep_by_key_alias!(resource, @alias_path)
|
|
97
|
+
else
|
|
98
|
+
keep?(resource, 0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
resource
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Recursively decides whether a given resource should be kept, pruning its non-matching
|
|
107
|
+
# children in place along the way.
|
|
108
|
+
#
|
|
109
|
+
# @param resource [Object] structure or leaf we are evaluating
|
|
110
|
+
# @param current_depth [Integer] current depth from the root
|
|
111
|
+
# @return [Boolean] true if the resource (or any of its descendants) matches the query
|
|
112
|
+
def keep?(resource, current_depth)
|
|
113
|
+
# Do not prune beyond the max depth. We report a match so we never delete data we
|
|
114
|
+
# refused to look into.
|
|
115
|
+
return true if current_depth > MAX_DEPTH
|
|
116
|
+
|
|
117
|
+
object_id = resource.object_id
|
|
118
|
+
# Break circular references by returning the (optimistic) cached answer
|
|
119
|
+
return @seen[object_id] if @seen.key?(object_id)
|
|
120
|
+
|
|
121
|
+
@seen[object_id] = true
|
|
122
|
+
|
|
123
|
+
result =
|
|
124
|
+
case resource
|
|
125
|
+
when Hash
|
|
126
|
+
keep_hash!(resource, current_depth)
|
|
127
|
+
when Lib::HashProxy
|
|
128
|
+
# A hash proxy represents a single record (a process, a topic, a job). We match on
|
|
129
|
+
# its allowed attributes and treat it as a leaf, we do not dive into its internals.
|
|
130
|
+
record_match?(resource)
|
|
131
|
+
when Array
|
|
132
|
+
keep_array!(resource, current_depth)
|
|
133
|
+
when String
|
|
134
|
+
record_match?(resource)
|
|
135
|
+
when Enumerable
|
|
136
|
+
# Custom collections (e.g. the `Jobs` model) can be pruned only when they support
|
|
137
|
+
# in-place selection. Otherwise we treat them as an opaque leaf record.
|
|
138
|
+
if resource.respond_to?(:select!)
|
|
139
|
+
keep_array!(resource, current_depth)
|
|
140
|
+
else
|
|
141
|
+
record_match?(resource)
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
record_match?(resource)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@seen[object_id] = result
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Prunes a nested hash in place so only the branches whose key at the aliased position
|
|
152
|
+
# matches the query survive (with match-propagation up the tree). `path` is the sequence
|
|
153
|
+
# of keys to descend from the container before matching that hash's keys (empty matches
|
|
154
|
+
# the container's own keys).
|
|
155
|
+
#
|
|
156
|
+
# Descent is lenient: a key absent on a node falls back to the node itself, so trees that
|
|
157
|
+
# nest the same logical level differently (e.g. one endpoint wraps topics under `:topics`,
|
|
158
|
+
# another keys them directly) resolve without normalizing the data.
|
|
159
|
+
#
|
|
160
|
+
# @param hash [Object] structure to prune (a no-op unless it is a Hash)
|
|
161
|
+
# @param path [Array] keys to descend before matching keys
|
|
162
|
+
# @return [Boolean] true if anything was kept
|
|
163
|
+
# @note This method modifies the hash in place (mutates the caller).
|
|
164
|
+
def keep_by_key_alias!(hash, path)
|
|
165
|
+
return false unless hash.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
kept_any = false
|
|
168
|
+
|
|
169
|
+
hash.select! do |key, value|
|
|
170
|
+
# At the target level (empty path) we match the key itself; otherwise we descend and
|
|
171
|
+
# recurse, keeping the branch when its sub-tree keeps anything
|
|
172
|
+
kept =
|
|
173
|
+
if path.empty?
|
|
174
|
+
matches?(key)
|
|
175
|
+
else
|
|
176
|
+
keep_by_key_alias!(alias_descend(value, path.first), path[1..])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
kept_any ||= kept
|
|
180
|
+
kept
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
kept_any
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Lenient descent used by the key aliases: dig into `value[key]` when it is present,
|
|
187
|
+
# otherwise use the value itself.
|
|
188
|
+
#
|
|
189
|
+
# @param value [Object] node we descend from
|
|
190
|
+
# @param key [Object] key we descend into
|
|
191
|
+
# @return [Object] the descended value (or the original value when the key is absent)
|
|
192
|
+
def alias_descend(value, key)
|
|
193
|
+
return value[key] if value.is_a?(Hash) && value.key?(key)
|
|
194
|
+
|
|
195
|
+
value
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Filters an array in place, keeping only the elements that match (directly or through a
|
|
199
|
+
# descendant).
|
|
200
|
+
#
|
|
201
|
+
# @param array [Array] array we want to filter
|
|
202
|
+
# @param current_depth [Integer] current depth from the root
|
|
203
|
+
# @return [Boolean] true if any element was kept
|
|
204
|
+
# @note This method modifies the array in place (mutates the caller).
|
|
205
|
+
def keep_array!(array, current_depth)
|
|
206
|
+
# We only prune arrays whose elements we can actually reason about (records or nested
|
|
207
|
+
# containers). Arrays of bare, non-matchable scalars are left untouched so we never
|
|
208
|
+
# empty a collection we have no criteria for. This mirrors the sorter only acting on
|
|
209
|
+
# collections it knows how to compare.
|
|
210
|
+
return false unless array.any? { |element| actionable?(element) }
|
|
211
|
+
|
|
212
|
+
any_kept = false
|
|
213
|
+
|
|
214
|
+
array.select! do |element|
|
|
215
|
+
kept = keep?(element, current_depth + 1)
|
|
216
|
+
any_kept ||= kept
|
|
217
|
+
kept
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
any_kept
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Filters a hash in place.
|
|
224
|
+
#
|
|
225
|
+
# A hash can play two roles. When it is a record (a row such as `{ name: ..., active:
|
|
226
|
+
# ... }`) we match it as a whole on its allowed attributes and never prune its entries.
|
|
227
|
+
# When it is a structural container (a nested map such as `consumer_group => topics`) we
|
|
228
|
+
# prune its entries: an entry is kept when its key matches the query (in which case the
|
|
229
|
+
# whole value subtree is preserved) or when its value subtree contains a match.
|
|
230
|
+
#
|
|
231
|
+
# @param hash [Hash] hash we want to filter
|
|
232
|
+
# @param current_depth [Integer] current depth from the root
|
|
233
|
+
# @return [Boolean] true if the hash matches directly or keeps at least one entry
|
|
234
|
+
# @note This method modifies the hash in place (mutates the caller).
|
|
235
|
+
def keep_hash!(hash, current_depth)
|
|
236
|
+
return record_match?(hash) unless structural_hash?(hash)
|
|
237
|
+
|
|
238
|
+
any_kept = false
|
|
239
|
+
|
|
240
|
+
hash.select! do |key, value|
|
|
241
|
+
if matches?(key)
|
|
242
|
+
# The key (a structural label such as a topic name) matches, so we keep the whole
|
|
243
|
+
# branch untouched
|
|
244
|
+
any_kept = true
|
|
245
|
+
true
|
|
246
|
+
elsif prunable?(value)
|
|
247
|
+
kept = keep?(value, current_depth + 1)
|
|
248
|
+
any_kept ||= kept
|
|
249
|
+
kept
|
|
250
|
+
else
|
|
251
|
+
# Sibling values that are not prunable are metadata (counts, timestamps, sets of
|
|
252
|
+
# scalars) that live next to the nested collections we prune (e.g. `partitions_count`
|
|
253
|
+
# next to `partitions`, or `rebalance_ages` next to `topics`). We never delete them
|
|
254
|
+
# so we do not corrupt the records we keep, but on their own they do not keep the
|
|
255
|
+
# parent branch alive.
|
|
256
|
+
true
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
any_kept
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# A hash is treated as a structural container (to be pruned) rather than a record (to be
|
|
264
|
+
# matched as a whole) when it does not expose any allowed attribute as a key and at least
|
|
265
|
+
# one of its values is itself a nested container.
|
|
266
|
+
#
|
|
267
|
+
# @param hash [Hash] hash we want to classify
|
|
268
|
+
# @return [Boolean] true if the hash should be pruned entry by entry
|
|
269
|
+
def structural_hash?(hash)
|
|
270
|
+
return false if @allowed.any? { |attribute| hash.key?(attribute) || hash.key?(attribute.to_sym) }
|
|
271
|
+
|
|
272
|
+
hash.any? { |_key, value| prunable?(value) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Decides whether a value is something we should recurse into and prune. Plain hashes and
|
|
276
|
+
# hash proxies (records) always are. Collections are only prunable when they hold
|
|
277
|
+
# actionable elements, so a set/array of bare scalars (metadata) is left alone rather than
|
|
278
|
+
# emptied.
|
|
279
|
+
#
|
|
280
|
+
# @param value [Object] value we want to classify
|
|
281
|
+
# @return [Boolean] true if we should recurse into the value and prune it
|
|
282
|
+
def prunable?(value)
|
|
283
|
+
case value
|
|
284
|
+
when Hash, Lib::HashProxy
|
|
285
|
+
true
|
|
286
|
+
when String
|
|
287
|
+
false
|
|
288
|
+
when Array
|
|
289
|
+
value.any? { |element| actionable?(element) }
|
|
290
|
+
when Enumerable
|
|
291
|
+
value.respond_to?(:select!) && value.any? { |element| actionable?(element) }
|
|
292
|
+
else
|
|
293
|
+
false
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @param element [Object] element we want to classify
|
|
298
|
+
# @return [Boolean] true if the element is something we can prune on: a nested container
|
|
299
|
+
# or a record exposing at least one allowed attribute
|
|
300
|
+
def actionable?(element)
|
|
301
|
+
case element
|
|
302
|
+
when Hash, Lib::HashProxy, Array, Enumerable
|
|
303
|
+
true
|
|
304
|
+
else
|
|
305
|
+
matchable_record?(element)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# @param element [Object] element we want to classify
|
|
310
|
+
# @return [Boolean] true if the element responds to (or holds) at least one attribute we
|
|
311
|
+
# are matching on
|
|
312
|
+
def matchable_record?(element)
|
|
313
|
+
match_attributes.any? { |attribute| attribute_value(element, attribute) != NO_VALUE }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Checks whether a record matches the query on any of the attributes we are matching on.
|
|
317
|
+
#
|
|
318
|
+
# @param element [Object] record we want to check
|
|
319
|
+
# @return [Boolean] true if any matched attribute value includes the query
|
|
320
|
+
def record_match?(element)
|
|
321
|
+
match_attributes.any? do |attribute|
|
|
322
|
+
value = attribute_value(element, attribute)
|
|
323
|
+
|
|
324
|
+
value != NO_VALUE && matches?(value)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# @return [Array<String>] the attributes to match on: just the selected field when
|
|
329
|
+
# filtering is scoped to one, otherwise all the allowed attributes
|
|
330
|
+
def match_attributes
|
|
331
|
+
@field ? [@field] : @allowed
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Extracts an allowed attribute value from a record, supporting both method invocations
|
|
335
|
+
# and hash lookups (string and symbol keys).
|
|
336
|
+
#
|
|
337
|
+
# @param element [Object] record we want to read from
|
|
338
|
+
# @param attribute [String] allowed attribute name
|
|
339
|
+
# @return [Object] the attribute value or {NO_VALUE} when the record does not expose it
|
|
340
|
+
def attribute_value(element, attribute)
|
|
341
|
+
if element.respond_to?(attribute)
|
|
342
|
+
element.public_send(attribute)
|
|
343
|
+
elsif element.respond_to?(:key?)
|
|
344
|
+
if element.key?(attribute)
|
|
345
|
+
element[attribute]
|
|
346
|
+
elsif element.key?(attribute.to_sym)
|
|
347
|
+
element[attribute.to_sym]
|
|
348
|
+
else
|
|
349
|
+
NO_VALUE
|
|
350
|
+
end
|
|
351
|
+
else
|
|
352
|
+
NO_VALUE
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @param value [Object] value we want to match against the query
|
|
357
|
+
# @return [Boolean] true if the (stringified) value includes the query (case-insensitive).
|
|
358
|
+
# Multi-valued attributes (arrays, e.g. a process's assigned topics or tags) match when
|
|
359
|
+
# any of their elements includes the query.
|
|
360
|
+
def matches?(value)
|
|
361
|
+
if value.is_a?(Array)
|
|
362
|
+
value.any? { |element| element.to_s.downcase.include?(@query) }
|
|
363
|
+
else
|
|
364
|
+
value.to_s.downcase.include?(@query)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Sentinel used to distinguish "attribute is absent" from an attribute that legitimately
|
|
369
|
+
# holds a `nil`/`false` value.
|
|
370
|
+
NO_VALUE = Object.new.freeze
|
|
371
|
+
|
|
372
|
+
private_constant :NO_VALUE
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karafka
|
|
4
|
+
module Web
|
|
5
|
+
module Ui
|
|
6
|
+
module Lib
|
|
7
|
+
# Flattens a nested hash into a single-level hash with dotted keys, so nested structures
|
|
8
|
+
# (e.g. a topic's routing settings) can be presented, sorted and filtered as a flat list of
|
|
9
|
+
# name/value rows.
|
|
10
|
+
module HashFlattener
|
|
11
|
+
class << self
|
|
12
|
+
# @param hash [Hash] hash we want to flatten
|
|
13
|
+
# @param prefix [String, nil] key prefix used during recursion
|
|
14
|
+
# @param result [Hash] accumulator used during recursion
|
|
15
|
+
# @return [Hash] flattened `{ "a.b.c" => value }` hash
|
|
16
|
+
def call(hash, prefix = nil, result = {})
|
|
17
|
+
hash.each do |key, value|
|
|
18
|
+
flat_key = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
19
|
+
|
|
20
|
+
case value
|
|
21
|
+
when Hash
|
|
22
|
+
call(value, flat_key, result)
|
|
23
|
+
when Array
|
|
24
|
+
value.each_with_index { |item, index| call({ index => item }, flat_key, result) }
|
|
25
|
+
else
|
|
26
|
+
result[flat_key] = value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -61,7 +61,9 @@ module Karafka
|
|
|
61
61
|
# in the hash proxy object, so we can do it that way
|
|
62
62
|
sort_hash!(resource.to_h, current_depth)
|
|
63
63
|
when Enumerable
|
|
64
|
-
|
|
64
|
+
# Some enumerables (like Range) are leaf values that cannot be sorted in place
|
|
65
|
+
# (they do not respond to `map!`), so we skip them rather than crash
|
|
66
|
+
sort_array!(resource, current_depth) if resource.respond_to?(:map!)
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
resource
|
|
@@ -11,8 +11,9 @@ module Karafka
|
|
|
11
11
|
include Enumerable
|
|
12
12
|
extend Forwardable
|
|
13
13
|
|
|
14
|
-
#
|
|
15
|
-
|
|
14
|
+
# `map!`, `sort_by!` and `reverse!` are needed to provide sorting, `select!` to provide
|
|
15
|
+
# filtering (both mutate the underlying jobs in place)
|
|
16
|
+
def_delegators :@jobs_array, :empty?, :size, :map!, :sort_by!, :reverse!, :select!
|
|
16
17
|
|
|
17
18
|
# @param jobs_array [Array<Job>] all jobs we want to enclose
|
|
18
19
|
def initialize(jobs_array)
|
|
@@ -59,6 +59,18 @@ module Karafka
|
|
|
59
59
|
.sum
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# @return [Array<String>] names of all the topics this process is subscribed to across
|
|
63
|
+
# its consumer and subscription groups. Used (among others) to filter processes by the
|
|
64
|
+
# topic they are assigned to.
|
|
65
|
+
def subscribed_topics
|
|
66
|
+
consumer_groups
|
|
67
|
+
.flat_map(&:subscription_groups)
|
|
68
|
+
.flat_map(&:topics)
|
|
69
|
+
.map(&:name)
|
|
70
|
+
.uniq
|
|
71
|
+
.sort
|
|
72
|
+
end
|
|
73
|
+
|
|
62
74
|
# @return [Boolean] true if there are any active subscriptions, otherwise false.
|
|
63
75
|
def subscribed?
|
|
64
76
|
return false if consumer_groups.empty?
|
|
Binary file
|
|
@@ -86,3 +86,12 @@ table td.commands-inline-3 {
|
|
|
86
86
|
.alert {
|
|
87
87
|
box-shadow: none !important;
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
daisyUI 5.7's `.table` sets `border-collapse: separate`, which makes the 1px borders we put on
|
|
92
|
+
our data-table cells sit next to each other and render as a 2px double border. This unlayered
|
|
93
|
+
rule wins over the layered daisyUI styles and collapses adjacent cell borders back to 1px.
|
|
94
|
+
*/
|
|
95
|
+
.data-table {
|
|
96
|
+
border-collapse: collapse !important;
|
|
97
|
+
}
|