karafka-web 0.11.0.rc1 → 0.11.0.rc3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/Gemfile.lock +6 -6
  4. data/config/locales/slogans.yml +1 -1
  5. data/docker-compose.yml +1 -1
  6. data/karafka-web.gemspec +1 -1
  7. data/lib/karafka/web/pro/ui/controllers/errors_controller.rb +8 -4
  8. data/lib/karafka/web/pro/ui/controllers/explorer/explorer_controller.rb +19 -9
  9. data/lib/karafka/web/pro/ui/views/consumers/jobs/_job.erb +1 -2
  10. data/lib/karafka/web/pro/ui/views/consumers/jobs/_no_jobs.erb +2 -6
  11. data/lib/karafka/web/pro/ui/views/consumers/partitions/offsets/edit.erb +5 -1
  12. data/lib/karafka/web/pro/ui/views/consumers/partitions/pauses/edit.erb +5 -1
  13. data/lib/karafka/web/pro/ui/views/consumers/partitions/pauses/new.erb +5 -1
  14. data/lib/karafka/web/pro/ui/views/errors/_breadcrumbs.erb +1 -6
  15. data/lib/karafka/web/pro/ui/views/errors/_error.erb +6 -1
  16. data/lib/karafka/web/pro/ui/views/errors/show.erb +39 -33
  17. data/lib/karafka/web/pro/ui/views/explorer/explorer/show.erb +76 -71
  18. data/lib/karafka/web/pro/ui/views/jobs/_job.erb +1 -2
  19. data/lib/karafka/web/tracking/consumers/listeners/errors.rb +1 -0
  20. data/lib/karafka/web/ui/base.rb +2 -0
  21. data/lib/karafka/web/ui/controllers/errors_controller.rb +12 -3
  22. data/lib/karafka/web/ui/helpers/application_helper.rb +0 -70
  23. data/lib/karafka/web/ui/helpers/time_helper.rb +82 -0
  24. data/lib/karafka/web/ui/helpers/topics_helper.rb +156 -0
  25. data/lib/karafka/web/ui/models/message.rb +20 -2
  26. data/lib/karafka/web/ui/public/stylesheets/application.min.css +10 -35
  27. data/lib/karafka/web/ui/public/stylesheets/application.min.css.br +0 -0
  28. data/lib/karafka/web/ui/public/stylesheets/application.min.css.gz +0 -0
  29. data/lib/karafka/web/ui/routes/errors.rb +5 -1
  30. data/lib/karafka/web/ui/views/consumers/_assignments_badges.erb +2 -7
  31. data/lib/karafka/web/ui/views/errors/_breadcrumbs.erb +3 -8
  32. data/lib/karafka/web/ui/views/errors/_error.erb +6 -1
  33. data/lib/karafka/web/ui/views/errors/show.erb +39 -33
  34. data/lib/karafka/web/ui/views/jobs/_job.erb +1 -2
  35. data/lib/karafka/web/ui/views/shared/_compacted_message_info.erb +16 -0
  36. data/lib/karafka/web/version.rb +1 -1
  37. data/package-lock.json +73 -73
  38. data/package.json +6 -6
  39. data/renovate.json +7 -0
  40. metadata +6 -3
@@ -129,76 +129,6 @@ module Karafka
129
129
  parts.join('.')
130
130
  end
131
131
 
