pg_reports 0.8.2 → 0.9.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: 2800846cf0c02bdf3a2605151d0bfe7b7775a1eda3a1a920eed26bc3f731748d
4
- data.tar.gz: 6f07d07fcd9e396d6c9dd885f9e9c2e0c9d10595f5b8817eba59a2c8c734981a
3
+ metadata.gz: e2063c9302c5b737dc5272ef382242464d7f11d1dbc7d926bd5db0b55a842b54
4
+ data.tar.gz: 61ca606a24d3e6fed362acb58270a2c03526803c18c7c89e2e31cd13cc35f3ad
5
5
  SHA512:
6
- metadata.gz: 2c5165e3eaff390afb0f9369aa6a9a2351dbe410cf64fd26a5df0be6cd3492513d0510d84758886dfa42d04b378e524d8483490f991ab63f3cef25ed8594b088
7
- data.tar.gz: 312d69fcb4a15c0216a1381ec04d9db6bf4811b326b89e70ecd5ce5ed43974eddc38ab6ed3c59e7358eb4ce2e1f0fd7ffc054be0da747f3d903f40b808e205c1
6
+ metadata.gz: bf7867350c74791dfb0a36ec2ad013e7f42ef5186f8a29209472599e36ca147b76117039f2009bac86d456370e41bca9d49e45a4b6c51ae34d0829bf1ae0d300
7
+ data.tar.gz: 75910cc42fc73da32e88f2161b10054ffe1775e8cbfce8085c488d419355eacc3b8a5ed42af1b9149198c64f4a252a6ee0178aeb99ccc2b63fba4a7847c40466
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-09-16
11
+
12
+ ### Added
13
+
14
+ - **Settings modal.** The gear used to open an IDE-only dialog that existed as two separate copies — one inlined in the dashboard, one in the report-page partial, each with its own duplicate of the JavaScript. It is now a single shared partial with one copy of the script in the layout, titled **Settings** and split into sections, so there is somewhere to put the next preference. Language is the first section; the IDE choice is the second. Picking a language reloads the page (everything on it has to re-render in the new language), so the modal is reopened afterwards rather than vanishing mid-edit.
15
+ - **Language switcher on the dashboard.** The engine ships English, Russian and Ukrainian translations covering the whole UI — including category and report names — but there was no way to choose between them; the dashboard simply followed the host application's `I18n.locale`. A section in the settings modal now stores the choice in the session (the same pattern as the database and target switchers) and it applies to every page. Only locales that actually carry `pg_reports` translations are offered, detected by probing a key rather than by listing the shipped files, so a host app supplying its own translation is offered too. The locale is applied with `I18n.with_locale` around the action: `I18n.locale` is process-global, and assigning it outright would carry the dashboard's language into the host application's own rendering.
16
+
17
+ ## [0.9.0] - 2026-09-16
18
+
19
+ ### Changed
20
+ - **The CI matrix now resolves against committed lockfiles.** `gemfiles/*.gemfile.lock` were gitignored, so every matrix job re-resolved dependencies from scratch and drifted with whatever was newest on the day — which is how the Rails 6.1 job silently moved onto a json 3.x that ActiveSupport 6.1 cannot use, turning green builds red with no code change. Each lockfile is generated with that entry's own Ruby and pinned to the runner's `x86_64-linux` platform. The Rails 6.1 lockfile deliberately pins json 3.0.2, so the incompatibility that started this stays covered rather than being pinned away.
21
+ - **Updated `json`, `loofah` and `rails-html-sanitizer`** in the root lockfile to clear open advisories (`bundler-audit`).
22
+ - **Dashboard visual overhaul — a single design system instead of three drifting stylesheets.** The three `<style>` blocks (layout, `index`, `_show_styles`) had grown independent, conflicting copies of the same components: `.modal-close` was 28px in one and 32px in another, `.modal-small` was 360px and 420px, `.toast` and the whole `.btn` family were defined twice with different colours, and `.explain-stats` / `.explain-result` were each declared twice within one file. All shared components now live once in the layout, and the per-page blocks only hold what is genuinely page-specific. `_show_styles.html.erb` shrank from 1678 to ~1500 lines with no loss of coverage.
23
+ - **Design tokens.** New `:root` tokens for radii (`--radius-sm`/`--radius`/`--radius-lg`), control height, shadows, fonts, and an accent-tint scale (`--amber-soft`/`--amber-line`, …) derived from the actual `--accent-*` values. Every hard-coded `rgba()`/hex tint was replaced — many were leftover Tailwind-palette values (`#ef4444`, `rgba(59,130,246,…)`) that no longer matched the accent they bordered.
24
+ - **Fixed two undeclared variables.** `--text-tertiary` and `--accent-red` were referenced but never defined, so those rules silently fell back to inherited colour; both are now declared. Removed the unused `--accent-indigo`, `--gradient-start` and `--gradient-end`.
25
+ - **Buttons are solid, classic controls.** `.btn-primary` was a translucent tinted outline (`rgba(124,138,246,.14)` fill, accent text, accent border); it is now a filled button with white text, plus proper `:active` and `:focus-visible` states. `.btn-secondary`/`.btn-danger`/`.btn-muted`/`.btn-ghost`/`.btn-icon` share one height, radius and transition, and every variant is declared in exactly one place.
26
+ - **Status is no longer signalled by coloured left stripes.** The live metric cards carried a 3px green/amber/rose `border-left` — "healthy" was as decorated as "critical". Status now reads from the corner dot, the value colour and the sparkline, with the card's own border tinting only for warning and critical. The same treatment replaced the coloured rails and `linear-gradient` washes on EXPLAIN summary cards, problem cards and stat tiles.
27
+ - Table problem rows used `border-left` on `<tr>`, which does not render under `border-collapse: collapse`; they now use an inset shadow on the first cell, so the marker is actually visible.
28
+ - **Dropped the status dot from the live metric cards.** In the healthy state it was a grey dot in the corner conveying nothing, and in the warning/critical state it was a fourth encoding of what the card border, the value colour and the sparkline already say. Status now reads from those three alone.
29
+ - **Metric figures are right-aligned.** The value and its sub-label sit against the card's right edge, with the metric's name on the left — so each card reads name-then-number across, and the column of figures lines up down the row.
30
+ - **The sparkline became the card's bottom band.** It used to float inset in the lower half with margins on every side, leaving the bottom of the card reading as dead space. It now spans the full width and sits flush with the bottom edge. A series that never moves is drawn mid-band rather than pinned to the floor, where a steady 100% cache hit looked like a stray underline at zero.
31
+ - **Sparkline colour encodes state, not identity.** Each metric drew its trend in its own hue (blue/green/purple/amber/rose), which read as decoration. All five now draw in a muted neutral, switching to amber or rose only when that metric crosses its threshold.
32
+ - **Emoji replaced with an inline SVG icon sprite.** Emoji were used as UI icons throughout — the header logo tile, category and metric icons, the settings gear, lock, play/pause, and as prefixes baked into ~48 locale strings per language (`"📋 Copy"`, `"⬇ Export"`, `"🔒 Requires pg_stat_statements"`). They rendered differently on every OS and could not be coloured. All are now stroke-drawn `<symbol>`s defined once in the layout and referenced with `<use>`, inheriting colour and size from their context. Locale strings carry text only.
33
+ - **Removed the header logo tile.** The wordmark and version carry the branding.
34
+ - **Dropped the decorative motion.** Hover lifts (`translateY(-1px/-2px)`, `translateX(4px)` plus shadow), the glassmorphism `backdrop-filter: blur()` on modals, and the pulsing animation on the static status-badge dot are gone. Non-interactive panels no longer light up in accent purple on hover.
35
+ - **Typography.** Plus Jakarta Sans → Inter, body line-height 1.6 → 1.5, and a consistent type scale for a dense dashboard. Numeric surfaces (result tables, live metric values, EXPLAIN stats) use `tabular-nums` so digits stop reflowing as values refresh.
36
+ - **Accessibility.** Added `:focus-visible` rings on buttons, inputs and textareas; icon-only buttons carry `aria-label`; decorative icons are `aria-hidden`.
37
+ - **Source locations in `.rake`, `.jbuilder`, `config.ru` and extensionless files were not recognised at all.** The client-side parser matched paths against a hardcoded extension allow-list (`rb|erb|js|ts|py|go|java`); anything outside it fell through unparsed, which silently cost it the root-stripping, the start-clipping *and* its IDE link — a `/lib/tasks/setup.rake:17` rendered as the raw absolute path, home directory included. The allow-list is gone: the matcher now takes any path followed by `:<line>`, which also fixes Windows paths (`C:\app\models\post.rb:12`) that the old `[^:]+` broke on at the drive letter. Sources that still don't parse (`PostsController#index`) are clipped from the start too, instead of having their tail cut off.
38
+ - **Source locations are shown short, and never truncated where it matters.** The badge used to print the path verbatim and clip it with `text-overflow: ellipsis` — which cuts the *end*, throwing away the file name and line and leaving only a long shared prefix. It now strips the host application's root (when the engine is mounted in one), collapses a bundled gem's install prefix to the gem's own directory (`…/lib/ruby/gems/3.4.0/gems/activerecord-8.1.3/lib/…` → `activerecord-8.1.3/lib/…`), and clips what remains from the *start*, so the line number is the last thing to go: the directory is given up first and almost entirely, and only once there is no directory left does the file name itself start losing leading characters. The badge is capped at its cell's width and clips its own overflow, so a single long unbreakable file name can no longer push the badge past the column. The directory is dimmed, the file and line are not. Standalone mode has no host-app root to strip, so it gets the gem collapse and the start-clipping. The full path is unchanged in the tooltip, the expanded row and the exports, and IDE links are still built from it.
39
+ - **"Load History (50)" is now "Load History", and is disabled when there is nothing to load.** The number was the request's page size, not a count of anything the user had; read as "50 entries are waiting". The button is also enabled only when history is actually retrievable — it reads from `config.query_monitor_log_file`, so with no log file configured (or none written yet) it could only ever no-op. `QueryMonitor#status` now reports `history_available`, and the disabled button explains why in its tooltip.
40
+ - **The report page's "Back" button moved into the breadcrumb.** It sat at the far right of the action row — away from the navigation context, and a second control doing what the "Dashboard" crumb already did. The first crumb is now the back action, with a left-arrow icon making the affordance explicit.
41
+ - **One vertical rhythm for text and code blocks.** The global `* { margin: 0 }` reset leaves prose containers with no spacing of their own, and the per-element margins that filled the gap had drifted into asymmetry — a `<pre>` inherited 1rem above (from the preceding `<p>`'s `margin-bottom`) and 0 below, so snippets floated away from their intro line and collided with the line under them. Spacing is now owned by one `> * + *` rule per container, driven by a `--flow-gap` token, with the ad-hoc `margin-bottom`/`margin-top` on each block removed.
42
+ - **One code-block definition.** The same "here is how to enable this feature" panel existed in five hand-spaced copies (`<br><br>` for paragraph breaks, `&nbsp;&nbsp;` for indentation, inline `style=` for the box) across the SQL Console, EXPLAIN, Execute Query and migration flows. All five now render through a shared `pgReportsDisabledNotice()` helper using a real `<pre class="code-block">`, so indentation is actual whitespace and every snippet in the UI is styled identically.
43
+ - **One field-label definition.** `.explain-label`, `.explain-stat-label`, `.row-detail-label`, `.problem-field-label`, `.saved-record-field-name` and `.saved-record-detail-label` were six variants of "small-caps label above a value", spread across 0.65–0.8rem, three letter-spacings and two colours. Collapsed into a single rule.
44
+
45
+ ### Fixed
46
+ - **The dashboard broke out of the viewport on a phone.** The `@media (max-width: 768px)` block covered the container, the header and the report actions, and nothing else, so everything added since was laid out at desktop proportions: the breadcrumb squeezed the database selector down to a few characters, the results header packed its title and three meta items into one unwrappable row, filter labels fought their inputs for the same line, and the `.btn { flex: 1 }` in that block stretched *every* button on the page — including "Clear All", which then overran the "Saved for Comparison" title next to it. Two things pushed the page itself sideways: the Query Monitor's scope tooltip (a 320px absolutely-positioned `::after` anchored mid-row, which widened the layout viewport and dragged the fixed toast with it), and the toast's own `max-width: 26rem`, wider than the screen and pinned by `right`. The expanded-row detail was the worst of it — a two-column grid inside the horizontally scrolling results table, with its second column parked off-screen. It is now one column, pinned to the viewport with `position: sticky` so it stays readable while the table scrolls sideways under it; the generic cell rule's `overflow: hidden`, which made the cell its own scroll container and killed the sticky, is lifted for that one cell. The tooltip becomes a bottom sheet, the toast spans the screen width, and the button stretch is scoped to the page's actual action bars. Verified at 320/360/390/414/768px across the dashboard and five report pages: no page-level horizontal overflow anywhere, with only the results table scrolling sideways as intended.
47
+ - **The proxy scrollbar above a wide results table was dead space on touch.** It renders a 12px strip whose only purpose is to be dragged, and touch devices draw no scrollbar in it; it is now skipped when the pointer is coarse, where the table is dragged directly anyway.
48
+ - **Query-monitor file logging silently wrote nothing on Rails <= 7.0 with json >= 3.** The writer serialized with `#to_json`, which ActiveSupport overrides and (up to Rails 7.0) calls with json's `quirks_mode` option — removed in json 3, so it raises `ArgumentError: unknown keyword: quirks_mode`. The writer's `rescue` swallowed that by design ("don't break monitoring if the file write fails"), so the log file just stayed empty with no visible error. Serialization now goes through `JSON.generate`, json's own generator, which takes no such option. Covered by a regression spec.
49
+
50
+
51
+ - **Migration-disabled toast was hardcoded Russian** in an otherwise fully translated UI. Now goes through `errors.migration_disabled_toast` (added to all three locales).
52
+ - **Migration-disabled panel title was hardcoded English.** Now goes through `modals.migration_disabled_title` (added to all three locales).
53
+ - **Switching to a database without pg_stat_statements leaked `PG::UndefinedTable` into the UI.** The extension is created per-database, but the availability gate lived only in the dashboard template (`category_key == :queries && !@pg_stat_status[:ready]`). `show`, `run`, `download` and `send_to_telegram` never consulted it, so opening a Queries report and then switching the database ran the report anyway and rendered the driver's `relation "pg_stat_statements" does not exist LINE 11:` in the error banner. The requirement is now declared on the category (`requires: :pg_stat_statements`) and enforced in `#category_disabled_reason`, which every entry point already calls — the dashboard template now reads that same value instead of repeating the rule. The message distinguishes the two remedies: an extension that was never created here, versus one that exists but is missing from `shared_preload_libraries`. An unreachable database is left to the existing connection-error banner rather than being mislabelled as a missing extension.
54
+ - **Redirects out of an unavailable report were silent.** `show` has always redirected to the dashboard with `alert:` when a category is unavailable, but no view rendered `flash`, so the user was bounced with no explanation. The layout now renders `flash[:alert]` / `flash[:notice]`.
55
+ - **`ErrorTranslator` handles missing relations and functions** (`42P01`, `42883`). When the missing relation is one an extension provides, it names the extension and hints the `CREATE EXTENSION` instead of echoing the raw driver message. The three report endpoints route failures through the translator; the raw-SQL endpoints (SQL Console, Execute Query, EXPLAIN) still return PostgreSQL's own wording, which is what you want for a query you typed yourself.
56
+ - `ExplainAnalyzer` no longer emits a `status_icon` emoji from the analysis layer; the dashboard picks the icon from `status`.
57
+
10
58
  ## [0.8.2] - 2026-07-10
11
59
 
12
60
  ### Added
@@ -12,6 +12,7 @@ module PgReports
12
12
  protect_from_forgery with: :exception
13
13
 
14
14
  before_action :authenticate_dashboard!, if: -> { PgReports.config.dashboard_auth.present? }
15
+ around_action :within_selected_locale
15
16
  before_action :set_categories
16
17
  before_action :resolve_database_selection
17
18
  around_action :within_selected_database
@@ -24,9 +25,10 @@ module PgReports
24
25
  ]
