pg_reports 0.8.0 → 0.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70975bd7aa15cac037bc8ac3f658c7cabe1e7492a3c37a1db588c92bce3d0f3d
4
- data.tar.gz: 587f0a5474d427303c483603467155c4e0602ff604d5628c39e45d7137d1354d
3
+ metadata.gz: 2ff4489af5016c4bfbe14f7187daa5ca2ea279b15887bab928c8effefc4b2dad
4
+ data.tar.gz: 26815859090bbf8a84000acb3545c1eecb982887f8d0f07d9ab0e582f2380c58
5
5
  SHA512:
6
- metadata.gz: 3174ba9362c38d77da9a41653453de23e6c462fac8be71ceacf82b645fffb68343eb09421df8cfbab9f1100d1ca70fbca6234bc3a4450e83e5be9c35079653da
7
- data.tar.gz: 70f91b168cc0890414741f75de6aea545400d738ef2a00e5a664a7555aac9bcfd923ba53ce169ca2ffa610efe9dbd95ff26902c77dcbfcf79b872421cf63e644
6
+ metadata.gz: f94d1f175001f3a8140d8ef9b826d353bb20963d3b75487aace882fde54186466be500840252bc9e87268e55244bb96746f59fd82b6ba648271a828b596b47d4
7
+ data.tar.gz: f89ff6622a18f3daf33fabf898a8736da510e2ecf84f4455e5720a8a07f3a219ed1336a1f1a286437a23da505560e724679bb27957b74b501567db2cfaee9f17
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.1] - 2026-07-03
11
+
12
+ ### Added
13
+
14
+ - **Standalone mode — run the dashboard without a host Rails app.** A new `pg_reports server` executable (and `rake pg_reports:server` task) boots a minimal Rails application that mounts the engine at `/` and serves it on port **4000**, straight from the gem's root folder. The connection is resolved from `--database-url`, then `DATABASE_URL`, then libpq-style `PG*` env vars; flags cover `--port`, `--host`, `--mount`, and `--server`. All existing multi-database / multi-cluster switching works unchanged, since the connection registry auto-registers the standalone connection as the `:primary` target.
15
+ - **No new runtime dependencies.** `rack` and `rackup` already ship transitively via `actionpack`/`railties`; the web server (Puma, WEBrick, …) is resolved at run time and is not a hard dependency — the runner uses whichever is installed and prints a clear message if none is.
16
+ - New `PgReports::Standalone` module encapsulating app construction, connection resolution, and server boot.
17
+ - `./bin/pg_reports` runs from a checkout without `bundle exec` via a soft bundler shim (activated only when a Gemfile sits next to the executable; skipped for the installed gem).
18
+ - **[docs/standalone.md](docs/standalone.md).**
19
+ - **Dashboard footer** with links to the project on GitHub and a contact address.
20
+ - **ESC closes any open modal.** A single global handler triggers the modal's own close button, so per-modal cleanup still runs.
21
+
22
+ ### Changed
23
+
24
+ - **Redesigned the pg_stat_statements status badge.** The header badge now reports four clearly distinguished states with plain-language labels instead of a raw identifier glued to an adjective (`pg_stat_statements готовий`):
25
+ - 🟢 **Active** — monitoring works.
26
+ - 🟡 **Preload required** — the extension exists but isn't in `shared_preload_libraries`; clicking the badge opens the setup instructions.
27
+ - 🟡 **Extension required** — the library is loaded but the extension isn't created; clicking opens a modal with a one-click **Create extension** button.
28
+ - 🔴 **No connection** — the database itself is unreachable.
29
+ Warning/error badges are now clickable and self-explanatory. The redundant `?` info button and the header **Create extension** button were removed in favor of the badge-driven modals. Labels no longer use a negative "Not …" framing. Translations updated for `en` / `uk` / `ru`.
30
+ - **pg_stat_statements status detection no longer reads `shared_preload_libraries`.** That setting requires the `pg_read_all_settings` role and is unreadable by a typical monitoring user, which made the "preloaded but extension missing" state unreachable. State is now derived entirely from signals every role can observe: connectivity (`SELECT 1`), extension presence in `pg_extension`, and whether the `pg_stat_statements` view is queryable. `pg_stat_statements_status` gains a `connected:` key, and `PgReports.system.connected?` is a new public helper.
31
+ - **Primary buttons toned down.** `btn-primary` (Start monitoring, Run report, Create extension, …) switched from a solid accent fill to a subtle tinted style, so it reads as the accent action without dominating the page.
32
+ - **Live-metrics "long queries" threshold lowered from 60s to 5s** — the default the top-of-dashboard tile counts against.
33
+ - **Header/layout polish** — the status badge, settings button, and Reset button are unified to the same height; the settings button is now square; the pg_stat_statements category warning banner no longer overhangs its card; and the "scope: host application" note in the query monitor uses a real styled tooltip (hover + keyboard focus) instead of the unreliable native `title`.
34
+ - **README trimmed**: standalone, Telegram, and Grafana/Prometheus details moved to dedicated docs ([docs/standalone.md](docs/standalone.md), [docs/telegram.md](docs/telegram.md), [docs/grafana.md](docs/grafana.md)).
35
+
36
+ ### Fixed
37
+
38
+ - **Query Monitor no longer records pg_reports' own queries.** Internal queries (live-metrics polling, status checks, database listing) run through `Executor`/`Target` are now tagged with a `"PgReports"` statement name, so `QueryMonitor#should_skip?` filters them by name regardless of backtrace depth. Previously the deep `ActiveSupport::Notifications` stack could push the pg_reports frames past the 30-frame backtrace scan, leaking these queries into the monitor history.
39
+ - **Dashboard requests broke when the engine is mounted at the root path** (e.g. standalone). The client base path resolved to `/`, so `fetch` URLs became `//live_metrics` — a protocol-relative URL the browser sent to a bogus host, breaking live metrics and report runs. The base now strips a trailing slash.
40
+
10
41
  ## [0.8.0] - 2026-05-01
11
42
 
12
43
  ### Added
data/README.md CHANGED
@@ -61,6 +61,17 @@ Visit `http://localhost:3000/pg_reports`.
61
61
 
