pghero_fork 2.7.3

Sign up to get free protection for your applications and to get access to all the features.
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