kafka_command 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +179 -0
  3. data/.env +1 -0
  4. data/.env.test +1 -0
  5. data/.gitignore +41 -0
  6. data/.rspec +1 -0
  7. data/.rubocop.yml +12 -0
  8. data/.ruby-version +1 -0
  9. data/Gemfile +17 -0
  10. data/Gemfile.lock +194 -0
  11. data/LICENSE +21 -0
  12. data/README.md +138 -0
  13. data/Rakefile +34 -0
  14. data/app/assets/config/manifest.js +3 -0
  15. data/app/assets/images/.keep +0 -0
  16. data/app/assets/images/kafka_command/cluster_view.png +0 -0
  17. data/app/assets/images/kafka_command/kafka.png +0 -0
  18. data/app/assets/images/kafka_command/topic_view.png +0 -0
  19. data/app/assets/javascripts/kafka_command/application.js +14 -0
  20. data/app/assets/stylesheets/kafka_command/application.css +27 -0
  21. data/app/assets/stylesheets/kafka_command/clusters.css +8 -0
  22. data/app/assets/stylesheets/kafka_command/topics.css +3 -0
  23. data/app/channels/application_cable/channel.rb +6 -0
  24. data/app/channels/application_cable/connection.rb +6 -0
  25. data/app/controllers/kafka_command/application_controller.rb +96 -0
  26. data/app/controllers/kafka_command/brokers_controller.rb +26 -0
  27. data/app/controllers/kafka_command/clusters_controller.rb +46 -0
  28. data/app/controllers/kafka_command/consumer_groups_controller.rb +44 -0
  29. data/app/controllers/kafka_command/topics_controller.rb +187 -0
  30. data/app/helpers/kafka_command/application_helper.rb +29 -0
  31. data/app/helpers/kafka_command/consumer_group_helper.rb +13 -0
  32. data/app/jobs/application_job.rb +6 -0
  33. data/app/mailers/application_mailer.rb +8 -0
  34. data/app/models/kafka_command/broker.rb +47 -0
  35. data/app/models/kafka_command/client.rb +102 -0
  36. data/app/models/kafka_command/cluster.rb +172 -0
  37. data/app/models/kafka_command/consumer_group.rb +142 -0
  38. data/app/models/kafka_command/consumer_group_partition.rb +23 -0
  39. data/app/models/kafka_command/group_member.rb +18 -0
  40. data/app/models/kafka_command/partition.rb +36 -0
  41. data/app/models/kafka_command/topic.rb +153 -0
  42. data/app/views/kafka_command/brokers/index.html.erb +38 -0
  43. data/app/views/kafka_command/clusters/_tabs.html.erb +9 -0
  44. data/app/views/kafka_command/clusters/index.html.erb +54 -0
  45. data/app/views/kafka_command/clusters/new.html.erb +115 -0
  46. data/app/views/kafka_command/configuration_error.html.erb +1 -0
  47. data/app/views/kafka_command/consumer_groups/index.html.erb +32 -0
  48. data/app/views/kafka_command/consumer_groups/show.html.erb +115 -0
  49. data/app/views/kafka_command/shared/_alert.html.erb +13 -0
  50. data/app/views/kafka_command/shared/_search_bar.html.erb +31 -0
  51. data/app/views/kafka_command/shared/_title.html.erb +6 -0
  52. data/app/views/kafka_command/topics/_form_fields.html.erb +49 -0
  53. data/app/views/kafka_command/topics/edit.html.erb +17 -0
  54. data/app/views/kafka_command/topics/index.html.erb +46 -0
  55. data/app/views/kafka_command/topics/new.html.erb +36 -0
  56. data/app/views/kafka_command/topics/show.html.erb +126 -0
  57. data/app/views/layouts/kafka_command/application.html.erb +50 -0
  58. data/bin/rails +16 -0
  59. data/config/initializers/kafka.rb +13 -0
  60. data/config/initializers/kafka_command.rb +11 -0
  61. data/config/routes.rb +11 -0
  62. data/docker-compose.yml +18 -0
  63. data/kafka_command.gemspec +27 -0
  64. data/lib/assets/.keep +0 -0
  65. data/lib/core_extensions/kafka/broker/attr_readers.rb +11 -0
  66. data/lib/core_extensions/kafka/broker_pool/attr_readers.rb +11 -0
  67. data/lib/core_extensions/kafka/client/attr_readers.rb +11 -0
  68. data/lib/core_extensions/kafka/cluster/attr_readers.rb +11 -0
  69. data/lib/core_extensions/kafka/protocol/metadata_response/partition_metadata/attr_readers.rb +15 -0
  70. data/lib/kafka_command/configuration.rb +150 -0
  71. data/lib/kafka_command/engine.rb +11 -0
  72. data/lib/kafka_command/errors.rb +6 -0
  73. data/lib/kafka_command/version.rb +5 -0
  74. data/lib/kafka_command.rb +13 -0
  75. data/lib/tasks/.keep +0 -0
  76. data/spec/dummy/Rakefile +6 -0
  77. data/spec/dummy/app/assets/config/manifest.js +4 -0
  78. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  79. data/spec/dummy/app/assets/javascripts/cable.js +13 -0
  80. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  81. data/spec/dummy/app/channels/application_cable/channel.rb +4 -0
  82. data/spec/dummy/app/channels/application_cable/connection.rb +4 -0
  83. data/spec/dummy/app/controllers/application_controller.rb +2 -0
  84. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  85. data/spec/dummy/app/jobs/application_job.rb +2 -0
  86. data/spec/dummy/app/mailers/application_mailer.rb +4 -0
  87. data/spec/dummy/app/models/application_record.rb +3 -0
  88. data/spec/dummy/app/views/layouts/application.html.erb +15 -0
  89. data/spec/dummy/app/views/layouts/mailer.html.erb +13 -0
  90. data/spec/dummy/app/views/layouts/mailer.text.erb +1 -0
  91. data/spec/dummy/bin/bundle +3 -0
  92. data/spec/dummy/bin/rails +4 -0
  93. data/spec/dummy/bin/rake +4 -0
  94. data/spec/dummy/bin/setup +36 -0
  95. data/spec/dummy/bin/update +31 -0
  96. data/spec/dummy/bin/yarn +11 -0
  97. data/spec/dummy/config/application.rb +19 -0
  98. data/spec/dummy/config/boot.rb +5 -0
  99. data/spec/dummy/config/cable.yml +10 -0
  100. data/spec/dummy/config/database.yml +25 -0
  101. data/spec/dummy/config/environment.rb +5 -0
  102. data/spec/dummy/config/environments/development.rb +61 -0
  103. data/spec/dummy/config/environments/production.rb +94 -0
  104. data/spec/dummy/config/environments/test.rb +46 -0
  105. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  106. data/spec/dummy/config/initializers/assets.rb +14 -0
  107. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  108. data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
  109. data/spec/dummy/config/initializers/cookies_serializer.rb +5 -0
  110. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  111. data/spec/dummy/config/initializers/inflections.rb +16 -0
  112. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  113. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  114. data/spec/dummy/config/kafka_command.yml +18 -0
  115. data/spec/dummy/config/locales/en.yml +33 -0
  116. data/spec/dummy/config/puma.rb +34 -0
  117. data/spec/dummy/config/routes.rb +3 -0
  118. data/spec/dummy/config/spring.rb +6 -0
  119. data/spec/dummy/config/ssl/test_ca_cert +1 -0
  120. data/spec/dummy/config/ssl/test_client_cert +1 -0
  121. data/spec/dummy/config/ssl/test_client_cert_key +1 -0
  122. data/spec/dummy/config/storage.yml +34 -0
  123. data/spec/dummy/config.ru +5 -0
  124. data/spec/dummy/db/schema.rb +42 -0
  125. data/spec/dummy/db/test.sqlite3 +0 -0
  126. data/spec/dummy/log/development.log +0 -0
  127. data/spec/dummy/log/hey.log +0 -0
  128. data/spec/dummy/log/test.log +2227 -0
  129. data/spec/dummy/package.json +5 -0
  130. data/spec/dummy/public/404.html +67 -0
  131. data/spec/dummy/public/422.html +67 -0
  132. data/spec/dummy/public/500.html +66 -0
  133. data/spec/dummy/public/apple-touch-icon-precomposed.png +0 -0
  134. data/spec/dummy/public/apple-touch-icon.png +0 -0
  135. data/spec/dummy/public/favicon.ico +0 -0
  136. data/spec/examples.txt +165 -0
  137. data/spec/fast_helper.rb +20 -0
  138. data/spec/fixtures/files/kafka_command_sasl.yml +10 -0
  139. data/spec/fixtures/files/kafka_command_ssl.yml +10 -0
  140. data/spec/fixtures/files/kafka_command_ssl_file_paths.yml +11 -0
  141. data/spec/fixtures/files/kafka_command_staging.yml +8 -0
  142. data/spec/lib/kafka_command/configuration_spec.rb +311 -0
  143. data/spec/models/kafka_command/broker_spec.rb +83 -0
  144. data/spec/models/kafka_command/client_spec.rb +306 -0
  145. data/spec/models/kafka_command/cluster_spec.rb +163 -0
  146. data/spec/models/kafka_command/consumer_group_partition_spec.rb +43 -0
  147. data/spec/models/kafka_command/consumer_group_spec.rb +236 -0
  148. data/spec/models/kafka_command/partition_spec.rb +95 -0
  149. data/spec/models/kafka_command/topic_spec.rb +311 -0
  150. data/spec/rails_helper.rb +63 -0
  151. data/spec/requests/json/brokers_spec.rb +50 -0
  152. data/spec/requests/json/clusters_spec.rb +58 -0
  153. data/spec/requests/json/consumer_groups_spec.rb +139 -0
  154. data/spec/requests/json/topics_spec.rb +274 -0
  155. data/spec/spec_helper.rb +109 -0
  156. data/spec/support/factory_bot.rb +5 -0
  157. data/spec/support/json_helper.rb +13 -0
  158. data/spec/support/kafka_helper.rb +93 -0
  159. metadata +326 -0
