pg_reports 0.9.0 → 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: 8a307810bc4ac9d34917eddd4d3a231a4a9fd0010f2b8bd1d1f56a654336028b
4
- data.tar.gz: 175e0568589ca06b81a7482550d6eb1e3bfe05089ea1fda74b47fc33901aa4d0
3
+ metadata.gz: e2063c9302c5b737dc5272ef382242464d7f11d1dbc7d926bd5db0b55a842b54
4
+ data.tar.gz: 61ca606a24d3e6fed362acb58270a2c03526803c18c7c89e2e31cd13cc35f3ad
5
5
  SHA512:
6
- metadata.gz: 2f817ec173d59b77e7a70ec69b15292bd9a7eb9e074d7c5edd1cd82003f76bd382efdecc754313a453e5f851d3c1bd5c447cf6bd29723afae49110f49e95631e
7
- data.tar.gz: 7188d4caa875624d210c4b00fbd4d3e3046b0df7bce877b5484d3ebad919d8a19df908e6c923fe2f48ac62e0595cfa5a48699accfccfc5c4cb4bc603e9dba5db
6
+ metadata.gz: bf7867350c74791dfb0a36ec2ad013e7f42ef5186f8a29209472599e36ca147b76117039f2009bac86d456370e41bca9d49e45a4b6c51ae34d0829bf1ae0d300
7
+ data.tar.gz: 75910cc42fc73da32e88f2161b10054ffe1775e8cbfce8085c488d419355eacc3b8a5ed42af1b9149198c64f4a252a6ee0178aeb99ccc2b63fba4a7847c40466
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ 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
+
10
17
  ## [0.9.0] - 2026-09-16
11
18
 
12
19
  ### Changed
@@ -18,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
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.
19
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.
20
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.
21
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.
22
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.
23
33
  - **Removed the header logo tile.** The wordmark and version carry the branding.
@@ -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,6 +25,7 @@ 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
31
  @pg_stat_status = pg_stat_status
@@ -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
 
@@ -629,6 +646,36 @@ module PgReports
629
646
  @categories = Dashboard::ReportsRegistry.all
630
647
  end
631
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
+
632
679
  # Resolves which target/database every action should run against, based on:
633
680
  # 1. session[:pg_reports_target] (set by #switch_target)
634
681
  # 2. session[:pg_reports_database] (set by #switch_database)
@@ -1330,44 +1330,57 @@
1330
1330
  font-size: 0.9rem;
1331
1331
  }
1332
1332
 