25
26
 
26
27
  helper_method :category_disabled_reason, :category_disabled?
28
+ helper_method :dashboard_locales, :selected_locale
27
29
 
28
30
  def index
29
- @pg_stat_status = PgReports.pg_stat_statements_status
31
+ @pg_stat_status = pg_stat_status
30
32
  @current_database = PgReports.system.current_database
31
33
  end
32
34
 
@@ -48,6 +50,21 @@ module PgReports
48
50
  # POST /switch_target
49
51
  # Persists the chosen target in session, clears the database choice (each
50
52
  # target has its own list of databases), and redirects back.
53
+ # POST /switch_locale
54
+ # Persists the chosen language in session; #within_selected_locale applies it
55
+ # to this and every later request.
56
+ def switch_locale
57
+ requested = params[:locale].to_s
58
+
59
+ if requested.empty?
60
+ session.delete(:pg_reports_locale)
61
+ elsif dashboard_locales.any? { |locale| locale.to_s == requested }
62
+ session[:pg_reports_locale] = requested
63
+ end
64
+
65
+ redirect_back fallback_location: root_path
66
+ end
67
+
51
68
  def switch_target
52
69
  requested = params[:target].to_s
53
70
 
@@ -140,7 +157,7 @@ module PgReports
140
157
 
141
158
  @report = execute_report(@category, @report_key)
