pg_peek 0.1.0

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 (120) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +217 -0
  4. data/Rakefile +16 -0
  5. data/app/assets/javascripts/pg_peek/application.js +147 -0
  6. data/app/assets/stylesheets/pg_peek/application.css +362 -0
  7. data/app/controllers/pg_peek/activity_controller.rb +16 -0
  8. data/app/controllers/pg_peek/application_controller.rb +36 -0
  9. data/app/controllers/pg_peek/databases_controller.rb +96 -0
  10. data/app/controllers/pg_peek/endpoints_controller.rb +10 -0
  11. data/app/controllers/pg_peek/indexes_controller.rb +7 -0
  12. data/app/controllers/pg_peek/jobs_controller.rb +44 -0
  13. data/app/controllers/pg_peek/pg_stat_statements_controller.rb +11 -0
  14. data/app/controllers/pg_peek/queries_controller.rb +13 -0
  15. data/app/controllers/pg_peek/tables_controller.rb +6 -0
  16. data/app/helpers/pg_peek/application_helper.rb +179 -0
  17. data/app/helpers/pg_peek/report_helper.rb +58 -0
  18. data/app/jobs/pg_peek/application_job.rb +4 -0
  19. data/app/mailers/pg_peek/application_mailer.rb +6 -0
  20. data/app/models/pg_peek/application_record.rb +3 -0
  21. data/app/models/pg_peek/database.rb +131 -0
  22. data/app/models/pg_peek/pg_stat_statements.rb +132 -0
  23. data/app/models/pg_peek/sql_comment.rb +25 -0
  24. data/app/models/pg_peek/table.rb +266 -0
  25. data/app/queries/pg_peek/pg14/activity/sessions.sql +26 -0
  26. data/app/queries/pg_peek/pg14/activity/summary.sql +8 -0
  27. data/app/queries/pg_peek/pg14/activity/waiting_locks.sql +11 -0
  28. data/app/queries/pg_peek/pg14/database/vitals.sql +11 -0
  29. data/app/queries/pg_peek/pg14/indexes/all.sql +17 -0
  30. data/app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql +43 -0
  31. data/app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql +12 -0
  32. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +13 -0
  33. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +14 -0
  34. data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +11 -0
  35. data/app/queries/pg_peek/pg14/tables/inline_stats.sql +9 -0
  36. data/app/queries/pg_peek/pg14/tables/stats.sql +32 -0
  37. data/app/queries/pg_peek/pg15/activity/sessions.sql +26 -0
  38. data/app/queries/pg_peek/pg15/activity/summary.sql +8 -0
  39. data/app/queries/pg_peek/pg15/activity/waiting_locks.sql +11 -0
  40. data/app/queries/pg_peek/pg15/database/vitals.sql +11 -0
  41. data/app/queries/pg_peek/pg15/indexes/all.sql +17 -0
  42. data/app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql +43 -0
  43. data/app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql +12 -0
  44. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +13 -0
  45. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +14 -0
  46. data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +11 -0
  47. data/app/queries/pg_peek/pg15/tables/inline_stats.sql +9 -0
  48. data/app/queries/pg_peek/pg15/tables/stats.sql +32 -0
  49. data/app/queries/pg_peek/pg16/activity/sessions.sql +26 -0
  50. data/app/queries/pg_peek/pg16/activity/summary.sql +8 -0
  51. data/app/queries/pg_peek/pg16/activity/waiting_locks.sql +11 -0
  52. data/app/queries/pg_peek/pg16/database/vitals.sql +11 -0
  53. data/app/queries/pg_peek/pg16/indexes/all.sql +17 -0
  54. data/app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql +43 -0
  55. data/app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql +12 -0
  56. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +13 -0
  57. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +14 -0
  58. data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +11 -0
  59. data/app/queries/pg_peek/pg16/tables/inline_stats.sql +9 -0
  60. data/app/queries/pg_peek/pg16/tables/stats.sql +32 -0
  61. data/app/queries/pg_peek/pg17/activity/sessions.sql +26 -0
  62. data/app/queries/pg_peek/pg17/activity/summary.sql +8 -0
  63. data/app/queries/pg_peek/pg17/activity/waiting_locks.sql +11 -0
  64. data/app/queries/pg_peek/pg17/database/vitals.sql +11 -0
  65. data/app/queries/pg_peek/pg17/indexes/all.sql +17 -0
  66. data/app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql +43 -0
  67. data/app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql +12 -0
  68. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +13 -0
  69. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +14 -0
  70. data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +11 -0
  71. data/app/queries/pg_peek/pg17/tables/inline_stats.sql +9 -0
  72. data/app/queries/pg_peek/pg17/tables/stats.sql +32 -0
  73. data/app/queries/pg_peek/pg18/activity/sessions.sql +26 -0
  74. data/app/queries/pg_peek/pg18/activity/summary.sql +8 -0
  75. data/app/queries/pg_peek/pg18/activity/waiting_locks.sql +11 -0
  76. data/app/queries/pg_peek/pg18/database/vitals.sql +11 -0
  77. data/app/queries/pg_peek/pg18/indexes/all.sql +17 -0
  78. data/app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql +43 -0
  79. data/app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql +12 -0
  80. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +13 -0
  81. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +14 -0
  82. data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +11 -0
  83. data/app/queries/pg_peek/pg18/tables/inline_stats.sql +9 -0
  84. data/app/queries/pg_peek/pg18/tables/stats.sql +32 -0
  85. data/app/reports/pg_peek/column.rb +27 -0
  86. data/app/reports/pg_peek/report.rb +63 -0
  87. data/app/reports/pg_peek/reports/blocked.rb +60 -0
  88. data/app/reports/pg_peek/reports/databases.rb +47 -0
  89. data/app/reports/pg_peek/reports/endpoints.rb +35 -0
  90. data/app/reports/pg_peek/reports/indexes.rb +44 -0
  91. data/app/reports/pg_peek/reports/job_queries.rb +14 -0
  92. data/app/reports/pg_peek/reports/jobs.rb +21 -0
  93. data/app/reports/pg_peek/reports/queries.rb +23 -0
  94. data/app/reports/pg_peek/reports/sessions.rb +88 -0
  95. data/app/reports/pg_peek/reports/tables.rb +25 -0
  96. data/app/views/layouts/pg_peek/application.html.erb +56 -0
  97. data/app/views/pg_peek/activity/show.html.erb +70 -0
  98. data/app/views/pg_peek/authentication_required.html.erb +31 -0
  99. data/app/views/pg_peek/databases/index.html.erb +43 -0
  100. data/app/views/pg_peek/databases/show.html.erb +169 -0
  101. data/app/views/pg_peek/endpoints/index.html.erb +24 -0
  102. data/app/views/pg_peek/indexes/index.html.erb +31 -0
  103. data/app/views/pg_peek/jobs/index.html.erb +51 -0
  104. data/app/views/pg_peek/jobs/show.html.erb +17 -0
  105. data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +17 -0
  106. data/app/views/pg_peek/pg_stat_statements/not_preloaded.html.erb +5 -0
  107. data/app/views/pg_peek/queries/index.html.erb +10 -0
  108. data/app/views/pg_peek/reports/_report.html.erb +32 -0
  109. data/app/views/pg_peek/tables/show.html.erb +128 -0
  110. data/config/routes.rb +16 -0
  111. data/lib/generators/pg_peek/install/install_generator.rb +40 -0
  112. data/lib/generators/pg_peek/install/templates/README +31 -0
  113. data/lib/generators/pg_peek/install/templates/initializer.rb +38 -0
  114. data/lib/pg_peek/configuration.rb +59 -0
  115. data/lib/pg_peek/engine.rb +7 -0
  116. data/lib/pg_peek/query_loader.rb +59 -0
  117. data/lib/pg_peek/version.rb +3 -0
  118. data/lib/pg_peek.rb +10 -0
  119. data/lib/tasks/pg_peek_tasks.rake +4 -0
  120. metadata +192 -0