1333
- .ide-options {
1333
+ /* Settings modal sections. The option rows are shared by every section —
1334
+ language, IDE, and whatever is added next. */
1335
+ .settings-section-title {
1336
+ font-size: 0.6875rem;
1337
+ font-weight: 600;
1338
+ line-height: 1.4;
1339
+ color: var(--text-muted);
1340
+ text-transform: uppercase;
1341
+ letter-spacing: 0.06em;
1342
+ margin-bottom: 0.5rem;
1343
+ }
1344
+
1345
+ .settings-options {
1334
1346
  display: flex;
1335
1347
  flex-direction: column;
1336
- gap: 0.5rem;
1348
+ gap: 0.375rem;
1337
1349
  }
1338
1350
 
1339
- .ide-option {
1351
+ .settings-option {
1340
1352
  display: flex;
1341
1353
  align-items: center;
1342
- gap: 0.75rem;
1343
- padding: 0.75rem 1rem;
1354
+ gap: 0.625rem;
1355
+ padding: 0.5rem 0.75rem;
1344
1356
  background: var(--bg-tertiary);
1345
1357
  border: 1px solid var(--border-color);
1346
- border-radius: 6px;
1358
+ border-radius: var(--radius-sm);
1347
1359
  cursor: pointer;
1348
- transition: all 0.15s;
1360
+ transition: background-color 0.12s ease, border-color 0.12s ease;
1349
1361
  }
1350
1362
 
1351
- .ide-option:hover {
1363
+ .settings-option:hover {
1352
1364
  background: var(--bg-hover);
1353
1365
  border-color: var(--border-strong);
1354
1366
  }
1355
1367
 
1356
- .ide-option:has(input:checked) {
1368
+ .settings-option:has(input:checked) {
1357
1369
  background: var(--purple-soft);
1358
1370
  border-color: var(--purple-line);
1359
1371
  }
1360
1372
 
1361
- .ide-option input[type="radio"] {
1373
+ .settings-option input[type="radio"] {
1362
1374
  accent-color: var(--accent-purple);
1375
+ flex: none;
1363
1376
  }
1364
1377
 
1365
- .ide-option span {
1378
+ .settings-option span {
1366
1379
  color: var(--text-secondary);
1367
- font-size: 0.875rem;
1380
+ font-size: 0.8125rem;
1368
1381
  }
1369
1382
 
1370
- .ide-option:has(input:checked) span {
1383
+ .settings-option:has(input:checked) span {
1371
1384
  color: var(--text-primary);
1372
1385
  }
1373
1386
 
@@ -1634,6 +1647,76 @@
1634
1647
  ].join('');
1635
1648
  };
1636
1649
 
1650
+ // Settings modal. Shared by the dashboard and the report pages; the language
1651
+ // section posts a form (server-side session), the IDE section only writes
1652
+ // localStorage, so there is nothing to submit for it.
1653
+ // Picking a language posts a form and the page reloads (everything on it has
1654
+ // to re-render in the new language), which would leave the user staring at a
1655
+ // dashboard with the settings they were editing gone. Remember that the
1656
+ // modal was open and bring it back on the way in.
1657
+ const SETTINGS_REOPEN_KEY = 'pgReportsSettingsReopen';
1658
+
1659
+ function rememberSettingsOpen() {
1660
+ try {
1661
+ sessionStorage.setItem(SETTINGS_REOPEN_KEY, '1');
1662
+ } catch (e) {
1663
+ // Storage can be unavailable (private mode, blocked cookies); losing the
1664
+ // reopen is a cosmetic downgrade, not a reason to block the switch.
1665
+ }
1666
+ }
1667
+
1668
+ function consumeSettingsReopen() {
1669
+ try {
1670
+ if (!sessionStorage.getItem(SETTINGS_REOPEN_KEY)) return false;
1671
+ sessionStorage.removeItem(SETTINGS_REOPEN_KEY);
1672
+ return true;
1673
+ } catch (e) {
1674
+ return false;
1675
+ }
1676
+ }
1677
+
1678
+ function showSettingsModal() {
1679
+ const modal = document.getElementById('settings-modal');
1680
+ if (!modal) return;
1681
+
1682
+ modal.style.display = 'flex';
1683
+ loadSettingsState();
1684
+ }
1685
+
1686
+ function closeSettingsModal() {
1687
+ const modal = document.getElementById('settings-modal');
1688
+ if (modal) modal.style.display = 'none';
1689
+ }
1690
+
1691
+ function setDefaultIde(ideKey) {
1692
+ if (ideKey) {
1693
+ localStorage.setItem('pgReportsDefaultIde', ideKey);
1694
+ } else {
1695
+ localStorage.removeItem('pgReportsDefaultIde');
1696
+ }
1697
+ }
1698
+
1699
+ function getDefaultIde() {
1700
+ return localStorage.getItem('pgReportsDefaultIde') || '';
1701
+ }
1702
+
1703
+ // The language radios are rendered already checked by the server; only the
1704
+ // IDE choice lives in localStorage and has to be restored on open.
1705
+ function loadSettingsState() {
1706
+ const currentIde = getDefaultIde();
1707
+ document.querySelectorAll('input[name="default-ide"]').forEach(radio => {
1708
+ radio.checked = (radio.value === currentIde);
1709
+ });
1710
+ }
1711
+
1712
+ document.addEventListener('DOMContentLoaded', function() {
1713
+ document.getElementById('settings-modal')?.addEventListener('click', function(e) {
1714
+ if (e.target === this) closeSettingsModal();
1715
+ });
1716
+
1717
+ if (consumeSettingsReopen()) showSettingsModal();
1718
+ });
1719
+
1637
1720
  function showToast(message, type = 'success') {
1638
1721
  const toast = document.getElementById('toast');
1639
1722
  toast.textContent = message;
@@ -0,0 +1,55 @@
1
+ <%# Shared by the dashboard and the report pages. Language is persisted server
2
+ side (session, via a form post); the IDE choice is client side in
3
+ localStorage — hence one <form> section and one plain section. %>
4
+ <div id="settings-modal" class="modal" style="display: none;">
5
+ <div class="modal-content modal-small">
6
+ <div class="modal-header">
7
+ <h3><%= t("pg_reports.ui.modals.settings_title") %></h3>
8
+ <button class="modal-close" onclick="closeSettingsModal()" aria-label="<%= t("pg_reports.ui.actions.cancel") %>">&times;</button>
9
+ </div>
10
+ <div class="modal-body">
11
+ <% if dashboard_locales.size > 1 %>
12
+ <section class="settings-section">
13
+ <h4 class="settings-section-title"><%= t("pg_reports.ui.language.label") %></h4>
14
+ <form class="settings-options"
15
+ action="<%= switch_locale_path %>"
16
+ method="post"
17
+ data-turbo="false">
18
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
19
+ <% dashboard_locales.each do |locale| %>
20
+ <%# Each option is named in its own language, not the current one. %>
21
+ <label class="settings-option">
22
+ <input type="radio"
23
+ name="locale"
24
+ value="<%= locale %>"
25
+ <%= "checked" if locale.to_s == selected_locale.to_s %>
26
+ onchange="rememberSettingsOpen(); this.form.submit()">
27
+ <span><%= t("pg_reports.ui.language.name", locale: locale, default: locale.to_s.upcase) %></span>
28
+ </label>
29
+ <% end %>
30
+ </form>
31
+ </section>
32
+ <% end %>
33
+
34
+ <section class="settings-section">
35
+ <h4 class="settings-section-title"><%= t("pg_reports.ui.settings.default_ide_label") %></h4>
36
+ <div class="settings-options">
37
+ <% {
38
+ "" => "ide_show_menu",
39
+ "vscode-wsl" => "ide_vscode_wsl",
40
+ "vscode" => "ide_vscode",
41
+ "rubymine" => "ide_rubymine",
42
+ "intellij" => "ide_intellij",
43
+ "cursor-wsl" => "ide_cursor_wsl",
44
+ "cursor" => "ide_cursor"
45
+ }.each do |value, key| %>
46
+ <label class="settings-option">
47
+ <input type="radio" name="default-ide" value="<%= value %>" onchange="setDefaultIde('<%= value %>')">
48
+ <span><%= t("pg_reports.ui.settings.#{key}") %></span>
49
+ </label>
50
+ <% end %>
51
+ </div>
52
+ </section>
53
+ </div>
54
+ </div>
55
+ </div>
@@ -1,45 +1,4 @@
1
- <!-- IDE Settings Modal -->
2
- <div id="ide-settings-modal" class="modal" style="display: none;">
3
- <div class="modal-content modal-small">
4
- <div class="modal-header">
5
- <h3><%= t("pg_reports.ui.modals.ide_settings_title") %></h3>
6
- <button class="modal-close" onclick="closeIdeSettingsModal()">&times;</button>
7
- </div>
8
- <div class="modal-body">
9
- <p class="settings-label"><%= t("pg_reports.ui.settings.default_ide_label") %></p>
10
- <div class="ide-options">
11
- <label class="ide-option">
12
- <input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
13
- <span><%= t("pg_reports.ui.settings.ide_show_menu") %></span>
14
- </label>
15
- <label class="ide-option">
16
- <input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
17
- <span><%= t("pg_reports.ui.settings.ide_vscode_wsl") %></span>
18
- </label>
19
- <label class="ide-option">
20
- <input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
21
- <span><%= t("pg_reports.ui.settings.ide_vscode") %></span>
22
- </label>
23
- <label class="ide-option">
24
- <input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
25
- <span><%= t("pg_reports.ui.settings.ide_rubymine") %></span>
26
- </label>
27
- <label class="ide-option">
28
- <input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
29
- <span><%= t("pg_reports.ui.settings.ide_intellij") %></span>
30
- </label>
31
- <label class="ide-option">
32
- <input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
33
- <span><%= t("pg_reports.ui.settings.ide_cursor_wsl") %></span>
34
- </label>
35
- <label class="ide-option">
36
- <input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
37
- <span><%= t("pg_reports.ui.settings.ide_cursor") %></span>
38
- </label>
39
- </div>
40
- </div>
41
- </div>
42
- </div>
1
+ <%= render "settings_modal" %>
43
2
 
44
3
  <!-- Problem Explanation Modal -->
45
4
  <div id="problem-modal" class="problem-modal" style="display: none;">
@@ -854,7 +854,7 @@
854
854
  document.addEventListener('keydown', function(e) {
855
855
  if (e.key === 'Escape') {
856
856
  closeProblemModal();
857
- closeIdeSettingsModal();
857
+ closeSettingsModal();
858
858
  closeExplainModal();
859
859
  closeMigrationModal();
860
860
  }
@@ -867,44 +867,6 @@
867
867
  }
868
868
  });
869
869
 
870
- // IDE Settings functions
871
- function showIdeSettingsModal() {
872
- const modal = document.getElementById('ide-settings-modal');
873
- modal.style.display = 'flex';
874
- loadIdeSettingsState();
875
- }
876
-
877
- function closeIdeSettingsModal() {
878
- document.getElementById('ide-settings-modal').style.display = 'none';
879
- }
880
-
881
- function setDefaultIde(ideKey) {
882
- if (ideKey) {
883
- localStorage.setItem('pgReportsDefaultIde', ideKey);
884
- } else {
885
- localStorage.removeItem('pgReportsDefaultIde');
886
- }
887
- }
888
-
889
- function getDefaultIde() {
890
- return localStorage.getItem('pgReportsDefaultIde') || '';
891
- }
892
-
893
- function loadIdeSettingsState() {
894
- const currentIde = getDefaultIde();
895
- const radios = document.querySelectorAll('input[name="default-ide"]');
896
- radios.forEach(radio => {
897
- radio.checked = (radio.value === currentIde);
898
- });
899
- }
900
-
901
- // Close IDE settings modal on backdrop click
902
- document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
903
- if (e.target === this) {
904
- closeIdeSettingsModal();
905
- }
906
- });
907
-
908
870
  // ==========================================
909
871
  // SAVED RECORDS FUNCTIONALITY
910
872
  // ==========================================
@@ -28,7 +28,7 @@
28
28
  <%= t("pg_reports.ui.actions.reset_statistics") %>
29
29
  </button>
30
30
  <% end %>
31
- <button type="button" class="btn-icon" onclick="showIdeSettingsModal()" title="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>" aria-label="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>">
31
+ <button type="button" class="btn-icon" onclick="showSettingsModal()" title="<%= t("pg_reports.ui.actions.settings_button_title") %>" aria-label="<%= t("pg_reports.ui.actions.settings_button_title") %>">
32
32
  <svg class="icon icon-lg"><use href="#i-settings"></use></svg>
33
33
  </button>
34
34
 
@@ -127,47 +127,7 @@ pg_stat_statements.track = all</pre>
127
127
  </div>
128
128
 
129
129
  <!-- IDE Settings Modal -->
130
- <div id="ide-settings-modal" class="modal" style="display: none;">
131
- <div class="modal-content modal-small">
132
- <div class="modal-header">
133
- <h3><%= t("pg_reports.ui.modals.ide_settings_title") %></h3>
134
- <button class="modal-close" onclick="closeIdeSettingsModal()">&times;</button>
135
- </div>
136
- <div class="modal-body">
137
- <p class="settings-label"><%= t("pg_reports.ui.settings.default_ide_label") %></p>
138
- <div class="ide-options">
139
- <label class="ide-option">
140
- <input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
141
- <span><%= t("pg_reports.ui.settings.ide_show_menu") %></span>
142
- </label>
143
- <label class="ide-option">
144
- <input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
145
- <span><%= t("pg_reports.ui.settings.ide_vscode_wsl") %></span>
146
- </label>
147
- <label class="ide-option">
148
- <input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
149
- <span><%= t("pg_reports.ui.settings.ide_vscode") %></span>
150
- </label>
151
- <label class="ide-option">
152
- <input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
153
- <span><%= t("pg_reports.ui.settings.ide_rubymine") %></span>
154
- </label>
155
- <label class="ide-option">
156
- <input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
157
- <span><%= t("pg_reports.ui.settings.ide_intellij") %></span>
158
- </label>
159
- <label class="ide-option">
160
- <input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
161
- <span><%= t("pg_reports.ui.settings.ide_cursor_wsl") %></span>
162
- </label>
163
- <label class="ide-option">
164
- <input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
165
- <span><%= t("pg_reports.ui.settings.ide_cursor") %></span>
166
- </label>
167
- </div>
168
- </div>
169
- </div>
170
- </div>
130
+ <%= render "settings_modal" %>
171
131
 
172
132
  <!-- Run Query Modal -->
173
133
  <div id="run-query-modal" class="modal" style="display: none;">
@@ -239,7 +199,6 @@ end</pre>
239
199
  <svg id="sparkline-connections" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
240
200
  </div>
241
201
  </div>
242
- <div class="metric-status" id="status-connections"></div>
243
202
  </div>
244
203
 
245
204
  <!-- TPS -->
@@ -260,7 +219,6 @@ end</pre>
260
219
  <svg id="sparkline-tps" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
261
220
  </div>
262
221
  </div>
263
- <div class="metric-status" id="status-tps"></div>
264
222
  </div>
265
223
 
266
224
  <!-- Cache Hit Ratio -->
@@ -279,7 +237,6 @@ end</pre>
279
237
  <svg id="sparkline-cache" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
280
238
  </div>
281
239
  </div>
282
- <div class="metric-status" id="status-cache"></div>
283
240
  </div>
284
241
 
285
242
  <!-- Long Running Queries -->
@@ -298,7 +255,6 @@ end</pre>
298
255
  <svg id="sparkline-longrunning" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
299
256
  </div>
300
257
  </div>
301
- <div class="metric-status" id="status-longrunning"></div>
302
258
  </div>
303
259
 
304
260
  <!-- Blocked Queries -->
@@ -317,7 +273,6 @@ end</pre>
317
273
  <svg id="sparkline-blocked" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
318
274
  </div>
319
275
  </div>
320
- <div class="metric-status" id="status-blocked"></div>
321
276
  </div>
322
277
  </div>
323
278
  </div>
@@ -654,11 +609,14 @@ end</pre>
654
609
  value colour and the sparkline, never by a coloured edge: "healthy" is
655
610
  the common case and should read as quiet, not decorated. */
656
611
  .live-metric-card {
612
+ display: flex;
613
+ flex-direction: column;
657
614
  background: var(--bg-tertiary);
658
615
  border: 1px solid var(--border-color);
659
616
  border-radius: var(--radius);
660
617
  padding: 0.875rem 1rem;
661
- position: relative;
618
+ /* The trend band below bleeds to the card's edges. */
619
+ overflow: hidden;
662
620
  transition: border-color 0.12s ease;
663
621
  }
664
622
 
@@ -674,7 +632,7 @@ end</pre>
674
632
  display: flex;
675
633
  align-items: center;
676
634
  gap: 0.5rem;
677
- margin-bottom: 0.625rem;
635
+ margin-bottom: 0.25rem;
678
636
  }
679
637
 
680
638
  .metric-icon {
@@ -693,6 +651,9 @@ end</pre>
693
651
  .metric-body {
694
652
  display: flex;
695
653
  flex-direction: column;
654
+ /* Figures hug the right edge; the trend band below opts back out. */
655
+ align-items: flex-end;
656
+ flex: 1;
696
657
  gap: 0.1875rem;
697
658
  }
698
659
 
@@ -719,17 +680,27 @@ end</pre>
719
680
  }
720
681
 
721
682
  .metric-detail {
683
+ max-width: 100%;
722
684
  font-size: 0.7rem;
723
685
  color: var(--text-muted);
686
+ text-align: right;
724
687
  white-space: nowrap;
725
688
  overflow: hidden;
726
689
  text-overflow: ellipsis;
727
690
  }
728
691
 
729
- /* Sparkline */
692
+ /* Trend band. Negative margins cancel the card's padding so it spans the full
693
+ width and sits flush with the bottom edge — as a floating inset line it left
694
+ the lower half of the card reading as dead space. `margin-top: auto` keeps
695
+ it pinned down when cards in a row differ in height. */
730
696
  .metric-sparkline {
731
- height: 28px;
732
- margin-top: 0.625rem;
697
+ /* Full width, regardless of the right-alignment its siblings use. */
698
+ align-self: stretch;
699
+ height: 2.25rem;
700
+ /* auto keeps the band on the bottom edge when cards in a row differ in
701
+ height; the negative side/bottom margins cancel the card's padding. */
702
+ margin: auto -1rem -0.875rem;
703
+ padding-top: 0.5rem;
733
704
  color: var(--text-muted);
734
705
  }
735
706
 
@@ -742,20 +713,6 @@ end</pre>
742
713
  display: block;
743
714
  }
744
715
 
745
- /* Status indicator */
746
- .metric-status {
747
- position: absolute;
748
- top: 0.75rem;
749
- right: 0.75rem;
750
- width: 6px;
751
- height: 6px;
752
- border-radius: 50%;
753
- background: var(--border-strong);
754
- }
755
-
756
- .metric-status.warning { background: var(--accent-amber); }
757
- .metric-status.critical { background: var(--accent-rose); }
758
-
759
716
  /* Run Query Modal */
760
717
  .modal-run-query {
761
718
  max-width: 1100px;
@@ -1329,23 +1286,30 @@ end</pre>
1329
1286
 
1330
1287
  const width = 100;
1331
1288
  const height = 30;
1289
+ // No horizontal inset: the band spans the card's full width, so the trace
1290
+ // should reach both edges. Vertical inset only, to keep the stroke from
1291
+ // being clipped at the extremes.
1332
1292
  const padding = 2;
1333
1293
 
1334
1294
  const min = Math.min(...data);
1335
1295
  const max = Math.max(...data);
1336
- const range = max - min || 1;
1296
+ const range = max - min;
1337
1297
 
1338
1298
  const points = data.map((value, index) => {
1339
- const x = padding + (index / (data.length - 1)) * (width - 2 * padding);
1340
- const y = height - padding - ((value - min) / range) * (height - 2 * padding);
1299
+ const x = (index / (data.length - 1)) * width;
1300
+ // A series that never moves is drawn mid-band: pinned to the floor it
1301
+ // reads as "zero" (or as a stray underline) rather than "steady".
1302
+ const y = range === 0
1303
+ ? height / 2
1304
+ : height - padding - ((value - min) / range) * (height - 2 * padding);
1341
1305
  return { x, y };
1342
1306
  });
1343
1307
 
1344
1308
  const pathD = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ');
1345
1309
 
1346
1310
  const areaD = pathD +
1347
- ` L ${points[points.length - 1].x} ${height - padding}` +
1348
- ` L ${points[0].x} ${height - padding} Z`;
1311
+ ` L ${points[points.length - 1].x} ${height}` +
1312
+ ` L ${points[0].x} ${height} Z`;
1349
1313
 
1350
1314
  const area = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1351
1315
  area.setAttribute('d', areaD);
@@ -1366,8 +1330,7 @@ end</pre>
1366
1330
 
1367
1331
  function updateStatus(metric, value, badDirection) {
1368
1332
  const card = document.querySelector(`[data-metric="${metric}"]`);
1369
- const statusDot = document.getElementById(`status-${metric}`);
1370
- if (!card || !statusDot) return;
1333
+ if (!card) return;
1371
1334
 
1372
1335
  const thresholds = LIVE_CONFIG.thresholds[metric];
1373
1336
  let status = 'ok';
@@ -1384,8 +1347,6 @@ end</pre>
1384
1347
 
1385
1348
  card.classList.remove('status-ok', 'status-warning', 'status-critical');
1386
1349
  card.classList.add(`status-${status}`);
1387
- statusDot.classList.remove('ok', 'warning', 'critical');
1388
- statusDot.classList.add(status);
1389
1350
  }
1390
1351
 
1391
1352
  document.addEventListener('DOMContentLoaded', initLiveMonitoring);
@@ -1407,10 +1368,6 @@ end</pre>
1407
1368
  'cursor': 5
1408
1369
  };
1409
1370
 
1410
- function getDefaultIde() {
1411
- return localStorage.getItem('pgReportsDefaultIde') || '';
1412
- }
1413
-
1414
1371
  function generateIdeUrls(filePath, lineNumber) {
1415
1372
  if (!filePath) return [];
1416
1373
 
@@ -1509,41 +1466,6 @@ end</pre>
1509
1466
  document.getElementById('ide-dropdown-overlay')?.classList.remove('show');
1510
1467
  }
1511
1468
 
1512
- function showIdeSettingsModal() {
1513
- const modal = document.getElementById('ide-settings-modal');
1514
- modal.style.display = 'flex';
1515
- loadIdeSettingsState();
1516
- }
1517
-
1518
- function closeIdeSettingsModal() {
1519
- document.getElementById('ide-settings-modal').style.display = 'none';
1520
- }
1521
-
1522
- function setDefaultIde(ideKey) {
1523
- if (ideKey) {
1524
- localStorage.setItem('pgReportsDefaultIde', ideKey);
1525
- } else {
1526
- localStorage.removeItem('pgReportsDefaultIde');
1527
- }
1528
- }
1529
-
1530
- function loadIdeSettingsState() {
1531
- const currentIde = getDefaultIde();
1532
- const radios = document.querySelectorAll('input[name="default-ide"]');
1533
- radios.forEach(radio => {
1534
- radio.checked = (radio.value === currentIde);
1535
- });
1536
- }
1537
-
1538
- // Close IDE settings modal on backdrop click
1539
- document.addEventListener('DOMContentLoaded', function() {
1540
- document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
1541
- if (e.target === this) {
1542
- closeIdeSettingsModal();
1543
- }
1544
- });
1545
- });
1546
-
1547
1469
  // ============================================