142
159
  rescue => e
143
- @error = e.message
160
+ @error = report_error_message(e)
144
161
  @report = nil
145
162
  end
146
163
 
@@ -181,7 +198,7 @@ module PgReports
181
198
  problem_explanations: problem_explanations
182
199
  }
183
200
  rescue => e
184
- render json: {success: false, error: e.message}, status: :unprocessable_entity
201
+ render json: {success: false, error: report_error_message(e)}, status: :unprocessable_entity
185
202
  end
186
203
 
187
204
  def send_to_telegram
@@ -198,7 +215,7 @@ module PgReports
198
215
 
199
216
  render json: {success: true, message: I18n.t("pg_reports.ui.success.telegram_sent")}
200
217
  rescue => e
201
- render json: {success: false, error: e.message}, status: :unprocessable_entity
218
+ render json: {success: false, error: report_error_message(e)}, status: :unprocessable_entity
202
219
  end
203
220
 
204
221
  def download
@@ -227,7 +244,7 @@ module PgReports
227
244
  disposition: "attachment"
228
245
  end
229
246
  rescue => e
230
- render json: {success: false, error: e.message}, status: :unprocessable_entity
247
+ render json: {success: false, error: report_error_message(e)}, status: :unprocessable_entity
231
248
  end
232
249
 
