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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: edfdc4a86128d1eec3f1c69e731780b9ef71afdd071d76cde2756eee57d55ff8
4
+ data.tar.gz: 666c7f0204c05e295f6c6950e32362720e273d579ec3eec6c557fd2769ab14c0
5
+ SHA512:
6
+ metadata.gz: 4ecde1526a25bd02fe30c47dbbe62ff2058ce2d8f6b1ff741ffd50b2eea26236e102646f98b67dabf4d9f6d566717a838c04c15be895d5d3b335a59bde342945
7
+ data.tar.gz: 44fa0fbf8d8ccd408c9e967b78ec29503fc94f605beb961cdc8480137773e7b6145af740990c039ac4f09be1c7664d03c204290190c0f9cd94a1e674b9517d15
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tomasz Mazur
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # PgPeek
2
+
3
+ A Rails Engine that provides a web UI for monitoring and analyzing PostgreSQL query performance. Mount it into your Rails application for easy database introspection and slow query analysis.
4
+
5
+ ## Features
6
+
7
+ - **Database Overview**: View PostgreSQL version, installed extensions, and table listings
8
+ - **Query Performance Analysis**: Identify slow queries using the `pg_stat_statements` extension
9
+ - **SQLcommenter Support**: Parses and displays SQLcommenter tags from queries
10
+ - **Endpoint and Active Job Breakdowns**: Groups query cost by controller/action and by job class, with a per-job drill-down into its query shapes
11
+ - **Multi-Database Support**: Works with multiple database configurations (primary, replica, etc.)
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem "pg_peek"
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ Then run the installer, which mounts the engine at `/pg_peek` and creates the
28
+ initializer:
29
+
30
+ ```bash
31
+ bin/rails generate pg_peek:install
32
+ ```
33
+
34
+ Pass `--skip-routes` if you would rather mount the engine yourself, for example
35
+ inside an authenticated scope.
36
+
37
+ Now map each database in your `database.yml` to the ActiveRecord class that
38
+ connects to it:
39
+
40
+ ```ruby
41
+ # config/initializers/pg_peek.rb
42
+ Rails.application.config.pg_peek.tap do |config|
43
+ config.connections = {
44
+ "primary" => "ApplicationRecord"
45
+ }
46
+ end
47
+ ```
48
+
49
+ This step is required. A database without an entry cannot be opened and is not
50
+ listed.
51
+
52
+ Visit `/pg_peek` in your application to access the dashboard.
53
+
54
+ ### Authentication
55
+
56
+ pg_peek exposes your schema, every normalized query in the database, and an
57
+ endpoint that resets query statistics. Outside development and test it refuses
58
+ to serve until you say how it is protected.
59
+
60
+ Give it credentials and it asks for them over HTTP basic:
61
+
62
+ ```bash
63
+ PG_PEEK_USERNAME=peek PG_PEEK_PASSWORD=...
64
+ ```
65
+
66
+ or equivalently in `config/credentials.yml.enc`:
67
+
68
+ ```yaml
69
+ pg_peek:
70
+ username: peek
71
+ password: ...
72
+ ```
73
+
74
+ Credentials, once set, apply in **every** environment — including a development
75
+ server exposed through a tunnel.
76
+
77
+ If you would rather mount the engine behind authentication you already have, say
78
+ so and pg_peek stays out of the way:
79
+
80
+ ```ruby
81
+ # config/initializers/pg_peek.rb
82
+ config.public_dashboard = true
83
+ ```
84
+
85
+ ```ruby
86
+ # config/routes.rb
87
+ constraints ->(request) { request.session[:admin_id].present? } do
88
+ mount PgPeek::Engine, at: "/pg_peek"
89
+ end
90
+ ```
91
+
92
+ Development and test are exempt when no credentials are set, so local work is
93
+ never interrupted.
94
+
95
+ ### Enabling pg_stat_statements
96
+
97
+ Query analysis needs the `pg_stat_statements` extension, and enabling it is two
98
+ separate steps on two different things. Doing only the first is the most common
99
+ way to end up with a dashboard that shows nothing.
100
+
101
+ **1. Load the module into the server.** This is PostgreSQL configuration, not
102
+ database state: a migration cannot do it, and it takes effect only after a
103
+ restart.
104
+
105
+ ```sql
106
+ ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
107
+ ```
108
+
109
+ or the equivalent line in `postgresql.conf`. Then restart PostgreSQL. On a
110
+ managed service this is usually a parameter-group setting; in Docker, pass
111
+ `-c shared_preload_libraries=pg_stat_statements` to the server command.
112
+
113
+ **2. Create the extension in each database.** A migration, so it is tracked in
114
+ your schema and applied per environment:
115
+
116
+ ```ruby
117
+ class EnablePgStatStatements < ActiveRecord::Migration[8.1]
118
+ def change
119
+ enable_extension "pg_stat_statements"
120
+ end
121
+ end
122
+ ```
123
+
124
+ For a secondary database, generate it into that database's migrations path:
125
+
126
+ ```bash
127
+ bin/rails generate migration EnablePgStatStatements --database analytics
128
+ ```
129
+
130
+ If you do step 2 without step 1, the extension reports itself as installed but
131
+ its views cannot be queried. pg_peek detects this and shows the instructions
132
+ above in place of every page that needs statistics, so you will not be left
133
+ guessing — but you will be left waiting for a restart.
134
+
135
+ A restart also clears all collected statistics, so expect an empty dashboard
136
+ until your application has run for a while afterwards.
137
+
138
+ ### Requirements
139
+
140
+ - Rails 8.1+
141
+ - PostgreSQL 14+ with `pg_stat_statements` extension (for query analysis features)
142
+ - Only officially supported PostgreSQL versions are supported
143
+
144
+ ### Why not pg_stat_monitor?
145
+
146
+ pg_peek reads `pg_stat_statements` only. This is deliberate, not an omission.
147
+
148
+ Percona's `pg_stat_monitor` has two genuine advantages: it parses SQLcommenter
149
+ tags into a column of their own, and it keeps time-bucketed history rather than
150
+ one counter since the last reset. Both would simplify this engine.
151
+
152
+ But it is not part of PostgreSQL's contrib, so it is unavailable on Heroku and
153
+ most managed PostgreSQL. And its bucketed figures are not comparable with
154
+ `pg_stat_statements`' cumulative ones: supporting both would give two different
155
+ answers to "what does this endpoint cost" depending on which extension happened
156
+ to be installed. One data source that works everywhere beats two that disagree.
157
+
158
+ ## Development
159
+
160
+ ### Prerequisites
161
+
162
+ - Ruby (see `.ruby-version`)
163
+ - Docker and Docker Compose (for PostgreSQL)
164
+
165
+ ### Setup
166
+
167
+ ```bash
168
+ # Install dependencies
169
+ bundle install
170
+
171
+ # Start PostgreSQL 18 (default for development)
172
+ docker compose up postgres18 -d
173
+
174
+ # Setup the dummy app database
175
+ bin/rails db:setup
176
+
177
+ # Start the development server
178
+ bin/rails s
179
+
180
+ # Visit http://localhost:3000/pg_peek
181
+ ```
182
+
183
+ ### Testing Against Other PostgreSQL Versions
184
+
185
+ ```bash
186
+ # Run databases
187
+ docker compose up -d
188
+
189
+ # PostgreSQL 14
190
+ bin/test-pg14
191
+
192
+ # PostgreSQL 15
193
+ bin/test-pg15
194
+
195
+ # PostgreSQL 16
196
+ bin/test-pg16
197
+
198
+ # PostgreSQL 17
199
+ bin/test-pg17
200
+
201
+ # PostgreSQL 18
202
+ bin/test-pg18
203
+ ```
204
+
205
+ ## Testing
206
+
207
+ ```bash
208
+ # Run the full test suite
209
+ bin/test
210
+
211
+ # Code style check
212
+ bin/rubocop
213
+ ```
214
+
215
+ ## License
216
+
217
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ require "rake/testtask"
9
+
10
+ Rake::TestTask.new(:test) do |t|
11
+ t.libs << "test"
12
+ t.pattern = "test/**/*_test.rb"
13
+ t.verbose = false
14
+ end
15
+
16
+ task default: :test
@@ -0,0 +1,147 @@
1
+ // pg_peek — no framework, no CDN, no import map. Three small behaviours.
2
+ (function () {
3
+ "use strict";
4
+
5
+ // Theme -------------------------------------------------------------------
6
+ function applyTheme(theme) {
7
+ document.documentElement.dataset.theme = theme;
8
+
9
+ var meta = document.querySelector('meta[name="color-scheme"]');
10
+ if (meta) meta.content = theme;
11
+
12
+ document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
13
+ button.textContent = "theme: " + theme;
14
+ });
15
+ }
16
+
17
+ function storedTheme() {
18
+ try {
19
+ return localStorage.getItem("pg_peek.theme");
20
+ } catch (error) {
21
+ return null; // Private windows and blocked site data both throw.
22
+ }
23
+ }
24
+
25
+ function toggleTheme() {
26
+ var next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
27
+ applyTheme(next);
28
+ try {
29
+ localStorage.setItem("pg_peek.theme", next);
30
+ } catch (error) {
31
+ /* Nothing to do: the toggle still works for this page view. */
32
+ }
33
+ }
34
+
35
+ function typing() {
36
+ var el = document.activeElement;
37
+ return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA");
38
+ }
39
+
40
+ function initTheme() {
41
+ var preferred = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
42
+ applyTheme(storedTheme() || preferred);
43
+
44
+ document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
45
+ button.addEventListener("click", function (event) {
46
+ event.preventDefault();
47
+ toggleTheme();
48
+ });
49
+ });
50
+
51
+ document.addEventListener("keydown", function (event) {
52
+ if (event.key === "t" && !typing() && !event.metaKey && !event.ctrlKey) toggleTheme();
53
+ });
54
+ }
55
+
56
+ // Filtering ---------------------------------------------------------------
57
+ function initFilter() {
58
+ var input = document.querySelector("[data-filter-input]");
59
+ if (!input) return;
60
+
61
+ // Scoped to the nearest [data-filter] ancestor when there is one, so a
62
+ // page with several report tables -- the overview, with endpoints, jobs,
63
+ // queries and tables all on it -- filters only the one the input actually
64
+ // belongs to, rather than every row on the page.
65
+ var scope = input.closest("[data-filter]") || document;
66
+ var items = Array.prototype.slice.call(scope.querySelectorAll("[data-filter-value]"));
67
+ var count = scope.querySelector("[data-filter-count]");
68
+ var empty = scope.querySelector("[data-filter-empty]");
69
+ var noun = input.dataset.filterNoun || "row";
70
+ var timer;
71
+
72
+ function render() {
73
+ var query = input.value.trim().toLowerCase();
74
+ var matched = 0;
75
+
76
+ items.forEach(function (item) {
77
+ var hit = !query || item.dataset.filterValue.indexOf(query) !== -1;
78
+ item.hidden = !hit;
79
+ if (hit) matched++;
80
+ });
81
+
82
+ if (count) count.textContent = matched + " " + noun + (matched === 1 ? "" : "s");
83
+ if (empty) empty.hidden = matched > 0;
84
+ }
85
+
86
+ input.addEventListener("input", function () {
87
+ clearTimeout(timer);
88
+ timer = setTimeout(render, 120);
89
+ });
90
+
91
+ document.addEventListener("keydown", function (event) {
92
+ if (event.key === "/" && document.activeElement !== input) {
93
+ event.preventDefault();
94
+ input.focus();
95
+ } else if (event.key === "Escape" && document.activeElement === input) {
96
+ input.value = "";
97
+ render();
98
+ }
99
+ });
100
+
101
+ render();
102
+ }
103
+
104
+ // Confirmation ------------------------------------------------------------
105
+ // Previously data-confirm, which needed rails-ujs the engine never shipped:
106
+ // the reset button asked nothing and simply reset.
107
+ function initConfirm() {
108
+ document.querySelectorAll("form[data-confirm]").forEach(function (form) {
109
+ form.addEventListener("submit", function (event) {
110
+ if (!window.confirm(form.dataset.confirm)) event.preventDefault();
111
+ });
112
+ });
113
+ }
114
+
115
+ // Database switcher -------------------------------------------------------
116
+ function initSwitcher() {
117
+ document.querySelectorAll("[data-switcher]").forEach(function (select) {
118
+ select.addEventListener("change", function () {
119
+ window.location.assign(select.value);
120
+ });
121
+ });
122
+ }
123
+
124
+ // Reload ------------------------------------------------------------------
125
+ // The activity page is a snapshot; "r" refreshes it the way top does.
126
+ function initReload() {
127
+ document.addEventListener("keydown", function (event) {
128
+ if (event.key === "r" && !typing() && !event.metaKey && !event.ctrlKey && !event.altKey) {
129
+ window.location.reload();
130
+ }
131
+ });
132
+ }
133
+
134
+ function init() {
135
+ initTheme();
136
+ initFilter();
137
+ initConfirm();
138
+ initSwitcher();
139
+ initReload();
140
+ }
141
+
142
+ if (document.readyState === "loading") {
143
+ document.addEventListener("DOMContentLoaded", init);
144
+ } else {
145
+ init();
146
+ }
147
+ })();