1548
1470
  // Query Monitoring
1549
1471
  // ============================================
@@ -48,7 +48,7 @@
48
48
  <%= t("pg_reports.ui.actions.send_telegram") %>
49
49
  </button>
50
50
  <% end %>
51
- <button type="button" class="btn-icon" onclick="showIdeSettingsModal()" title="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>" aria-label="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>">
51
+ <button type="button" class="btn-icon" onclick="showSettingsModal()" title="<%= t("pg_reports.ui.actions.settings_button_title") %>" aria-label="<%= t("pg_reports.ui.actions.settings_button_title") %>">
52
52
  <svg class="icon icon-lg"><use href="#i-settings"></use></svg>
53
53
  </button>
54
54
  </div>
@@ -589,6 +589,9 @@ en:
589
589
  title: "PgReports"
590
590
  subtitle: "PostgreSQL Analysis Dashboard"
591
591
  page_title: "PgReports Dashboard"
592
+ language:
593
+ label: "Language"
594
+ name: "English"
592
595
  navigation:
593
596
  dashboard: "Dashboard"
594
597
  back: "Back"
@@ -619,7 +622,7 @@ en:
619
622
  confirm_reset: "Yes, Reset"
620
623
  create_extension: "Create Extension"
621
624
  creating: "Creating..."
