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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +18 -69
- data/app/controllers/pg_reports/dashboard_controller.rb +1 -1
- data/app/views/layouts/pg_reports/application.html.erb +88 -6
- data/app/views/pg_reports/dashboard/index.html.erb +101 -22
- data/bin/pg_reports +85 -0
- data/config/locales/en.yml +9 -5
- data/config/locales/ru.yml +9 -5
- data/config/locales/uk.yml +9 -5
- data/lib/pg_reports/connection/target.rb +1 -1
- data/lib/pg_reports/executor.rb +7 -2
- data/lib/pg_reports/modules/system.rb +28 -4
- data/lib/pg_reports/standalone.rb +152 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +4 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ff4489af5016c4bfbe14f7187daa5ca2ea279b15887bab928c8effefc4b2dad
|
|
4
|
+
data.tar.gz: 26815859090bbf8a84000acb3545c1eecb982887f8d0f07d9ab0e582f2380c58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
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)** · **[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
|
-
|
|
251
|
+
**[Grafana / Prometheus integration guide →](docs/grafana.md)** · **[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
|
-
|
|
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)
|
|
279
|
+
Inspired by [rails-pg-extras](https://github.com/pawurb/rails-pg-extras) and built with ❤️ for the Rails community.
|
|
@@ -113,13 +113,32 @@
|
|
|
113
113
|
display: flex;
|
|
114
114
|
align-items: center;
|
|
115
115
|
gap: 0.4rem;
|
|
116
|
-
|
|
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:
|
|
310
|
-
color:
|
|
311
|
-
border-color:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
44
|
-
|
|
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.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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"
|
|
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:
|
|
380
|
-
height:
|
|
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:
|
|
386
|
-
font-size: 0.
|
|
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: -
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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: "
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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: ">
|
|
698
|
+
long_running_threshold: "> 5s runtime"
|
|
695
699
|
blocked_label: "Blocked"
|
|
696
700
|
processes_unit: "processes"
|
|
697
701
|
waiting_for_locks: "waiting for locks"
|
data/config/locales/ru.yml
CHANGED
|
@@ -598,11 +598,12 @@ ru:
|
|
|
598
598
|
save_for_comparison: "📌 Сохранить для сравнения"
|
|
599
599
|
saved_marker: "📌 Сохранено"
|
|
600
600
|
status:
|
|
601
|
-
pg_stat_ready: "
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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: ">
|
|
663
|
+
long_running_threshold: "> 5с выполнения"
|
|
660
664
|
blocked_label: "Заблокировано"
|
|
661
665
|
processes_unit: "процессов"
|
|
662
666
|
waiting_for_locks: "ждут блокировок"
|
data/config/locales/uk.yml
CHANGED
|
@@ -598,11 +598,12 @@ uk:
|
|
|
598
598
|
save_for_comparison: "📌 Зберегти для порівняння"
|
|
599
599
|
saved_marker: "📌 Збережено"
|
|
600
600
|
status:
|
|
601
|
-
pg_stat_ready: "
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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: ">
|
|
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
|
data/lib/pg_reports/executor.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
74
|
+
connected: true,
|
|
75
|
+
extension_installed: installed,
|
|
52
76
|
preloaded: pg_stat_statements_preloaded?,
|
|
53
|
-
ready:
|
|
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:
|
|
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
|
data/lib/pg_reports/version.rb
CHANGED
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.
|
|
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:
|
|
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: []
|