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,43 @@
1
+ <h1>Databases</h1>
2
+
3
+ <%# An empty list means one of two quite different things, and the engine knows
4
+ which: PostgreSQL databases exist but none is mapped to a connection class,
5
+ or there are no PostgreSQL databases to show at all. %>
6
+ <% if @configured_databases.empty? && @databases.any? %>
7
+ <article aria-label="No database connections configured">
8
+ <header>pg_peek has no database connections configured</header>
9
+ <p>
10
+ Found <%= pluralize(@databases.size, "PostgreSQL database") %> in
11
+ <code>config/database.yml</code>
12
+ (<%= safe_join(@databases.map { |database| content_tag(:code, database.name) }, ", ") %>),
13
+ but <code>config.pg_peek.connections</code> is empty, so none of them can be opened.
14
+ </p>
15
+ <p>Generate the initializer:</p>
16
+ <pre><code>bin/rails generate pg_peek:install</code></pre>
17
+ <p>Or add <code>config/initializers/pg_peek.rb</code> yourself:</p>
18
+ <pre><code>Rails.application.config.pg_peek.tap do |config|
19
+ config.connections = {
20
+ <%= @databases.map { |database|
21
+ klass = database.primary ? "ApplicationRecord" : "#{database.name.camelize}Record"
22
+ %( "#{database.name}" => "#{klass}")
23
+ }.join(",\n") %>
24
+ }
25
+ end</code></pre>
26
+ </article>
27
+ <% elsif @databases.empty? %>
28
+ <article aria-label="No PostgreSQL databases">
29
+ <header>No PostgreSQL databases found</header>
30
+ <p>
31
+ No entry in <code>config/database.yml</code> for the
32
+ <code><%= Rails.env %></code> environment uses the
33
+ <code>postgresql</code> adapter, so there is nothing for pg_peek to inspect.
34
+ </p>
35
+ </article>
36
+ <% end %>
37
+
38
+ <% if @configured_databases.any? %>
39
+ <%= render "pg_peek/reports/report",
40
+ report: @report,
41
+ link_column: :name,
42
+ link: ->(row) { database_path(row["database"]) } %>
43
+ <% end %>
@@ -0,0 +1,169 @@
1
+ <h1><%= @database.name %> <small class="dim">overview</small></h1>
2
+
3
+ <p class="vitals dim">
4
+ <span>postgres <%= @database.version %></span>
5
+ <% if @pg_stat_statements.usable? %>
6
+ <span>pg_stat_statements <%= @pg_stat_statements.installed_version %></span>
7
+ <span><%= stats_since_link(@database, @stats_reset_at) %></span>
8
+ <span><%= pluralize(number_with_delimiter(@pg_stat_statements.statement_count), "statement") %></span>
9
+ <% end %>
10
+ <% if @vitals["cache_hit_ratio"] %><span>cache hit <%= @vitals["cache_hit_ratio"] %>%</span><% end %>
11
+ <% if @vitals["connections"] %><span><%= pluralize(@vitals["connections"].to_i, "connection") %></span><% end %>
12
+ </p>
13
+
14
+ <% if @pg_stat_statements.usable? %>
15
+ <% elsif @pg_stat_statements.installed? %>
16
+ <%= render "pg_peek/pg_stat_statements/preload_instructions",
17
+ database: @database,
18
+ pg_stat_statements: @pg_stat_statements %>
19
+ <% elsif @pg_stat_statements.available? %>
20
+ <article aria-label="Extension pg_stat_statements is not enabled">
21
+ <header>pg_stat_statements extension is not enabled</header>
22
+ <p>Create it with a migration, so it is tracked in your schema:</p>
23
+ <% if @database.primary %>
24
+ <pre><code>bin/rails generate migration EnablePgStatStatements</code></pre>
25
+ <% else %>
26
+ <pre><code>bin/rails generate migration EnablePgStatStatements --database <%= @database.name %></code></pre>
27
+ <% end %>
28
+ <pre><code>def change
29
+ enable_extension "pg_stat_statements"
30
+ end</code></pre>
31
+ <p>Then <code>bin/rails db:migrate</code>.</p>
32
+ </article>
33
+ <% else %>
34
+ <article aria-label="Extension pg_stat_statements is not available">
35
+ <header>pg_stat_statements extension is not available</header>
36
+ <p>The pg_stat_statements extension is not available on this PostgreSQL server.</p>
37
+ <p>To make it available, add to <code>postgresql.conf</code>:</p>
38
+ <pre><code>shared_preload_libraries = 'pg_stat_statements'</code></pre>
39
+ <footer>
40
+ <small>A server restart is required after this change.</small>
41
+ </footer>
42
+ </article>
43
+ <% end %>
44
+
45
+ <div class="panels">
46
+ <section class="wide">
47
+ <h2>attention</h2>
48
+ <ul class="attention">
49
+ <% if @attention.empty? %>
50
+ <li class="ok">nothing needs attention</li>
51
+ <% else %>
52
+ <% @attention.each do |item| %>
53
+ <li><%= item[:href] ? link_to(item[:text], item[:href]) : item[:text] %></li>
54
+ <% end %>
55
+ <% end %>
56
+ </ul>
57
+ </section>
58
+
59
+ <% if @pg_stat_statements.usable? %>
60
+ <section>
61
+ <h2>endpoints <%= link_to "all →", database_endpoints_path(@database) %></h2>
62
+ <% if @endpoints.empty? %>
63
+ <p class="dim">No endpoint has run a query yet.</p>
64
+ <% else %>
65
+ <%= render "pg_peek/reports/report", report: @endpoints,
66
+ columns: %i[endpoint share total_exec_time_ms max_queries_per_request],
67
+ limit: PgPeek::DatabasesController::OVERVIEW_ROWS %>
68
+ <% end %>
69
+ </section>
70
+
71
+ <section>
72
+ <h2>jobs <%= link_to "all →", database_jobs_path(@database) %></h2>
73
+ <% if @jobs.empty? %>
74
+ <p class="dim">No job has run a query yet.</p>
75
+ <% else %>
76
+ <%= render "pg_peek/reports/report", report: @jobs,
77
+ columns: %i[job_class share total_exec_time_ms],
78
+ limit: PgPeek::DatabasesController::OVERVIEW_ROWS %>
79
+ <% end %>
80
+ </section>
81
+
82
+ <section class="wide">
83
+ <h2>slowest queries <%= link_to "all →", database_queries_path(@database) %></h2>
84
+ <% if @queries.empty? %>
85
+ <p class="dim">No statements recorded yet.</p>
86
+ <% else %>
87
+ <%= render "pg_peek/reports/report", report: @queries, limit: PgPeek::DatabasesController::OVERVIEW_ROWS %>
88
+ <% end %>
89
+ </section>
90
+ <% end %>
91
+
92
+ <section class="wide">
93
+ <h2>activity <%= link_to "now →", database_activity_path(@database) %></h2>
94
+ <p class="vitals dim">
95
+ <span><%= @connection_summary["client_connections"] %> of <%= @connection_summary["max_connections"] %> server connections in use</span>
96
+ <span>this database: <%= @sessions.active.size %> active</span>
97
+ <span><%= @sessions.in_transaction.size %> idle in transaction</span>
98
+ <span><%= @sessions.idle.size %> idle</span>
99
+ <% if @sessions.blocked.any? %><span><%= @sessions.blocked.size %> blocked</span><% end %>
100
+ </p>
101
+ <% if @sessions.busy.any? %>
102
+ <%= render "pg_peek/reports/report", report: @sessions, rows: @sessions.busy,
103
+ limit: PgPeek::DatabasesController::OVERVIEW_ROWS %>
104
+ <% end %>
105
+ </section>
106
+
107
+ <section class="wide" id="tables" data-filter>
108
+ <h2>tables (<%= @tables.rows.size %>)</h2>
109
+ <input type="search"
110
+ placeholder="filter tables… (press / to focus)"
111
+ data-filter-input
112
+ data-filter-noun="table"
113
+ autocomplete="off">
114
+ <p class="dim" data-filter-count></p>
115
+
116
+ <%= render "pg_peek/reports/report",
117
+ report: @tables,
118
+ link_column: :name,
119
+ link: ->(row) { database_table_path(@database, row["name"]) } %>
120
+
121
+ <p class="dim" data-filter-empty hidden>No tables match that filter.</p>
122
+ </section>
123
+ </div>
124
+
125
+ <% if @pg_stat_statements.usable? %>
126
+ <section id="pg_stat_statements">
127
+ <h2>pg_stat_statements <%= @pg_stat_statements.installed_version %></h2>
128
+ <p class="dim">
129
+ Queries, endpoints and jobs are cumulative since
130
+ <%= @stats_reset_at.strftime("%Y-%m-%d %H:%M:%S %Z") %> (<%= format_time_ago(@stats_reset_at) %>),
131
+ <%= pluralize(number_with_delimiter(@pg_stat_statements.statement_count), "statement") %> tracked.
132
+ A reset starts a fresh window: exact counts and attribution, at the
133
+ price of the history so far.
134
+ <% peers = @database.server_peers %>
135
+ <% if peers.any? %>
136
+ It also clears <%= peers.map(&:name).to_sentence %>, which
137
+ <%= peers.one? ? "shares" : "share" %> this server: PostgreSQL keeps
138
+ one reset time for all of them.
139
+ <% end %>
140
+ </p>
141
+ <%= button_to "reset statistics",
142
+ reset_database_pg_stat_statements_path(@database),
143
+ method: :delete,
144
+ class: "danger",
145
+ form_class: "inline",
146
+ form: { data: { confirm: "Reset pg_stat_statements? This clears statement statistics for every database on this server." } } %>
147
+ <% if @pg_stat_statements.outdated? %>
148
+ <p class="dim">
149
+ Version <%= @pg_stat_statements.default_version %> is available:
150
+ <code>ALTER EXTENSION pg_stat_statements UPDATE;</code>
151
+ </p>
152
+ <% end %>
153
+ </section>
154
+ <% end %>
155
+
156
+ <details>
157
+ <summary>database details</summary>
158
+ <pre>name: <%= @database.name %>
159
+ primary: <%= @database.primary %>
160
+ replica: <%= @database.replica %>
161
+ schema_format: <%= @database.schema_format %>
162
+ version: <%= @database.version_full %></pre>
163
+ </details>
164
+
165
+ <details>
166
+ <summary>installed extensions</summary>
167
+ <pre><% @database.installed_extensions.each do |extension| %><%= extension['name'] %> <%= extension['installed_version'] %>
168
+ <% end %></pre>
169
+ </details>
@@ -0,0 +1,24 @@
1
+ <div class="toolbar">
2
+ <h1>endpoints</h1>
3
+ <small class="dim"><%= stats_since_link(@database, @pg_stat_statements.reset_at) %></small>
4
+ </div>
5
+
6
+ <% if @report.empty? %>
7
+ <article aria-label="No endpoints found">
8
+ <header>No controller action has run a query yet</header>
9
+ <p>
10
+ Endpoints appear once requests have run database queries with
11
+ <code>:controller</code> (or <code>:namespaced_controller</code>) and
12
+ <code>:action</code> among <code>config.active_record.query_log_tags</code>.
13
+ </p>
14
+ </article>
15
+ <% else %>
16
+ <%= render "pg_peek/reports/report", report: @report %>
17
+
18
+ <p class="dim">
19
+ <strong>q/req</strong> is an estimate: the request count is inferred from the
20
+ least-called query of each endpoint, so an action whose every query is
21
+ conditional will read low. A value above
22
+ <%= PgPeek::Reports::Endpoints::N_PLUS_ONE_THRESHOLD %> is the shape of an N+1.
23
+ </p>
24
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <div class="toolbar">
2
+ <h1>indexes</h1>
3
+ <small class="dim">
4
+ <% if @stats_reset_at %>
5
+ scan counts since <%= format_time_ago(@stats_reset_at) %>
6
+ <% else %>
7
+ scan counts since statistics began &mdash; never reset
8
+ <% end %>
9
+ </small>
10
+ </div>
11
+
12
+ <% if @report.unused.any? %>
13
+ <article aria-label="Unused indexes">
14
+ <header>
15
+ <%= pluralize(@report.unused.size, "index") %> never scanned,
16
+ <%= number_to_human_size(@report.unused_bytes) %> of disk and write cost
17
+ </header>
18
+ <p>
19
+ Not read by any query since statistics were last reset. Primary keys and
20
+ unique indexes are not counted: they enforce constraints whether or not a
21
+ query reads them. Before dropping one, make sure the window covers your
22
+ rarest workloads &mdash; a month-end report is still a workload.
23
+ </p>
24
+ </article>
25
+ <% end %>
26
+
27
+ <% if @report.empty? %>
28
+ <p class="dim">No indexes on user tables.</p>
29
+ <% else %>
30
+ <%= render "pg_peek/reports/report", report: @report %>
31
+ <% end %>
@@ -0,0 +1,51 @@
1
+ <div class="toolbar">
2
+ <h1>jobs</h1>
3
+ <small class="dim"><%= stats_since_link(@database, @stats_reset_at) %></small>
4
+ </div>
5
+
6
+ <% if @report.empty? %>
7
+ <% if !@job_tag_configured %>
8
+ <article aria-label="Job tag not configured">
9
+ <header>Queries are not tagged with the job that ran them</header>
10
+ <p>
11
+ <code>config.active_record.query_log_tags</code> is
12
+ <code><%= Rails.application.config.active_record.query_log_tags.to_a.inspect %></code>,
13
+ which does not include <code>:job</code>. Until it does, no query can be
14
+ attributed to a job and this page cannot fill, however many jobs run.
15
+ </p>
16
+ <p>In <code>config/application.rb</code>:</p>
17
+ <pre><code>config.active_record.query_log_tags = <%= (Rails.application.config.active_record.query_log_tags.to_a + [ :job ]).inspect %></code></pre>
18
+ </article>
19
+ <% else %>
20
+ <article aria-label="No jobs have run yet">
21
+ <header>No job has run since statistics were last collected</header>
22
+ <p>
23
+ Query log tags include <code>:job</code> and pg_stat_statements is
24
+ collecting, so this page is working &mdash; there is simply nothing in it
25
+ yet. Jobs appear as they execute their first database query.
26
+ </p>
27
+ <% if @stats_reset_at %>
28
+ <p>
29
+ Statistics were last reset <strong><%= format_time_ago(@stats_reset_at) %></strong>
30
+ (<%= @stats_reset_at %>), so anything that ran before then is gone.
31
+ Restarting PostgreSQL clears them too.
32
+ </p>
33
+ <% end %>
34
+ </article>
35
+ <% end %>
36
+ <% else %>
37
+ <input type="search"
38
+ placeholder="filter jobs… (press / to focus)"
39
+ data-filter-input
40
+ data-filter-noun="job"
41
+ autofocus
42
+ autocomplete="off">
43
+ <p class="dim" data-filter-count></p>
44
+
45
+ <%= render "pg_peek/reports/report",
46
+ report: @report,
47
+ link_column: :job_class,
48
+ link: ->(row) { database_job_path(@database, ERB::Util.url_encode(row["job_class"])) } %>
49
+
50
+ <p class="dim" data-filter-empty hidden>No jobs match that filter.</p>
51
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <div class="toolbar">
2
+ <h1><%= @job_class %></h1>
3
+ <%= link_to "← jobs", database_jobs_path(@database) %>
4
+ </div>
5
+
6
+ <% if @report.empty? %>
7
+ <article aria-label="No queries found">
8
+ <header>No queries found for this job</header>
9
+ <p>This could mean:</p>
10
+ <ul>
11
+ <li>The job hasn't executed any database queries since statistics were last reset</li>
12
+ <li>The job's queries are not among the top outliers</li>
13
+ </ul>
14
+ </article>
15
+ <% else %>
16
+ <%= render "pg_peek/reports/report", report: @report %>
17
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <article aria-label="Extension pg_stat_statements is not preloaded">
2
+ <header>pg_stat_statements is not loaded</header>
3
+ <p>
4
+ The extension is installed on <strong><%= database.name %></strong>, but the module was not
5
+ loaded when the server started, so it collects no statistics and its views cannot be queried.
6
+ </p>
7
+ <p>Add it to <code>postgresql.conf</code>:</p>
8
+ <pre><code>shared_preload_libraries = 'pg_stat_statements'</code></pre>
9
+ <p>Or set it without editing the file by hand:</p>
10
+ <pre><code>ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';</code></pre>
11
+ <footer>
12
+ <small>
13
+ A server restart is required — this setting cannot be reloaded.
14
+ Currently loaded: <code><%= pg_stat_statements.shared_preload_libraries.presence || "(none)" %></code>
15
+ </small>
16
+ </footer>
17
+ </article>
@@ -0,0 +1,5 @@
1
+ <h1>pg_stat_statements</h1>
2
+
3
+ <%= render "pg_peek/pg_stat_statements/preload_instructions",
4
+ database: @database,
5
+ pg_stat_statements: @pg_stat_statements %>
@@ -0,0 +1,10 @@
1
+ <div class="toolbar">
2
+ <h1>queries</h1>
3
+ <small class="dim"><%= stats_since_link(@database, @pg_stat_statements.reset_at) %></small>
4
+ </div>
5
+
6
+ <% if @queries.empty? %>
7
+ <p class="dim">No statements recorded since the last reset.</p>
8
+ <% else %>
9
+ <%= render "pg_peek/reports/report", report: @queries %>
10
+ <% end %>
@@ -0,0 +1,32 @@
1
+ <%# Draws any report. Pass columns: [keys], rows: [subset] and/or limit: n to show a projection. %>
2
+ <% columns = local_assigns[:columns] ? report.columns.select { |c| local_assigns[:columns].include?(c.key) } : report.columns %>
3
+ <% rows = local_assigns[:rows] || report.rows %>
4
+ <% rows = rows.first(local_assigns[:limit]) if local_assigns[:limit] %>
5
+ <div class="scroll">
6
+ <table>
7
+ <thead>
8
+ <tr>
9
+ <% columns.each do |column| %>
10
+ <th class="<%= cell_class(column) %>" <%= "title=#{column.title.inspect}".html_safe if column.title %>>
11
+ <%= column.header %>
12
+ </th>
13
+ <% end %>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% rows.each do |row| %>
18
+ <tr <%= "data-filter-value=#{row.value(report.columns.first).to_s.downcase.inspect}".html_safe %>>
19
+ <% columns.each do |column| %>
20
+ <td class="<%= cell_class(column) %>">
21
+ <% if local_assigns[:link] && column.key == local_assigns[:link_column] %>
22
+ <%= link_to format_cell(report, column, row), local_assigns[:link].call(row) %>
23
+ <% else %>
24
+ <%= format_cell(report, column, row) %>
25
+ <% end %>
26
+ </td>
27
+ <% end %>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ </div>
@@ -0,0 +1,128 @@
1
+ <div class="toolbar">
2
+ <h1><%= @table.name %></h1>
3
+ <%= link_to "← tables", database_path(@database, anchor: "tables") %>
4
+ </div>
5
+
6
+ <% seconds_elapsed = @table.seconds_since_reset %>
7
+
8
+ <section>
9
+ <h2>cache performance</h2>
10
+ <table>
11
+ <tbody>
12
+ <tr>
13
+ <th>heap cache hit</th>
14
+ <td><%= format_percentage(@table.heap_cache_hit_ratio) %></td>
15
+ </tr>
16
+ <tr>
17
+ <th>index cache hit</th>
18
+ <td><%= format_percentage(@table.index_cache_hit_ratio) %></td>
19
+ </tr>
20
+ <tr>
21
+ <th>toast cache hit</th>
22
+ <td><%= format_percentage(@table.toast_cache_hit_ratio) %></td>
23
+ </tr>
24
+ <tr>
25
+ <th>toast index cache hit</th>
26
+ <td><%= format_percentage(@table.toast_index_cache_hit_ratio) %></td>
27
+ </tr>
28
+ </tbody>
29
+ </table>
30
+ </section>
31
+
32
+ <section>
33
+ <h2>row activity</h2>
34
+ <table>
35
+ <tbody>
36
+ <tr>
37
+ <th>estimated rows</th>
38
+ <td><%= format_number(@table.row_estimate) %></td>
39
+ </tr>
40
+ <tr>
41
+ <th>sequential scans</th>
42
+ <td>
43
+ <%= format_number(@table.seq_scan) %>
44
+ <% if @table.seq_scan_percentage %><span class="dim">(<%= @table.seq_scan_percentage %>%)</span><% end %>
45
+ </td>
46
+ </tr>
47
+ <tr>
48
+ <th>index scans</th>
49
+ <td>
50
+ <%= format_number(@table.idx_scan) %>
51
+ <% if @table.idx_scan_percentage %><span class="dim">(<%= @table.idx_scan_percentage %>%)</span><% end %>
52
+ </td>
53
+ </tr>
54
+ <tr>
55
+ <th>index usage</th>
56
+ <td><%= format_percentage(@table.index_usage_percentage) %></td>
57
+ </tr>
58
+ <tr>
59
+ <th>rows fetched</th>
60
+ <td><%= format_count_with_rate(@table.rows_fetched, seconds_elapsed) %></td>
61
+ </tr>
62
+ <tr>
63
+ <th>rows inserted</th>
64
+ <td><%= format_count_with_rate(@table.rows_inserted, seconds_elapsed) %></td>
65
+ </tr>
66
+ <tr>
67
+ <th>rows updated</th>
68
+ <td><%= format_count_with_rate(@table.rows_updated, seconds_elapsed) %></td>
69
+ </tr>
70
+ <tr>
71
+ <th>rows deleted</th>
72
+ <td><%= format_count_with_rate(@table.rows_deleted, seconds_elapsed) %></td>
73
+ </tr>
74
+ <tr>
75
+ <th>live tuples</th>
76
+ <td><%= format_number(@table.live_tuples) %></td>
77
+ </tr>
78
+ <tr>
79
+ <th>dead tuples</th>
80
+ <td>
81
+ <%= format_number(@table.dead_tuples) %>
82
+ <% if @table.dead_tuple_ratio %><span class="dim">(<%= @table.dead_tuple_ratio %>% of total)</span><% end %>
83
+ <% if @table.dead_tuple_warning? %><br><span class="warn">consider running VACUUM</span><% end %>
84
+ </td>
85
+ </tr>
86
+ </tbody>
87
+ </table>
88
+ </section>
89
+
90
+ <section>
91
+ <h2>maintenance</h2>
92
+ <table>
93
+ <tbody>
94
+ <tr>
95
+ <th>last vacuum</th>
96
+ <td><%= format_time_ago(@table.last_vacuum) %></td>
97
+ </tr>
98
+ <tr>
99
+ <th>last autovacuum</th>
100
+ <td><%= format_time_ago(@table.last_autovacuum) %></td>
101
+ </tr>
102
+ <tr>
103
+ <th>last analyze</th>
104
+ <td><%= format_time_ago(@table.last_analyze) %></td>
105
+ </tr>
106
+ <tr>
107
+ <th>last autoanalyze</th>
108
+ <td><%= format_time_ago(@table.last_autoanalyze) %></td>
109
+ </tr>
110
+ <tr>
111
+ <th>vacuum count</th>
112
+ <td><%= format_number(@table.vacuum_count) %></td>
113
+ </tr>
114
+ <tr>
115
+ <th>autovacuum count</th>
116
+ <td><%= format_number(@table.autovacuum_count) %></td>
117
+ </tr>
118
+ <tr>
119
+ <th>analyze count</th>
120
+ <td><%= format_number(@table.analyze_count) %></td>
121
+ </tr>
122
+ <tr>
123
+ <th>autoanalyze count</th>
124
+ <td><%= format_number(@table.autoanalyze_count) %></td>
125
+ </tr>
126
+ </tbody>
127
+ </table>
128
+ </section>
data/config/routes.rb ADDED
@@ -0,0 +1,16 @@
1
+ PgPeek::Engine.routes.draw do
2
+ resources :databases, only: [ :index, :show ] do
3
+ resources :queries, only: [ :index ]
4
+ resources :tables, only: [ :show ], param: :name
5
+ resources :endpoints, only: [ :index ]
6
+ resources :indexes, only: [ :index ]
7
+ resource :activity, only: [ :show ], controller: "activity"
8
+ resources :jobs, only: [ :index, :show ], param: :job_class
9
+
10
+ resource :pg_stat_statements, only: [] do
11
+ delete :reset
12
+ end
13
+ end
14
+
15
+ root to: "databases#home"
16
+ end
@@ -0,0 +1,40 @@
1
+ module PgPeek
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ desc "Creates a PgPeek initializer and mounts the engine."
7
+
8
+ class_option :skip_routes, type: :boolean, default: false,
9
+ desc: "Don't mount the engine in config/routes.rb"
10
+
11
+ def copy_initializer
12
+ template "initializer.rb", "config/initializers/pg_peek.rb"
13
+ end
14
+
15
+ def mount_engine
16
+ return if options[:skip_routes]
17
+
18
+ if engine_mounted?
19
+ say_status :identical, "config/routes.rb (PgPeek::Engine already mounted)", :blue
20
+ return
21
+ end
22
+
23
+ route %(mount PgPeek::Engine, at: "/pg_peek")
24
+ end
25
+
26
+ def show_readme
27
+ readme "README" if behavior == :invoke
28
+ end
29
+
30
+ private
31
+ # Rails' route action appends unconditionally, so re-running the
32
+ # generator would mount the engine a second time.
33
+ def engine_mounted?
34
+ routes = File.join(destination_root, "config/routes.rb")
35
+
36
+ File.exist?(routes) && File.read(routes).include?("PgPeek::Engine")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ ===============================================================================
2
+
3
+ PgPeek has been installed and mounted at /pg_peek.
4
+
5
+ Next steps:
6
+
7
+ 1. Map your databases to their ActiveRecord classes in
8
+ config/initializers/pg_peek.rb:
9
+
10
+ config.connections = { "primary" => "ApplicationRecord" }
11
+
12
+ Every database you want to inspect needs an entry, or it will not be listed.
13
+
14
+ 2. Visit /pg_peek to view your PostgreSQL databases
15
+
16
+ Before deploying:
17
+
18
+ pg_peek exposes your schema, every normalized query in the database, and an
19
+ endpoint that resets query statistics. Outside development and test it
20
+ refuses to serve until you say how it is protected.
21
+
22
+ Give it credentials, and it will ask for them:
23
+
24
+ PG_PEEK_USERNAME=... PG_PEEK_PASSWORD=...
25
+
26
+ Or, if you mount it behind authentication you already have, say so in
27
+ config/initializers/pg_peek.rb:
28
+
29
+ config.public_dashboard = true
30
+
31
+ ===============================================================================
@@ -0,0 +1,38 @@
1
+ # PgPeek configuration
2
+ # See https://github.com/trix/pg_peek for more information.
3
+
4
+ Rails.application.config.pg_peek.tap do |config|
5
+ # Authentication.
6
+ #
7
+ # Outside development and test, pg_peek refuses to serve until it knows how it
8
+ # is protected. Give it credentials with PG_PEEK_USERNAME / PG_PEEK_PASSWORD or
9
+ # a pg_peek: entry in credentials.yml.enc and it asks for them over HTTP basic.
10
+ # Credentials, once set, apply in every environment -- including a development
11
+ # server exposed through a tunnel.
12
+ #
13
+ # If you mount the engine behind your own authentication instead, say so:
14
+ # config.public_dashboard = true
15
+
16
+ # Database connections mapping.
17
+ # Maps database names (from database.yml) to their ActiveRecord base class.
18
+ config.connections = {
19
+ "primary" => "ApplicationRecord"
20
+ }
21
+
22
+ # Tables to exclude from the tables list.
23
+ # Supports strings and regular expressions.
24
+ config.excluded_tables = %w[schema_migrations ar_internal_metadata]
25
+
26
+ # Maximum number of outlier queries to display.
27
+ # config.outliers_limit = 20
28
+
29
+ # Dead tuple ratio threshold (percentage) for warnings.
30
+ # config.dead_tuple_warning_threshold = 10
31
+
32
+ # A query running longer than this is called out on the overview.
33
+ # config.long_query_warning_seconds = 5
34
+
35
+ # A session idle in transaction longer than this holds locks and blocks
36
+ # vacuum, and is called out on the overview.
37
+ # config.idle_in_transaction_warning_seconds = 60
38
+ end