622
- ide_settings_button_title: "IDE Settings"
625
+ settings_button_title: "Settings"
623
626
  explain_analyze: "EXPLAIN ANALYZE"
624
627
  execute_query: "Execute Query"
625
628
  run_query: "SQL Console"
@@ -654,7 +657,7 @@ en:
654
657
  reset_stats_title: "Reset Statistics"
655
658
  reset_stats_confirm: "Are you sure you want to reset pg_stat_statements statistics?"
656
659
  reset_stats_warning: "This action will clear all collected query statistics and cannot be undone."
657
- ide_settings_title: "IDE Settings"
660
+ settings_title: "Settings"
658
661
  problem_detected_title: "Problem Detected"
659
662
  query_analyzer_title: "Query Analyzer"
660
663
  query_label: "Query:"
@@ -670,7 +673,7 @@ en:
670
673
  run_query_title: "SQL Console"
671
674
  run_query_placeholder: "SELECT * FROM users LIMIT 10;"
672
675
  settings:
673
- default_ide_label: "Default IDE for source links:"
676
+ default_ide_label: "Default IDE for source links"
674
677
  ide_show_menu: "Show menu (default)"
675
678
  ide_vscode_wsl: "VS Code (WSL)"
676
679
  ide_vscode: "VS Code"
@@ -554,6 +554,9 @@ ru:
554
554
  title: "PgReports"