233
250
  def explain_analyze
@@ -536,7 +553,8 @@ module PgReports
536
553
  success: true,
537
554
  enabled: status[:enabled],
538
555
  session_id: status[:session_id],
539
- query_count: status[:query_count]
556
+ query_count: status[:query_count],
557
+ history_available: status[:history_available]
540
558
  }
541
559
  rescue => e
542
560
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -628,6 +646,36 @@ module PgReports
628
646
  @categories = Dashboard::ReportsRegistry.all
629
647
  end
630
648
 
649
+ # I18n.locale is global to the process, so assigning it outright would leak
650
+ # the dashboard's language into the host application's own rendering (and
651
+ # into whatever request runs next on this thread). with_locale restores the
652
+ # previous value once the action and its view are done.
653
+ def within_selected_locale(&block)
654
+ I18n.with_locale(selected_locale, &block)
655
+ end
656
+
657
+ def selected_locale
658
+ @selected_locale ||= begin
659
+ stored = session[:pg_reports_locale].to_s
660
+ match = dashboard_locales.find { |locale| locale.to_s == stored }
661
+ match || I18n.locale
662
+ end
663
+ end
664
+
665
+ # Only locales that actually carry pg_reports translations. A host app can
666
+ # have dozens of its own locales while translating none of this dashboard,
667
+ # and offering those would just switch the UI to fallback English. Picked by
668
+ # probing one key rather than by listing the shipped files, so a host app
669
+ # that supplies its own pg_reports translations is offered too.
670
+ def dashboard_locales
671
+ @dashboard_locales ||= begin
672
+ translated = I18n.available_locales.select do |locale|
673
+ I18n.exists?("pg_reports.ui.branding.title", locale)
674
+ end
675
+ translated.presence || [I18n.default_locale]
676
+ end
677
+ end
678
+
631
679
  # Resolves which target/database every action should run against, based on:
632
680
  # 1. session[:pg_reports_target] (set by #switch_target)
633
681
  # 2. session[:pg_reports_database] (set by #switch_database)
@@ -752,6 +800,18 @@ module PgReports
752
800
  # otherwise a localized string explaining why it is disabled. Exposed to
753
801
  # views via helper_method.
754
802
  def category_disabled_reason(category)
803
+ # pg_stat_statements is created per-database. A category that reads it is
804
+ # available on one database in the cluster and missing on the next, so the
805
+ # check belongs here — where every entry point (show, run, download,
806
+ # telegram) already asks — rather than in the dashboard template alone.
807
+ # Only meaningful once we know the connection itself works: an unreachable
808
+ # database has its own banner and must not be reported as a missing
809
+ # extension.
810
+ if Dashboard::ReportsRegistry.requires(category) == :pg_stat_statements &&
811
+ pg_stat_status[:connected] && !pg_stat_status[:ready]
812
+ return pg_stat_unavailable_reason
813
+ end
814
+
755
815
  constraint = Dashboard::ReportsRegistry.target_constraint(category)
756
816
  return nil unless constraint == :primary_default_database_only
757
817
 
@@ -777,6 +837,44 @@ module PgReports
777
837
  category_disabled_reason(category).present?
778
838
  end
