pgbouncerhero 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +155 -58
  4. data/app/assets/builds/pgbouncerhero/application.css +2 -0
  5. data/app/assets/stylesheets/pgbouncerhero/application.css +1 -0
  6. data/app/controllers/pg_bouncer_hero/application_controller.rb +6 -9
  7. data/app/controllers/pg_bouncer_hero/database_controller.rb +39 -13
  8. data/app/controllers/pg_bouncer_hero/home_controller.rb +0 -2
  9. data/app/helpers/pg_bouncer_hero/application_helper.rb +19 -13
  10. data/app/javascript/pgbouncerhero/application.js +6 -0
  11. data/app/javascript/pgbouncerhero/controllers/polling_controller.js +27 -0
  12. data/app/views/layouts/pg_bouncer_hero/application.html.erb +19 -22
  13. data/app/views/pg_bouncer_hero/database/_actions.html.erb +9 -9
  14. data/app/views/pg_bouncer_hero/database/_clients.html.erb +21 -23
  15. data/app/views/pg_bouncer_hero/database/_conf.html.erb +21 -23
  16. data/app/views/pg_bouncer_hero/database/_databases.html.erb +21 -23
  17. data/app/views/pg_bouncer_hero/database/_menu.html.erb +20 -18
  18. data/app/views/pg_bouncer_hero/database/_pools.html.erb +21 -23
  19. data/app/views/pg_bouncer_hero/database/_state.html.erb +11 -0
  20. data/app/views/pg_bouncer_hero/database/_stats.html.erb +27 -33
  21. data/app/views/pg_bouncer_hero/database/state.html.erb +4 -0
  22. data/app/views/pg_bouncer_hero/database/summary.html.erb +3 -0
  23. data/app/views/pg_bouncer_hero/home/_card.html.erb +9 -22
  24. data/app/views/pg_bouncer_hero/home/_card_content.html.erb +33 -33
  25. data/app/views/pg_bouncer_hero/home/_card_loading_content.html.erb +5 -5
  26. data/app/views/pg_bouncer_hero/home/index.html.erb +13 -37
  27. data/app/views/shared/_alert.html.erb +2 -5
  28. data/config/importmap.rb +5 -0
  29. data/config/routes.rb +10 -2
  30. data/lib/generators/pgbouncerhero/templates/config.yml +13 -8
  31. data/lib/pgbouncerhero/connection.rb +53 -16
  32. data/lib/pgbouncerhero/database.rb +69 -4
  33. data/lib/pgbouncerhero/engine.rb +13 -10
  34. data/lib/pgbouncerhero/group.rb +0 -2
  35. data/lib/pgbouncerhero/methods/basics.rb +28 -12
  36. data/lib/pgbouncerhero/version.rb +1 -1
  37. data/lib/pgbouncerhero.rb +124 -41
  38. metadata +187 -46
  39. data/.gitignore +0 -19
  40. data/Gemfile +0 -4
  41. data/app/assets/images/pgbouncerhero/short-paragraph.png +0 -0
  42. data/app/assets/javascripts/pgbouncerhero/application.js +0 -3
  43. data/app/assets/stylesheets/pgbouncerhero/application.css.scss +0 -15
  44. data/app/views/pg_bouncer_hero/database/summary.js.erb +0 -5
  45. data/doc/screenshot-1.png +0 -0
  46. data/doc/screenshot-2.png +0 -0
  47. data/doc/screenshot-3.png +0 -0
  48. data/pgbouncerhero.gemspec +0 -32
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0beaacfe8514247e5d6aa9a5a2cee2432f830a265911faaab2998fd406c23aab
4
- data.tar.gz: cf85e4ce7b62d81ab756be4031fc1aa268f9823329fd108f372c0f930c6fb068
3
+ metadata.gz: 913492602328ce8fb2d3731212ccd7cef95c2a9429fbe6d4112d9a65d5853b35
4
+ data.tar.gz: 5475f266d924090db51b7a242cc7a7e9b90e917dd485e562f346d7f471d3e9bf
5
5
  SHA512:
