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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/Gemfile.lock +13 -13
  4. data/lib/karafka/web/management/actions/enable.rb +74 -10
  5. data/lib/karafka/web/pro/ui/app.rb +13 -0
  6. data/lib/karafka/web/pro/ui/controllers/base_controller.rb +4 -0
  7. data/lib/karafka/web/pro/ui/controllers/cluster_controller.rb +66 -4
  8. data/lib/karafka/web/pro/ui/controllers/consumers/consumers_controller.rb +9 -2
  9. data/lib/karafka/web/pro/ui/controllers/consumers/controls_controller.rb +6 -0
  10. data/lib/karafka/web/pro/ui/controllers/consumers/jobs_controller.rb +8 -2
  11. data/lib/karafka/web/pro/ui/controllers/dlq_controller.rb +6 -0
  12. data/lib/karafka/web/pro/ui/controllers/explorer/explorer_controller.rb +6 -0
  13. data/lib/karafka/web/pro/ui/controllers/health_controller.rb +19 -3
  14. data/lib/karafka/web/pro/ui/controllers/jobs_controller.rb +25 -0
  15. data/lib/karafka/web/pro/ui/controllers/recurring_tasks_controller.rb +10 -1
  16. data/lib/karafka/web/pro/ui/controllers/requests/params.rb +67 -0
  17. data/lib/karafka/web/pro/ui/controllers/routing_controller.rb +23 -1
  18. data/lib/karafka/web/pro/ui/controllers/scheduled_messages/schedules_controller.rb +6 -0
  19. data/lib/karafka/web/pro/ui/controllers/topics/configs_controller.rb +6 -1
  20. data/lib/karafka/web/pro/ui/controllers/topics/distributions_controller.rb +1 -1
  21. data/lib/karafka/web/pro/ui/controllers/topics/offsets_controller.rb +1 -1
  22. data/lib/karafka/web/pro/ui/controllers/topics/replications_controller.rb +1 -1
  23. data/lib/karafka/web/pro/ui/controllers/topics/topics_controller.rb +6 -0
  24. data/lib/karafka/web/pro/ui/helpers/filtering_helper.rb +173 -0
  25. data/lib/karafka/web/pro/ui/helpers/topics_filtering_helper.rb +80 -0
  26. data/lib/karafka/web/pro/ui/lib/filtering.rb +128 -0
  27. data/lib/karafka/web/pro/ui/lib/search/matchers/base.rb +2 -2
  28. data/lib/karafka/web/pro/ui/routes/cluster.rb +0 -2
  29. data/lib/karafka/web/pro/ui/views/cluster/_brokers_table.erb +41 -0
  30. data/lib/karafka/web/pro/ui/views/cluster/_configs_table.erb +41 -0
  31. data/lib/karafka/web/pro/ui/views/cluster/_partition.erb +2 -2
  32. data/lib/karafka/web/pro/ui/views/cluster/index.erb +12 -20
  33. data/lib/karafka/web/pro/ui/views/cluster/replication.erb +39 -0
  34. data/lib/karafka/web/pro/ui/views/cluster/show.erb +13 -21
  35. data/lib/karafka/web/pro/ui/views/consumers/consumers/_consumer_performances_table.erb +56 -0
  36. data/lib/karafka/web/pro/ui/views/consumers/consumers/_consumers_table.erb +43 -0
  37. data/lib/karafka/web/pro/ui/views/consumers/consumers/consumer/_subscription_group.erb +10 -2
  38. data/lib/karafka/web/pro/ui/views/consumers/consumers/index.erb +8 -24
  39. data/lib/karafka/web/pro/ui/views/consumers/consumers/performance.erb +8 -37
  40. data/lib/karafka/web/pro/ui/views/consumers/consumers/subscriptions.erb +20 -6
  41. data/lib/karafka/web/pro/ui/views/consumers/controls/_processes_table.erb +45 -0
  42. data/lib/karafka/web/pro/ui/views/consumers/controls/index.erb +14 -27
  43. data/lib/karafka/web/pro/ui/views/consumers/jobs/_jobs_table.erb +48 -0
  44. data/lib/karafka/web/pro/ui/views/consumers/jobs/pending.erb +8 -29
  45. data/lib/karafka/web/pro/ui/views/consumers/jobs/running.erb +8 -29
  46. data/lib/karafka/web/pro/ui/views/dlq/index.erb +9 -1
  47. data/lib/karafka/web/pro/ui/views/explorer/explorer/index.erb +9 -1
  48. data/lib/karafka/web/pro/ui/views/health/_changes_table.erb +59 -0
  49. data/lib/karafka/web/pro/ui/views/health/_cluster_lags_table.erb +43 -0
  50. data/lib/karafka/web/pro/ui/views/health/_lags_table.erb +60 -0
  51. data/lib/karafka/web/pro/ui/views/health/_no_data.erb +5 -1
  52. data/lib/karafka/web/pro/ui/views/health/_offsets_table.erb +63 -0
  53. data/lib/karafka/web/pro/ui/views/health/_overview_table.erb +83 -0
  54. data/lib/karafka/web/pro/ui/views/health/_tabs.erb +6 -0
  55. data/lib/karafka/web/pro/ui/views/health/changes.erb +13 -43
  56. data/lib/karafka/web/pro/ui/views/health/cluster_lags.erb +11 -25
  57. data/lib/karafka/web/pro/ui/views/health/lags.erb +13 -44
  58. data/lib/karafka/web/pro/ui/views/health/offsets.erb +13 -47
  59. data/lib/karafka/web/pro/ui/views/health/overview.erb +13 -67
  60. data/lib/karafka/web/pro/ui/views/jobs/_jobs_table.erb +48 -0
  61. data/lib/karafka/web/pro/ui/views/jobs/pending.erb +8 -30
  62. data/lib/karafka/web/pro/ui/views/jobs/running.erb +8 -30
  63. data/lib/karafka/web/pro/ui/views/recurring_tasks/_tasks_table.erb +43 -0
  64. data/lib/karafka/web/pro/ui/views/recurring_tasks/schedule.erb +8 -24
  65. data/lib/karafka/web/pro/ui/views/routing/_consumer_group.erb +36 -32
  66. data/lib/karafka/web/pro/ui/views/routing/_details_table.erb +43 -0
  67. data/lib/karafka/web/pro/ui/views/routing/index.erb +18 -6
  68. data/lib/karafka/web/pro/ui/views/routing/show.erb +12 -40
  69. data/lib/karafka/web/pro/ui/views/scheduled_messages/schedules/index.erb +7 -1
  70. data/lib/karafka/web/pro/ui/views/shared/_filter_box.erb +61 -0
  71. data/lib/karafka/web/pro/ui/views/topics/configs/_configs_table.erb +42 -0
  72. data/lib/karafka/web/pro/ui/views/topics/configs/index.erb +13 -22
  73. data/lib/karafka/web/pro/ui/views/topics/replications/_partition.erb +2 -2
  74. data/lib/karafka/web/pro/ui/views/topics/replications/show.erb +12 -2
  75. data/lib/karafka/web/pro/ui/views/topics/topics/index.erb +9 -1
  76. data/lib/karafka/web/tracking/consumers/listeners/errors.rb +80 -14
  77. data/lib/karafka/web/ui/base.rb +4 -0
  78. data/lib/karafka/web/ui/controllers/base_controller.rb +1 -1
  79. data/lib/karafka/web/ui/controllers/cluster_controller.rb +21 -22
  80. data/lib/karafka/web/ui/controllers/consumers_controller.rb +1 -1
  81. data/lib/karafka/web/ui/controllers/jobs_controller.rb +23 -27
  82. data/lib/karafka/web/ui/controllers/requests/hookable.rb +5 -0
  83. data/lib/karafka/web/ui/controllers/routing_controller.rb +15 -1
  84. data/lib/karafka/web/ui/helpers/application_helper.rb +3 -278
  85. data/lib/karafka/web/ui/helpers/badges_helper.rb +66 -0
  86. data/lib/karafka/web/ui/helpers/formatting_helper.rb +77 -0
  87. data/lib/karafka/web/ui/helpers/partitions_helper.rb +167 -0
  88. data/lib/karafka/web/ui/helpers/sorting_helper.rb +78 -0
  89. data/lib/karafka/web/ui/lib/filter.rb +377 -0
  90. data/lib/karafka/web/ui/lib/hash_flattener.rb +37 -0
  91. data/lib/karafka/web/ui/lib/sorter.rb +3 -1
  92. data/lib/karafka/web/ui/models/jobs.rb +3 -2
  93. data/lib/karafka/web/ui/models/process.rb +12 -0
  94. data/lib/karafka/web/ui/public/javascripts/application.min.js.gz +0 -0
  95. data/lib/karafka/web/ui/public/stylesheets/application.css +9 -0
  96. data/lib/karafka/web/ui/public/stylesheets/application.min.css +4230 -1258
  97. data/lib/karafka/web/ui/public/stylesheets/application.min.css.br +0 -0
  98. data/lib/karafka/web/ui/public/stylesheets/application.min.css.gz +0 -0
  99. data/lib/karafka/web/ui/public/stylesheets/libs/highlight_dark.min.css.gz +0 -0
  100. data/lib/karafka/web/ui/public/stylesheets/libs/highlight_light.min.css.gz +0 -0
  101. data/lib/karafka/web/ui/views/cluster/_brokers_table.erb +21 -0
  102. data/lib/karafka/web/ui/views/cluster/_partition.erb +2 -2
  103. data/lib/karafka/web/ui/views/cluster/_partitions_table.erb +31 -0
  104. data/lib/karafka/web/ui/views/cluster/brokers.erb +1 -21
  105. data/lib/karafka/web/ui/views/cluster/replication.erb +1 -23
  106. data/lib/karafka/web/ui/views/consumers/_consumers_table.erb +22 -0
  107. data/lib/karafka/web/ui/views/consumers/index.erb +1 -22
  108. data/lib/karafka/web/ui/views/errors/_table.erb +28 -0
  109. data/lib/karafka/web/ui/views/errors/index.erb +1 -28
  110. data/lib/karafka/web/ui/views/jobs/_jobs_table.erb +22 -0
  111. data/lib/karafka/web/ui/views/jobs/pending.erb +1 -22
  112. data/lib/karafka/web/ui/views/jobs/running.erb +1 -22
  113. data/lib/karafka/web/ui/views/routing/_consumer_group.erb +18 -12
  114. data/lib/karafka/web/ui/views/routing/_details_table.erb +23 -0
  115. data/lib/karafka/web/ui/views/routing/show.erb +1 -29
  116. data/lib/karafka/web/ui/views/shared/_no_filter_results.erb +6 -0
  117. data/lib/karafka/web/version.rb +1 -1
  118. data/package-lock.json +98 -61
  119. 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
- sort_array!(resource, current_depth)
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
- # Last three methods are needed to provide sorting
15
- def_delegators :@jobs_array, :empty?, :size, :map!, :sort_by!, :reverse!
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?
@@ -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
+ }