555
555
  subtitle: "Дашборд анализа PostgreSQL"
556
556
  page_title: "PgReports — Дашборд"
557
+ language:
558
+ label: "Язык"
559
+ name: "Русский"
557
560
  navigation:
558
561
  dashboard: "Дашборд"
559
562
  back: "Назад"
@@ -584,7 +587,7 @@ ru:
584
587
  confirm_reset: "Да, сбросить"
585
588
  create_extension: "Создать расширение"
586
589
  creating: "Создание..."
587
- ide_settings_button_title: "Настройки IDE"
590
+ settings_button_title: "Настройки"
588
591
  explain_analyze: "EXPLAIN ANALYZE"
589
592
  execute_query: "Выполнить запрос"
590
593
  run_query: "SQL-консоль"
@@ -619,7 +622,7 @@ ru:
619
622
  reset_stats_title: "Сброс статистики"
620
623
  reset_stats_confirm: "Вы уверены, что хотите сбросить статистику pg_stat_statements?"
621
624
  reset_stats_warning: "Это действие очистит всю собранную статистику запросов и не может быть отменено."
622
- ide_settings_title: "Настройки IDE"
625
+ settings_title: "Настройки"
623
626
  problem_detected_title: "Обнаружена проблема"
624
627
  query_analyzer_title: "Анализатор запроса"