6
- metadata.gz: '09add1cb73ceb2c23e35f7261eeece28bb87d503087cb7d61d26937f78e0843b7df1205542512b1bef76d8a0244e3ca8f5b5ce0b55259e327ef36f8a7fcc0c2b'
7
- data.tar.gz: 3f7caf907cfc1334b6f319c5d85104a71c41f70780fbac948f1bd790ee4c01bf99a774c56dc90652d223f2ba8c3adbb0241cbf3b95c084ed0d58e660212c81dc
6
+ metadata.gz: 0e6ceb49a3f0c1b28a2edeb0939fdc7bd9f4284c02c5361f879ecedd7cb9af17f1862f5943fb75b9c79e03f5d75d0242fefc4c8ee7b67cf43b62c1ee0414c345
7
+ data.tar.gz: 96bc7a41d920340e9fce91c6686b6819a71393713b8e4e559e95cf790ebcae3b5d7fba13a29b62d32342aced8dceb8376c0db92b2c29f3678252199ae4c2f4e4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,67 @@
1
+ ## Unreleased
2
+
3
+ ## 3.1.0 - 2026-08-23
4
+
5
+ **New:**
6
+ - Read-only mode for hiding and server-side blocking PgBouncer administrative commands
7
+ - PgBouncer state visibility and Resume controls
8
+ - Integration coverage across supported PgBouncer 1.23, 1.24, and 1.25 releases
9
+ - Bounded, thread-safe connection pools per PgBouncer with configurable size and checkout timeout
10
+ - Explicit `Database#with_connection` leases for running multiple commands on one connection
11
+ - Real PgBouncer integration coverage for admin queries, summaries, reloads, authentication, and reconnection
12
+ - A dedicated CI integration job backed by the repository's Docker Compose stack
13
+ - Configurable `PgBouncerHero.config_path`, resolved from the host application's `Rails.root` by default
14
+ - `PgBouncerHero.reset!` for reloading configuration and connections safely
15
+ - Request-level engine coverage and gem-package validation in CI
16
+
17
+ **Improved:**
18
+ - Use `SHUTDOWN WAIT_FOR_CLIENTS` for graceful shutdowns initiated from the dashboard
19
+ - Reconnect stale or failed pooled connections automatically and close every pool on reset
20
+ - Pin PostgreSQL and PgBouncer development images and wait for container health before testing
21
+ - Let Dependabot maintain Docker image versions alongside gems and GitHub Actions
22
+ - Protect PgBouncer connections from concurrent use while allowing bounded parallel queries
23
+ - Resolve parameterized group and database names consistently in routes and controllers
24
+ - Load Rails engine dependencies explicitly and declare Propshaft as a runtime dependency
25
+ - Cache Appraisal dependencies directly in the Ruby/Rails CI matrix
26
+ - Pin GitHub Actions to their current immutable release commits
27
+ - Add read-only workflow permissions, cancellation of superseded runs, and job timeouts
28
+
29
+ ## 3.0.0
30
+
31
+ **Breaking Changes:**
32
+ - Requires Ruby >= 3.2 and Rails >= 7.2
33
+ - Removed jQuery, Semantic UI, and Sprockets dependencies
34
+ - Replaced with Hotwire (Turbo + Stimulus), Tailwind CSS 4, Propshaft, and importmap-rails
35
+ - Removed JRuby support
36
+ - Host apps must use Propshaft and importmap-rails
37
+
38
+ **New:**
39
+ - Modern Tailwind CSS 4 UI with responsive design
40
+ - Turbo Frames for lazy-loading database cards (replaces jQuery AJAX polling)
41
+ - Stimulus controller for 60-second auto-refresh
42
+ - RuboCop (rubocop-rails-omakase) for Ruby linting
43
+ - Herb for ERB template linting
44
+ - Minitest test suite with dummy Rails app
45
+ - Appraisal for multi-Rails version testing (7.2, 8.0, 8.1)
46
+ - GitHub Actions CI (Ruby 3.2/3.3/3.4/4.0 x Rails 7.2/8.0/8.1)
47
+ - Gem metadata (source_code_uri, changelog_uri, bug_tracker_uri, rubygems_mfa_required)
48
+
49
+ **Improved:**
50
+ - `rescue Exception` replaced with `rescue StandardError`
51
+ - `YAML.load` replaced with `YAML.safe_load`
52
+ - Removed deprecated `require_dependency`
53
+ - Removed `before_filter` fallback (Rails 3/4 compat)
54
+ - Config caching uses module-level `@config` instead of `Thread.current`
55
+ - Removed `html_safe` on flash messages
56
+ - Updated config template terminology: `master`/`slave` to `primary`/`replica`
57
+
58
+ ## 2.0.0
59
+
60
+ - Easier setup
61
+ - Automatically require jquery
62
+ - Automatically require semantic-ui-sass
63
+ - Use pgbouncerhero/application stylesheets and javascript
64
+
1
65
  ## 1.0.3
