pghero_fork 2.7.3

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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +391 -0
  3. data/CONTRIBUTING.md +42 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +3 -0
  6. data/app/assets/images/pghero/favicon.png +0 -0
  7. data/app/assets/javascripts/pghero/Chart.bundle.js +20755 -0
  8. data/app/assets/javascripts/pghero/application.js +158 -0
  9. data/app/assets/javascripts/pghero/chartkick.js +2436 -0
  10. data/app/assets/javascripts/pghero/highlight.pack.js +2 -0
  11. data/app/assets/javascripts/pghero/jquery.js +10872 -0
  12. data/app/assets/javascripts/pghero/nouislider.js +2672 -0
  13. data/app/assets/stylesheets/pghero/application.css +514 -0
  14. data/app/assets/stylesheets/pghero/arduino-light.css +86 -0
  15. data/app/assets/stylesheets/pghero/nouislider.css +310 -0
  16. data/app/controllers/pg_hero/home_controller.rb +449 -0
  17. data/app/helpers/pg_hero/home_helper.rb +30 -0
  18. data/app/views/layouts/pg_hero/application.html.erb +68 -0
  19. data/app/views/pg_hero/home/_connections_table.html.erb +16 -0
  20. data/app/views/pg_hero/home/_live_queries_table.html.erb +51 -0
  21. data/app/views/pg_hero/home/_queries_table.html.erb +72 -0
  22. data/app/views/pg_hero/home/_query_stats_slider.html.erb +16 -0
  23. data/app/views/pg_hero/home/_suggested_index.html.erb +18 -0
  24. data/app/views/pg_hero/home/connections.html.erb +32 -0
  25. data/app/views/pg_hero/home/explain.html.erb +27 -0
  26. data/app/views/pg_hero/home/index.html.erb +518 -0
  27. data/app/views/pg_hero/home/index_bloat.html.erb +72 -0
  28. data/app/views/pg_hero/home/live_queries.html.erb +11 -0
  29. data/app/views/pg_hero/home/maintenance.html.erb +55 -0
  30. data/app/views/pg_hero/home/queries.html.erb +33 -0
  31. data/app/views/pg_hero/home/relation_space.html.erb +14 -0
  32. data/app/views/pg_hero/home/show_query.html.erb +106 -0
  33. data/app/views/pg_hero/home/space.html.erb +83 -0
  34. data/app/views/pg_hero/home/system.html.erb +34 -0
  35. data/app/views/pg_hero/home/tune.html.erb +53 -0
  36. data/config/routes.rb +32 -0
  37. data/lib/generators/pghero/config_generator.rb +13 -0
  38. data/lib/generators/pghero/query_stats_generator.rb +18 -0
  39. data/lib/generators/pghero/space_stats_generator.rb +18 -0
  40. data/lib/generators/pghero/templates/config.yml.tt +46 -0
  41. data/lib/generators/pghero/templates/query_stats.rb.tt +15 -0
  42. data/lib/generators/pghero/templates/space_stats.rb.tt +13 -0
  43. data/lib/pghero.rb +246 -0
  44. data/lib/pghero/connection.rb +5 -0
  45. data/lib/pghero/database.rb +175 -0
  46. data/lib/pghero/engine.rb +16 -0
  47. data/lib/pghero/methods/basic.rb +160 -0
  48. data/lib/pghero/methods/connections.rb +77 -0
  49. data/lib/pghero/methods/constraints.rb +30 -0
  50. data/lib/pghero/methods/explain.rb +29 -0
  51. data/lib/pghero/methods/indexes.rb +332 -0
  52. data/lib/pghero/methods/kill.rb +28 -0
  53. data/lib/pghero/methods/maintenance.rb +93 -0
  54. data/lib/pghero/methods/queries.rb +75 -0
  55. data/lib/pghero/methods/query_stats.rb +349 -0
  56. data/lib/pghero/methods/replication.rb +74 -0
  57. data/lib/pghero/methods/sequences.rb +124 -0
  58. data/lib/pghero/methods/settings.rb +37 -0
  59. data/lib/pghero/methods/space.rb +141 -0
  60. data/lib/pghero/methods/suggested_indexes.rb +329 -0
  61. data/lib/pghero/methods/system.rb +287 -0
  62. data/lib/pghero/methods/tables.rb +68 -0
  63. data/lib/pghero/methods/users.rb +87 -0
  64. data/lib/pghero/query_stats.rb +5 -0
  65. data/lib/pghero/space_stats.rb +5 -0
  66. data/lib/pghero/stats.rb +6 -0
  67. data/lib/pghero/version.rb +3 -0
  68. data/lib/tasks/pghero.rake +27 -0
  69. data/licenses/LICENSE-chart.js.txt +9 -0
  70. data/licenses/LICENSE-chartkick.js.txt +22 -0
  71. data/licenses/LICENSE-highlight.js.txt +29 -0
  72. data/licenses/LICENSE-jquery.txt +20 -0
  73. data/licenses/LICENSE-moment.txt +22 -0
  74. data/licenses/LICENSE-nouislider.txt +21 -0
  75. metadata +130 -0