779
839
 
840
+ # A report failing because the selected database lacks an extension-provided
841
+ # relation is the common case here (extensions are per-database, the
842
+ # dashboard's database picker is not), and "PG::UndefinedTable: ERROR:
843
+ # relation ... does not exist LINE 11:" is not something to show a user.
844
+ def report_error_message(error)
845
+ info = Connection::ErrorTranslator.translate(error)
846
+ [info[:detail], info[:hint]].reject { |part| part.to_s.strip.empty? }.join(" ")
847
+ rescue
848
+ error.message
849
+ end
850
+
851
+ # Scoped to the request's selected database by the surrounding
852
+ # #within_selected_database, and memoized because the dashboard asks once
853
+ # per category while rendering the grid.
854
+ def pg_stat_status
855
+ @pg_stat_status ||= PgReports.pg_stat_statements_status
856
+ end
857
+
858
+ # The two failure modes need different remedies: an extension that was never
859
+ # created here can be created, one that isn't preloaded needs a restart.
860
+ def pg_stat_unavailable_reason
861
+ database = @selected_database.presence || PgReports.system.current_database
862
+
863
+ if pg_stat_status[:extension_installed]
864
+ I18n.t("pg_reports.ui.categories.pg_stat_not_preloaded_reason",
865
+ database: database,
866
+ default: "Requires pg_stat_statements. The extension exists on " \
867
+ "\"%{database}\" but is not in shared_preload_libraries, so it " \
868
+ "returns no data until PostgreSQL is restarted with it preloaded.")
869
+ else
870
+ I18n.t("pg_reports.ui.categories.pg_stat_missing_reason",
871
+ database: database,
872
+ default: "Requires pg_stat_statements, which is not installed on " \
873
+ "\"%{database}\". Create the extension there, or switch to a " \
874
+ "database that has it.")
875
+ end
876
+ end
877
+
780
878
  # SQL Query Monitor taps ActiveSupport::Notifications in the host
781
879
  # application's process. In standalone mode there is no host app — pg_reports
782
880
  # is the only process running, so there's nothing meaningful to observe.