2
66
 
3
67
  - Drop Haml dependency
data/README.md CHANGED
@@ -1,21 +1,29 @@
1
1
  # PgBouncerHero
2
2
 
3
- A graphical user interface for your PGBouncers.
3
+ [![Gem Version](https://badge.fury.io/rb/pgbouncerhero.svg)](https://badge.fury.io/rb/pgbouncerhero)
4
+ [![CI](https://github.com/kwent/pgbouncerhero/actions/workflows/test.yml/badge.svg)](https://github.com/kwent/pgbouncerhero/actions/workflows/test.yml)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-ruby.svg)](https://www.ruby-lang.org)
4
6
 
5
- [See it in action](https://pgbouncerhero-demo.herokuapp.com/). [Source Code](https://github.com/kwent/pgbouncerhero-demo).
7
+ A graphical user interface for your PgBouncers.
6
8
 
7
- [![Screenshot1](https://github.com/kwent/pgbouncerhero/blob/master/doc/screenshot-1.png?raw=true)](https://pgbouncerhero-demo.herokuapp.com/)
8
- [![Screenshot2](https://github.com/kwent/pgbouncerhero/blob/master/doc/screenshot-2.png?raw=true)](https://pgbouncerhero-demo.herokuapp.com/)
9
- [![Screenshot2](https://github.com/kwent/pgbouncerhero/blob/master/doc/screenshot-3.png?raw=true)](https://pgbouncerhero-demo.herokuapp.com/)
9
+ [![Screenshot1](https://github.com/kwent/pgbouncerhero/blob/master/doc/screenshot-1.png?raw=true)](https://github.com/kwent/pgbouncerhero)
10
+ [![Screenshot2](https://github.com/kwent/pgbouncerhero/blob/master/doc/screenshot-2.png?raw=true)](https://github.com/kwent/pgbouncerhero)
11
+ [![Screenshot3](https://github.com/kwent/pgbouncerhero/blob/master/doc/screenshot-3.png?raw=true)](https://github.com/kwent/pgbouncerhero)
10
12
 
11
- ## Installation
13
+ ## Requirements
14
+
15
+ - Ruby >= 3.2
16
+ - Rails >= 7.2
17
+ - PgBouncer >= 1.23
18
+ - Propshaft (asset pipeline)
19
+ - importmap-rails
12
20
 
13
- PgBouncerHero is available as a Rails engine.
21
+ ## Installation
14
22
 
15
- Add those dependencies to your applications Gemfile:
23
+ Add to your application's Gemfile:
16
24
 
17
25
  ```ruby
18
- gem 'pgbouncerhero'
26
+ gem "pgbouncerhero"
19
27
  ```
20
28
 
21
29
  And mount the engine in your `config/routes.rb`:
@@ -36,7 +44,7 @@ ENV["PGBOUNCERHERO_PASSWORD"] = "triforce"
36
44
  ### Devise
37
45
 
38
46
  ```ruby
39
- authenticate :user, -> (user) { user.admin? } do
47
+ authenticate :user, ->(user) { user.admin? } do
40
48
  mount PgBouncerHero::Engine, at: "pgbouncerhero"
41
49
  end
42
50
  ```
@@ -49,58 +57,147 @@ export PGBOUNCERHERO_DATABASE_URL=postgres://user:password@host:port/pgbouncer
49
57
 
50
58
  ## Multiple PgBouncers
51
59
 
52
- Create `config/pgbouncerhero.yml` with:
60
+ Generate a config file:
61
+
62
+ ```bash
63
+ rails generate pgbouncerhero:config
64
+ ```
65
+
66
+ Or create `config/pgbouncerhero.yml` manually:
53
67
 
54
68
  ```yml
55
- default: &default
56
- pgbouncers:
57
- production:
58
- master:
59
- url: <%= ENV["PGBOUNCER_PRODUCTION_MASTER_DATABASE_URL"] %>
60
- slave:
61
- url: <%= ENV["PGBOUNCER_PRODUCTION_SLAVE_DATABASE_URL"] %>
62
- staging:
63
- master:
64
- url: <%= ENV["PGBOUNCER_STAGING_MASTER_DATABASE_URL"] %>
65
- slave:
66
- url: <%= ENV["PGBOUNCER_STAGING_SLAVE_DATABASE_URL"] %>
67
-
68
- development:
69
- <<: *default
70
-
71
- production:
72
- <<: *default
69
+ pgbouncers:
70
+ production:
71
+ primary:
72
+ url: <%= ENV["PGBOUNCER_PRODUCTION_PRIMARY_DATABASE_URL"] %>
73
+ pool_size: 5
74
+ pool_timeout: 5
75
+ replica:
76
+ url: <%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
77
+ staging:
78
+ primary:
79
+ url: <%= ENV["PGBOUNCER_STAGING_PRIMARY_DATABASE_URL"] %>
80
+ replica:
81
+ url: <%= ENV["PGBOUNCER_STAGING_REPLICA_DATABASE_URL"] %>
82
+
83
+ # Optional: allow monitoring while blocking all administrative commands
84
+ read_only: true
73
85
  ```
74
86
 
75
- # Authors
87
+ Environment-specific top-level keys are also supported. PgBouncerHero reads
88
+ `config/pgbouncerhero.yml` from the host application's `Rails.root` by default.
89
+ An initializer can point to a different file:
76
90
 
77
- - [Quentin Rousseau](https://github.com/kwent)
91
+ ```ruby
92
+ PgBouncerHero.config_path = Rails.root.join("config/pgbouncerhero.production.yml")
93
+ ```
94
+
95
+ Changing `PgBouncerHero.env` or `config_path` resets cached groups and closes
96
+ their open connections. Call `PgBouncerHero.reset!` after changing a config
97
+ file at runtime, or `PgBouncerHero.disconnect!` when only the connections need
98
+ to be closed. Stale connections reconnect automatically.
99
+
100
+ The PostgreSQL connection timeout defaults to five seconds and can be changed
101
+ with `PGBOUNCERHERO_TIMEOUT`.
102
+
103
+ PgBouncerHero keeps a bounded, lazily created connection pool for each
104
+ configured PgBouncer. The pool defaults to five connections with a five-second
105
+ checkout timeout. Set `PGBOUNCERHERO_POOL_SIZE` and
106
+ `PGBOUNCERHERO_POOL_TIMEOUT` to change the defaults for every PgBouncer, or set
107
+ `pool_size` and `pool_timeout` on an individual database entry as shown above.
108
+ Per-database settings take precedence over environment variables.
78
109
 
79
- # License
80
-
81
- ```plain
82
- Copyright (c) 2022 Quentin Rousseau <contact@quent.in>
83
-
84
- MIT License
85
-
86
- Permission is hereby granted, free of charge, to any person
87
- obtaining a copy of this software and associated documentation
88
- files (the "Software"), to deal in the Software without
89
- restriction, including without limitation the rights to use,
90
- copy, modify, merge, publish, distribute, sublicense, and/or sell
91
- copies of the Software, and to permit persons to whom the
92
- Software is furnished to do so, subject to the following
93
- conditions:
94
-
95
- The above copyright notice and this permission notice shall be
96
- included in all copies or substantial portions of the Software.
97
-
98
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
99
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
100
- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
101
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
102
- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
103
- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
104
- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
105
- OTHER DEALINGS IN THE SOFTWARE.
110
+ Code that needs to issue multiple commands on the same PostgreSQL connection
111
+ can lease one explicitly:
112
+
113
+ ```ruby
114
+ database.with_connection do |connection|
115
+ connection.exec("SHOW VERSION")
116
+ connection.exec("SHOW CONFIG")
117
+ end
118
+ ```
119
+
120
+ `database.connection` remains available as a connection-compatible proxy for
121
+ existing integrations. Connections are checked when borrowed, and stale or
122
+ failed connections are discarded and recreated automatically.
123
+
124
+ ### Safe Administration
125
+
126
+ The dashboard shows PgBouncer's current state and provides Reload, Suspend,
127
+ Resume, and graceful shutdown controls. Dashboard shutdown uses
128
+ `SHUTDOWN WAIT_FOR_CLIENTS`: PgBouncer stops accepting new clients and exits
129
+ after existing clients disconnect. The Ruby API keeps `database.shutdown` as
130
+ the immediate command for compatibility and also accepts `:immediate`,
131
+ `:wait_for_clients`, or `:wait_for_servers`. State visibility requires
132
+ PgBouncer 1.19 or newer; graceful shutdown modes require PgBouncer 1.23 or
133
+ newer.
134
+
135
+ Set `read_only: true` at the top level of `config/pgbouncerhero.yml`, or set
136
+ `PGBOUNCERHERO_READ_ONLY=true`, to hide and server-side reject every
137
+ administrative command. The environment variable takes precedence over YAML.
138
+
139
+ ## Development
140
+
141
+ Start PostgreSQL and PgBouncer with Docker:
142
+
143
+ ```bash
144
+ docker compose up --detach --wait
106
145
  ```
146
+
147
+ Run the dummy Rails app:
148
+
149
+ ```bash
150
+ PGBOUNCERHERO_DATABASE_URL=postgres://pgbouncer:pgbouncer@localhost:6432/pgbouncer \
151
+ bundle exec rackup test/dummy/config.ru -p 3000
152
+ ```
153
+
154
+ Then open http://localhost:3000/pgbouncerhero.
155
+
156
+ Run the test suite:
157
+
158
+ ```bash
159
+ bundle exec rake # unit/integration-free tests + rubocop + herb
160
+ bundle exec appraisal rake test # tests across Rails 7.2, 8.0, and 8.1
161
+ bundle exec rake build # build and validate the gem package
162
+ bundle exec rake test:integration # real PgBouncer admin-console tests
163
+ ```
164
+
165
+ The integration task expects PostgreSQL and PgBouncer from the Compose stack.
166
+ Override `PGBOUNCERHERO_INTEGRATION_URL` to test another PgBouncer instance.
167
+ The default is `postgres://pgbouncer:pgbouncer@127.0.0.1:6432/pgbouncer`.
168
+ CI runs this suite against PgBouncer 1.23, 1.24, and 1.25.
169
+
170
+ Stop Docker when done:
171
+
172
+ ```bash
173
+ docker compose down --volumes
174
+ ```
175
+
176
+ ### Releasing
177
+
178
+ Releases use a scoped RubyGems OIDC API Key Role through GitHub Actions, with no
179
+ long-lived RubyGems API key. The role is restricted to the `pgbouncerhero` gem,
180
+ the `kwent/pgbouncerhero` repository, and the `rubygems.org` audience. Store its
181
+ role token in the `RUBYGEMS_OIDC_ROLE` GitHub Actions secret. Then run the
182
+ **Release** workflow on `master` and enter the version already committed in
183
+ `lib/pgbouncerhero/version.rb`. The workflow verifies the version and test
184
+ suite, exchanges GitHub's OIDC identity for a short-lived credential, publishes
185
+ the gem, pushes the version tag, and creates the matching GitHub release.
186
+
187
+ ## Contributing
188
+
189
+ 1. Fork it
190
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
191
+ 3. Commit your changes (`git commit -am "Add some feature"`)
192
+ 4. Push to the branch (`git push origin my-new-feature`)
193
+ 5. Create a new Pull Request
194
+
195
+ ## Authors
196
+
197
+ - [Quentin Rousseau](https://github.com/kwent)
198
+
199
+ ## License
200
+
201
+ Copyright (c) 2025 Quentin Rousseau
202
+
203
+ MIT License. See [LICENSE.txt](LICENSE.txt) for details.
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-blue-50:oklch(97% .014 254.604);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.fixed{position:fixed}.static{position:static}.sticky{position:sticky}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-0{right:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.table{display:table}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-14{height:calc(var(--spacing) * 14)}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-2\/3{width:66.6667%}.w-3\/4{width:75%}.w-24{width:calc(var(--spacing) * 24)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.min-w-full{min-width:100%}.flex-1{flex:1}.table-auto{table-layout:auto}.animate-pulse{animation:var(--animate-pulse)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-yellow-200{border-color:var(--color-yellow-200)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-500{background-color:var(--color-green-500)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-inherit{color:inherit}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-white{color:var(--color-white)}.text-yellow-800{color:var(--color-yellow-800)}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-green-50:hover{background-color:var(--color-green-50)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}}@media (min-width:40rem){.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes pulse{50%{opacity:.5}}
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
@@ -2,25 +2,22 @@ module PgBouncerHero
2
2
  class ApplicationController < ActionController::Base
3
3
  layout "pg_bouncer_hero/application"
4
4
 
5
- protect_from_forgery
5
+ protect_from_forgery with: :exception
6
6
 
7
7
  http_basic_authenticate_with name: ENV["PGBOUNCERHERO_USERNAME"], password: ENV["PGBOUNCERHERO_PASSWORD"] if ENV["PGBOUNCERHERO_PASSWORD"]
8
8
 
9
- if respond_to?(:before_action)
10
- before_action :set_database
11
- else
12
- before_filter :set_database
13
- end
9
+ before_action :set_database
14
10
 
15
11
  protected
16
12
 
17
13
  def set_database
18
14
  @groups = PgBouncerHero.groups
19
15
  if params[:group] && params[:database]
20
- @database = PgBouncerHero.groups[params[:group]].databases.find { |db| db.id.to_s == params[:database].to_s }
16
+ @group = @groups.fetch(params[:group])
17
+ @database = @group.databases.find { |database| database.name.parameterize == params[:database] }
21
18
  else
22
- @group = @groups.first
23
- @database = @groups.first.last.databases.first
19
+ _group_id, @group = @groups.first
20
+ @database = @group.databases.first
24
21
  end
25
22
  end
26
23
  end
@@ -1,7 +1,7 @@
1
- require_dependency 'pg_bouncer_hero/application_controller'
2
-
3
1
  module PgBouncerHero
4
2
  class DatabaseController < ApplicationController
3
+ before_action :ensure_writable!, only: %i[reload suspend resume shutdown]
4
+
5
5
  def summary
6
6
  if @database.connection
7
7
  @dbs = @database.summary
@@ -9,6 +9,7 @@ module PgBouncerHero
9
9
  flash[:error] = "#{@database.name} does not look online."
10
10
  end
11
11
  end
12
+
12
13
  def databases
13
14
  if @database.connection
14
15
  @dbs = @database.databases
@@ -16,6 +17,7 @@ module PgBouncerHero
16
17
  flash[:error] = "#{@database.name} does not look online."
17
18
  end
18
19
  end
20
+
19
21
  def stats
20
22
  if @database.connection
21
23
  @stats = @database.stats
@@ -23,6 +25,7 @@ module PgBouncerHero
23
25
  flash[:error] = "#{@database.name} does not look online."
24
26
  end
25
27
  end
28
+
26
29
  def pools
27
30
  if @database.connection
28
31
  @pools = @database.pools
@@ -30,6 +33,7 @@ module PgBouncerHero
30
33
  flash[:error] = "#{@database.name} does not look online."
31
34
  end
32
35
  end
36
+
33
37
  def clients
34
38
  if @database.connection
35
39
  @clients = @database.clients
@@ -37,6 +41,7 @@ module PgBouncerHero
37
41
  flash[:error] = "#{@database.name} does not look online."
38
42
  end
39
43
  end
44
+
40
45
  def conf
41
46
  if @database.connection
42
47
  @conf = @database.conf
@@ -44,29 +49,50 @@ module PgBouncerHero
44
49
  flash[:error] = "#{@database.name} does not look online."
45
50
  end
46
51
  end
47
- def reload
52
+
53
+ def state
48
54
  if @database.connection
49
- @database.reload
50
- flash[:success] = "#{@database.name} has been reloaded."
55
+ @state = @database.state
51
56
  else
52
57
  flash[:error] = "#{@database.name} does not look online."
53
58
  end
54
59
  end
60
+
61
+ def reload
62
+ execute_admin_command(:reload, "reloaded")
63
+ end
64
+
55
65
  def suspend
56
- if @database.connection
57
- @database.suspend
58
- flash[:success] = "#{@database.name} has been suspended."
59
- else
60
- flash[:error] = "#{@database.name} does not look online."
61
- end
66
+ execute_admin_command(:suspend, "suspended")
62
67
  end
68
+
69
+ def resume
70
+ execute_admin_command(:resume, "resumed")
71
+ end
72
+
63
73
  def shutdown
74
+ execute_admin_command(:shutdown, "asked to shut down after its clients disconnect", :wait_for_clients)
75
+ end
76
+
77
+ private
78
+
79
+ def ensure_writable!
80
+ return unless PgBouncerHero.read_only?
81
+
82
+ render plain: "PgBouncerHero is configured as read-only.", status: :forbidden
83
+ end
84
+
85
+ def execute_admin_command(command, past_tense, *arguments)
64
86
  if @database.connection
65
- @database.shutdown
66
- flash[:success] = "#{@database.name} has been shutdown."
87
+ @database.public_send(command, *arguments)
88
+ flash[:success] = "#{@database.name} has been #{past_tense}."
67
89
  else
68
90
  flash[:error] = "#{@database.name} does not look online."
69
91
  end
92
+ rescue PG::Error => e
93
+ flash[:error] = "#{@database.name}: #{e.message.strip}"
94
+ ensure
95
+ redirect_back fallback_location: root_path
70
96
  end
71
97
  end
72
98
  end
@@ -1,5 +1,3 @@
1
- require_dependency 'pg_bouncer_hero/application_controller'
2
-
3
1
  module PgBouncerHero
4
2
  class HomeController < ApplicationController
5
3
  def index
@@ -1,29 +1,35 @@
1
1
  module PgBouncerHero
2
2
  module ApplicationHelper
3
3
  def is_active(action_name)
4
- params[:action] == action_name ? 'active' : nil
4
+ params[:action] == action_name ? "active" : nil
5
5
  end
6
+
6
7
  def alert_class_for(flash_type)
7
8
  case flash_type
8
- when 'success'
9
- 'success'
10
- when 'error'
11
- 'error'
12
- when 'notice'
13
- 'info'
14
- when 'warning'
15
- 'warning'
16
- else
17
- nil
9
+ when "success" then "success"
10
+ when "error" then "error"
11
+ when "notice" then "info"
12
+ when "warning" then "warning"
18
13
  end
19
14
  end
15
+
16
+ def alert_style_for(class_name)
17
+ case class_name
18
+ when "success" then "bg-green-50 text-green-800 border border-green-200"
19
+ when "error" then "bg-red-50 text-red-800 border border-red-200"
20
+ when "info" then "bg-blue-50 text-blue-800 border border-blue-200"
21
+ when "warning" then "bg-yellow-50 text-yellow-800 border border-yellow-200"
22
+ else "bg-gray-50 text-gray-800 border border-gray-200"
23
+ end
24
+ end
25
+
20
26
  def humanize_ms(millis)
21
- [[1000, :ms], [60, :s], [60, :min], [24, :h], [1000, :d]].map{ |count, name|
27
+ [ [ 1000, :ms ], [ 60, :s ], [ 60, :min ], [ 24, :h ], [ 1000, :d ] ].map { |count, name|
22
28
  if millis > 0
23
29
  millis, n = millis.divmod(count)
24
30
  "#{n.to_i} #{name}"
25
31
  end
26
- }.compact.reverse.join(' ')
32
+ }.compact.reverse.join(" ")
27
33
  end
28
34
  end
29
35
  end
@@ -0,0 +1,6 @@
1
+ import "@hotwired/turbo-rails"
2
+ import { Application } from "@hotwired/stimulus"
3
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
4
+
5
+ const application = Application.start()
6
+ eagerLoadControllersFrom("pgbouncerhero/controllers", application)
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { interval: { type: Number, default: 60000 } }
5
+
6
+ connect() {
7
+ this.startPolling()
8
+ }
9
+
10
+ disconnect() {
11
+ this.stopPolling()
12
+ }
13
+
14
+ startPolling() {
15
+ this.timer = setInterval(() => {
16
+ this.element.querySelectorAll("turbo-frame[src]").forEach(frame => {
17
+ frame.reload()
18
+ })
19
+ }, this.intervalValue)
20
+ }
21
+
22
+ stopPolling() {
23
+ if (this.timer) {
24
+ clearInterval(this.timer)
25
+ }
26
+ }
27
+ }
@@ -1,32 +1,29 @@
1
- <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
2
- <html>
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
3
  <head>
4
- <meta charset="utf-8"/>
5
- <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible"/>
6
- <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0" name="viewport"/>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
6
  <title>
8
7
  <%= [@groups.size > 1 ? "#{@database.group.name} | #{@database.name}" : "PgBouncerHero", @title].compact.join(" / ") %>
9
8
  </title>
10
- <%= stylesheet_link_tag 'pgbouncerhero/application', media: 'all', 'data-turbolinks-track': true %>
11
- <%= javascript_include_tag "pgbouncerhero/application" %>
9
+ <link rel="icon" href="data:,">
10
+ <%= stylesheet_link_tag "pgbouncerhero/application" %>
11
+ <%= javascript_importmap_tags("pgbouncerhero/application", importmap: PgBouncerHero.importmap) %>
12
12
  <%= csrf_meta_tags %>
13
13
  </head>
14
- <body>
15
- <div class="ui fixed menu">
16
- <div class="ui container">
17
- <%= link_to'PGBouncerHero', root_path, class: 'header item' %>
18
- </div>
19
- </div>
20
- <div class="pusher">
21
- <div class="ui main container">
22
- <div class="row" id="flash">
23
- <%= render partial: 'shared/flash_messages', flash: flash %>
14
+ <body class="bg-gray-100 min-h-screen">
15
+ <nav class="bg-white border-b border-gray-200 fixed top-0 left-0 right-0 z-50 shadow-sm">
16
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
17
+ <div class="flex items-center h-14">
18
+ <%= link_to "PgBouncerHero", root_path, class: "text-lg font-bold text-gray-900 hover:text-gray-700" %>
24
19
  </div>
25
- <%= yield %>
26
20
  </div>
27
- <% if content_for? :inline_script %>
28
- <%= yield :inline_script %>
29
- <% end %>
30
- </div>
21
+ </nav>
22
+ <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-8">
23
+ <div id="flash">
24
+ <%= render partial: "shared/flash_messages", flash: flash %>
25
+ </div>
26
+ <%= yield %>
27
+ </main>
31
28
  </body>
32
29
  </html>