@@ -0,0 +1,27 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
6
+ * vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
16
+
17
+ .is-borderless {
18
+ border-width: 0px !important;
19
+ }
20
+
21
+ .kafka-logo {
22
+ margin-left: 0 !important;
23
+ }
24
+
25
+ .page-title {
26
+ margin-top: 1rem;
27
+ }
@@ -0,0 +1,8 @@
1
+ .cluster-card {
2
+ margin-bottom: 1.5rem;
3
+ }
4
+
5
+ .delete-cluster {
6
+ /* justify-content: flex-end; */
7
+ padding: 0.5rem;
8
+ }
@@ -0,0 +1,3 @@
1
+ .config-table {
2
+ margin-bottom: 2.5rem !important;
3
+ }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Channel < ActionCable::Channel::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Connection < ActionCable::Connection::Base
5
+ end
6
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KafkaCommand
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery unless: -> { request.format.json? }
6
+
7
+ rescue_from Kafka::ConnectionError, with: :kafka_connection_error
8
+ rescue_from Kafka::ClusterAuthorizationFailed, with: :kafka_authorization_error
9
+ rescue_from UnsupportedApiError, with: :unsupported_api_error
10
+
11
+ before_action do
12
+ unless KafkaCommand.config.valid?
13
+ flash[:error] = KafkaCommand.config.errors.join("\n")
14
+ render 'kafka_command/configuration_error'
15
+ end
16
+ end
17
+
18
+ protected
19
+
20
+ def unsupported_api_error(exception)
21
+ render_error(exception.message, status: 422, flash: { error: exception.message })
22
+ end
23
+
24
+ def record_not_found
25
+ render_error('Not Found', status: :not_found)
26
+ end
27
+
28
+ def kafka_connection_error
29
+ error_msg = 'Could not connect to Kafka with the specified brokers'
30
+ render_error(
31
+ error_msg,
32
+ status: 500,
33
+ flash: { error: error_msg }
34
+ )
35
+ end
36
+
37
+ def kafka_authorization_error
38
+ error_msg = 'You are not authorized to perform that action'
39
+ render_error(
40
+ error_msg,
41
+ status: 401,
42
+ flash: { error: error_msg }
43
+ )
44
+ end
45
+
46
+ def serialize_json(data, **kwargs)
47
+ if data.is_a?(ActiveRecord::Relation) || data.is_a?(Array)
48
+ return {
49
+ data: data.map { |d| d.as_json(**kwargs) }
50
+ }
51
+ end
52
+
53
+ data.as_json(**kwargs)
54
+ end
55
+
56
+ def render_success(data, status: :ok, redirection_path: nil, flash: {}, **kwargs)
57
+ respond_to do |format|
58
+ format.html do
59
+ redirect_to redirection_path, flash: flash if redirection_path
60
+ end
61
+
62
+ format.json do
63
+ if status == :no_content || status.to_s.to_i == 204
64
+ head :no_content
65
+ else
66
+ render_json(data, status: status, **kwargs)
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ def render_error(data, status: :unprocessible_entity, flash: {})
73
+ respond_to do |format|
74
+ format.html do
75
+ redirect_back(fallback_location: root_path, flash: flash) && (return) if flash.present?
76
+
77
+ case status
78
+ when :not_found, 404
79
+ render json: '404 Not Found', status: status, layout: false
80
+ else
81
+ render json: '500 Internal Server Error', status: status, layout: false
82
+ end
83
+ end
84
+ format.json { render_json_errors(data, status: status) }
85
+ end
86
+ end
87
+
88
+ def render_json(data, status:, **kwargs)
89
+ render json: serialize_json(data, **kwargs), status: status
90
+ end
91
+
92
+ def render_json_errors(errors, status: :unprocessible_entity)
93
+ render json: errors, status: status
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'kafka_command/application_controller'
4
+
5
+ module KafkaCommand
6
+ class BrokersController < ApplicationController
7
+ # GET /clusters/:cluster_id/brokers
8
+ def index
9
+ @cluster = Cluster.find(params[:cluster_id])
10
+ @brokers = @cluster.brokers
11
+ render_success(@brokers)
12
+ end
13
+
14
+ # GET /clusters/:cluster_id/brokers/:id
15
+ def show
16
+ cluster = Cluster.find(params[:cluster_id])
17
+ @broker = cluster.brokers.find { |b| b.node_id == params[:id].to_i }
18
+
19
+ if @broker.present?
20
+ render_success(@broker)
21
+ else
22
+ record_not_found
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'kafka_command/application_controller'
4
+
5
+ module KafkaCommand
6
+ class ClustersController < ApplicationController
7
+ # GET /clusters
8
+ def index
9
+ @clusters = Cluster.all
10
+
11
+ flash[:search] = params[:name]
12
+
13
+ if params[:name].present?
14
+ @clusters = @clusters.select do |c|
15
+ regex = /#{params[:name]}/i
16
+ c.name.match?(regex)
17
+ end
18
+ end
19
+
20
+ redirection_path = new_cluster_path if Cluster.none?
21
+ render_success(@clusters, redirection_path: redirection_path, flash: flash.to_hash)
22
+ end
23
+
24
+ # GET /clusters/:id
25
+ def show
26
+ @cluster = Cluster.find(params[:id])
27
+
28
+ if @cluster
29
+ render_success(@cluster)
30
+ else
31
+ record_not_found
32
+ end
33
+ end
34
+
35
+ private
36
+ # leave for config validation
37
+
38
+ def cluster_params
39
+ params.permit(*cluster_params_keys)
40
+ end
41
+
42
+ def cluster_params_keys
43
+ [:name, :description, :version]
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'kafka_command/application_controller'
4
+
5
+ module KafkaCommand
6
+ class ConsumerGroupsController < ApplicationController
7
+ # GET /clusters/:cluster_id/consumer_groups
8
+ def index
9
+ @cluster = Cluster.find(params[:cluster_id])
10
+ @groups = @cluster.groups
11
+
12
+ flash[:search] = params[:group_id]
13
+
14
+ if params[:group_id].present?
15
+ @groups = @groups.select do |g|
16
+ regex = /#{params[:group_id]}/i
17
+ g.group_id.match?(regex)
18
+ end
19
+ end
20
+
21
+ render_success(@groups)
22
+ end
23
+
24
+ # GET /alusters/:cluster_id/consumer_groups/:id
25
+ def show
26
+ @cluster = Cluster.find(params[:cluster_id])
27
+ @group = @cluster.groups.find { |g| g.group_id == params[:id] }
28
+
29
+ if @group.nil?
30
+ render_error('Consumer group not found', status: 404)
31
+ return
32
+ end
33
+
34
+ @current_topic =
35
+ if params[:topic]
36
+ @group.consumed_topics.find { |t| t.name == params[:topic] }
37
+ else
38
+ @group.consumed_topics.first
39
+ end
40
+
41
+ render_success(@group)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'kafka_command/application_controller'
4
+
5
+ module KafkaCommand
6
+ class TopicsController < ApplicationController
7
+ rescue_from Kafka::InvalidPartitions, with: :invalid_partitions
8
+ rescue_from Kafka::InvalidReplicationFactor, with: :invalid_replication_factor
9
+ rescue_from Kafka::InvalidTopic, with: :invalid_topic_name
10
+ rescue_from Kafka::TopicAlreadyExists, with: :topic_already_exists
11
+ rescue_from Kafka::UnknownError, with: :unknown_error
12
+ rescue_from Kafka::InvalidRequest, with: :unknown_error
13
+ rescue_from Kafka::InvalidConfig, with: :unknown_error
14
+ rescue_from Kafka::TopicAuthorizationFailed, with: :kafka_authorization_error
15
+ rescue_from KafkaCommand::Topic::DeletionError, with: :topic_deletion_error
16
+
17
+ # GET /clusters/:cluster_id/topics
18
+ def index
19
+ @cluster = Cluster.find(params[:cluster_id])
20
+ @topics = @cluster.topics
21
+
22
+ flash[:search] = params[:name]
23
+
24
+ if params[:name].present?
25
+ @topics = @topics.select do |t|
26
+ regex = /#{params[:name]}/i
27
+ t.name.match?(regex)
28
+ end
29
+ end
30
+
31
+ render_success(@topics)
32
+ end
33
+
34
+ # GET /clusters/:cluster_id/topics/:id
35
+ def show
36
+ @cluster = Cluster.find(params[:cluster_id])
37
+ @topic = @cluster.topics.find { |t| t.name == params[:id] }
38
+
39
+ if @topic.nil?
40
+ render_error('Topic not found', status: 404)
41
+ else
42
+ render_success(@topic, include_config: true)
43
+ end
44
+ end
45
+
46
+ # GET /clusters/:cluster_id/topics/new
47
+ def new
48
+ @cluster = Cluster.find(params[:cluster_id])
49
+ end
50
+
51
+ # GET /clusters/:cluster_id/topics/edit
52
+ def edit
53
+ @cluster = Cluster.find(params[:cluster_id])
54
+ @topic = @cluster.topics.find { |t| t.name == params[:id] }
55
+ @redirect_path = params[:redirect_path]
56
+ end
57
+
58
+ # POST /clusters/:cluster_id/topics
59
+ def create
60
+ @cluster = Cluster.find(params[:cluster_id])
61
+ @topic = @cluster.create_topic(
62
+ params[:name],
63
+ num_partitions: params[:num_partitions].to_i,
64
+ replication_factor: params[:replication_factor].to_i,
65
+ config: build_config
66
+ )
67
+
68
+ render_success(
69
+ @topic,
70
+ status: :created,
71
+ redirection_path: cluster_topics_path,
72
+ flash: { success: 'Topic created' },
73
+ include_config: true
74
+ )
75
+ end
76
+
77
+ # PATCH/PUT /clusters/:cluster_id/topics/:id
78
+ def update
79
+ cluster = Cluster.find(params[:cluster_id])
80
+ @topic = cluster.topics.find { |t| t.name == params[:id] }
81
+
82
+ render_error('Topic not found', status: 404) && (return) if @topic.nil?
83
+ if params[:num_partitions]
84
+ @topic.set_partitions!(params[:num_partitions].to_i) unless params[:num_partitions].to_i == @topic.partitions.count
85
+ end
86
+
87
+ if build_config.present?
88
+ @topic.set_configs!(
89
+ max_message_bytes: params[:max_message_bytes],
90
+ retention_ms: params[:retention_ms],
91
+ retention_bytes: params[:retention_bytes]
92
+ )
93
+ end
94
+
95
+ render_success(
96
+ @topic,
97
+ status: :ok,
98
+ redirection_path: params[:redirect_path] || cluster_topics_path,
99
+ flash: { success: 'Topic updated' }
100
+ )
101
+ end
102
+
103
+ # DELETE /clusters/:cluster_id/topics/:id
104
+ def destroy
105
+ @cluster = Cluster.find(params[:cluster_id])
106
+ @topic = @cluster.topics.find { |t| t.name == params[:id] }
107
+
108
+ if @topic.nil?
109
+ render_error('Topic not found', status: 404)
110
+ else
111
+ @topic.destroy
112
+ render_success(
113
+ @topic,
114
+ status: :no_content,
115
+ redirection_path: cluster_topics_path,
116
+ flash: { success: "Topic \"#{@topic.name}\" is marked for deletion. <strong>Note: This will have no impact if delete.topic.enable is not set to true.</strong>".html_safe }
117
+ )
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def build_config
124
+ {}.tap do |config|
125
+ config['max.message.bytes'] = params[:max_message_bytes] if params[:max_message_bytes]
126
+ config['retention.ms'] = params[:retention_ms] if params[:retention_ms]
127
+ config['retention.bytes'] = params[:retention_bytes] if params[:retention_bytes]
128
+ end
129
+ end
130
+
131
+ def invalid_partitions
132
+ error_msg = 'Num partitions must be > 0 or > current number of partitions'
133
+
134
+ render_error(
135
+ error_msg,
136
+ status: 422,
137
+ flash: { error: error_msg }
138
+ )
139
+ end
140
+
141
+ def invalid_replication_factor
142
+ error_msg = 'Replication factor must be > 0 and < total number of brokers'
143
+
144
+ render_error(
145
+ error_msg,
146
+ status: 422,
147
+ flash: { error: error_msg }
148
+ )
149
+ end
150
+
151
+ def invalid_topic_name
152
+ error_msg = 'Topic must have a name'
153
+ render_error(
154
+ error_msg,
155
+ status: 422,
156
+ flash: { error: error_msg }
157
+ )
158
+ end
159
+
160
+ def topic_already_exists
161
+ error_msg = 'Topic already exists'
162
+ render_error(
163
+ error_msg,
164
+ status: 422,
165
+ flash: { error: error_msg }
166
+ )
167
+ end
168
+
169
+ def unknown_error
170
+ error_msg = 'An unknown error occurred with the request to Kafka. Check any request parameters.'
171
+
172
+ render_error(
173
+ error_msg,
174
+ status: 422,
175
+ flash: { error: error_msg }
176
+ )
177
+ end
178
+
179
+ def topic_deletion_error(exception)
180
+ render_error(
181
+ exception.message,
182
+ status: 422,
183
+ flash: { error: exception.message }
184
+ )
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KafkaCommand
4
+ module ApplicationHelper
5
+ def format_flash_errors
6
+ return '' if flash[:error].blank?
7
+ return flash[:error] if flash[:error].is_a?(String)
8
+
9
+ '<ul>'.tap do |str|
10
+ flash[:error].each do |k, v|
11
+ str << "<li><span class='has-text-weight-bold'>#{k.humanize}:</span> #{v.join('. ')}</li>"
12
+ end
13
+ end.html_safe
14
+ end
15
+
16
+ def topic_path(topic)
17
+ "#{cluster_path(@cluster)}/topics/#{CGI.escape(topic.name)}"
18
+ end
19
+
20
+ def consumer_groups_path(group)
21
+ "#{cluster_path(@cluster)}/consumer_groups/#{CGI.escape(group.group_id)}"
22
+ end
23
+
24
+ def trim_name(name)
25
+ return name if name.length < 25
26
+ name[0..22] + '...'
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KafkaCommand
4
+ module ConsumerGroupHelper
5
+ def status_color(group)
6
+ if group.empty?
7
+ 'has-text-info'
8
+ elsif group.stable?
9
+ 'has-text-success'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KafkaCommand
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KafkaCommand
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: 'from@example.com'
6
+ layout 'mailer'
7
+ end
8
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module KafkaCommand
6
+ class Broker
7
+ extend Forwardable
8
+ attr_reader :broker
9
+ def_delegators :@broker, :port, :host, :node_id, :fetch_metadata, :fetch_offsets
10
+ alias_method :kafka_broker_id, :node_id
11
+ alias_method :hostname, :host
12
+
13
+
14
+ def initialize(broker)
15
+ @broker = broker
16
+ end
17
+
18
+ def host_with_port
19
+ "#{host}:#{port}"
20
+ end
21
+
22
+ def as_json(*)
23
+ {
24
+ id: node_id,
25
+ host: host_with_port
26
+ }
27
+ end
28
+
29
+ # needs to be the group coordinator to work
30
+ def offsets_for(group, topic)
31
+ offsets = @broker.fetch_offsets(
32
+ group_id: group.group_id,
33
+ topics: { topic.name => topic.partitions.map(&:partition_id) }
34
+ ).topics[topic.name]
35
+
36
+ offsets.keys.each { |partition_id| offsets[partition_id] = offsets[partition_id].offset }
37
+ offsets
38
+ end
39
+
40
+ def connected?
41
+ @broker.api_versions # simple request to check connections
42
+ true
43
+ rescue Kafka::ConnectionError
44
+ false
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module KafkaCommand
6
+ class Client
7
+ extend Forwardable
8
+
9
+ CLUSTER_METHOD_DELGATIONS = %i(
10
+ broker_pool
11
+ delete_topic
12
+ alter_topic
13
+ describe_topic
14
+ create_partitions_for
15
+ resolve_offset
16
+ resolve_offsets
17
+ describe_group
18
+ supports_api?
19
+ ).freeze
20
+
21
+ attr_reader :cluster, :client
22
+ def_delegators :@client, :create_topic
23
+ def_delegators :@cluster, *CLUSTER_METHOD_DELGATIONS
24
+
25
+ def initialize(brokers:, **kwargs)
26
+ @client = Kafka.new(brokers, **kwargs)
27
+ @cluster = @client.cluster
28
+ end
29
+
30
+ def refresh!
31
+ refresh_brokers!
32
+ refresh_topics!
33
+ refresh_groups!
34
+ end
35
+
36
+ def topics
37
+ @topics ||= initialize_topics
38
+ end
39
+
40
+ def brokers
41
+ @brokers ||= initialize_brokers
42
+ end
43
+
44
+ def groups
45
+ @groups ||= initialize_groups
46
+ end
47
+
48
+ def refresh_groups!
49
+ @groups = initialize_groups
50
+ end
51
+
52
+ def refresh_topics!
53
+ @topics = initialize_topics
54
+ end
55
+
56
+ def refresh_brokers!
57
+ @brokers = initialize_brokers
58
+ end
59
+
60
+ def fetch_metadata(topics: nil)
61
+ brokers.sample.fetch_metadata(topics: topics)
62
+ end
63
+
64
+ def get_group_coordinator(group_id:)
65
+ broker = @cluster.get_group_coordinator(group_id: group_id)
66
+ Broker.new(broker)
67
+ end
68
+
69
+ def find_topic(topic_name)
70
+ topics.find { |t| t.name == topic_name }
71
+ end
72
+
73
+ def connect_to_broker(host:, port:, broker_id:)
74
+ Broker.new(broker_pool.connect(host, port, node_id: broker_id))
75
+ end
76
+
77
+ private
78
+
79
+ def initialize_brokers
80
+ cluster_info = @cluster.refresh_metadata!
81
+
82
+ cluster_info.brokers.map do |broker|
83
+ connect_to_broker(
84
+ host: broker.host,
85
+ port: broker.port,
86
+ broker_id: broker.node_id
87
+ )
88
+ end
89
+ end
90
+
91
+ def initialize_topics
92
+ # returns information about each topic
93
+ # i.e isr, leader, partitions
94
+ fetch_metadata.topics.map { |tm| Topic.new(tm, self) }
95
+ end
96
+
97
+ def initialize_groups
98
+ group_ids = @cluster.list_groups
99
+ group_ids.map { |id| ConsumerGroup.new(id, self) }
100
+ end
101
+ end
102
+ end