@@ -0,0 +1,287 @@
1
+ module PgHero
2
+ module Methods
3
+ module System
4
+ def system_stats_enabled?
5
+ !system_stats_provider.nil?
6
+ end
7
+
8
+ # TODO remove defined checks in 3.0
9
+ def system_stats_provider
10
+ if aws_db_instance_identifier && (defined?(Aws) || defined?(AWS))
11
+ :aws
12
+ elsif gcp_database_id
13
+ :gcp
14
+ elsif azure_resource_id
15
+ :azure
16
+ end
17
+ end
18
+
19
+ def cpu_usage(**options)
20
+ system_stats(:cpu, **options)
21
+ end
22
+
23
+ def connection_stats(**options)
24
+ system_stats(:connections, **options)
25
+ end
26
+
27
+ def replication_lag_stats(**options)
28
+ system_stats(:replication_lag, **options)
29
+ end
30
+
31
+ def read_iops_stats(**options)
32
+ system_stats(:read_iops, **options)
33
+ end
34
+
35
+ def write_iops_stats(**options)
36
+ system_stats(:write_iops, **options)
37
+ end
38
+
39
+ def free_space_stats(**options)
40
+ system_stats(:free_space, **options)
41
+ end
42
+
43
+ def rds_stats(metric_name, duration: nil, period: nil, offset: nil, series: false)
44
+ if system_stats_enabled?
45
+ aws_options = {region: region}
46
+ if access_key_id
47
+ aws_options[:access_key_id] = access_key_id
48
+ aws_options[:secret_access_key] = secret_access_key
49
+ end
50
+
51
+ client =
52
+ if defined?(Aws)
53
+ Aws::CloudWatch::Client.new(aws_options)
54
+ else
55
+ AWS::CloudWatch.new(aws_options).client
56
+ end
57
+
58
+ duration = (duration || 1.hour).to_i
59
+ period = (period || 1.minute).to_i
60
+ offset = (offset || 0).to_i
61
+ end_time = Time.at(((Time.now - offset).to_f / period).ceil * period)
62
+ start_time = end_time - duration
63
+
64
+ resp = client.get_metric_statistics(
65
+ namespace: "AWS/RDS",
66
+ metric_name: metric_name,
67
+ dimensions: [{name: "DBInstanceIdentifier", value: aws_db_instance_identifier}],
68
+ start_time: start_time.iso8601,
69
+ end_time: end_time.iso8601,
70
+ period: period,
71
+ statistics: ["Average"]
72
+ )
73
+ data = {}
74
+ resp[:datapoints].sort_by { |d| d[:timestamp] }.each do |d|
75
+ data[d[:timestamp]] = d[:average]
76
+ end
77
+
78
+ add_missing_data(data, start_time, end_time, period) if series
79
+
80
+ data
81
+ else
82
+ raise NotEnabled, "System stats not enabled"
83
+ end
84
+ end
85
+
86
+ def azure_stats(metric_name, duration: nil, period: nil, offset: nil, series: false)
87
+ # TODO DRY with RDS stats
88
+ duration = (duration || 1.hour).to_i
89
+ period = (period || 1.minute).to_i
90
+ offset = (offset || 0).to_i
91
+ end_time = Time.at(((Time.now - offset).to_f / period).ceil * period)
92
+ start_time = end_time - duration
93
+
94
+ interval =
95
+ case period
96
+ when 60
97
+ "PT1M"
98
+ when 300
99
+ "PT5M"
100
+ when 900
101
+ "PT15M"
102
+ when 1800
103
+ "PT30M"
104
+ when 3600
105
+ "PT1H"
106
+ else
107
+ raise Error, "Unsupported period"
108
+ end
109
+
110
+ client = Azure::Monitor::Profiles::Latest::Mgmt::Client.new
111
+ timespan = "#{start_time.iso8601}/#{end_time.iso8601}"
112
+ results = client.metrics.list(
113
+ azure_resource_id,
114
+ metricnames: metric_name,
115
+ aggregation: "Average",
116
+ timespan: timespan,
117
+ interval: interval
118
+ )
119
+
120
+ data = {}
121
+ result = results.value.first
122
+ if result
123
+ result.timeseries.first.data.each do |point|
124
+ data[point.time_stamp.to_time] = point.average
125
+ end
126
+ end
127
+
128
+ add_missing_data(data, start_time, end_time, period) if series
129
+
130
+ data
131
+ end
132
+
133
+ private
134
+
135
+ def gcp_stats(metric_name, duration: nil, period: nil, offset: nil, series: false)
136
+ require "google/cloud/monitoring/v3"
137
+
138
+ # TODO DRY with RDS stats
139
+ duration = (duration || 1.hour).to_i
140
+ period = (period || 1.minute).to_i
141
+ offset = (offset || 0).to_i
142
+ end_time = Time.at(((Time.now - offset).to_f / period).ceil * period)
143
+ start_time = end_time - duration
144
+
145
+ # validate input since we need to interpolate below
146
+ raise Error, "Invalid metric name" unless metric_name =~ /\A[a-z\/_]+\z/i
147
+ raise Error, "Invalid database id" unless gcp_database_id =~ /\A[a-z0-9\-:]+\z/i
148
+
149
+ # we handle three situations:
150
+ # 1. google-cloud-monitoring-v3
151
+ # 2. google-cloud-monitoring >= 1
152
+ # 3. google-cloud-monitoring < 1
153
+
154
+ # for situations 1 and 2
155
+ # Google::Cloud::Monitoring.metric_service is documented
156
+ # but doesn't work for situation 1
157
+ if defined?(Google::Cloud::Monitoring::V3::MetricService::Client)
158
+ client = Google::Cloud::Monitoring::V3::MetricService::Client.new
159
+
160
+ interval = Google::Cloud::Monitoring::V3::TimeInterval.new
161
+ interval.end_time = Google::Protobuf::Timestamp.new(seconds: end_time.to_i)
162
+ # subtract period to make sure we get first data point
163
+ interval.start_time = Google::Protobuf::Timestamp.new(seconds: (start_time - period).to_i)
164
+
165
+ aggregation = Google::Cloud::Monitoring::V3::Aggregation.new
166
+ # may be better to use ALIGN_NEXT_OLDER for space stats to show most recent data point
167
+ # stick with average for now to match AWS
168
+ aggregation.per_series_aligner = Google::Cloud::Monitoring::V3::Aggregation::Aligner::ALIGN_MEAN
169
+ aggregation.alignment_period = period
170
+
171
+ results = client.list_time_series({
172
+ name: "projects/#{gcp_database_id.split(":").first}",
173
+ filter: "metric.type = \"cloudsql.googleapis.com/database/#{metric_name}\" AND resource.label.database_id = \"#{gcp_database_id}\"",
174
+ interval: interval,
175
+ view: Google::Cloud::Monitoring::V3::ListTimeSeriesRequest::TimeSeriesView::FULL,
176
+ aggregation: aggregation
177
+ })
178
+ else
179
+ require "google/cloud/monitoring"
180
+
181
+ client = Google::Cloud::Monitoring::Metric.new
182
+
183
+ interval = Google::Monitoring::V3::TimeInterval.new
184
+ interval.end_time = Google::Protobuf::Timestamp.new(seconds: end_time.to_i)
185
+ # subtract period to make sure we get first data point
186
+ interval.start_time = Google::Protobuf::Timestamp.new(seconds: (start_time - period).to_i)
187
+
188
+ aggregation = Google::Monitoring::V3::Aggregation.new
189
+ # may be better to use ALIGN_NEXT_OLDER for space stats to show most recent data point
190
+ # stick with average for now to match AWS
191
+ aggregation.per_series_aligner = Google::Monitoring::V3::Aggregation::Aligner::ALIGN_MEAN
192
+ aggregation.alignment_period = period
193
+
194
+ results = client.list_time_series(
195
+ "projects/#{gcp_database_id.split(":").first}",
196
+ "metric.type = \"cloudsql.googleapis.com/database/#{metric_name}\" AND resource.label.database_id = \"#{gcp_database_id}\"",
197
+ interval,
198
+ Google::Monitoring::V3::ListTimeSeriesRequest::TimeSeriesView::FULL,
199
+ aggregation: aggregation
200
+ )
201
+ end
202
+
203
+ data = {}
204
+ result = results.first
205
+ if result
206
+ result.points.each do |point|
207
+ time = Time.at(point.interval.start_time.seconds)
208
+ value = point.value.double_value
209
+ value *= 100 if metric_name == "cpu/utilization"
210
+ data[time] = value
211
+ end
212
+ end
213
+
214
+ add_missing_data(data, start_time, end_time, period) if series
215
+
216
+ data
217
+ end
218
+
219
+ def system_stats(metric_key, **options)
220
+ case system_stats_provider
221
+ when :aws
222
+ metrics = {
223
+ cpu: "CPUUtilization",
224
+ connections: "DatabaseConnections",
225
+ replication_lag: "ReplicaLag",
226
+ read_iops: "ReadIOPS",
227
+ write_iops: "WriteIOPS",
228
+ free_space: "FreeStorageSpace"
229
+ }
230
+ rds_stats(metrics[metric_key], **options)
231
+ when :gcp
232
+ if metric_key == :free_space
233
+ quota = gcp_stats("disk/quota", **options)
234
+ used = gcp_stats("disk/bytes_used", **options)
235
+ free_space(quota, used)
236
+ else
237
+ metrics = {
238
+ cpu: "cpu/utilization",
239
+ connections: "postgresql/num_backends",
240
+ replication_lag: "replication/replica_lag",
241
+ read_iops: "disk/read_ops_count",
242
+ write_iops: "disk/write_ops_count"
243
+ }
244
+ gcp_stats(metrics[metric_key], **options)
245
+ end
246
+ when :azure
247
+ if metric_key == :free_space
248
+ quota = azure_stats("storage_limit", **options)
249
+ used = azure_stats("storage_used", **options)
250
+ free_space(quota, used)
251
+ else
252
+ # no read_iops, write_iops
253
+ # could add io_consumption_percent
254
+ metrics = {
255
+ cpu: "cpu_percent",
256
+ connections: "active_connections",
257
+ replication_lag: "pg_replica_log_delay_in_seconds"
258
+ }
259
+ raise Error, "Metric not supported" unless metrics[metric_key]
260
+ azure_stats(metrics[metric_key], **options)
261
+ end
262
+ else
263
+ raise NotEnabled, "System stats not enabled"
264
+ end
265
+ end
266
+
267
+ # only use data points included in both series
268
+ # this also eliminates need to align Time.now
269
+ def free_space(quota, used)
270
+ data = {}
271
+ quota.each do |k, v|
272
+ data[k] = v - used[k] if v && used[k]
273
+ end
274
+ data
275
+ end
276
+
277
+ def add_missing_data(data, start_time, end_time, period)
278
+ time = start_time
279
+ end_time = end_time
280
+ while time < end_time
281
+ data[time] ||= nil
282
+ time += period
283
+ end
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,68 @@
1
+ module PgHero
2
+ module Methods
3
+ module Tables
4
+ def table_hit_rate
5
+ select_one(<<-SQL
6
+ SELECT
7
+ sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS rate
8
+ FROM
9
+ pg_statio_user_tables
10
+ SQL
11
+ )
12
+ end
13
+
14
+ def table_caching
15
+ select_all <<-SQL
16
+ SELECT
17
+ schemaname AS schema,
18
+ relname AS table,
19
+ CASE WHEN heap_blks_hit + heap_blks_read = 0 THEN
20
+ 0
21
+ ELSE
22
+ ROUND(1.0 * heap_blks_hit / (heap_blks_hit + heap_blks_read), 2)
23
+ END AS hit_rate
24
+ FROM
25
+ pg_statio_user_tables
26
+ ORDER BY
27
+ 2 DESC, 1
28
+ SQL
29
+ end
30
+
31
+ def unused_tables
32
+ select_all <<-SQL
33
+ SELECT
34
+ schemaname AS schema,
35
+ relname AS table,
36
+ n_live_tup AS estimated_rows
37
+ FROM
38
+ pg_stat_user_tables
39
+ WHERE
40
+ idx_scan = 0
41
+ ORDER BY
42
+ n_live_tup DESC,
43
+ relname ASC
44
+ SQL
45
+ end
46
+
47
+ def table_stats(schema: nil, table: nil)
48
+ select_all <<-SQL
49
+ SELECT
50
+ nspname AS schema,
51
+ relname AS table,
52
+ reltuples::bigint AS estimated_rows,
53
+ pg_total_relation_size(pg_class.oid) AS size_bytes
54
+ FROM
55
+ pg_class
56
+ INNER JOIN
57
+ pg_namespace ON pg_namespace.oid = pg_class.relnamespace
58
+ WHERE
59
+ relkind = 'r'
60
+ #{schema ? "AND nspname = #{quote(schema)}" : nil}
61
+ #{table ? "AND relname IN (#{Array(table).map { |t| quote(t) }.join(", ")})" : nil}
62
+ ORDER BY
63
+ 1, 2
64
+ SQL
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,87 @@
1
+ module PgHero
2
+ module Methods
3
+ module Users
4
+ # documented as unsafe to pass user input
5
+ # TODO quote in 3.0, but still not officially supported
6
+ def create_user(user, password: nil, schema: "public", database: nil, readonly: false, tables: nil)
7
+ password ||= random_password
8
+ database ||= PgHero.connection_config(connection_model)[:database]
9
+
10
+ commands =
11
+ [
12
+ "CREATE ROLE #{user} LOGIN PASSWORD #{quote(password)}",
13
+ "GRANT CONNECT ON DATABASE #{database} TO #{user}",
14
+ "GRANT USAGE ON SCHEMA #{schema} TO #{user}"
15
+ ]
16
+ if readonly
17
+ if tables
18
+ commands.concat table_grant_commands("SELECT", tables, user)
19
+ else
20
+ commands << "GRANT SELECT ON ALL TABLES IN SCHEMA #{schema} TO #{user}"
21
+ commands << "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} GRANT SELECT ON TABLES TO #{user}"
22
+ end
23
+ else
24
+ if tables
25
+ commands.concat table_grant_commands("ALL PRIVILEGES", tables, user)
26
+ else
27
+ commands << "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA #{schema} TO #{user}"
28
+ commands << "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA #{schema} TO #{user}"
29
+ commands << "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} GRANT ALL PRIVILEGES ON TABLES TO #{user}"
30
+ commands << "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} GRANT ALL PRIVILEGES ON SEQUENCES TO #{user}"
31
+ end
32
+ end
33
+
34
+ # run commands
35
+ connection_model.transaction do
36
+ commands.each do |command|
37
+ execute command
38
+ end
39
+ end
40
+
41
+ {password: password}
42
+ end
43
+
44
+ # documented as unsafe to pass user input
45
+ # TODO quote in 3.0, but still not officially supported
46
+ def drop_user(user, schema: "public", database: nil)
47
+ database ||= PgHero.connection_config(connection_model)[:database]
48
+
49
+ # thanks shiftb
50
+ commands =
51
+ [
52
+ "REVOKE CONNECT ON DATABASE #{database} FROM #{user}",
53
+ "REVOKE USAGE ON SCHEMA #{schema} FROM #{user}",
54
+ "REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA #{schema} FROM #{user}",
55
+ "REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA #{schema} FROM #{user}",
56
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} REVOKE SELECT ON TABLES FROM #{user}",
57
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} REVOKE SELECT ON SEQUENCES FROM #{user}",
58
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} REVOKE ALL ON SEQUENCES FROM #{user}",
59
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA #{schema} REVOKE ALL ON TABLES FROM #{user}",
60
+ "DROP ROLE #{user}"
61
+ ]
62
+
63
+ # run commands
64
+ connection_model.transaction do
65
+ commands.each do |command|
66
+ execute command
67
+ end
68
+ end
69
+
70
+ true
71
+ end
72
+
73
+ private
74
+
75
+ def random_password
76
+ require "securerandom"
77
+ SecureRandom.base64(40).delete("+/=")[0...24]
78
+ end
79
+
80
+ def table_grant_commands(privilege, tables, user)
81
+ tables.map do |table|
82
+ "GRANT #{privilege} ON TABLE #{table} TO #{user}"
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,5 @@
1
+ module PgHero
2
+ class QueryStats < Stats
3
+ self.table_name = "pghero_query_stats"
4
+ end
5
+ end