625
628
  query_label: "Запрос:"
@@ -635,7 +638,7 @@ ru:
635
638
  run_query_title: "SQL-консоль"
636
639
  run_query_placeholder: "SELECT * FROM users LIMIT 10;"
637
640
  settings:
638
- default_ide_label: "IDE по умолчанию для ссылок на исходники:"
641
+ default_ide_label: "IDE по умолчанию для ссылок на исходники"
639
642
  ide_show_menu: "Показывать меню (по умолчанию)"
640
643
  ide_vscode_wsl: "VS Code (WSL)"
641
644
  ide_vscode: "VS Code"
@@ -554,6 +554,9 @@ uk:
554
554
  title: "PgReports"
555
555
  subtitle: "Дашборд аналізу PostgreSQL"
556
556
  page_title: "PgReports — Дашборд"
557
+ language:
558
+ label: "Мова"
559
+ name: "Українська"
557
560
  navigation:
558
561
  dashboard: "Дашборд"
559
562
  back: "Назад"
@@ -584,7 +587,7 @@ uk:
584
587
  confirm_reset: "Так, скинути"
585
588
  create_extension: "Створити розширення"
586
589
  creating: "Створення..."
587
- ide_settings_button_title: "Налаштування IDE"
590
+ settings_button_title: "Налаштування"
588
591
  explain_analyze: "EXPLAIN ANALYZE"