@@ -0,0 +1,11 @@
1
+ -- Tables whose dead-tuple share is at or above the warning threshold.
2
+ SELECT
3
+ relname,
4
+ n_live_tup,
5
+ n_dead_tup,
6
+ round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
7
+ last_autovacuum
8
+ FROM pg_stat_user_tables
9
+ WHERE n_live_tup + n_dead_tup > 0
10
+ AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
11
+ ORDER BY dead_ratio DESC
@@ -0,0 +1,9 @@
1
+ SELECT
2
+ t.relname AS table_name,
3
+ s.heap_blks_read,
4
+ s.heap_blks_hit,
5
+ s.idx_blks_read,
6
+ s.idx_blks_hit
7
+ FROM pg_stat_user_tables t
8
+ LEFT JOIN pg_statio_user_tables s ON t.relid = s.relid
9
+ WHERE t.relname IN ({{placeholders:count}})
@@ -0,0 +1,32 @@
1
+ SELECT
2
+ t.relname AS table_name,
3
+ t.seq_scan,
4
+ t.seq_tup_read,
5
+ t.idx_scan,
6
+ t.idx_tup_fetch,
7
+ t.n_tup_ins,
8
+ t.n_tup_upd,
9
+ t.n_tup_del,
10
+ t.n_live_tup,
11
+ t.n_dead_tup,
12
+ t.last_vacuum,
13
+ t.last_autovacuum,
14
+ t.last_analyze,
15
+ t.last_autoanalyze,
16
+ t.vacuum_count,
17
+ t.autovacuum_count,
18
+ t.analyze_count,
19
+ t.autoanalyze_count,
20
+ s.heap_blks_read,
21
+ s.heap_blks_hit,
22
+ s.idx_blks_read,
23
+ s.idx_blks_hit,
24
+ s.toast_blks_read,
25
+ s.toast_blks_hit,
26
+ s.tidx_blks_read,
27
+ s.tidx_blks_hit,
28
+ c.reltuples AS row_estimate
29
+ FROM pg_stat_user_tables t
30
+ LEFT JOIN pg_statio_user_tables s ON t.relid = s.relid
31
+ LEFT JOIN pg_class c ON t.relid = c.oid
32
+ WHERE t.relname = $1
@@ -0,0 +1,27 @@
1
+ # One column of a report: what to read from a row, how to label it, and how the
2
+ # value should be drawn. Renderers decide what those choices mean -- the HTML
3
+ # table right-aligns, a terminal renderer pads -- so a column stays free of
4
+ # either.
5
+ class PgPeek::Column
6
+ ALIGNMENTS = %i[left right].freeze
7
+
8
+ attr_reader :key, :align, :format, :title
9
+
10
+ def initialize(key, header: nil, align: :left, format: :text, title: nil)
11
+ raise ArgumentError, "unknown alignment #{align.inspect}" unless ALIGNMENTS.include?(align)
12
+
13
+ @key = key
14
+ @header = header
15
+ @align = align
16
+ @format = format
17
+ @title = title
18
+ end
19
+
20
+ def header
21
+ @header || key.to_s.tr("_", " ")
22
+ end
23
+
24
+ def numeric?
25
+ align == :right
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ # A report is columns plus rows, and nothing about how either is drawn.
2
+ #
3
+ # Every angle pg_peek offers -- queries, endpoints, jobs, tables -- is one of
4
+ # these, so a new angle is a subclass and a .sql file rather than a controller,
5
+ # a view and its own styling. It also means a terminal renderer can draw the
6
+ # same report as the HTML one, from the same declaration.
7
+ class PgPeek::Report
8
+ class Row
9
+ def initialize(attributes) = @attributes = attributes
10
+
11
+ def value(column) = @attributes[column.key.to_s]
12
+ def [](key) = @attributes[key.to_s]
13
+ def to_h = @attributes
14
+ end
15
+
16
+ class_attribute :columns, instance_writer: false, default: []
17
+
18
+ attr_reader :database
19
+
20
+ class << self
21
+ def title(value = nil)
22
+ @title = value if value
23
+ @title || name.demodulize.titleize
24
+ end
25
+
26
+ def column(key, **options)
27
+ # Subclasses get their own array rather than appending to the parent's.
28
+ self.columns = columns + [ PgPeek::Column.new(key, **options) ]
29
+ end
30
+ end
31
+
32
+ # Most reports describe one database. A few -- the database list itself --
33
+ # describe the set of them, and pass nothing.
34
+ def initialize(database: nil)
35
+ @database = database
36
+ end
37
+
38
+ def rows
39
+ @rows ||= fetch_rows.map { |attributes| Row.new(attributes) }
40
+ end
41
+
42
+ def empty? = rows.empty?
43
+
44
+ def title = self.class.title
45
+
46
+ # Relative formats -- an intensity bar, a share of total -- need the extent of
47
+ # the column, not just the cell.
48
+ def max_for(column)
49
+ @maxima ||= {}
50
+ @maxima[column.key] ||= rows.filter_map { |row| row.value(column)&.to_f }.max
51
+ end
52
+
53
+ private
54
+ # Subclasses return an array of hashes keyed by column name, which is what
55
+ # PG::Result already yields.
56
+ def fetch_rows
57
+ raise NotImplementedError, "#{self.class} must define #fetch_rows"
58
+ end
59
+
60
+ def pg_stat_statements
61
+ @pg_stat_statements ||= PgPeek::PgStatStatements.new(database: database)
62
+ end
63
+ end
@@ -0,0 +1,60 @@
1
+ # Sessions waiting for a lock, and who holds it. Built from the sessions
2
+ # report rather than a second look at pg_stat_activity, so both tables on the
3
+ # page describe the same instant.
4
+ class PgPeek::Reports::Blocked < PgPeek::Report
5
+ title "Blocked sessions"
6
+
7
+ column :pid, align: :right, title: "The waiting session"
8
+ column :waiting_for
9
+ column :duration_ms, header: "for", align: :right, format: :duration_ms, title: "How long the waiting query has run"
10
+ column :source, title: "Endpoint or job of the waiting session"
11
+ column :blocked_by, align: :right, title: "The session holding the lock"
12
+ column :blocker, title: "State and source of the session holding the lock"
13
+ column :blocker_query, header: "blocker's query", format: :sql
14
+
15
+ def initialize(database:, sessions: nil)
16
+ super(database: database)
17
+ @sessions = sessions || PgPeek::Reports::Sessions.new(database: database)
18
+ end
19
+
20
+ def any? = rows.any?
21
+
22
+ private
23
+ attr_reader :sessions
24
+
25
+ def fetch_rows
26
+ blocked = sessions.blocked
27
+ return [] if blocked.empty?
28
+
29
+ by_pid = sessions.rows.index_by { |row| row["pid"].to_i }
30
+ locks = database.waiting_locks.index_by { |row| row["pid"].to_i }
31
+
32
+ blocked.map do |row|
33
+ blockers = row["blocked_by"].map { |pid| by_pid[pid] }.compact
34
+
35
+ {
36
+ "pid" => row["pid"],
37
+ "waiting_for" => describe(locks[row["pid"].to_i]),
38
+ "duration_ms" => row["query_ms"],
39
+ "source" => row["source"],
40
+ "blocked_by" => row["blocked_by"].join(", "),
41
+ "blocker" => blockers.map { |b| [ b["state"], b["source"] ].compact_blank.join(" · ") }.join("; ").presence || "a session on another database",
42
+ "blocker_query" => blockers.map { |b| b["query"] }.compact_blank.join("\n")
43
+ }
44
+ end
45
+ end
46
+
47
+ # "access exclusive lock on posts", "advisory lock (exclusive)",
48
+ # "transactionid lock (share)" -- the last is what a row-level conflict
49
+ # looks like from pg_locks.
50
+ def describe(lock)
51
+ return "a lock" unless lock
52
+
53
+ mode = lock["mode"].to_s.delete_suffix("Lock").underscore.tr("_", " ")
54
+ if lock["relation"].present?
55
+ "#{mode} lock on #{lock["relation"]}"
56
+ else
57
+ "#{lock["locktype"]} lock (#{mode})"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,47 @@
1
+ # The databases pg_peek can open, and whether each is actually collecting
2
+ # statistics. Whether a database is worth visiting is the question this page
3
+ # answers, so the extension's state belongs in the list rather than behind a
4
+ # click.
5
+ class PgPeek::Reports::Databases < PgPeek::Report
6
+ title "Databases"
7
+
8
+ column :name, header: "database"
9
+ column :role
10
+ column :version, align: :right
11
+ column :statistics, title: "State of the pg_stat_statements extension"
12
+ column :tables, align: :right, format: :number
13
+
14
+ def initialize(databases:)
15
+ super(database: nil)
16
+ @databases = databases
17
+ end
18
+
19
+ private
20
+ def fetch_rows
21
+ @databases.map do |database|
22
+ pg_stat = PgPeek::PgStatStatements.new(database: database)
23
+
24
+ { "name" => database.name,
25
+ "role" => role_for(database),
26
+ "version" => database.version,
27
+ "statistics" => statistics_for(pg_stat),
28
+ "tables" => database.tables.size,
29
+ "database" => database }
30
+ end
31
+ end
32
+
33
+ def role_for(database)
34
+ return "primary" if database.primary
35
+ return "replica" if database.replica
36
+
37
+ ""
38
+ end
39
+
40
+ def statistics_for(pg_stat)
41
+ return "not available" unless pg_stat.available?
42
+ return "not installed" unless pg_stat.installed?
43
+ return "not preloaded" unless pg_stat.preloaded?
44
+
45
+ "collecting · #{pg_stat.installed_version}"
46
+ end
47
+ end
@@ -0,0 +1,35 @@
1
+ # Database cost per controller#action, attributed through SQLcommenter tags.
2
+ class PgPeek::Reports::Endpoints < PgPeek::Report
3
+ title "Endpoints"
4
+
5
+ # Above this, a query is running many times per request rather than a few:
6
+ # the shape of an N+1 rather than a page that legitimately asks twice.
7
+ N_PLUS_ONE_THRESHOLD = 5
8
+
9
+ column :endpoint
10
+ column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms
11
+ column :share, align: :right, format: :intensity, title: "Share of database time across endpoints"
12
+ column :request_count, header: "requests", align: :right, format: :number,
13
+ title: "Estimated from the least-called query of this endpoint"
14
+ column :total_calls, header: "queries", align: :right, format: :number
15
+ column :query_count, header: "shapes", align: :right, format: :number
16
+ column :total_rows, header: "rows", align: :right, format: :number
17
+ column :max_queries_per_request, header: "q/req", align: :right, format: :ratio,
18
+ title: "Worst queries-per-request in this endpoint. An estimate, not a measurement."
19
+
20
+ def self.n_plus_one?(row)
21
+ row["max_queries_per_request"].to_f >= N_PLUS_ONE_THRESHOLD
22
+ end
23
+
24
+ private
25
+ def fetch_rows
26
+ rows = pg_stat_statements.endpoints
27
+ return [] if rows.blank?
28
+
29
+ rows.each do |row|
30
+ # namespaced_controller arrives url-encoded: staff%2Fprocesses#show.
31
+ row["endpoint"] = CGI.unescape(row["endpoint"].to_s)
32
+ row["share"] = row["total_exec_time_ms"]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,44 @@
1
+ # Every index in the database, unused ones first. "Unused" means never scanned
2
+ # since the statistics were last reset and not enforcing a constraint --
3
+ # primary keys and unique indexes do their work on writes, whether or not a
4
+ # query ever reads them.
5
+ class PgPeek::Reports::Indexes < PgPeek::Report
6
+ title "Indexes"
7
+
8
+ column :table_name, header: "table"
9
+ column :index_name, header: "index"
10
+ column :kind
11
+ column :size, align: :right
12
+ column :scans, align: :right, format: :number, title: "Scans since statistics were last reset"
13
+ column :note
14
+
15
+ def unused
16
+ rows.select { |row| row["unused"] }
17
+ end
18
+
19
+ def unused_bytes
20
+ unused.sum { |row| row["size_bytes"].to_i }
21
+ end
22
+
23
+ private
24
+ def fetch_rows
25
+ rows = database.indexes.reject { |row| PgPeek::Table.excluded?(row["table_name"]) }
26
+
27
+ rows.each do |row|
28
+ primary = truthy?(row["primary_key"])
29
+ unique = truthy?(row["unique_index"])
30
+
31
+ row["kind"] = primary ? "primary key" : (unique ? "unique" : "")
32
+ row["unused"] = row["scans"].to_i.zero? && !primary && !unique
33
+ row["note"] = row["unused"] ? "unused" : ""
34
+ end
35
+
36
+ rows.sort_by { |row| [ row["unused"] ? 0 : 1, -row["size_bytes"].to_i, row["table_name"], row["index_name"] ] }
37
+ end
38
+
39
+ # Raw results hand booleans over as "t"/"f" or true/false depending on
40
+ # the adapter's type map.
41
+ def truthy?(value)
42
+ ActiveModel::Type::Boolean.new.cast(value) == true
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ # The query shapes one job class issues, most expensive first.
2
+ class PgPeek::Reports::JobQueries < PgPeek::Reports::Queries
3
+ attr_reader :job_class
4
+
5
+ def initialize(database:, job_class:)
6
+ super(database: database)
7
+ @job_class = job_class
8
+ end
9
+
10
+ private
11
+ def source_rows
12
+ pg_stat_statements.outliers_by_job(job_class)
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # Database cost per ActiveJob class, attributed through the `job` SQLcommenter
2
+ # tag. Queue depth, retries and failures belong to the job backend; this is the
3
+ # database's view of the same jobs.
4
+ class PgPeek::Reports::Jobs < PgPeek::Report
5
+ title "Jobs"
6
+
7
+ column :job_class, header: "job"
8
+ column :total_exec_time_ms, header: "db time", align: :right, format: :duration_ms
9
+ column :share, align: :right, format: :intensity, title: "Share of database time across jobs"
10
+ column :total_calls, header: "queries", align: :right, format: :number
11
+ column :query_count, header: "shapes", align: :right, format: :number,
12
+ title: "Distinct query shapes issued by this job"
13
+
14
+ private
15
+ def fetch_rows
16
+ rows = pg_stat_statements.jobs
17
+ return [] if rows.blank?
18
+
19
+ rows.each { |row| row["share"] = row["total_exec_time_ms"] }
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # The most expensive query shapes in the database.
2
+ class PgPeek::Reports::Queries < PgPeek::Report
3
+ title "Queries"
4
+
5
+ column :calls, align: :right, format: :intensity, title: "Call count relative to the other queries listed"
6
+ column :total_exec_time, header: "db time", align: :right, format: :duration
7
+ column :prop_exec_time, header: "share", align: :right
8
+ column :avg_exec_ms, header: "avg", align: :right, format: :duration_ms
9
+ column :query, format: :sql
10
+
11
+ private
12
+ def fetch_rows
13
+ rows = source_rows
14
+ return [] if rows.blank?
15
+
16
+ # ncalls arrives already formatted with separators; the bar needs a number.
17
+ rows.each { |row| row["calls"] = row["ncalls"].to_s.delete(",").to_i }
18
+ end
19
+
20
+ def source_rows
21
+ pg_stat_statements.outliers
22
+ end
23
+ end
@@ -0,0 +1,88 @@
1
+ # Every other client session on the database right now, the busiest first:
2
+ # blocked sessions, then running queries by age, then open transactions by
3
+ # age, then idle connections. Unlike every other report this is a snapshot,
4
+ # not a sum since the last reset.
5
+ class PgPeek::Reports::Sessions < PgPeek::Report
6
+ title "Sessions"
7
+
8
+ HIDDEN_QUERY = "<insufficient privilege>".freeze
9
+
10
+ column :pid, align: :right
11
+ column :state
12
+ column :duration_ms, header: "for", align: :right, format: :duration_ms,
13
+ title: "Active: how long the current query has run. Idle in transaction: age of the open transaction. Idle: time since the last query finished."
14
+ column :wait, title: "What the session is waiting on: another session's lock, IO, its client -- or nothing, which means CPU"
15
+ column :source, title: "Endpoint or job from the query's SQLcommenter tags, otherwise the application name"
16
+ column :query, format: :sql
17
+
18
+ def active = rows.select { |row| row["state"] == "active" }
19
+ def idle = rows.select { |row| row["state"] == "idle" }
20
+ def in_transaction = rows.select { |row| row["state"].to_s.start_with?("idle in transaction") }
21
+ def blocked = rows.select { |row| row["blocked_by"].any? }
22
+
23
+ # Everything an operator would look at; idle connections are a pool doing
24
+ # its job.
25
+ def busy = rows.reject { |row| row["state"] == "idle" }
26
+
27
+ def long_running(threshold_ms)
28
+ active.select { |row| row["duration_ms"].to_i >= threshold_ms }
29
+ end
30
+
31
+ def stale_transactions(threshold_ms)
32
+ in_transaction.select { |row| row["duration_ms"].to_i >= threshold_ms }
33
+ end
34
+
35
+ # PostgreSQL shows a session's query text only to its own user, superusers
36
+ # and members of pg_read_all_stats. Anything else reads as a placeholder.
37
+ def hidden_queries?
38
+ rows.any? { |row| row["query"] == HIDDEN_QUERY }
39
+ end
40
+
41
+ private
42
+ def fetch_rows
43
+ rows = database.sessions
44
+
45
+ rows.each do |row|
46
+ row["blocked_by"] = row["blocked_by"].to_s.split(",").map(&:to_i)
47
+ row["duration_ms"] = duration_for(row)
48
+ row["wait"] = wait_for(row)
49
+ row["source"] = source_for(row)
50
+ end
51
+
52
+ rows.sort_by { |row| [ priority(row), -row["duration_ms"].to_i, row["pid"].to_i ] }
53
+ end
54
+
55
+ def priority(row)
56
+ return 0 if row["blocked_by"].any?
57
+
58
+ case row["state"]
59
+ when "active" then 1
60
+ when "idle" then 3
61
+ else 2
62
+ end
63
+ end
64
+
65
+ # The figure that matters depends on what the session is doing: a running
66
+ # query's age, an open transaction's age, or how long a connection has
67
+ # sat unused.
68
+ def duration_for(row)
69
+ value = case row["state"]
70
+ when "active" then row["query_ms"]
71
+ when "idle" then row["state_ms"]
72
+ else row["xact_ms"] || row["state_ms"]
73
+ end
74
+ value&.to_i
75
+ end
76
+
77
+ def wait_for(row)
78
+ return "blocked by #{row["blocked_by"].join(", ")}" if row["blocked_by"].any?
79
+ return "" if row["wait_event_type"].blank?
80
+
81
+ "#{row["wait_event_type"].downcase}: #{row["wait_event"]}"
82
+ end
83
+
84
+ def source_for(row)
85
+ tags = PgPeek::SqlComment.tags(row["query"])
86
+ PgPeek::SqlComment.label(tags) || row["application_name"].presence || ""
87
+ end
88
+ end
@@ -0,0 +1,25 @@
1
+ # Every table in the database with the cache ratios already fetched in one
2
+ # round trip by Table.inline_stats_for.
3
+ class PgPeek::Reports::Tables < PgPeek::Report
4
+ title "Tables"
5
+
6
+ column :name, header: "table"
7
+ column :heap_hit_ratio, header: "heap cache", align: :right, format: :percent,
8
+ title: "Share of heap block reads served from cache"
9
+ column :index_hit_ratio, header: "index cache", align: :right, format: :percent,
10
+ title: "Share of index block reads served from cache"
11
+
12
+ private
13
+ def fetch_rows
14
+ names = database.tables
15
+ stats = PgPeek::Table.inline_stats_for(database, names)
16
+
17
+ names.map do |name|
18
+ row = stats[name] || {}
19
+
20
+ { "name" => name,
21
+ "heap_hit_ratio" => row[:heap_hit_ratio],
22
+ "index_hit_ratio" => row[:index_hit_ratio] }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <title>pg_peek</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light">
7
+ <%= csrf_meta_tags %>
8
+ <%= csp_meta_tag %>
9
+
10
+ <%= yield :head %>
11
+
12
+ <%= stylesheet_link_tag "pg_peek/application", media: "all" %>
13
+ <%= javascript_include_tag "pg_peek/application", defer: true %>
14
+ </head>
15
+ <body>
16
+ <main>
17
+ <% if flash[:notice] %><p class="notice"><%= flash[:notice] %></p><% end %>
18
+ <nav>
19
+ <ul>
20
+ <li><%= link_to "pg_peek", databases_path, class: "wordmark" %></li>
21
+ </ul>
22
+ <ul>
23
+ <% if @database %>
24
+ <li><%= link_to "overview", database_path(@database) %></li>
25
+ <li><%= link_to "queries", database_queries_path(@database) %></li>
26
+ <li><%= link_to "endpoints", database_endpoints_path(@database) %></li>
27
+ <li><%= link_to "jobs", database_jobs_path(@database) %></li>
28
+ <li><%= link_to "indexes", database_indexes_path(@database) %></li>
29
+ <li><%= link_to "activity", database_activity_path(@database) %></li>
30
+ <li class="current-database">
31
+ <% if switchable_databases.many? %>
32
+ <span class="switcher">
33
+ <select data-switcher aria-label="Switch database">
34
+ <% switchable_databases.each do |candidate| %>
35
+ <option value="<%= switch_database_path(candidate) %>" <%= "selected" if candidate.name == @database.name %>><%= candidate.name %></option>
36
+ <% end %>
37
+ </select>
38
+ </span>
39
+ <% else %>
40
+ <%= link_to @database.name, database_path(@database) %>
41
+ <% end %>
42
+ </li>
43
+ <% end %>
44
+ </ul>
45
+ </nav>
46
+
47
+ <%= yield %>
48
+ </main>
49
+
50
+ <footer class="status">
51
+ <span>pg_peek <%= PgPeek::VERSION %></span>
52
+ <a href="https://github.com/trix/pg_peek" rel="noopener noreferrer">source</a>
53
+ <button type="button" data-theme-toggle aria-label="Toggle theme" title="Press t to toggle">theme: light</button>
54
+ </footer>
55
+ </body>
56
+ </html>
@@ -0,0 +1,70 @@
1
+ <% if @refresh %>
2
+ <% content_for :head do %><meta http-equiv="refresh" content="<%= @refresh %>"><% end %>
3
+ <% end %>
4
+
5
+ <div class="toolbar">
6
+ <h1>activity</h1>
7
+ <small class="dim">
8
+ captured <%= @captured_at.strftime("%H:%M:%S") %>
9
+ &middot; refresh:
10
+ <% PgPeek::ActivityController::REFRESH_INTERVALS.each do |seconds| %>
11
+ <%= link_to_unless @refresh == seconds, "#{seconds}s", database_activity_path(@database, refresh: seconds, idle: @show_idle ? 1 : nil) do |name| %><b><%= name %></b><% end %>
12
+ <% end %>
13
+ <%= link_to_unless @refresh.nil?, "off", database_activity_path(@database, idle: @show_idle ? 1 : nil) do |name| %><b><%= name %></b><% end %>
14
+ &middot; <kbd>r</kbd> reloads
15
+ </small>
16
+ </div>
17
+
18
+ <p class="vitals dim">
19
+ <span><%= @summary["client_connections"] %> of <%= @summary["max_connections"] %> server connections in use</span>
20
+ <span>this database: <%= @sessions.active.size %> active</span>
21
+ <span><%= @sessions.in_transaction.size %> idle in transaction</span>
22
+ <span><%= @sessions.idle.size %> idle</span>
23
+ <% if @blocked.any? %><span><%= @blocked.rows.size %> blocked</span><% end %>
24
+ </p>
25
+
26
+ <% if @blocked.any? %>
27
+ <article aria-label="Blocked sessions">
28
+ <header><%= pluralize(@blocked.rows.size, "session") %> waiting for a lock</header>
29
+ <%= render "pg_peek/reports/report", report: @blocked %>
30
+ <footer>
31
+ <small>
32
+ pg_peek only looks. To end a query: <code>SELECT pg_cancel_backend(pid)</code>;
33
+ to drop the whole session: <code>SELECT pg_terminate_backend(pid)</code>.
34
+ </small>
35
+ </footer>
36
+ </article>
37
+ <% end %>
38
+
39
+ <% if @sessions.hidden_queries? %>
40
+ <article aria-label="Query text hidden">
41
+ <header>queries of other users are hidden</header>
42
+ <p>
43
+ PostgreSQL shows a session's query only to its own user, superusers and
44
+ members of <code>pg_read_all_stats</code>. pg_peek connects as
45
+ <code><%= @summary["username"] %></code>:
46
+ </p>
47
+ <pre><code>GRANT pg_read_all_stats TO <%= @summary["username"] %>;</code></pre>
48
+ </article>
49
+ <% end %>
50
+
51
+ <% visible = @show_idle ? @sessions.rows : @sessions.busy %>
52
+
53
+ <h2>
54
+ sessions
55
+ <small class="dim">
56
+ <% if @show_idle %>
57
+ <%= link_to "hide idle", database_activity_path(@database, refresh: @refresh) %>
58
+ <% elsif @sessions.idle.any? %>
59
+ <%= @sessions.idle.size %> idle hidden &middot; <%= link_to "show idle", database_activity_path(@database, refresh: @refresh, idle: 1) %>
60
+ <% end %>
61
+ </small>
62
+ </h2>
63
+
64
+ <% if @sessions.empty? %>
65
+ <p class="dim">No other session is connected to <%= @database.name %>.</p>
66
+ <% elsif visible.empty? %>
67
+ <p class="dim">Nothing running. Every other session is idle.</p>
68
+ <% else %>
69
+ <%= render "pg_peek/reports/report", report: @sessions, rows: visible %>
70
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <h1>pg_peek</h1>
2
+
3
+ <article aria-label="Authentication is not configured">
4
+ <header>Authentication is not configured</header>
5
+ <p>
6
+ pg_peek exposes your schema, every normalized query in the database, and an
7
+ endpoint that resets query statistics. It refuses to serve
8
+ <code><%= Rails.env %></code> until you say how it should be protected.
9
+ </p>
10
+
11
+ <p>Either give it credentials, and it will ask for them:</p>
12
+ <pre><code>PG_PEEK_USERNAME=... PG_PEEK_PASSWORD=...</code></pre>
13
+ <p>or, equivalently, in <code>config/credentials.yml.enc</code>:</p>
14
+ <pre><code>pg_peek:
15
+ username: ...
16
+ password: ...</code></pre>
17
+
18
+ <p>
19
+ Or, if the engine is already mounted behind your own authentication, say so
20
+ in <code>config/initializers/pg_peek.rb</code>:
21
+ </p>
22
+ <pre><code>config.public_dashboard = true</code></pre>
23
+
24
+ <footer>
25
+ <small>
26
+ Development and test are exempt, so this never interrupts local work.
27
+ Credentials, once set, apply everywhere &mdash; including a development
28
+ server exposed through a tunnel.
29
+ </small>
30
+ </footer>
31
+ </article>