kafka_command 0.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 +7 -0
- data/.circleci/config.yml +179 -0
- data/.env +1 -0
- data/.env.test +1 -0
- data/.gitignore +41 -0
- data/.rspec +1 -0
- data/.rubocop.yml +12 -0
- data/.ruby-version +1 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +194 -0
- data/LICENSE +21 -0
- data/README.md +138 -0
- data/Rakefile +34 -0
- data/app/assets/config/manifest.js +3 -0
- data/app/assets/images/.keep +0 -0
- data/app/assets/images/kafka_command/cluster_view.png +0 -0
- data/app/assets/images/kafka_command/kafka.png +0 -0
- data/app/assets/images/kafka_command/topic_view.png +0 -0
- data/app/assets/javascripts/kafka_command/application.js +14 -0
- data/app/assets/stylesheets/kafka_command/application.css +27 -0
- data/app/assets/stylesheets/kafka_command/clusters.css +8 -0
- data/app/assets/stylesheets/kafka_command/topics.css +3 -0
- data/app/channels/application_cable/channel.rb +6 -0
- data/app/channels/application_cable/connection.rb +6 -0
- data/app/controllers/kafka_command/application_controller.rb +96 -0
- data/app/controllers/kafka_command/brokers_controller.rb +26 -0
- data/app/controllers/kafka_command/clusters_controller.rb +46 -0
- data/app/controllers/kafka_command/consumer_groups_controller.rb +44 -0
- data/app/controllers/kafka_command/topics_controller.rb +187 -0
- data/app/helpers/kafka_command/application_helper.rb +29 -0
- data/app/helpers/kafka_command/consumer_group_helper.rb +13 -0
- data/app/jobs/application_job.rb +6 -0
- data/app/mailers/application_mailer.rb +8 -0
- data/app/models/kafka_command/broker.rb +47 -0
- data/app/models/kafka_command/client.rb +102 -0
- data/app/models/kafka_command/cluster.rb +172 -0
- data/app/models/kafka_command/consumer_group.rb +142 -0
- data/app/models/kafka_command/consumer_group_partition.rb +23 -0
- data/app/models/kafka_command/group_member.rb +18 -0
- data/app/models/kafka_command/partition.rb +36 -0
- data/app/models/kafka_command/topic.rb +153 -0
- data/app/views/kafka_command/brokers/index.html.erb +38 -0
- data/app/views/kafka_command/clusters/_tabs.html.erb +9 -0
- data/app/views/kafka_command/clusters/index.html.erb +54 -0
- data/app/views/kafka_command/clusters/new.html.erb +115 -0
- data/app/views/kafka_command/configuration_error.html.erb +1 -0
- data/app/views/kafka_command/consumer_groups/index.html.erb +32 -0
- data/app/views/kafka_command/consumer_groups/show.html.erb +115 -0
- data/app/views/kafka_command/shared/_alert.html.erb +13 -0
- data/app/views/kafka_command/shared/_search_bar.html.erb +31 -0
- data/app/views/kafka_command/shared/_title.html.erb +6 -0
- data/app/views/kafka_command/topics/_form_fields.html.erb +49 -0
- data/app/views/kafka_command/topics/edit.html.erb +17 -0
- data/app/views/kafka_command/topics/index.html.erb +46 -0
- data/app/views/kafka_command/topics/new.html.erb +36 -0
- data/app/views/kafka_command/topics/show.html.erb +126 -0
- data/app/views/layouts/kafka_command/application.html.erb +50 -0
- data/bin/rails +16 -0
- data/config/initializers/kafka.rb +13 -0
- data/config/initializers/kafka_command.rb +11 -0
- data/config/routes.rb +11 -0
- data/docker-compose.yml +18 -0
- data/kafka_command.gemspec +27 -0
- data/lib/assets/.keep +0 -0
- data/lib/core_extensions/kafka/broker/attr_readers.rb +11 -0
- data/lib/core_extensions/kafka/broker_pool/attr_readers.rb +11 -0
- data/lib/core_extensions/kafka/client/attr_readers.rb +11 -0
- data/lib/core_extensions/kafka/cluster/attr_readers.rb +11 -0
- data/lib/core_extensions/kafka/protocol/metadata_response/partition_metadata/attr_readers.rb +15 -0
- data/lib/kafka_command/configuration.rb +150 -0
- data/lib/kafka_command/engine.rb +11 -0
- data/lib/kafka_command/errors.rb +6 -0
- data/lib/kafka_command/version.rb +5 -0
- data/lib/kafka_command.rb +13 -0
- data/lib/tasks/.keep +0 -0
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/assets/config/manifest.js +4 -0
- data/spec/dummy/app/assets/javascripts/application.js +15 -0
- data/spec/dummy/app/assets/javascripts/cable.js +13 -0
- data/spec/dummy/app/assets/stylesheets/application.css +15 -0
- data/spec/dummy/app/channels/application_cable/channel.rb +4 -0
- data/spec/dummy/app/channels/application_cable/connection.rb +4 -0
- data/spec/dummy/app/controllers/application_controller.rb +2 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/jobs/application_job.rb +2 -0
- data/spec/dummy/app/mailers/application_mailer.rb +4 -0
- data/spec/dummy/app/models/application_record.rb +3 -0
- data/spec/dummy/app/views/layouts/application.html.erb +15 -0
- data/spec/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/spec/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/spec/dummy/bin/bundle +3 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/bin/setup +36 -0
- data/spec/dummy/bin/update +31 -0
- data/spec/dummy/bin/yarn +11 -0
- data/spec/dummy/config/application.rb +19 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/cable.yml +10 -0
- data/spec/dummy/config/database.yml +25 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +61 -0
- data/spec/dummy/config/environments/production.rb +94 -0
- data/spec/dummy/config/environments/test.rb +46 -0
- data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
- data/spec/dummy/config/initializers/assets.rb +14 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
- data/spec/dummy/config/initializers/cookies_serializer.rb +5 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/initializers/mime_types.rb +4 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/kafka_command.yml +18 -0
- data/spec/dummy/config/locales/en.yml +33 -0
- data/spec/dummy/config/puma.rb +34 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/config/spring.rb +6 -0
- data/spec/dummy/config/ssl/test_ca_cert +1 -0
- data/spec/dummy/config/ssl/test_client_cert +1 -0
- data/spec/dummy/config/ssl/test_client_cert_key +1 -0
- data/spec/dummy/config/storage.yml +34 -0
- data/spec/dummy/config.ru +5 -0
- data/spec/dummy/db/schema.rb +42 -0
- data/spec/dummy/db/test.sqlite3 +0 -0
- data/spec/dummy/log/development.log +0 -0
- data/spec/dummy/log/hey.log +0 -0
- data/spec/dummy/log/test.log +2227 -0
- data/spec/dummy/package.json +5 -0
- data/spec/dummy/public/404.html +67 -0
- data/spec/dummy/public/422.html +67 -0
- data/spec/dummy/public/500.html +66 -0
- data/spec/dummy/public/apple-touch-icon-precomposed.png +0 -0
- data/spec/dummy/public/apple-touch-icon.png +0 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/examples.txt +165 -0
- data/spec/fast_helper.rb +20 -0
- data/spec/fixtures/files/kafka_command_sasl.yml +10 -0
- data/spec/fixtures/files/kafka_command_ssl.yml +10 -0
- data/spec/fixtures/files/kafka_command_ssl_file_paths.yml +11 -0
- data/spec/fixtures/files/kafka_command_staging.yml +8 -0
- data/spec/lib/kafka_command/configuration_spec.rb +311 -0
- data/spec/models/kafka_command/broker_spec.rb +83 -0
- data/spec/models/kafka_command/client_spec.rb +306 -0
- data/spec/models/kafka_command/cluster_spec.rb +163 -0
- data/spec/models/kafka_command/consumer_group_partition_spec.rb +43 -0
- data/spec/models/kafka_command/consumer_group_spec.rb +236 -0
- data/spec/models/kafka_command/partition_spec.rb +95 -0
- data/spec/models/kafka_command/topic_spec.rb +311 -0
- data/spec/rails_helper.rb +63 -0
- data/spec/requests/json/brokers_spec.rb +50 -0
- data/spec/requests/json/clusters_spec.rb +58 -0
- data/spec/requests/json/consumer_groups_spec.rb +139 -0
- data/spec/requests/json/topics_spec.rb +274 -0
- data/spec/spec_helper.rb +109 -0
- data/spec/support/factory_bot.rb +5 -0
- data/spec/support/json_helper.rb +13 -0
- data/spec/support/kafka_helper.rb +93 -0
- metadata +326 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
|
|
5
|
+
module KafkaCommand
|
|
6
|
+
class Cluster
|
|
7
|
+
extend Forwardable
|
|
8
|
+
DEFAULT_PROTOCOL = 'PLAINTEXT'
|
|
9
|
+
|
|
10
|
+
attr_reader :client,
|
|
11
|
+
:name,
|
|
12
|
+
:description,
|
|
13
|
+
:protocol,
|
|
14
|
+
:sasl_scram_username,
|
|
15
|
+
:sasl_scram_password,
|
|
16
|
+
:version,
|
|
17
|
+
:connect_timeout,
|
|
18
|
+
:socket_timeout
|
|
19
|
+
|
|
20
|
+
alias_method :id, :name
|
|
21
|
+
|
|
22
|
+
def_delegators :@client, :topics, :groups, :brokers
|
|
23
|
+
|
|
24
|
+
def initialize(name:, seed_brokers:, description: nil, protocol: DEFAULT_PROTOCOL,
|
|
25
|
+
sasl_scram_username: nil, sasl_scram_password: nil, ssl_ca_cert_file_path: nil,
|
|
26
|
+
ssl_client_cert_file_path: nil, ssl_client_cert_key_file_path: nil, version: nil,
|
|
27
|
+
ssl_ca_cert: nil, ssl_client_cert: nil, ssl_client_cert_key: nil, connect_timeout: nil,
|
|
28
|
+
socket_timeout: nil
|
|
29
|
+
)
|
|
30
|
+
@name = name
|
|
31
|
+
@seed_brokers = seed_brokers
|
|
32
|
+
@description = description
|
|
33
|
+
@sasl_scram_username = sasl_scram_username
|
|
34
|
+
@sasl_scram_password = sasl_scram_password
|
|
35
|
+
@ssl_ca_cert = ssl_ca_cert
|
|
36
|
+
@ssl_ca_cert_file_path = ssl_ca_cert_file_path
|
|
37
|
+
@ssl_client_cert = ssl_client_cert
|
|
38
|
+
@ssl_client_cert_file_path = ssl_client_cert_file_path
|
|
39
|
+
@ssl_client_cert_key = ssl_client_cert_key
|
|
40
|
+
@ssl_client_cert_key_file_path = ssl_client_cert_key_file_path
|
|
41
|
+
@version = version
|
|
42
|
+
@connect_timeout = connect_timeout
|
|
43
|
+
@socket_timeout = socket_timeout
|
|
44
|
+
@client = initialize_client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def connected?
|
|
48
|
+
# Tried using all?(&:connected?) here, but was getting some weird behavior with the views
|
|
49
|
+
brokers.map(&:connected?).all?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_topic(name, **kwargs)
|
|
53
|
+
client.create_topic(name, **kwargs)
|
|
54
|
+
|
|
55
|
+
# Give the cluster time to know about the topic
|
|
56
|
+
3.times do
|
|
57
|
+
client.refresh_topics!
|
|
58
|
+
topic = topics.find { |t| t.name == name }
|
|
59
|
+
return topic if topic
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_human
|
|
64
|
+
name.humanize.capitalize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_s
|
|
68
|
+
name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ssl?
|
|
72
|
+
get_ssl_ca_cert.present?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sasl?
|
|
76
|
+
sasl_scram_username.present? && sasl_scram_password.present?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ==(other)
|
|
80
|
+
name == other.name
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.find(cluster_name)
|
|
84
|
+
all.find { |c| c.name == cluster_name }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.none?
|
|
88
|
+
all.none?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.count
|
|
92
|
+
all.count
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.all
|
|
96
|
+
KafkaCommand.config.clusters.map do |name, cluster_info|
|
|
97
|
+
new(
|
|
98
|
+
name: name,
|
|
99
|
+
seed_brokers: cluster_info['seed_brokers'],
|
|
100
|
+
protocol: cluster_info['protocol'],
|
|
101
|
+
description: cluster_info['description'],
|
|
102
|
+
connect_timeout: cluster_info['connect_timeout'],
|
|
103
|
+
socket_timeout: cluster_info['socket_timeout'],
|
|
104
|
+
sasl_scram_username: cluster_info['sasl_scram_username'],
|
|
105
|
+
sasl_scram_password: cluster_info['sasl_scram_password'],
|
|
106
|
+
ssl_ca_cert: cluster_info['ssl_ca_cert'],
|
|
107
|
+
ssl_ca_cert_file_path: cluster_info['ssl_ca_cert_file_path'],
|
|
108
|
+
ssl_client_cert: cluster_info['ssl_client_cert'],
|
|
109
|
+
ssl_client_cert_file_path: cluster_info['ssl_client_cert_file_path'],
|
|
110
|
+
ssl_client_cert_key: cluster_info['ssl_client_cert_key'],
|
|
111
|
+
ssl_client_cert_key_file_path: cluster_info['ssl_client_cert_key_file_path'],
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def get_ssl_ca_cert
|
|
119
|
+
return @ssl_ca_cert if @ssl_ca_cert
|
|
120
|
+
|
|
121
|
+
if @ssl_ca_cert_file_path && File.exists?(@ssl_ca_cert_file_path)
|
|
122
|
+
File.read(@ssl_ca_cert_file_path).strip
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def get_ssl_client_cert
|
|
127
|
+
return @ssl_client_cert if @ssl_client_cert
|
|
128
|
+
|
|
129
|
+
if @ssl_client_cert_file_path && File.exists?(@ssl_client_cert_file_path)
|
|
130
|
+
File.read(@ssl_client_cert_file_path).strip
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get_ssl_client_cert_key
|
|
135
|
+
return @ssl_client_cert_key if @ssl_client_cert_key
|
|
136
|
+
|
|
137
|
+
if @ssl_client_cert_key_file_path && File.exists?(@ssl_client_cert_key_file_path)
|
|
138
|
+
File.read(@ssl_client_cert_key_file_path).strip
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def seed_brokers
|
|
143
|
+
return @seed_brokers.split(',') if @seed_brokers.is_a?(String)
|
|
144
|
+
@seed_brokers
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def initialize_client
|
|
148
|
+
@client ||= begin
|
|
149
|
+
client_kwargs = {
|
|
150
|
+
brokers: seed_brokers,
|
|
151
|
+
client_id: name
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if sasl?
|
|
155
|
+
client_kwargs[:sasl_scram_username] = sasl_scram_username
|
|
156
|
+
client_kwargs[:sasl_scram_password] = sasl_scram_password
|
|
157
|
+
client_kwargs[:sasl_scram_mechanism] = 'sha256'
|
|
158
|
+
client_kwargs[:ssl_ca_cert] = get_ssl_ca_cert
|
|
159
|
+
elsif ssl?
|
|
160
|
+
client_kwargs[:ssl_ca_cert] = get_ssl_ca_cert
|
|
161
|
+
client_kwargs[:ssl_client_cert] = get_ssl_client_cert
|
|
162
|
+
client_kwargs[:ssl_client_cert_key] = get_ssl_client_cert_key
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
client_kwargs[:connect_timeout] = connect_timeout if connect_timeout
|
|
166
|
+
client_kwargs[:socket_timeout] = socket_timeout if socket_timeout
|
|
167
|
+
|
|
168
|
+
Client.new(**client_kwargs)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KafkaCommand
|
|
4
|
+
class ConsumerGroup
|
|
5
|
+
attr_reader :group_id
|
|
6
|
+
|
|
7
|
+
def initialize(group_id, client)
|
|
8
|
+
@client = client
|
|
9
|
+
@group_id = group_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def refresh!
|
|
13
|
+
clear_group_metadata!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def stable?
|
|
17
|
+
state.match?(/stable/i)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def empty?
|
|
21
|
+
state.match?(/empty/i) || members.none?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def partitions_for(topic_name)
|
|
25
|
+
topic = @client.find_topic(topic_name)
|
|
26
|
+
partition_lag = lag_for(topic.name)
|
|
27
|
+
|
|
28
|
+
topic.partitions.map do |p|
|
|
29
|
+
ConsumerGroupPartition.new(
|
|
30
|
+
lag: partition_lag[p.partition_id][:lag],
|
|
31
|
+
offset: partition_lag[p.partition_id][:offset],
|
|
32
|
+
group_id: @group_id,
|
|
33
|
+
topic_name: topic.name,
|
|
34
|
+
partition_id: p.partition_id
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def total_lag_for(topic_name)
|
|
40
|
+
lag_for(topic_name).values.map { |lag_hash| lag_hash[:lag] || 0 }.reduce(:+)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def as_json(*)
|
|
44
|
+
topics_json = consumed_topics.map do |topic|
|
|
45
|
+
{
|
|
46
|
+
name: topic.name,
|
|
47
|
+
partitions: partitions_for(topic.name).map(&:as_json)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
group_id: @group_id,
|
|
53
|
+
state: state,
|
|
54
|
+
topics: topics_json
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def consumed_topics
|
|
59
|
+
topic_names = members.flat_map(&:topic_names).uniq
|
|
60
|
+
|
|
61
|
+
@client.topics.select do |t|
|
|
62
|
+
topic_names.include?(t.name)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def coordinator
|
|
67
|
+
@coordinator ||= @client.get_group_coordinator(group_id: @group_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def group_metadata
|
|
71
|
+
@group_metadata ||= initialize_group_metadata
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def state
|
|
75
|
+
group_metadata.state
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def members
|
|
79
|
+
group_metadata.members.map do |member|
|
|
80
|
+
GroupMember.new(member)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def lag_for(topic_name)
|
|
87
|
+
topic = @client.find_topic(topic_name)
|
|
88
|
+
topic_offsets = topic.offsets
|
|
89
|
+
group_offsets = offsets_for(topic_name)
|
|
90
|
+
|
|
91
|
+
lag_hash = compute_lag(topic_offsets, group_offsets)
|
|
92
|
+
lag_hash.each_with_object({}) do |(partition_id, lag), return_hash|
|
|
93
|
+
return_hash[partition_id] = {
|
|
94
|
+
offset: group_offsets[partition_id],
|
|
95
|
+
lag: lag
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def offsets_for(topic_name)
|
|
101
|
+
topic = @client.find_topic(topic_name)
|
|
102
|
+
|
|
103
|
+
offsets = coordinator.fetch_offsets(
|
|
104
|
+
group_id: @group_id,
|
|
105
|
+
topics: { topic.name => topic.partitions.map(&:partition_id) }
|
|
106
|
+
).topics[topic.name]
|
|
107
|
+
|
|
108
|
+
offsets.keys.each do |partition_id|
|
|
109
|
+
if offsets[partition_id].offset == -1
|
|
110
|
+
offsets[partition_id] = nil
|
|
111
|
+
else
|
|
112
|
+
offsets[partition_id] = offsets[partition_id].offset
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
offsets
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def compute_lag(topic_offsets, group_offsets)
|
|
120
|
+
topic_offsets.each_with_object({}) do |(partition_id, latest_offset), lag_hash|
|
|
121
|
+
lag =
|
|
122
|
+
if group_offsets[partition_id].nil?
|
|
123
|
+
nil
|
|
124
|
+
elsif group_offsets[partition_id] >= latest_offset
|
|
125
|
+
0
|
|
126
|
+
else
|
|
127
|
+
latest_offset - group_offsets[partition_id]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
lag_hash[partition_id] = lag
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def initialize_group_metadata
|
|
135
|
+
@client.describe_group(@group_id)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def clear_group_metadata!
|
|
139
|
+
@group_metadata = nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KafkaCommand
|
|
4
|
+
class ConsumerGroupPartition
|
|
5
|
+
attr_reader :lag, :topic_name, :offset, :partition_id, :group_id
|
|
6
|
+
|
|
7
|
+
def initialize(lag:, topic_name:, offset:, group_id:, partition_id:)
|
|
8
|
+
@group_id = group_id
|
|
9
|
+
@lag = lag
|
|
10
|
+
@topic_name = topic_name
|
|
11
|
+
@offset = offset
|
|
12
|
+
@partition_id = partition_id
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def as_json(*)
|
|
16
|
+
{
|
|
17
|
+
lag: @lag,
|
|
18
|
+
offset: @offset,
|
|
19
|
+
partition_id: @partition_id
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KafkaCommand
|
|
4
|
+
class GroupMember
|
|
5
|
+
attr_reader :member_id, :client_host, :client_id, :topic_assignment
|
|
6
|
+
|
|
7
|
+
def initialize(member_metadata)
|
|
8
|
+
@member_id = member_metadata.member_id
|
|
9
|
+
@client_host = member_metadata.client_host
|
|
10
|
+
@client_id = member_metadata.client_id
|
|
11
|
+
@topic_assignment = member_metadata.member_assignment.topics
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def topic_names
|
|
15
|
+
@topic_assignment.keys
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
|
|
5
|
+
module KafkaCommand
|
|
6
|
+
class Partition
|
|
7
|
+
extend Forwardable
|
|
8
|
+
attr_reader :topic
|
|
9
|
+
def_delegators :@partition_metadata, :isr, :leader, :partition_id, :replicas
|
|
10
|
+
|
|
11
|
+
def initialize(partition_metadata, topic)
|
|
12
|
+
@topic = topic
|
|
13
|
+
@partition_metadata = partition_metadata
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def highwater_mark_offset
|
|
17
|
+
@topic.offset_for(self)
|
|
18
|
+
end
|
|
19
|
+
alias offset highwater_mark_offset
|
|
20
|
+
|
|
21
|
+
def as_json(*)
|
|
22
|
+
{
|
|
23
|
+
isr: isr,
|
|
24
|
+
leader: leader,
|
|
25
|
+
id: partition_id,
|
|
26
|
+
highwater_mark_offset: offset
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# TODO
|
|
31
|
+
#
|
|
32
|
+
# implement describe to retrieve important configs
|
|
33
|
+
# def describe
|
|
34
|
+
# end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KafkaCommand
|
|
4
|
+
class Topic
|
|
5
|
+
attr_reader :name,
|
|
6
|
+
:partitions,
|
|
7
|
+
:replication_factor,
|
|
8
|
+
:max_message_bytes,
|
|
9
|
+
:retention_ms,
|
|
10
|
+
:retention_bytes
|
|
11
|
+
|
|
12
|
+
alias_method :id, :name
|
|
13
|
+
|
|
14
|
+
API_TIMEOUT = 10
|
|
15
|
+
CONSUMER_OFFSET_TOPIC = '__consumer_offsets'
|
|
16
|
+
DEFAULT_MAX_MESSAGE_BYTES = 1000012
|
|
17
|
+
DEFAULT_RETENTION_MS = 604800000
|
|
18
|
+
DEFAULT_RETENTION_BYTES = -1
|
|
19
|
+
TOPIC_CONFIGS = %w[
|
|
20
|
+
max.message.bytes
|
|
21
|
+
retention.bytes
|
|
22
|
+
retention.ms
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
class DeletionError < StandardError; end
|
|
26
|
+
|
|
27
|
+
def initialize(topic_metadata, client)
|
|
28
|
+
@client = client
|
|
29
|
+
@topic_metadata = topic_metadata
|
|
30
|
+
initialize_from_metadata
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def brokers_spread
|
|
34
|
+
((replication_factor.to_f / @client.brokers.count.to_f) * 100).round
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def destroy
|
|
38
|
+
if name == CONSUMER_OFFSET_TOPIC
|
|
39
|
+
raise DeletionError, "Cannot delete the #{CONSUMER_OFFSET_TOPIC} topic"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@client.delete_topic(@name, timeout: API_TIMEOUT)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_configs!(max_message_bytes: nil, retention_ms: nil, retention_bytes: nil)
|
|
46
|
+
config = {}
|
|
47
|
+
config['max.message.bytes'] = max_message_bytes if max_message_bytes
|
|
48
|
+
config['retention.ms'] = retention_ms if retention_ms
|
|
49
|
+
config['retention.bytes'] = retention_bytes if retention_bytes
|
|
50
|
+
|
|
51
|
+
@client.alter_topic(@name, config) unless config.empty?
|
|
52
|
+
refresh!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def set_partitions!(num_partitions)
|
|
56
|
+
unless @client.supports_api?(Kafka::Protocol::CREATE_PARTITIONS_API)
|
|
57
|
+
raise UnsupportedApiError, 'This version of Kafka does not support the create partitions API.'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@client.create_partitions_for(
|
|
61
|
+
@name,
|
|
62
|
+
num_partitions: num_partitions,
|
|
63
|
+
timeout: API_TIMEOUT
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
refresh!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def refresh!
|
|
70
|
+
@topic_metadata = @client.fetch_metadata(topics: [@name]).topics.first
|
|
71
|
+
initialize_from_metadata
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def offset_for(partition)
|
|
75
|
+
tries = 0
|
|
76
|
+
@client.resolve_offset(@name, partition.partition_id, :latest)
|
|
77
|
+
rescue Kafka::UnknownTopicOrPartition
|
|
78
|
+
raise if tries >= 3
|
|
79
|
+
tries += 1
|
|
80
|
+
retry
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def offsets(partition_ids = nil)
|
|
84
|
+
partition_ids ||= @partitions.map(&:partition_id)
|
|
85
|
+
@client.resolve_offsets(@name, partition_ids, :latest)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def offset_sum
|
|
89
|
+
offsets.values.reduce(:+)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def consumer_offset_topic?
|
|
93
|
+
name == CONSUMER_OFFSET_TOPIC
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def groups
|
|
97
|
+
@client.groups.select do |g|
|
|
98
|
+
g.consumed_topics.include?(self)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Needs arguments to be compatible with rails as_json calls
|
|
103
|
+
def as_json(include_config: false, **kwargs)
|
|
104
|
+
json = {
|
|
105
|
+
name: @name,
|
|
106
|
+
replication_factor: @replication_factor,
|
|
107
|
+
partitions: @partitions.sort_by(&:partition_id).map(&:as_json)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if include_config
|
|
111
|
+
json[:config] = {
|
|
112
|
+
max_message_bytes: max_message_bytes,
|
|
113
|
+
retention_ms: retention_ms,
|
|
114
|
+
retention_bytes: retention_bytes
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
json
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ==(other)
|
|
122
|
+
@name == other.name
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def topic_configs
|
|
126
|
+
@topic_configs ||= describe
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def retention_ms
|
|
130
|
+
topic_configs['retention.ms'].to_i
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def retention_bytes
|
|
134
|
+
topic_configs['retention.bytes'].to_i
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def max_message_bytes
|
|
138
|
+
topic_configs['max.message.bytes'].to_i
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def describe
|
|
144
|
+
@client.describe_topic(@name, TOPIC_CONFIGS)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def initialize_from_metadata
|
|
148
|
+
@name = @topic_metadata.topic_name
|
|
149
|
+
@partitions = @topic_metadata.partitions.map { |pm| Partition.new(pm, self) }
|
|
150
|
+
@replication_factor = @partitions.map(&:isr).map(&:length).max
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<%= render partial: 'kafka_command/shared/title', locals: { title: @cluster.to_human, subtitle: 'Brokers' } %>
|
|
2
|
+
|
|
3
|
+
<div class="level"a>
|
|
4
|
+
<div class="level-left">
|
|
5
|
+
<div class="level-item" style="margin-left: 0.2rem">
|
|
6
|
+
<p class="subtitle is-5">
|
|
7
|
+
<strong><%= @brokers.count %></strong> brokers
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<table class="table is-striped is-fullwidth is-bordered">
|
|
14
|
+
<thead>
|
|
15
|
+
<th>Id</th>
|
|
16
|
+
<th>Host</th>
|
|
17
|
+
<th>Connection Status</th>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% @brokers.each do |broker| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td><%= broker.kafka_broker_id %></td>
|
|
23
|
+
<td><%= broker.host %></td>
|
|
24
|
+
<td>
|
|
25
|
+
<% if broker.connected? %>
|
|
26
|
+
<span class="icon has-text-success">
|
|
27
|
+
<i class="fas fa-check-circle"></i>
|
|
28
|
+
</span>
|
|
29
|
+
<% else %>
|
|
30
|
+
<span class="icon has-text-danger">
|
|
31
|
+
<i class="fas fa-times-circle"></i>
|
|
32
|
+
</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
</td>
|
|
35
|
+
</tr>
|
|
36
|
+
<% end %>
|
|
37
|
+
</tbody>
|
|
38
|
+
</table>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%= render partial: 'kafka_command/shared/title', locals: { title: @cluster.to_human, subtitle: @cluster.description } %>
|
|
2
|
+
|
|
3
|
+
<div class="tabs">
|
|
4
|
+
<ul>
|
|
5
|
+
<li class="<%= tab_class('topics') %>"><%= link_to 'Topics', cluster_topics_path(@cluster) %></li>
|
|
6
|
+
<li class="<%= tab_class('consumer_groups') %>"><%= link_to 'Consumer Groups', cluster_consumer_groups_path(@cluster) %></li>
|
|
7
|
+
<li class="<%= tab_class('brokers') %>"><%= link_to 'Brokers', cluster_brokers_path(@cluster) %></li>
|
|
8
|
+
</ul>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<%= render partial: 'kafka_command/shared/title', locals: { title: 'Clusters', subtitle: 'Manage multiple clusters' } %>
|
|
2
|
+
|
|
3
|
+
<div class="level"a>
|
|
4
|
+
<%=
|
|
5
|
+
render(
|
|
6
|
+
partial: 'kafka_command/shared/search_bar',
|
|
7
|
+
locals: {
|
|
8
|
+
resources: @clusters,
|
|
9
|
+
resource_name: 'cluster',
|
|
10
|
+
resource_path: clusters_path,
|
|
11
|
+
filter_property: :name
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
%>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<table class="table is-striped is-fullwidth is-bordered">
|
|
18
|
+
<thead>
|
|
19
|
+
<th>Name</th>
|
|
20
|
+
<th>Description</th>
|
|
21
|
+
<th>Brokers</th>
|
|
22
|
+
<th>Protocol</th>
|
|
23
|
+
<th>Connection Status</th>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
<% @clusters.each do |cluster| %>
|
|
27
|
+
<tr>
|
|
28
|
+
<td><%= link_to cluster.name, cluster_topics_path(cluster) %></td>
|
|
29
|
+
<td><%= cluster.description %></td>
|
|
30
|
+
<td><%= cluster.brokers.count %></td>
|
|
31
|
+
<td>
|
|
32
|
+
<% if cluster.ssl? %>
|
|
33
|
+
SSL
|
|
34
|
+
<% elsif cluster.sasl? %>
|
|
35
|
+
SASL/SCRAM
|
|
36
|
+
<% else %>
|
|
37
|
+
PLAINTEXT
|
|
38
|
+
<% end %>
|
|
39
|
+
</td>
|
|
40
|
+
<td>
|
|
41
|
+
<% if cluster.connected? %>
|
|
42
|
+
<span class="icon has-text-success">
|
|
43
|
+
<i class="fas fa-check-circle"></i>
|
|
44
|
+
</span>
|
|
45
|
+
<% else %>
|
|
46
|
+
<span class="icon has-text-danger">
|
|
47
|
+
<i class="fas fa-times-circle"></i>
|
|
48
|
+
</span>
|
|
49
|
+
<% end %>
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
<% end %>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|