589
592
  execute_query: "Виконати запит"
590
593
  run_query: "SQL-консоль"
@@ -619,7 +622,7 @@ uk:
619
622
  reset_stats_title: "Скидання статистики"
620
623
  reset_stats_confirm: "Ви впевнені, що хочете скинути статистику pg_stat_statements?"
621
624
  reset_stats_warning: "Ця дія очистить усю зібрану статистику запитів і не може бути скасована."
622
- ide_settings_title: "Налаштування IDE"
625
+ settings_title: "Налаштування"
623
626
  problem_detected_title: "Виявлено проблему"
624
627
  query_analyzer_title: "Аналізатор запиту"
625
628
  query_label: "Запит:"
@@ -635,7 +638,7 @@ uk:
635
638
  run_query_title: "SQL-консоль"
636
639
  run_query_placeholder: "SELECT * FROM users LIMIT 10;"
637
640
  settings:
638
- default_ide_label: "IDE за замовчуванням для посилань на джерела:"
641
+ default_ide_label: "IDE за замовчуванням для посилань на джерела"
639
642
  ide_show_menu: "Показувати меню (за замовчуванням)"
640
643
  ide_vscode_wsl: "VS Code (WSL)"
641
644
  ide_vscode: "VS Code"
data/config/routes.rb CHANGED
@@ -9,6 +9,7 @@ PgReports::Engine.routes.draw do
9
9
 
10
10
  post "switch_database", to: "dashboard#switch_database", as: :switch_database
11
11
  post "switch_target", to: "dashboard#switch_target", as: :switch_target
12
+ post "switch_locale", to: "dashboard#switch_locale", as: :switch_locale
12
13
 
13
14
  post "enable_pg_stat_statements", to: "dashboard#enable_pg_stat_statements", as: :enable_pg_stat_statements
14
15
  post "reset_statistics", to: "dashboard#reset_statistics", as: :reset_statistics
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.1"
5
5
  end
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.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eldar Avatov
@@ -153,6 +153,7 @@ files:
153
153
  - app/views/layouts/pg_reports/application.html.erb
154
154
  - app/views/pg_reports/dashboard/_database_selector.html.erb
155
155
  - app/views/pg_reports/dashboard/_fake_source_data.html.erb
156
+ - app/views/pg_reports/dashboard/_settings_modal.html.erb
156
157
  - app/views/pg_reports/dashboard/_show_modals.html.erb
157
158
  - app/views/pg_reports/dashboard/_show_scripts.html.erb
158
159
  - app/views/pg_reports/dashboard/_show_styles.html.erb