62
62
  For query analysis, also enable `pg_stat_statements` — see [setup instructions in docs/configuration.md](docs/configuration.md#pg_stat_statements-setup).
63
63
 
64
+ ## Standalone (no host app)
65
+
66
+ You can also run the dashboard on its own, straight from the gem's root folder — no Rails app to mount it in. It serves at `/` on port **4000** and connects via `DATABASE_URL` or libpq env vars:
67
+
68
+ ```bash
69
+ ./bin/pg_reports server # from a checkout; no `bundle exec` needed
70
+ DATABASE_URL=postgres://user:pass@localhost/myapp bundle exec pg_reports server
71
+ ```
72
+
73
+ Adds no runtime dependencies to the gem. **[Standalone guide → docs/standalone.md](docs/standalone.md)**
74
+
64
75
  ## Usage
65
76
 
66
77
  ```ruby
@@ -73,12 +84,9 @@ report = PgReports.expensive_queries
73
84
  report.to_text
74
85
  report.to_csv
75
86
  report.to_a
76
-
77
- # Telegram
78
- PgReports.slow_queries.send_to_telegram
79
87
  ```
80
88
 
81
- **[Full list of reports →](docs/reports.md)**
89
+ **[Full list of reports →](docs/reports.md)**  ·  **[Send reports to Telegram →](docs/telegram.md)**
82
90
 
83
91
  ## Multi-database
84
92
 
@@ -100,8 +108,6 @@ PgReports works out of the box once mounted. Common options:
100
108
  ```ruby
101
109
  # config/initializers/pg_reports.rb
102
110
  PgReports.configure do |config|
103
- config.telegram_bot_token = ENV["PG_REPORTS_TELEGRAM_TOKEN"]
104
- config.telegram_chat_id = ENV["PG_REPORTS_TELEGRAM_CHAT_ID"]
105
111
  config.slow_query_threshold_ms = 100
106
112
  config.unused_index_threshold_scans = 50
107
113
  config.bloat_threshold_percent = 20
@@ -115,8 +121,8 @@ PgReports.configure do |config|
115
121
  end
116
122
  ```
117
123
 
118
- **Multi-database, thresholds, query monitor, Grafana, raw query execution, source tracking, locale, Telegram —**
119
- **[full reference in docs/configuration.md →](docs/configuration.md)**
124
+ **Multi-database, thresholds, query monitor, raw query execution, source tracking, locale —**
125
+ **[full reference in docs/configuration.md →](docs/configuration.md)**  ·  **[Telegram](docs/telegram.md)**  ·  **[Grafana / Prometheus](docs/grafana.md)**
120
126
 
121
127
  ## Report object
122
128
 
@@ -240,66 +246,9 @@ The Export dropdown includes **Copy Prompt** (visible on actionable reports). It
240
246
  <details>
241
247
  <summary><strong>Grafana / Prometheus exporter</strong></summary>
242
248
 
243
- Expose selected reports at `<mount_point>/metrics` in Prometheus exposition format. The default mount is `/pg_reports`, so the endpoint is typically `/pg_reports/metrics` but it follows whatever path you used in `mount PgReports::Engine, at: "..."`. Severity (`ok` / `warning` / `critical`) is derived automatically from the thresholds defined in [`Dashboard::ReportsRegistry::REPORT_CONFIG`](lib/pg_reports/dashboard/reports_registry.rb).
244
-
245
- ```ruby
246
- PgReports.configure do |config|
247
- config.grafana_favorites = [
248
- :slow_queries,
249
- :unused_indexes,
250
- :bloated_tables,
251
- :missing_validations,
252
- :polymorphic_without_index
253
- ]
254
- config.grafana_metrics_token = ENV["PG_REPORTS_METRICS_TOKEN"] # optional bearer token
255
- config.grafana_cache_ttl = 60 # seconds
256
- end
257
- ```
258
-
259
- Scrape with Prometheus:
260
-
261
- ```yaml
262
- scrape_configs:
263
- - job_name: pg_reports
264
- metrics_path: /pg_reports/metrics # adjust to your Engine mount point
265
- scrape_interval: 60s
266
- authorization: { credentials: "${PG_REPORTS_METRICS_TOKEN}" }
267
- static_configs:
268
- - targets: ["app.internal:3000"]
269
- ```
270
-
271
- > [!WARNING]
272
- > Reports are cached via `Rails.cache` for `grafana_cache_ttl` so frequent scrapes don't hammer the database. Without it, Prometheus' default 15s scrape interval against heavy reports like `missing_validations` will DDoS your own DB. Always set a TTL ≥ scrape interval, and consider a longer per-report TTL for expensive reports.
273
-
274
- The exporter also emits a `pg_reports_row` series per report row (each column becomes a Prometheus label), so the auto-generated dashboard can show a **table panel** with the actual rows that need fixing — not just an aggregate count.
275
-
276
- Generate a matching Grafana dashboard from the same favorites:
277
-
278
- ```bash
279
- bundle exec rake pg_reports:grafana:dashboard
280
- # writes pg_reports.json in pwd; then Dashboards → Import in Grafana
281
- ```
282
-
283
- **[Full Grafana integration guide →](docs/grafana.md)** &nbsp;·&nbsp; **[Local Prometheus + Grafana without Docker →](docs/grafana-local-setup.md)**
284
-
285
- </details>
286
-
287
- <details>
288
- <summary><strong>Telegram delivery</strong></summary>
289
-
290
- Get a bot token from [@BotFather](https://t.me/BotFather) and your chat ID from [@userinfobot](https://t.me/userinfobot), then:
291
-
292
- ```ruby
293
- PgReports.configure do |config|
294
- config.telegram_bot_token = "123456:ABC-DEF..."
295
- config.telegram_chat_id = "-1001234567890"
296
- end
297
-
298
- PgReports.slow_queries.send_to_telegram
299
- PgReports.health_report.send_to_telegram_as_file
300
- ```
249
+ Expose selected reports at `<mount_point>/metrics` in Prometheus exposition format, with severity (`ok` / `warning` / `critical`) derived automatically from each report's thresholds. Reports are cached per a configurable TTL so frequent scrapes don't hammer the database, and a matching Grafana dashboard can be generated from the same favorites (`rake pg_reports:grafana:dashboard`).
301
250
 
302
- Reports under ~50 rows go as a message; larger ones are sent as a file attachment.
251
+ **[Grafana / Prometheus integration guide →](docs/grafana.md)** &nbsp;·&nbsp; **[Local Prometheus + Grafana without Docker →](docs/grafana-local-setup.md)**
303
252
 
304
253
  </details>
305
254
 
@@ -323,8 +272,8 @@ bundle exec rubocop
323
272
 
324
273
  ## License
325
274
 
326
- MIT. See [LICENSE.txt](LICENSE.txt).
275
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
327
276
 
328
277
  ## Acknowledgments
329
278
 
330
- Inspired by [rails-pg-extras](https://github.com/pawurb/rails-pg-extras). UI built with [Claude](https://www.anthropic.com/claude) by Anthropic.
279
+ Inspired by [rails-pg-extras](https://github.com/pawurb/rails-pg-extras) and built with ❤️ for the Rails community.
@@ -70,7 +70,7 @@ module PgReports
70
70
  end
71
71
 
72
72
  def live_metrics
73
- threshold = params[:long_query_threshold]&.to_i || 60
73
+ threshold = params[:long_query_threshold]&.to_i || 5
74
74
 
75
75
  # Check if we have access to required statistics
76
76
  begin
@@ -113,13 +113,32 @@
113
113
  display: flex;
114
114
  align-items: center;
115
115
  gap: 0.4rem;
116
- padding: 0.4rem 0.75rem;
116
+ height: 2.125rem;
117
+ padding: 0 0.75rem;
117
118
  background: var(--bg-tertiary);
118
119
  border: 1px solid var(--border-color);
119
120
  border-radius: 4px;
120
121
  font-size: 0.75rem;
121
122
  }
122
123
 
124
+ .header-badge-clickable {
125
+ cursor: pointer;
126
+ font-family: inherit;
127
+ color: inherit;
128
+ transition: border-color 0.15s ease, background 0.15s ease;
129
+ }
130
+
131
+ .header-badge-clickable:hover {
132
+ background: var(--bg-card);
133
+ border-color: var(--text-muted);
134
+ }
135
+
136
+ .badge-info-icon {
137
+ color: var(--text-muted);
138
+ font-size: 0.85rem;
139
+ line-height: 1;
140
+ }
141
+
123
142
  .badge-dot {
124
143
  width: 8px;
125
144
  height: 8px;
@@ -306,13 +325,14 @@
306
325
  }
307
326
 
308
327
  .btn-primary {
309
- background: var(--accent-purple);
310
- color: #fff;
311
- border-color: var(--accent-purple);
328
+ background: rgba(124, 138, 246, 0.14);
329
+ color: var(--accent-purple);
330
+ border-color: rgba(124, 138, 246, 0.35);
312
331
  }
313
332
 
314
333
  .btn-primary:hover {
315
- background: #6b79e4;
334
+ background: rgba(124, 138, 246, 0.24);
335
+ border-color: rgba(124, 138, 246, 0.55);
316
336
  }
317
337
 
318
338
  .btn-secondary {
@@ -634,6 +654,7 @@
634
654
  }
635
655
 
636
656
  .query-monitoring-scope {
657
+ position: relative;
637
658
  font-weight: 400;
638
659
  font-size: 0.75rem;
639
660
  color: var(--text-muted);
@@ -643,6 +664,40 @@
643
664
  cursor: help;
644
665
  }
645
666
 
667
+ .query-monitoring-scope::after {
668
+ content: attr(data-tooltip);
669
+ position: absolute;
670
+ top: calc(100% + 8px);
671
+ left: 0;
672
+ z-index: 100;
673
+ width: max-content;
674
+ max-width: 320px;
675
+ padding: 0.6rem 0.75rem;
676
+ background: var(--bg-card);
677
+ color: var(--text-secondary);
678
+ border: 1px solid var(--border-color);
679
+ border-radius: 6px;
680
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
681
+ font-size: 0.75rem;
682
+ font-weight: 400;
683
+ line-height: 1.5;
684
+ white-space: normal;
685
+ text-align: left;
686
+ opacity: 0;
687
+ visibility: hidden;
688
+ transform: translateY(-4px);
689
+ transition: opacity 0.15s ease, transform 0.15s ease;
690
+ pointer-events: none;
691
+ }
692
+
693
+ .query-monitoring-scope:hover::after,
694
+ .query-monitoring-scope:focus::after,
695
+ .query-monitoring-scope:focus-visible::after {
696
+ opacity: 1;
697
+ visibility: visible;
698
+ transform: translateY(0);
699
+ }
700
+
646
701
  .monitoring-indicator {
647
702
  width: 7px;
648
703
  height: 7px;
@@ -1000,6 +1055,10 @@
1000
1055
  max-width: 360px;
1001
1056
  }
1002
1057
 
1058
+ .modal-medium {
1059
+ max-width: 560px;
1060
+ }
1061
+
1003
1062
  /* Database selector — used inside .live-monitoring-title and .breadcrumb.
1004
1063
  Sizes to fit its longest option (no artificial cap), capped at the
1005
1064
  parent's available width so it never overruns neighboring controls. */
@@ -1128,7 +1187,10 @@
1128
1187
  <div id="ide-menu" class="ide-menu"></div>
1129
1188
 
1130
1189
  <script>
1131
- const pgReportsRoot = document.querySelector('meta[name="pg-reports-root"]')?.content || '/pg_reports';
1190
+ // Trailing slash stripped so `${pgReportsRoot}/foo` never becomes `//foo`
1191
+ // a protocol-relative URL (host "foo") — when the engine is mounted at "/"
1192
+ // (e.g. standalone mode), where the mount path resolves to "/".
1193
+ const pgReportsRoot = (document.querySelector('meta[name="pg-reports-root"]')?.content || '/pg_reports').replace(/\/+$/, '');
1132
1194
  window.PG_REPORTS_I18N = <%= raw I18n.t("pg_reports.ui").to_json %>;
1133
1195
 
1134
1196
  // Format strings with %{var} placeholders
@@ -1137,6 +1199,26 @@
1137
1199
  return template.replace(/%\{(\w+)\}/g, function(_, k) { return vars[k] != null ? vars[k] : ''; });
1138
1200
  };
1139
1201
 
1202
+ // Close the topmost open modal on ESC. Applies to every `.modal` that has a
1203
+ // close (×) button — it triggers that button so each modal's own close
1204
+ // handler (and any cleanup) runs.
1205
+ document.addEventListener('keydown', function(e) {
1206
+ if (e.key !== 'Escape' && e.key !== 'Esc') return;
1207
+ const open = Array.prototype.slice.call(document.querySelectorAll('.modal'))
1208
+ .filter(function(m) {
1209
+ return getComputedStyle(m).display !== 'none' && m.querySelector('.modal-close');
1210
+ });
1211
+ if (!open.length) return;
1212
+ e.preventDefault();
1213
+ const modal = open[open.length - 1];
1214
+ const closeBtn = modal.querySelector('.modal-close');
1215
+ if (closeBtn) {
1216
+ closeBtn.click();
1217
+ } else {
1218
+ modal.style.display = 'none';
1219
+ }
1220
+ });
1221
+
1140
1222
  function showToast(message, type = 'success') {
1141
1223
  const toast = document.getElementById('toast');
1142
1224
  toast.textContent = message;
@@ -27,28 +27,34 @@
27
27
  <button class="btn btn-small btn-muted" onclick="showResetConfirmModal()" id="reset-btn">
28
28
  <%= t("pg_reports.ui.actions.reset_statistics") %>
29
29
  </button>
30
- <% else %>
31
- <button class="btn btn-small btn-primary" onclick="enablePgStatStatements(this)" id="enable-btn">
32
- <%= t("pg_reports.ui.actions.create_extension") %>
33
- </button>
34
- <button class="btn-info" onclick="showPgStatInfo()">?</button>
35
30
  <% end %>
36
31
  <button class="btn-info" onclick="showIdeSettingsModal()" title="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>">⚙️</button>
37
- <div class="header-badge" id="pg-stat-badge">
38
- <% if @pg_stat_status[:ready] %>
32
+
33
+ <% if !@pg_stat_status[:connected] %>
34
+ <div class="header-badge" id="pg-stat-badge">
35
+ <span class="badge-dot error"></span>
36
+ <span><%= t("pg_reports.ui.status.disconnected") %></span>
37
+ </div>
38
+ <% elsif @pg_stat_status[:ready] %>
39
+ <div class="header-badge" id="pg-stat-badge">
39
40
  <span class="badge-dot"></span>
40
41
  <span><%= t("pg_reports.ui.status.pg_stat_ready") %></span>
41
- <% elsif @pg_stat_status[:extension_installed] %>
42
+ </div>
43
+ <% elsif @pg_stat_status[:extension_installed] %>
44
+ <button type="button" class="header-badge header-badge-clickable" id="pg-stat-badge"
45
+ onclick="showPgStatInfo()" title="<%= t("pg_reports.ui.status.click_for_details") %>">
42
46
  <span class="badge-dot warning"></span>
43
- <span><%= t("pg_reports.ui.status.extension_installed") %></span>
44
- <% elsif @pg_stat_status[:preloaded] %>
47
+ <span><%= t("pg_reports.ui.status.not_preloaded") %></span>
48
+ <span class="badge-info-icon" aria-hidden="true">ⓘ</span>
49
+ </button>
50
+ <% else %>
51
+ <button type="button" class="header-badge header-badge-clickable" id="pg-stat-badge"
52
+ onclick="showCreateExtensionModal()" title="<%= t("pg_reports.ui.status.click_for_details") %>">
45
53
  <span class="badge-dot warning"></span>
46
- <span><%= t("pg_reports.ui.status.preloaded") %></span>
47
- <% else %>
48
- <span class="badge-dot error"></span>
49
- <span><%= t("pg_reports.ui.status.not_configured") %></span>
50
- <% end %>
51
- </div>
54
+ <span><%= t("pg_reports.ui.status.extension_missing") %></span>
55
+ <span class="badge-info-icon" aria-hidden="true">ⓘ</span>
56
+ </button>
57
+ <% end %>
52
58
  </div>
53
59
  </header>
54
60
 
@@ -81,6 +87,25 @@ pg_stat_statements.track = all</pre>
81
87
  </div>
82
88
  </div>
83
89
 
90
+ <!-- Create extension modal -->
91
+ <div id="create-extension-modal" class="modal" style="display: none;">
92
+ <div class="modal-content modal-medium">
93
+ <div class="modal-header">
94
+ <h3><%= t("pg_reports.ui.modals.create_extension_title") %></h3>
95
+ <button class="modal-close" onclick="closeCreateExtensionModal()">×</button>
96
+ </div>
97
+ <div class="modal-body">
98
+ <p><%= t("pg_reports.ui.modals.create_extension_intro") %></p>
99
+ <pre>CREATE EXTENSION IF NOT EXISTS pg_stat_statements;</pre>
100
+ <p class="warning-subtext"><%= t("pg_reports.ui.modals.create_extension_note") %></p>
101
+ <div class="modal-actions">
102
+ <button class="btn btn-secondary" onclick="closeCreateExtensionModal()"><%= t("pg_reports.ui.actions.cancel") %></button>
103
+ <button class="btn btn-primary" onclick="enablePgStatStatements(this)" id="modal-create-extension-btn"><%= t("pg_reports.ui.actions.create_extension") %></button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+
84
109
  <!-- Reset statistics confirmation modal -->
85
110
  <div id="reset-confirm-modal" class="modal" style="display: none;">
86
111
  <div class="modal-content modal-small">
@@ -267,7 +292,7 @@ pg_stat_statements.track = all</pre>
267
292
  <div class="query-monitoring-title">
268
293
  <span class="monitoring-indicator" id="monitor-indicator"></span>
269
294
  <span><%= t("pg_reports.ui.monitoring.query_monitor_title") %></span>
270
- <span class="query-monitoring-scope" title="<%= t("pg_reports.ui.monitoring.scope_note_long", default: "Query Monitor subscribes to ActiveSupport::Notifications in the host application's process. It captures every SQL the host app runs, on whichever database the host app is connected to. The dashboard's database selector does not change this scope.") %>">
295
+ <span class="query-monitoring-scope" tabindex="0" data-tooltip="<%= t("pg_reports.ui.monitoring.scope_note_long", default: "Query Monitor subscribes to ActiveSupport::Notifications in the host application's process. It captures every SQL the host app runs, on whichever database the host app is connected to. The dashboard's database selector does not change this scope.") %>">
271
296
  <%= t("pg_reports.ui.monitoring.scope_note_short", default: "scope: host application") %>
272
297
  </span>
273
298
  <span class="session-badge" id="session-badge" style="display: none;">
@@ -363,7 +388,37 @@ pg_stat_statements.track = all</pre>
363
388
  <% end %>
364
389
  </div>
365
390
 
391
+ <footer class="dashboard-footer">
392
+ <a href="https://github.com/deadalice/pg_reports" target="_blank" rel="noopener noreferrer">github.com/deadalice/pg_reports</a>
393
+ <span class="footer-sep">·</span>
394
+ <a href="mailto:deadalice@gmail.com">deadalice@gmail.com</a>
395
+ </footer>
396
+
366
397
  <style>
398
+ .dashboard-footer {
399
+ margin-top: 2rem;
400
+ padding-top: 1.25rem;
401
+ border-top: 1px solid var(--border-color);
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ gap: 0.6rem;
406
+ flex-wrap: wrap;
407
+ color: var(--text-muted);
408
+ font-size: 0.8rem;
409
+ }
410
+ .dashboard-footer a {
411
+ color: var(--text-secondary);
412
+ text-decoration: none;
413
+ }
414
+ .dashboard-footer a:hover {
415
+ color: var(--accent-purple);
416
+ text-decoration: underline;
417
+ }
418
+ .dashboard-footer .footer-sep {
419
+ opacity: 0.5;
420
+ }
421
+
367
422
  .header-actions {
368
423
  display: flex;
369
424
  align-items: center;
@@ -375,15 +430,26 @@ pg_stat_statements.track = all</pre>
375
430
  font-size: 0.8rem;
376
431
  }
377
432
 
433
+ /* Keep the header action button the same height as the status badge and
434
+ the settings button (all 2.125rem); inline-flex centers the label so no
435
+ vertical padding is needed. */
436
+ .header-actions .btn {
437
+ height: 2.125rem;
438
+ padding: 0 1rem;
439
+ }
440
+
378
441
  .btn-info {
379
- width: 24px;
380
- height: 24px;
442
+ width: 2.125rem;
443
+ height: 2.125rem;
381
444
  padding: 0;
445
+ display: inline-flex;
446
+ align-items: center;
447
+ justify-content: center;
382
448
  background: var(--bg-tertiary);
383
449
  color: var(--text-muted);
384
450
  border: 1px solid var(--border-color);
385
- border-radius: 50%;
386
- font-size: 0.8rem;
451
+ border-radius: 4px;
452
+ font-size: 0.9rem;
387
453
  font-weight: 600;
388
454
  cursor: pointer;
389
455
  transition: all 0.15s;
@@ -431,7 +497,7 @@ pg_stat_statements.track = all</pre>
431
497
 
432
498
  .category-warning {
433
499
  padding: 0.625rem 1rem;
434
- margin: -1.5rem -1.5rem 1rem -1.5rem;
500
+ margin: -1rem -1rem 1rem -1rem;
435
501
  background: rgba(245, 158, 11, 0.1);
436
502
  border-bottom: 1px solid rgba(245, 158, 11, 0.2);
437
503
  border-radius: 6px 6px 0 0;
@@ -857,6 +923,18 @@ pg_stat_statements.track = all</pre>
857
923
  if (e.target === this) closePgStatModal();
858
924
  });
859
925
 
926
+ function showCreateExtensionModal() {
927
+ document.getElementById('create-extension-modal').style.display = 'flex';
928
+ }
929
+
930
+ function closeCreateExtensionModal() {
931
+ document.getElementById('create-extension-modal').style.display = 'none';
932
+ }
933
+
934
+ document.getElementById('create-extension-modal')?.addEventListener('click', function(e) {
935
+ if (e.target === this) closeCreateExtensionModal();
936
+ });
937
+
860
938
  function showResetConfirmModal() {
861
939
  document.getElementById('reset-confirm-modal').style.display = 'flex';
862
940
  }
@@ -923,6 +1001,7 @@ pg_stat_statements.track = all</pre>
923
1001
  } else {
924
1002
  showToast(data.message, 'error');
925
1003
  if (data.requires_restart) {
1004
+ closeCreateExtensionModal();
926
1005
  showPgStatInfo();
927
1006
  }
928
1007
  button.disabled = false;
data/bin/pg_reports ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Soft bundler shim: when run straight from the gem checkout (a Gemfile sits at
5
+ # the repo root, next to bin/), activate the bundle so the local `lib` and the
6
+ # dev-group web server are on the load path — letting `./bin/pg_reports` work
7
+ # without an explicit `bundle exec`. Installed as a gem there is no Gemfile
8
+ # alongside the executable, so this is skipped and RubyGems resolves everything.
9
+ gemfile = File.expand_path("../Gemfile", __dir__)
10
+ if File.exist?(gemfile) && !defined?(Bundler)
11
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
12
+ begin
13
+ require "bundler/setup"
14
+ rescue LoadError
15
+ # Bundler unavailable — fall through and let RubyGems/$LOAD_PATH resolve.
16
+ end
17
+ end
18
+
19
+ require "optparse"
20
+
21
+ options = {
22
+ port: 4000,
23
+ host: "127.0.0.1",
24
+ mount: "/",
25
+ database_url: nil,
26
+ server: nil
27
+ }
28
+
29
+ banner = <<~BANNER
30
+ pg_reports — self-contained PostgreSQL insights dashboard
31
+
32
+ Usage:
33
+ pg_reports server [options]
34
+
35
+ Connection is resolved from --database-url, else DATABASE_URL, else the
36
+ standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE).
37
+
38
+ Options:
39
+ BANNER
40
+
41
+ parser = OptionParser.new do |o|
42
+ o.banner = banner
43
+ o.on("-p", "--port PORT", Integer, "Port to listen on (default: 4000)") { |v| options[:port] = v }
44
+ o.on("-b", "--host HOST", "Host/interface to bind (default: 127.0.0.1)") { |v| options[:host] = v }
45
+ o.on("-m", "--mount PATH", "Path to mount the dashboard at (default: /)") { |v| options[:mount] = v }
46
+ o.on("-d", "--database-url URL", "PostgreSQL connection URL") { |v| options[:database_url] = v }
47
+ o.on("-s", "--server NAME", "Web server to use (e.g. puma, webrick)") { |v| options[:server] = v }
48
+ o.on("-h", "--help", "Show this help") do
49
+ puts o
50
+ exit
51
+ end
52
+ o.on("-v", "--version", "Show version") do
53
+ require "pg_reports/version"
54
+ puts PgReports::VERSION
55
+ exit
56
+ end
57
+ end
58
+
59
+ # parse! permutes, so options are recognized before or after the subcommand
60
+ # (e.g. `pg_reports server --port 4055`).
61
+ parser.parse!(ARGV)
62
+ command = ARGV.shift || "server"
63
+
64
+ case command
65
+ when "server"
66
+ require "pg_reports"
67
+ begin
68
+ PgReports::Standalone.run(
69
+ port: options[:port],
70
+ host: options[:host],
71
+ mount_path: options[:mount],
72
+ database_url: options[:database_url],
73
+ server: options[:server]
74
+ )
75
+ rescue PgReports::Standalone::ServerUnavailable => e
76
+ warn e.message
77
+ exit 1
78
+ rescue Interrupt
79
+ warn "\npg_reports: stopped"
80
+ end
81
+ else
82
+ warn "Unknown command: #{command.inspect}"
83
+ warn parser.help
84
+ exit 1
85
+ end
@@ -633,11 +633,12 @@ en:
633
633
  save_for_comparison: "📌 Save for Comparison"
634
634
  saved_marker: "📌 Saved"
635
635
  status:
636
- pg_stat_ready: "pg_stat_statements ready"
637
- extension_installed: "Extension installed, not preloaded"
638
- preloaded: "Preloaded, extension not created"
639
- not_configured: "Not configured"
636
+ pg_stat_ready: "Active"
637
+ not_preloaded: "Preload required"
638
+ extension_missing: "Extension required"
639
+ disconnected: "No connection"
640
640
  monitoring_unavailable: "Status Unavailable"
641
+ click_for_details: "Click to see how to enable it"
641
642
  modals:
642
643
  enable_pg_stat_title: "Enable pg_stat_statements"
643
644
  enable_pg_stat_intro: "To enable pg_stat_statements, follow these steps:"
@@ -645,6 +646,9 @@ en:
645
646
  restart_postgresql: "Restart PostgreSQL:"
646
647
  create_extension_step: "Create extension:"
647
648
  enable_button_note: "Or click \"Enable\" button after restart."
649
+ create_extension_title: "Create pg_stat_statements extension"
650
+ create_extension_intro: "The pg_stat_statements extension isn't created in this database yet. Click below to create it:"
651
+ create_extension_note: "If the library isn't preloaded, you'll also need to add it to shared_preload_libraries and restart PostgreSQL."
648
652
  reset_stats_title: "⚠️ Reset Statistics"
649
653
  reset_stats_confirm: "Are you sure you want to reset pg_stat_statements statistics?"
650
654
  reset_stats_warning: "This action will clear all collected query statistics and cannot be undone."
@@ -691,7 +695,7 @@ en:
691
695
  cache_hit_detail: "heap blocks from cache"
692
696
  long_queries_label: "Long Queries"
693
697
  queries_unit: "queries"
694
- long_running_threshold: "> 60s runtime"
698
+ long_running_threshold: "> 5s runtime"
695
699
  blocked_label: "Blocked"
696
700
  processes_unit: "processes"
697
701
  waiting_for_locks: "waiting for locks"
@@ -598,11 +598,12 @@ ru:
598
598
  save_for_comparison: "📌 Сохранить для сравнения"
599
599
  saved_marker: "📌 Сохранено"
600
600
  status:
601
- pg_stat_ready: "pg_stat_statements готов"
602
- extension_installed: "Расширение установлено, не предзагружено"
603
- preloaded: "Предзагружено, расширение не создано"
604
- not_configured: "Не настроено"
601
+ pg_stat_ready: "Активно"
602
+ not_preloaded: "Требуется предзагрузка"
603
+ extension_missing: "Требуется расширение"
604
+ disconnected: "Нет соединения"
605
605
  monitoring_unavailable: "Статус недоступен"
606
+ click_for_details: "Нажмите, чтобы узнать, как включить"
606
607
  modals:
607
608
  enable_pg_stat_title: "Включение pg_stat_statements"
608
609
  enable_pg_stat_intro: "Чтобы включить pg_stat_statements, выполните следующие шаги:"
@@ -610,6 +611,9 @@ ru:
610
611
  restart_postgresql: "Перезапустите PostgreSQL:"
611
612
  create_extension_step: "Создайте расширение:"
612
613
  enable_button_note: "Или нажмите кнопку «Создать расширение» после перезапуска."
614
+ create_extension_title: "Создание расширения pg_stat_statements"
615
+ create_extension_intro: "Расширение pg_stat_statements ещё не создано в этой базе данных. Нажмите, чтобы создать его:"
616
+ create_extension_note: "Если библиотека не предзагружена, её также нужно добавить в shared_preload_libraries и перезапустить PostgreSQL."
613
617
  reset_stats_title: "⚠️ Сброс статистики"
614
618
  reset_stats_confirm: "Вы уверены, что хотите сбросить статистику pg_stat_statements?"
615
619
  reset_stats_warning: "Это действие очистит всю собранную статистику запросов и не может быть отменено."
@@ -656,7 +660,7 @@ ru:
656
660
  cache_hit_detail: "блоки heap из кэша"
657
661
  long_queries_label: "Долгие запросы"
658
662
  queries_unit: "запросов"
659
- long_running_threshold: "> 60с выполнения"
663
+ long_running_threshold: "> 5с выполнения"
660
664
  blocked_label: "Заблокировано"
661
665
  processes_unit: "процессов"
662
666
  waiting_for_locks: "ждут блокировок"
@@ -598,11 +598,12 @@ uk:
598
598
  save_for_comparison: "📌 Зберегти для порівняння"
599
599
  saved_marker: "📌 Збережено"
600
600
  status:
601
- pg_stat_ready: "pg_stat_statements готовий"
602
- extension_installed: "Розширення встановлено, не передзавантажено"
603
- preloaded: "Передзавантажено, розширення не створено"
604
- not_configured: "Не налаштовано"
601
+ pg_stat_ready: "Активно"
602
+ not_preloaded: "Потрібне передзавантаження"
603
+ extension_missing: "Потрібне розширення"
604
+ disconnected: "Немає з'єднання"
605
605
  monitoring_unavailable: "Статус недоступний"
606
+ click_for_details: "Натисніть, щоб дізнатися, як увімкнути"
606
607
  modals:
607
608
  enable_pg_stat_title: "Увімкнення pg_stat_statements"
608
609
  enable_pg_stat_intro: "Щоб увімкнути pg_stat_statements, виконайте такі кроки:"
@@ -610,6 +611,9 @@ uk:
610
611
  restart_postgresql: "Перезапустіть PostgreSQL:"
611
612
  create_extension_step: "Створіть розширення:"
612
613
  enable_button_note: "Або натисніть кнопку «Створити розширення» після перезапуску."
614
+ create_extension_title: "Створення розширення pg_stat_statements"
615
+ create_extension_intro: "Розширення pg_stat_statements ще не створено в цій базі даних. Натисніть, щоб створити його:"
616
+ create_extension_note: "Якщо бібліотеку не передзавантажено, її також потрібно додати до shared_preload_libraries та перезапустити PostgreSQL."
613
617
  reset_stats_title: "⚠️ Скидання статистики"
614
618
  reset_stats_confirm: "Ви впевнені, що хочете скинути статистику pg_stat_statements?"
615
619
  reset_stats_warning: "Ця дія очистить усю зібрану статистику запитів і не може бути скасована."
@@ -656,7 +660,7 @@ uk:
656
660
  cache_hit_detail: "блоки heap із кешу"
657
661
  long_queries_label: "Довгі запити"
658
662
  queries_unit: "запитів"
659
- long_running_threshold: "> 60с виконання"
663
+ long_running_threshold: "> 5с виконання"
660
664
  blocked_label: "Заблоковано"
661
665
  processes_unit: "процесів"
662
666
  waiting_for_locks: "чекають блокувань"
@@ -49,7 +49,7 @@ module PgReports
49
49
  # List all databases visible on this target's cluster (using pg_database).
50
50
  # Result rows: { "name" => String, "size" => String, "current" => Boolean }
51
51
  def list_databases(current: nil)
52
- rows = connection_for.exec_query(<<~SQL).to_a
52
+ rows = connection_for.exec_query(<<~SQL, "PgReports").to_a
53
53
  SELECT datname AS name,
54
54
  pg_size_pretty(pg_database_size(datname)) AS size
55
55
  FROM pg_database
@@ -17,10 +17,15 @@ module PgReports
17
17
  execute(sql, **params)
18
18
  end
19
19
 
20
- # Execute raw SQL and return results as array of hashes
20
+ # Execute raw SQL and return results as array of hashes.
21
+ #
22
+ # Every query is tagged with the "PgReports" AR statement name so the Query
23
+ # Monitor can skip our own queries by name (see QueryMonitor#should_skip?),
24
+ # reliably and independent of backtrace depth — the internal live_metrics /
25
+ # status polling would otherwise leak into the monitor's history.
21
26
  def execute(sql, **params)
22
27
  processed_sql = interpolate_params(sql, params)
23
- result = connection.exec_query(processed_sql)
28
+ result = connection.exec_query(processed_sql, "PgReports")
24
29
  result.to_a
25
30
  end
26
31
 
@@ -44,13 +44,37 @@ module PgReports
44
44
  false
45
45
  end
46
46
 
47
- # Get pg_stat_statements status details
47
+ # Whether the database connection can execute a basic query.
48
+ # Used to tell "the connection itself is down" apart from
49
+ # "connected, but pg_stat_statements isn't set up yet".
50
+ # @return [Boolean]
51
+ def connected?
52
+ executor.execute("SELECT 1")
53
+ true
54
+ rescue
55
+ false
56
+ end
57
+
58
+ # Get pg_stat_statements status details.
59
+ #
60
+ # Note: whether pg_stat_statements is in shared_preload_libraries cannot be
61
+ # read by a plain monitoring role (that requires the pg_read_all_settings
62
+ # role), so we never look at the setting. Instead we derive the state from
63
+ # signals every role can observe: can we run a query at all, does the
64
+ # extension exist in pg_extension, and is its view queryable.
65
+ #
48
66
  # @return [Hash] Status information
49
67
  def pg_stat_statements_status
68
+ unless connected?
69
+ return {connected: false, extension_installed: false, preloaded: false, ready: false}
70
+ end
71
+
72
+ installed = pg_stat_statements_available?
50
73
  {
51
- extension_installed: pg_stat_statements_available?,
74
+ connected: true,
75
+ extension_installed: installed,
52
76
  preloaded: pg_stat_statements_preloaded?,
53
- ready: pg_stat_statements_available? && pg_stat_statements_preloaded?
77
+ ready: installed && pg_stat_statements_preloaded?
54
78
  }
55
79
  end
56
80
 
@@ -58,7 +82,7 @@ module PgReports
58
82
  # @param long_query_threshold [Integer] Threshold in seconds for long queries
59
83
  # @return [Hash] Metrics data
60
84
  # @raise [StandardError] If no data is returned
61
- def live_metrics(long_query_threshold: 60)
85
+ def live_metrics(long_query_threshold: 5)
62
86
  data = executor.execute_from_file(:system, :live_metrics,
63
87
  long_query_threshold: long_query_threshold)
64
88
 
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module PgReports
6
+ # Runs the dashboard as a self-contained application, without a host Rails app.
7
+ #
8
+ # It boots a minimal Rails::Application that mounts PgReports::Engine and points
9
+ # ActiveRecord::Base at a PostgreSQL database, then serves it over HTTP. This is
10
+ # what powers the `pg_reports server` executable and the `pg_reports:server`
11
+ # rake task, so the project can be launched straight from the gem's root folder.
12
+ #
13
+ # Dependency note: this relies only on gems already pulled in transitively by
14
+ # the gem's runtime deps (rack via actionpack, rackup via railties). The actual
15
+ # web server (puma / webrick) is resolved at run time and is NOT a hard
16
+ # dependency — installed-gem users bring their own.
17
+ module Standalone
18
+ extend self
19
+
20
+ DEFAULT_PORT = 4000
21
+ DEFAULT_HOST = "127.0.0.1"
22
+ DEFAULT_MOUNT = "/"
23
+
24
+ # Rack handlers tried, in order, when none is named explicitly.
25
+ CANDIDATE_SERVERS = %w[puma webrick].freeze
26
+
27
+ class ServerUnavailable < PgReports::Error; end
28
+
29
+ # Boot the app and start a (blocking) web server.
30
+ #
31
+ # @param port [Integer]
32
+ # @param host [String]
33
+ # @param mount_path [String] where the engine is mounted (default "/")
34
+ # @param database_url [String, nil] explicit connection URL; otherwise resolved
35
+ # from DATABASE_URL or libpq-style PG* env vars
36
+ # @param server [String, nil] Rack handler name to force (e.g. "puma")
37
+ def run(port: DEFAULT_PORT, host: DEFAULT_HOST, mount_path: DEFAULT_MOUNT,
38
+ database_url: nil, server: nil)
39
+ # Rails' ActiveRecord railtie reads the connection from DATABASE_URL when no
40
+ # config/database.yml exists — so we route our resolved connection through
41
+ # it. The connection registry then auto-registers it as the :primary target,
42
+ # and database switching / multi-cluster all work unchanged.
43
+ ENV["DATABASE_URL"] = connection_url(database_url)
44
+
45
+ app = build_application(mount_path)
46
+ app.initialize!
47
+ verify_connection!
48
+
49
+ handler_name, handler = resolve_server(server)
50
+ banner(host: host, port: port, server: handler_name)
51
+ handler.run(app, Host: host, Port: port)
52
+ end
53
+
54
+ # Resolve the connection URL. Priority: explicit url > DATABASE_URL >
55
+ # libpq-style PG* env vars (PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE).
56
+ def connection_url(explicit = nil)
57
+ return explicit if explicit && !explicit.empty?
58
+ return ENV["DATABASE_URL"] if ENV["DATABASE_URL"] && !ENV["DATABASE_URL"].empty?
59
+
60
+ require "erb"
61
+ user = ENV["PGUSER"] || ENV["USER"]
62
+ password = ENV["PGPASSWORD"]
63
+ host = ENV["PGHOST"] || "localhost"
64
+ port = ENV["PGPORT"] || 5432
65
+ database = ENV["PGDATABASE"] || "postgres"
66
+
67
+ userinfo = +""
68
+ if user && !user.empty?
69
+ userinfo << ERB::Util.url_encode(user)
70
+ userinfo << ":#{ERB::Util.url_encode(password)}" if password && !password.empty?
71
+ userinfo << "@"
72
+ end
73
+
74
+ "postgresql://#{userinfo}#{host}:#{port}/#{ERB::Util.url_encode(database)}"
75
+ end
76
+
77
+ private
78
+
79
+ # Build (and register as Rails.application) a minimal Rails app that mounts
80
+ # the engine. Kept intentionally small: no asset pipeline (views are inline),
81
+ # cookie sessions for the dashboard's database selector + CSRF.
82
+ def build_application(mount_path)
83
+ require "rails"
84
+ require "action_controller/railtie"
85
+ require "active_record/railtie"
86
+ require "tmpdir"
87
+ # pg_reports.rb only requires the engine when Rails::Engine is already
88
+ # defined; when loaded outside a Rails app that guard was false, so load it
89
+ # now that the railties are present. This also registers its initializers.
90
+ require "pg_reports/engine"
91
+
92
+ target_mount = mount_path
93
+ # A throwaway, empty app root. We must NOT use the gem root here — Rails
94
+ # would then load the gem's engine config/routes.rb (and config/locales) as
95
+ # the *application's* own, which double-loads and breaks. The engine loads
96
+ # those itself relative to its own root; the app only needs the mount below.
97
+ app_root = Dir.mktmpdir("pg_reports-standalone")
98
+ at_exit { FileUtils.remove_entry(app_root, true) }
99
+
100
+ Class.new(Rails::Application) do
101
+ config.root = app_root
102
+ config.eager_load = false
103
+ config.consider_all_requests_local = true
104
+ config.secret_key_base = ENV["SECRET_KEY_BASE"] || SecureRandom.hex(64)
105
+ config.session_store :cookie_store, key: "_pg_reports_session"
106
+ config.hosts.clear # local tool: don't block by Host header
107
+ config.logger = ::Logger.new($stdout)
108
+ config.log_level = (ENV["LOG_LEVEL"] || "info").to_sym
109
+ config.active_support.report_deprecations = false
110
+
111
+ routes.append do
112
+ mount PgReports::Engine, at: target_mount, as: "pg_reports"
113
+ end
114
+ end
115
+ end
116
+
117
+ # Force an actual connection so the user gets a clear error at startup rather
118
+ # than a 500 on the first request when the database is unreachable.
119
+ def verify_connection!
120
+ ActiveRecord::Base.connection
121
+ rescue => e
122
+ raise PgReports::Error, "Cannot connect to the database (#{ENV["DATABASE_URL"]}): #{e.message}"
123
+ end
124
+
125
+ # Find a usable Rack handler. Honors an explicit name, otherwise tries the
126
+ # candidates in order and uses the first that is installed.
127
+ def resolve_server(name)
128
+ require "rackup"
129
+
130
+ candidates = name ? [name] : CANDIDATE_SERVERS
131
+ candidates.each do |candidate|
132
+ handler = begin
133
+ Rackup::Handler.get(candidate)
134
+ rescue LoadError, NameError
135
+ nil
136
+ end
137
+ return [candidate, handler] if handler
138
+ end
139
+
140
+ raise ServerUnavailable, <<~MSG.strip
141
+ No web server found (tried: #{candidates.join(", ")}).
142
+ Add one to run the standalone dashboard, e.g. `gem install puma`
143
+ (or add `gem "puma"` to your Gemfile).
144
+ MSG
145
+ end
146
+
147
+ def banner(host:, port:, server:)
148
+ url_host = (host == "0.0.0.0") ? "localhost" : host
149
+ warn "pg_reports: serving dashboard via #{server} on http://#{url_host}:#{port} (Ctrl-C to stop)"
150
+ end
151
+ end
152
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.1"
5
5
  end
data/lib/pg_reports.rb CHANGED
@@ -43,6 +43,10 @@ require_relative "pg_reports/grafana/dashboard_builder"
43
43
  # Rails Engine
44
44
  require_relative "pg_reports/engine" if defined?(Rails::Engine)
45
45
 
46
+ # Standalone runner (no host app). Only defines methods; the heavy Rails/web
47
+ # requires happen lazily inside PgReports::Standalone.run.
48
+ require_relative "pg_reports/standalone"
49
+
46
50
  module PgReports
47
51
  class << self
48
52
  # Query analysis methods
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_reports
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eldar Avatov
@@ -140,7 +140,8 @@ description: A comprehensive PostgreSQL monitoring and analysis library that pro
140
140
  a beautiful web dashboard and Telegram notifications.
141
141
  email:
142
142
  - eldar.avatov@gmail.com
143
- executables: []
143
+ executables:
144
+ - pg_reports
144
145
  extensions: []
145
146
  extra_rdoc_files: []
146
147
  files:
@@ -158,6 +159,7 @@ files:
158
159
  - app/views/pg_reports/dashboard/_target_selector.html.erb
159
160
  - app/views/pg_reports/dashboard/index.html.erb
160
161
  - app/views/pg_reports/dashboard/show.html.erb
162
+ - bin/pg_reports
161
163
  - config/locales/en.yml
162
164
  - config/locales/ru.yml
163
165
  - config/locales/uk.yml
@@ -284,6 +286,7 @@ files:
284
286
  - lib/pg_reports/sql/tables/update_hotspots.sql
285
287
  - lib/pg_reports/sql/tables/vacuum_needed.sql
286
288
  - lib/pg_reports/sql_loader.rb
289
+ - lib/pg_reports/standalone.rb
287
290
  - lib/pg_reports/telegram_sender.rb
288
291
  - lib/pg_reports/version.rb
289
292
  - lib/tasks/pg_reports.rake
@@ -308,7 +311,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
308
311
  - !ruby/object:Gem::Version
309
312
  version: '0'
310
313
  requirements: []
311
- rubygems_version: 4.0.4
314
+ rubygems_version: 3.6.9
312
315
  specification_version: 4
313
316
  summary: PostgreSQL analysis and reporting tool with Telegram integration
314
317
  test_files: []