132
- # @param time [Float] UTC time float
133
- # @return [String] relative time tag for timeago.js
134
- def relative_time(time)
135
- stamp = Time.at(time).getutc.iso8601(3)
136
- %(<time class="ltr" dir="ltr" title="#{stamp}" datetime="#{stamp}">#{time}</time>)
137
- end
138
-
139
- # @param time [Time] time object we want to present with detailed ms label
140
- # @return [String] span tag with raw timestamp as a title and time as a value
141
- def time_with_label(time)
142
- stamp = (time.to_f * 1000).to_i
143
-
144
- %(<span title="#{stamp}">#{time}</span>)
145
- end
146
-
147
- # Converts raw second count into human readable form like "12.2 minutes". etc based on
148
- # number of seconds
149
- #
150
- # @param seconds [Numeric] number of seconds
151
- # @return [String] human readable time
152
- def human_readable_time(seconds)
153
- case seconds
154
- when 0..59
155
- "#{seconds.round(2)} seconds"
156
- when 60..3_599
157
- minutes = seconds / 60.0
158
- "#{minutes.round(2)} minutes"
159
- when 3_600..86_399
160
- hours = seconds / 3_600.0
161
- "#{hours.round(2)} hours"
162
- else
163
- days = seconds / 86_400.0
164
- "#{days.round(2)} days"
165
- end
166
- end
167
-
168
- # @param state [String] poll state
169
- # @param state_ch [Integer] time until next change of the poll state
170
- # (from paused to active)
171
- # @return [String] span tag with label and title with change time if present
172
- def poll_state_with_change_time_label(state, state_ch)
173
- year_in_seconds = 131_556_926
174
- state_ch_in_seconds = state_ch / 1_000.0
175
-
176
- # If state is active, there is no date of change
177
- if state == 'active'
178
- %(
179
- <span class="badge #{kafka_state_badge(state)}">#{state}</span>
180
- )
181
- elsif state_ch_in_seconds > year_in_seconds
182
- %(
183
- <span
184
- class="badge #{kafka_state_badge(state)}"
185
- title="until manual resume"
186
- >
187
- #{state}
188
- </span>
189
- )
190
- else
191
- %(
192
- <span
193
- class="badge #{kafka_state_badge(state)} time-title"
194
- title="#{Time.now + state_ch_in_seconds}"
195
- >
196
- #{state}
197
- </span>
198
- )
199
- end
200
- end
201
-
202
132
  # @param lag [Integer] lag
203
133
  # @return [String] lag if correct or `N/A` with labeled explanation
204
134
  # @see #offset_with_label
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Ui
6
+ module Helpers
7
+ # Helper with time-related methods
8
+ module TimeHelper
9
+ # @param time [Float] UTC time float
10
+ # @return [String] relative time tag for timeago.js
11
+ def relative_time(time)
12
+ stamp = Time.at(time).getutc.iso8601(3)
13
+ %(<time class="ltr" dir="ltr" title="#{stamp}" datetime="#{stamp}">#{time}</time>)
14
+ end
15
+
16
+ # @param time [Time] time object we want to present with detailed ms label
17
+ # @return [String] span tag with raw timestamp as a title and time as a value
18
+ def time_with_label(time)
19
+ stamp = (time.to_f * 1000).to_i
20
+
21
+ %(<span title="#{stamp}">#{time}</span>)
22
+ end
23
+
24
+ # Converts raw second count into human readable form like "12.2 minutes". etc based on
25
+ # number of seconds
26
+ #
27
+ # @param seconds [Numeric] number of seconds
28
+ # @return [String] human readable time
29
+ def human_readable_time(seconds)
30
+ case seconds
31
+ when 0..59
32
+ "#{seconds.round(2)} seconds"
33
+ when 60..3_599
34
+ minutes = seconds / 60.0
35
+ "#{minutes.round(2)} minutes"
36
+ when 3_600..86_399
37
+ hours = seconds / 3_600.0
38
+ "#{hours.round(2)} hours"
39
+ else
40
+ days = seconds / 86_400.0
41
+ "#{days.round(2)} days"
42
+ end
43
+ end
44
+
45
+ # @param state [String] poll state
46
+ # @param state_ch [Integer] time until next change of the poll state
47
+ # (from paused to active)
48
+ # @return [String] span tag with label and title with change time if present
49
+ def poll_state_with_change_time_label(state, state_ch)
50
+ year_in_seconds = 131_556_926
51
+ state_ch_in_seconds = state_ch / 1_000.0
52
+
53
+ # If state is active, there is no date of change
54
+ if state == 'active'
55
+ %(
56
+ <span class="badge #{kafka_state_badge(state)}">#{state}</span>
57
+ )
58
+ elsif state_ch_in_seconds > year_in_seconds
59
+ %(
60
+ <span
61
+ class="badge #{kafka_state_badge(state)}"
62
+ title="until manual resume"
63
+ >
64
+ #{state}
65
+ </span>
66
+ )
67
+ else
68
+ %(
69
+ <span
70
+ class="badge #{kafka_state_badge(state)} time-title"
71
+ title="#{Time.now + state_ch_in_seconds}"
72
+ >
73
+ #{state}
74
+ </span>
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Web
5
+ module Ui
6
+ module Helpers
7
+ # Helper module for formatting Kafka topic and partition information
8
+ # in various contexts within the Karafka Web UI.
9
+ #
10
+ # This module provides consistent formatting for topic-partition assignments
11
+ # across different display contexts like inline text, labels, and identifiers.
12
+ #
13
+ # @see https://karafka.io/docs/Development-Naming-Conventions
14
+ module TopicsHelper
15
+ # Default limit for displaying partitions before truncation
16
+ DEFAULT_LIMIT = 5
17
+
18
+ # Formats topic and partitions for inline text display in views.
19
+ #
20
+ # This method is optimized for compact display in assignments, logs, and
21
+ # other inline contexts where space is limited.
22
+ #
23
+ # @param topic [String] the Kafka topic name
24
+ # @param partitions [Array<Integer>, Integer] partition number(s) to format
25
+ # @param limit [Integer] maximum number of partitions to display before truncation
26
+ # @return [String] formatted topic-partition string
27
+ #
28
+ # @example Single partition
29
+ # topics_assignment_text("user-events", 0)
30
+ # # => "user-events-[0]"
31
+ #
32
+ # @example Multiple consecutive partitions
33
+ # topics_assignment_text("user-events", [0, 1, 2, 3])
34
+ # # => "user-events-[0-3]"
35
+ #
36
+ # @example Multiple non-consecutive partitions
37
+ # topics_assignment_text("user-events", [0, 2, 4])
38
+ # # => "user-events-[0,2,4]"
39
+ #
40
+ # @example Truncated partitions list
41
+ # topics_assignment_text("user-events", [0, 1, 2, 3, 4, 5, 6], limit: 3)
42
+ # # => "user-events-[0,1,2...]"
43
+ #
44
+ # @example Empty partitions
45
+ # topics_assignment_text("user-events", [])
46
+ # # => "user-events"
47
+ def topics_assignment_text(topic, partitions, limit: DEFAULT_LIMIT)
48
+ partitions = Array(partitions)
49
+
50
+ if partitions.empty?
51
+ topic
52
+ elsif partitions.size == 1
53
+ "#{topic}-[#{partitions.first}]"
54
+ else
55
+ sorted_partitions = partitions.map(&:to_i).sort
56
+ # Check for consecutive first (best representation)
57
+ if topics_consecutive?(sorted_partitions) && sorted_partitions.size > 2
58
+ "#{topic}-[#{sorted_partitions.first}-#{sorted_partitions.last}]"
59
+ # Apply limit if specified and partitions exceed it
60
+ elsif limit && sorted_partitions.size > limit
61
+ displayed_partitions = sorted_partitions.first(limit)
62
+ "#{topic}-[#{displayed_partitions.join(',')}...]"
63
+ else
64
+ "#{topic}-[#{sorted_partitions.join(',')}]"
65
+ end
66
+ end
67
+ end
68
+
69
+ # Formats topic and partitions for human-readable labels and headers.
70
+ #
71
+ # This method provides more descriptive formatting suitable for page titles,
72
+ # section headers, and other contexts where additional context is helpful.
73
+ #
74
+ # @param topic [String] the Kafka topic name
75
+ # @param partitions [Array<Integer>, Integer] partition number(s) to format
76
+ # @param limit [Integer] maximum number of partitions to display before truncation
77
+ # @return [String] formatted topic-partition label with additional context
78
+ #
79
+ # @example Consecutive partitions with count
80
+ # topics_assignment_label("user-events", [0, 1, 2, 3])
81
+ # # => "user-events-[0-3] (4 partitions total)"
82
+ #
83
+ # @example Truncated with remaining count
84
+ # topics_assignment_label("user-events", [0, 1, 2, 3, 4, 5], limit: 3)
85
+ # # => "user-events-[0,1,2] (+3 more)"
86
+ #
87
+ # @example Non-consecutive partitions
88
+ # topics_assignment_label("user-events", [0, 2, 4])
89
+ # # => "user-events-[0,2,4]"
90
+ def topics_assignment_label(topic, partitions, limit: DEFAULT_LIMIT)
91
+ partitions = Array(partitions)
92
+
93
+ sorted_partitions = partitions.map(&:to_i).sort
94
+ if topics_consecutive?(sorted_partitions)
95
+ "#{topic}-[#{sorted_partitions.first}-#{sorted_partitions.last}] " \
96
+ "(#{partitions.size} partitions total)"
97
+ elsif sorted_partitions.size > limit
98
+ displayed = sorted_partitions.first(limit)
99
+ remaining = sorted_partitions.size - limit
100
+ "#{topic}-[#{displayed.join(',')}] (+#{remaining} more)"
101
+ else
102
+ "#{topic}-[#{sorted_partitions.join(',')}]"
103
+ end
104
+ end
105
+
106
+ # Creates a specific identifier for topic-partition combinations.
107
+ #
108
+ # This method generates consistent identifiers used in metrics collection,
109
+ # cache keys, and other contexts requiring unique topic-partition identification.
110
+ #
111
+ # @param topic [String] the Kafka topic name
112
+ # @param partition [Integer] the partition number
113
+ # @return [String] formatted topic-partition identifier
114
+ #
115
+ # @example Basic identifier
116
+ # topics_partition_identifier("user-events", 0)
117
+ # # => "user-events-0"
118
+ #
119
+ # @example Used for cache keys
120
+ # cache_key = topics_partition_identifier("orders", 3)
121
+ # Rails.cache.fetch(cache_key) { expensive_operation }
122
+ def topics_partition_identifier(topic, partition)
123
+ "#{topic}-#{partition}"
124
+ end
125
+
126
+ private
127
+
128
+ # Checks if an array of sorted integers contains consecutive numbers.
129
+ #
130
+ # This helper method determines whether partition numbers form a continuous
131
+ # sequence, which allows for more compact display formatting.
132
+ #
133
+ # @param sorted_array [Array<Integer>] array of sorted integers to check
134
+ # @return [Boolean] true if all numbers are consecutive, false otherwise
135
+ #
136
+ # @example Consecutive numbers
137
+ # topics_consecutive?([1, 2, 3, 4]) # => true
138
+ #
139
+ # @example Non-consecutive numbers
140
+ # topics_consecutive?([1, 3, 5, 7]) # => false
141
+ #
142
+ # @example Single element (not consecutive)
143
+ # topics_consecutive?([1]) # => false
144
+ #
145
+ # @example Empty array (not consecutive)
146
+ # topics_consecutive?([]) # => false
147
+ def topics_consecutive?(sorted_array)
148
+ return false if sorted_array.size < 2
149
+
150
+ sorted_array.each_cons(2).all? { |a, b| b == a + 1 }
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -10,13 +10,21 @@ module Karafka
10
10
  extend Lib::Paginations::Paginators
11
11
 
12
12
  class << self
13
- # Looks for a message from a given topic partition
13
+ # Looks for a message from a given topic partition. When no offsets provided, will
14
+ # raise if there is no data under the given offset. If watermarks were provided, it
15
+ # will check if this is a system entry and in such cases will return nil.
16
+ # Will always raise if request is out of range.
14
17
  #
15
18
  # @param topic_id [String]
16
19
  # @param partition_id [Integer]
17
20
  # @param offset [Integer]
21
+ # @param watermark_offsets [WatermarkOffsets, false] watermark offsets for this topic
22
+ # partition or false if not provided
23
+ # @return [Karafka::Messages::Message, nil] found message or nil in case watermark
24
+ # offsets were provided and we encountered a message matching watermarks
18
25
  # @raise [::Karafka::Web::Errors::Ui::NotFoundError] when not found
19
- def find(topic_id, partition_id, offset)
26
+ # @note If no watermark offsets provided will always raise if no message with data
27
+ def find(topic_id, partition_id, offset, watermark_offsets: false)
20
28
  message = Lib::Admin.read_topic(
21
29
  topic_id,
22
30
  partition_id,
@@ -26,6 +34,16 @@ module Karafka
26
34
 
27
35
  return message if message
28
36
 
37
+ # Not found can also occur for system entries and compacted messages.
38
+ # Since we want to know about this in some cases we handle this case and check if the
39
+ # requested offset is within the range and if so, it means it has been cleaned or
40
+ # is a system entry. In such cases we do display user an info message.
41
+ return nil if watermark_offsets &&
42
+ offset >= watermark_offsets.low &&
43
+ offset < watermark_offsets.high
44
+
45
+ # If beyond the watermark offsets, we raise 404 as user should not reach such
46
+ # non-existent messages as we cannot reason about them
29
47
  raise(
30
48
  ::Karafka::Web::Errors::Ui::NotFoundError,
31
49
  [topic_id, partition_id, offset].join(', ')
@@ -11,7 +11,7 @@
11
11
  .air-datepicker{--adp-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--adp-font-size: 14px;--adp-width: 246px;--adp-z-index: 100;--adp-padding: 4px;--adp-grid-areas: "nav" "body" "timepicker" "buttons";--adp-transition-duration: .3s;--adp-transition-ease: ease-out;--adp-transition-offset: 8px;--adp-background-color: #fff;--adp-background-color-hover: #f0f0f0;--adp-background-color-active: #eaeaea;--adp-background-color-in-range: rgba(92, 196, 239, .1);--adp-background-color-in-range-focused: rgba(92, 196, 239, .2);--adp-background-color-selected-other-month-focused: #8ad5f4;--adp-background-color-selected-other-month: #a2ddf6;--adp-color: #4a4a4a;--adp-color-secondary: #9c9c9c;--adp-accent-color: #4eb5e6;--adp-color-current-date: var(--adp-accent-color);--adp-color-other-month: #dedede;--adp-color-disabled: #aeaeae;--adp-color-disabled-in-range: #939393;--adp-color-other-month-hover: #c5c5c5;--adp-border-color: #dbdbdb;--adp-border-color-inner: #efefef;--adp-border-radius: 4px;--adp-border-color-inline: #d7d7d7;--adp-nav-height: 32px;--adp-nav-arrow-color: var(--adp-color-secondary);--adp-nav-action-size: 32px;--adp-nav-color-secondary: var(--adp-color-secondary);--adp-day-name-color: #ff9a19;--adp-day-name-color-hover: #8ad5f4;--adp-day-cell-width: 1fr;--adp-day-cell-height: 32px;--adp-month-cell-height: 42px;--adp-year-cell-height: 56px;--adp-pointer-size: 10px;--adp-poiner-border-radius: 2px;--adp-pointer-offset: 14px;--adp-cell-border-radius: 4px;--adp-cell-background-color-hover: var(--adp-background-color-hover);--adp-cell-background-color-selected: #5cc4ef;--adp-cell-background-color-selected-hover: #45bced;--adp-cell-background-color-in-range: rgba(92, 196, 239, 0.1);--adp-cell-background-color-in-range-hover: rgba(92, 196, 239, 0.2);--adp-cell-border-color-in-range: var(--adp-cell-background-color-selected);--adp-btn-height: 32px;--adp-btn-color: var(--adp-accent-color);--adp-btn-color-hover: var(--adp-color);--adp-btn-border-radius: var(--adp-border-radius);--adp-btn-background-color-hover: var(--adp-background-color-hover);--adp-btn-background-color-active: var(--adp-background-color-active);--adp-time-track-height: 1px;--adp-time-track-color: #dedede;--adp-time-track-color-hover: #b1b1b1;--adp-time-thumb-size: 12px;--adp-time-padding-inner: 10px;--adp-time-day-period-color: var(--adp-color-secondary);--adp-mobile-font-size: 16px;--adp-mobile-nav-height: 40px;--adp-mobile-width: 320px;--adp-mobile-day-cell-height: 38px;--adp-mobile-month-cell-height: 48px;--adp-mobile-year-cell-height: 64px}.air-datepicker-overlay{--adp-overlay-background-color: rgba(0, 0, 0, .3);--adp-overlay-transition-duration: .3s;--adp-overlay-transition-ease: ease-out;--adp-overlay-z-index: 99}
12
12
  .air-datepicker{background:var(--adp-background-color);border:1px solid var(--adp-border-color);box-shadow:0 4px 12px rgba(0,0,0,.15);border-radius:var(--adp-border-radius);box-sizing:content-box;display:grid;grid-template-columns:1fr;grid-template-rows:repeat(4, max-content);grid-template-areas:var(--adp-grid-areas);font-family:var(--adp-font-family),sans-serif;font-size:var(--adp-font-size);color:var(--adp-color);width:var(--adp-width);position:absolute;transition:opacity var(--adp-transition-duration) var(--adp-transition-ease),transform var(--adp-transition-duration) var(--adp-transition-ease);z-index:var(--adp-z-index)}.air-datepicker:not(.-custom-position-){opacity:0}.air-datepicker.-from-top-{transform:translateY(calc(var(--adp-transition-offset) * -1))}.air-datepicker.-from-right-{transform:translateX(var(--adp-transition-offset))}.air-datepicker.-from-bottom-{transform:translateY(var(--adp-transition-offset))}.air-datepicker.-from-left-{transform:translateX(calc(var(--adp-transition-offset) * -1))}.air-datepicker.-active-:not(.-custom-position-){transform:translate(0, 0);opacity:1}.air-datepicker.-active-.-custom-position-{transition:none}.air-datepicker.-inline-{border-color:var(--adp-border-color-inline);box-shadow:none;position:static;left:auto;right:auto;opacity:1;transform:none}.air-datepicker.-inline- .air-datepicker--pointer{display:none}.air-datepicker.-is-mobile-{--adp-font-size: var(--adp-mobile-font-size);--adp-day-cell-height: var(--adp-mobile-day-cell-height);--adp-month-cell-height: var(--adp-mobile-month-cell-height);--adp-year-cell-height: var(--adp-mobile-year-cell-height);--adp-nav-height: var(--adp-mobile-nav-height);--adp-nav-action-size: var(--adp-mobile-nav-height);position:fixed;width:var(--adp-mobile-width);border:none}.air-datepicker.-is-mobile- *{-webkit-tap-highlight-color:rgba(0,0,0,0)}.air-datepicker.-is-mobile- .air-datepicker--pointer{display:none}.air-datepicker.-is-mobile-:not(.-custom-position-){transform:translate(-50%, calc(-50% + var(--adp-transition-offset)))}.air-datepicker.-is-mobile-.-active-:not(.-custom-position-){transform:translate(-50%, -50%)}.air-datepicker.-custom-position-{transition:none}.air-datepicker-global-container{position:absolute;left:0;top:0}.air-datepicker--pointer{--pointer-half-size: calc(var(--adp-pointer-size) / 2);position:absolute;width:var(--adp-pointer-size);height:var(--adp-pointer-size);z-index:-1}.air-datepicker--pointer:after{content:"";position:absolute;background:#fff;border-top:1px solid var(--adp-border-color-inline);border-right:1px solid var(--adp-border-color-inline);border-top-right-radius:var(--adp-poiner-border-radius);width:var(--adp-pointer-size);height:var(--adp-pointer-size);box-sizing:border-box}.-top-left- .air-datepicker--pointer,.-top-center- .air-datepicker--pointer,.-top-right- .air-datepicker--pointer,[data-popper-placement^=top] .air-datepicker--pointer{top:calc(100% - var(--pointer-half-size) + 1px)}.-top-left- .air-datepicker--pointer:after,.-top-center- .air-datepicker--pointer:after,.-top-right- .air-datepicker--pointer:after,[data-popper-placement^=top] .air-datepicker--pointer:after{transform:rotate(135deg)}.-right-top- .air-datepicker--pointer,.-right-center- .air-datepicker--pointer,.-right-bottom- .air-datepicker--pointer,[data-popper-placement^=right] .air-datepicker--pointer{right:calc(100% - var(--pointer-half-size) + 1px)}.-right-top- .air-datepicker--pointer:after,.-right-center- .air-datepicker--pointer:after,.-right-bottom- .air-datepicker--pointer:after,[data-popper-placement^=right] .air-datepicker--pointer:after{transform:rotate(225deg)}.-bottom-left- .air-datepicker--pointer,.-bottom-center- .air-datepicker--pointer,.-bottom-right- .air-datepicker--pointer,[data-popper-placement^=bottom] .air-datepicker--pointer{bottom:calc(100% - var(--pointer-half-size) + 1px)}.-bottom-left- .air-datepicker--pointer:after,.-bottom-center- .air-datepicker--pointer:after,.-bottom-right- .air-datepicker--pointer:after,[data-popper-placement^=bottom] .air-datepicker--pointer:after{transform:rotate(315deg)}.-left-top- .air-datepicker--pointer,.-left-center- .air-datepicker--pointer,.-left-bottom- .air-datepicker--pointer,[data-popper-placement^=left] .air-datepicker--pointer{left:calc(100% - var(--pointer-half-size) + 1px)}.-left-top- .air-datepicker--pointer:after,.-left-center- .air-datepicker--pointer:after,.-left-bottom- .air-datepicker--pointer:after,[data-popper-placement^=left] .air-datepicker--pointer:after{transform:rotate(45deg)}.-top-left- .air-datepicker--pointer,.-bottom-left- .air-datepicker--pointer{left:var(--adp-pointer-offset)}.-top-right- .air-datepicker--pointer,.-bottom-right- .air-datepicker--pointer{right:var(--adp-pointer-offset)}.-top-center- .air-datepicker--pointer,.-bottom-center- .air-datepicker--pointer{left:calc(50% - var(--adp-pointer-size)/2)}.-left-top- .air-datepicker--pointer,.-right-top- .air-datepicker--pointer{top:var(--adp-pointer-offset)}.-left-bottom- .air-datepicker--pointer,.-right-bottom- .air-datepicker--pointer{bottom:var(--adp-pointer-offset)}.-left-center- .air-datepicker--pointer,.-right-center- .air-datepicker--pointer{top:calc(50% - var(--adp-pointer-size)/2)}.air-datepicker--navigation{grid-area:nav}.air-datepicker--content{box-sizing:content-box;padding:var(--adp-padding);grid-area:body}.-only-timepicker- .air-datepicker--content{display:none}.air-datepicker--time{grid-area:timepicker}.air-datepicker--buttons{grid-area:buttons}.air-datepicker--buttons,.air-datepicker--time{padding:var(--adp-padding);border-top:1px solid var(--adp-border-color-inner)}.air-datepicker-overlay{position:fixed;background:var(--adp-overlay-background-color);left:0;top:0;width:0;height:0;opacity:0;transition:opacity var(--adp-overlay-transition-duration) var(--adp-overlay-transition-ease),left 0s,height 0s,width 0s;transition-delay:0s,var(--adp-overlay-transition-duration),var(--adp-overlay-transition-duration),var(--adp-overlay-transition-duration);z-index:var(--adp-overlay-z-index)}.air-datepicker-overlay.-active-{opacity:1;width:100%;height:100%;transition:opacity var(--adp-overlay-transition-duration) var(--adp-overlay-transition-ease),height 0s,width 0s}
13
13
 
14
- /*! tailwindcss v4.1.7 | MIT License | https://tailwindcss.com */
14
+ /*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */
15
15
  @layer properties;
16
16
  @layer theme, base, components, utilities;
17
17
  @layer theme {
@@ -388,7 +388,7 @@
388
388
  background-color: var(--tt-bg);
389
389
  width: max-content;
390
390
  pointer-events: none;
391
- z-index: 1;
391
+ z-index: 2;
392
392
  --tw-content: attr(data-tip);
393
393
  content: var(--tw-content);
394
394
  }
@@ -1450,11 +1450,11 @@
1450
1450
  z-index: 1;
1451
1451
  position: absolute;
1452
1452
  white-space: nowrap;
1453
- top: var(--inidicator-t, 0);
1454
- bottom: var(--inidicator-b, auto);
1455
- left: var(--inidicator-s, auto);
1456
- right: var(--inidicator-e, 0);
1457
- translate: var(--inidicator-x, 50%) var(--indicator-y, -50%);
1453
+ top: var(--indicator-t, 0);
1454
+ bottom: var(--indicator-b, auto);
1455
+ left: var(--indicator-s, auto);
1456
+ right: var(--indicator-e, 0);
1457
+ translate: var(--indicator-x, 50%) var(--indicator-y, -50%);
1458
1458
  }
1459
1459
  }
1460
1460
  .table {
@@ -2108,6 +2108,7 @@
2108
2108
  border: var(--border) solid var(--input-color, color-mix(in oklab, var(--color-base-content) 20%, #0000));
2109
2109
  }
2110
2110
  position: relative;
2111
+ display: inline-block;
2111
2112
  flex-shrink: 0;
2112
2113
  cursor: pointer;
2113
2114
  appearance: none;
@@ -2181,6 +2182,7 @@
2181
2182
  }
2182
2183
  .radio {
2183
2184
  position: relative;
2185
+ display: inline-block;
2184
2186
  flex-shrink: 0;
2185
2187
  cursor: pointer;
2186
2188
  appearance: none;
@@ -3057,31 +3059,6 @@
3057
3059
  --badge-fg: var(--color-base-content);
3058
3060
  --size: calc(var(--size-selector, 0.25rem) * 6);
3059
3061
  height: var(--size);
3060
- &.badge-outline {
3061
- --badge-fg: var(--badge-color);
3062
- --badge-bg: #0000;
3063
- background-image: none;
3064
- border-color: currentColor;
3065
- }
3066
- &.badge-dash {
3067
- --badge-fg: var(--badge-color);
3068
- --badge-bg: #0000;
3069
- background-image: none;
3070
- border-color: currentColor;
3071
- border-style: dashed;
3072
- }
3073
- &.badge-soft {
3074
- color: var(--badge-color, var(--color-base-content));
3075
- background-color: var(--badge-color, var(--color-base-content));
3076
- @supports (color: color-mix(in lab, red, red)) {
3077
- background-color: color-mix( in oklab, var(--badge-color, var(--color-base-content)) 8%, var(--color-base-100) );
3078
- }
3079
- border-color: var(--badge-color, var(--color-base-content));
3080
- @supports (color: color-mix(in lab, red, red)) {
3081
- border-color: color-mix( in oklab, var(--badge-color, var(--color-base-content)) 10%, var(--color-base-100) );
3082
- }
3083
- background-image: none;
3084
- }
3085
3062
  }
3086
3063
  .tabs {
3087
3064
  display: flex;
@@ -3445,6 +3422,7 @@
3445
3422
  display: grid;
3446
3423
  column-gap: calc(0.25rem * 3);
3447
3424
  padding-block: calc(0.25rem * 1);
3425
+ --mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
3448
3426
  }
3449
3427
  .branding-label {
3450
3428
  @media (width >= 64rem) {
@@ -4769,9 +4747,6 @@ tr:not(:first-child) th[colspan]:not([colspan="1"]) {
4769
4747
  :root {
4770
4748
  --fx-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");
4771
4749
  }
4772
- .chat {
4773
- --mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
4774
- }
4775
4750
  }
4776
4751
  @layer base {
4777
4752
  :root:has( .modal-open, .modal[open], .modal:target, .modal-toggle:checked, .drawer:not([class*="drawer-open"]) > .drawer-toggle:checked ) {
@@ -11,7 +11,11 @@ module Karafka
11
11
  controller = build(Controllers::ErrorsController)
12
12
 
13
13
  r.get Integer do |offset|
14
- controller.show(offset)
14
+ if params.current_offset != -1
15
+ r.redirect root_path('errors', params.current_offset)
16
+ else
17
+ controller.show(offset)
18
+ end
15
19
  end
16
20
 
17
21
  r.get do
@@ -11,14 +11,9 @@
11
11
  <% partitions_list = partitions.join(', ') %>
12
12
  <span
13
13
  class="badge badge-secondary"
14
- title="Consumer group: <%= consumer_group.id %>, partitions: <%= partitions_list%>"
14
+ title="Consumer group: <%= consumer_group.id %>, assignments: <%= topics_assignment_label(topic_name, partitions, limit: 25) %>"
15
15
  >
16
- <%= topic_name %>:
17
- <% if partitions.size > 10 %>
18
- <%= "#{partitions.sort.first(10).join(',')}..." %>
19
- <% else %>
20
- <%= partitions.sort.join(',') %>
21
- <% end %>
16
+ <%= topics_assignment_text(topic_name, partitions) %>
22
17
  </span>
23
18
  <% end %>
24
19
  <% end %>
@@ -4,15 +4,10 @@
4
4
  </a>
5
5
  </li>
6
6
 
7
- <% if @error_message %>
7
+ <% if @offset %>
8
8
  <li>
9
- <a href="<%= root_path('errors', @offset) %>">
10
- <%=
11
- type = @error_message.payload[:type]
12
- error_class = @error_message.payload[:error_class]
13
-
14
- "#{type}: #{error_class}"
15
- %>
9
+ <a href="<%= root_path('errors', @partition_id, @offset) %>">
10
+ <%= "Offset #{@offset}" %>
16
11
  </a>
17
12
  </li>
18
13
  <% end %>
@@ -7,7 +7,12 @@
7
7
  <td>
8
8
  <span class="badge badge-secondary">
9
9
  <% if error[:details].key?(:topic) %>
10
- <%= error[:details][:topic] %>: <%= error[:details][:partition] %>
10
+ <%=
11
+ topic = error[:details][:topic]
12
+ partition = error[:details][:partition]
13
+
14
+ topics_partition_identifier(topic, partition)
15
+ %>
11
16
  <% else %>
12
17
  <%= error[:type] %>
13
18
  <% end %>
@@ -1,39 +1,45 @@
1
- <%
2
- type = @error_message.payload[:type]
3
- error_class = @error_message.payload[:error_class]
1
+ <% if @error_message %>
2
+ <%
3
+ type = @error_message.payload[:type]
4
+ error_class = @error_message.payload[:error_class]
4
5
 
5
- view_title "#{type}: #{error_class}"
6
- %>
6
+ view_title "#{type}: #{error_class}"
7
+ %>
7
8
 
8
- <div class="col-span-12 mb-3">
9
- <h2 class="h2">
10
- Metadata
11
- </h2>
9
+ <div class="col-span-12 mb-3">
10
+ <h2 class="h2">
11
+ Metadata
12
+ </h2>
12
13
 
13
- <div class="data-table-wrapper">
14
- <table class="data-table">
15
- <tbody>
16
- <% @error_message.payload.each do |k, v| %>
17
- <% next if k == :backtrace %>
18
- <%==
19
- partial(
20
- 'errors/detail',
21
- locals: {
22
- k: k,
23
- v: v
24
- }
25
- )
26
- %>
27
- <% end %>
28
- </tbody>
29
- </table>
14
+ <div class="data-table-wrapper">
15
+ <table class="data-table">
16
+ <tbody>
17
+ <% @error_message.payload.each do |k, v| %>
18
+ <% next if k == :backtrace %>
19
+ <%==
20
+ partial(
21
+ 'errors/detail',
22
+ locals: {
23
+ k: k,
24
+ v: v
25
+ }
26
+ )
27
+ %>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ </div>
30
32
  </div>
31
- </div>
32
33
 
33
- <div class="col-span-12 mb-3">
34
- <h2 class="h2">
35
- Backtrace
36
- </h2>
34
+ <div class="col-span-12 mb-6">
35
+ <h2 class="h2">
36
+ Backtrace
37
+ </h2>
37
38
 
38
- <pre class="mb-5 p-0 border border-gray-300 text-sm"><code class="wrapped json p-0 m-0"><%= @error_message.payload[:backtrace] %></code></pre>
39
- </div>
39
+ <pre class="mb-5 p-0 border border-gray-300 text-sm"><code class="wrapped json p-0 m-0"><%= @error_message.payload[:backtrace] %></code></pre>
40
+ </div>
41
+ <% else %>
42
+ <% view_title "Offset #{@offset}" %>
43
+
44
+ <%== partial 'shared/compacted_message_info' %>
45
+ <% end %>
@@ -6,8 +6,7 @@
6
6
  </td>
7
7
  <td>
8
8
  <span class="badge badge-secondary" title="Consumer group: <%= job.consumer_group %>">
9
- <%= job.topic %>:
10
- <%= job.partition %>
9
+ <%= topics_partition_identifier(job.topic, job.partition) %>
11
10
  </span>
12
11
  </td>
13
12
  <td>
@@ -0,0 +1,16 @@
1
+ <div class="col-span-12 mb-6">
2
+ <% alert_box_info('Empty Offset - No Data') do %>
3
+ <p>
4
+ This offset does not contain any consumable message data. This is normal Kafka behavior that occurs when:
5
+ </p>
6
+ <ul class="list-disc ml-6 mt-2">
7
+ <li>The message has been removed through log compaction</li>
8
+ <li>This offset contains internal Kafka system records (transaction markers, control records)</li>
9
+ <li>The message is a tombstone record (deletion marker with null value)</li>
10
+ <li>The topic uses cleanup policies that have processed this offset</li>
11
+ </ul>
12
+ <p class="mt-2">
13
+ Try navigating to nearby offsets or verify the topic's log compaction settings if you expected message data here.
14
+ </p>
15
+ <% end %>
16
+ </div>
@@ -3,6 +3,6 @@
3
3
  module Karafka
4
4
  module Web
5
5
  # Current gem version
6
- VERSION = '0.11.0.rc1'
6
+ VERSION = '0.11.0.rc3'
7
7
  end
8
8
  end