pgbouncerhero 3.0.0 → 3.2.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +119 -4
  4. data/app/assets/builds/pgbouncerhero/application.css +2 -2
  5. data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
  6. data/app/controllers/pg_bouncer_hero/application_controller.rb +4 -3
  7. data/app/controllers/pg_bouncer_hero/database_controller.rb +93 -9
  8. data/app/helpers/pg_bouncer_hero/application_helper.rb +54 -0
  9. data/app/javascript/pgbouncerhero/controllers/data_table_controller.js +183 -0
  10. data/app/javascript/pgbouncerhero/controllers/fleet_controller.js +52 -0
  11. data/app/javascript/pgbouncerhero/controllers/polling_controller.js +49 -5
  12. data/app/views/layouts/pg_bouncer_hero/application.html.erb +1 -1
  13. data/app/views/pg_bouncer_hero/database/_actions.html.erb +1 -1
  14. data/app/views/pg_bouncer_hero/database/_clients.html.erb +9 -8
  15. data/app/views/pg_bouncer_hero/database/_conf.html.erb +10 -9
  16. data/app/views/pg_bouncer_hero/database/_databases.html.erb +30 -9
  17. data/app/views/pg_bouncer_hero/database/_menu.html.erb +19 -4
  18. data/app/views/pg_bouncer_hero/database/_pools.html.erb +10 -9
  19. data/app/views/pg_bouncer_hero/database/_servers.html.erb +31 -0
  20. data/app/views/pg_bouncer_hero/database/_state.html.erb +11 -0
  21. data/app/views/pg_bouncer_hero/database/_stats.html.erb +12 -11
  22. data/app/views/pg_bouncer_hero/database/_users.html.erb +31 -0
  23. data/app/views/pg_bouncer_hero/database/clients.html.erb +4 -2
  24. data/app/views/pg_bouncer_hero/database/conf.html.erb +4 -2
  25. data/app/views/pg_bouncer_hero/database/databases.html.erb +4 -2
  26. data/app/views/pg_bouncer_hero/database/pools.html.erb +4 -2
  27. data/app/views/pg_bouncer_hero/database/servers.html.erb +6 -0
  28. data/app/views/pg_bouncer_hero/database/state.html.erb +6 -0
  29. data/app/views/pg_bouncer_hero/database/stats.html.erb +4 -2
  30. data/app/views/pg_bouncer_hero/database/users.html.erb +6 -0
  31. data/app/views/pg_bouncer_hero/home/_card.html.erb +5 -2
  32. data/app/views/pg_bouncer_hero/home/_card_content.html.erb +48 -31
  33. data/app/views/pg_bouncer_hero/home/index.html.erb +28 -8
  34. data/app/views/shared/_data_table_toolbar.html.erb +13 -0
  35. data/config/routes.rb +16 -2
  36. data/lib/generators/pgbouncerhero/templates/config.yml +7 -0
  37. data/lib/pgbouncerhero/connection.rb +54 -13
  38. data/lib/pgbouncerhero/database.rb +73 -3
  39. data/lib/pgbouncerhero/methods/basics.rb +86 -10
  40. data/lib/pgbouncerhero/version.rb +1 -1
  41. data/lib/pgbouncerhero.rb +139 -38
  42. metadata +83 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 269d1da746f222ec69b19046e30b54eda65b082be5be4df7177dc44007c71042
4
- data.tar.gz: 9653ffded27e1f8580f6de55f2385fedcd0ac3e52ce5fa607a1cab5bcff3b766
3
+ metadata.gz: 6b8defbd661c257b74bb66aa9f269d9b8b0a20625f6aaa683ca3bf0db9d11668
4
+ data.tar.gz: 95cf88e91a340ea1abdcee45c6ff8e185465e241577db676c5c17b64b4a00571
5
5
  SHA512:
6
- metadata.gz: 946980cd130e213f922110a77258beda36d4973cd9d3215f840bb62fde4960c0e84e479076c8dc5853b4c16f69c67b5546d3dd61f21e9e009ed0b1f15f422625
7
- data.tar.gz: 85d1f29c93363ff5dafe39cd0140229b40f623cd5c9433e1c68c4e18a9181e4eaa0bbc0765b35ed03bf981521c6995c0c9ae3d46b1b0737cc595cfa13ab3eb43
6
+ metadata.gz: 9c289632d8f9c62a2039d8bc3bcc52f083ef13b8b72b019bfff166bdb9de5536d64537a98d22c0a971bcca74f4569a58f09191af1b94886560505a8aa83b1a24
7
+ data.tar.gz: 05201b60414bb236a5f3ef50720c6f410126c400c0fd2f78503040dc0c54ca328169ea5db338a85866a77705f4b620093497b9a0eb05861e436dab14dfaf2ca4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,48 @@
1
+ ## Unreleased
2
+
3
+ ## 3.2.0 - 2026-08-24
4
+
5
+ **New:**
6
+ - Servers and Users monitoring views for PgBouncer connection and user limits
7
+ - Waiting-client pressure indicators on overview cards from pool `cl_waiting` counts
8
+ - Fleet health summary that prioritizes offline, waiting, and highly utilized PgBouncer instances
9
+ - Database-scoped Pause, Reconnect, Wait close, and Resume maintenance controls
10
+
11
+ **Improved:**
12
+ - Refresh every monitoring view automatically, with manual refresh and background-tab pausing
13
+ - Complete the Tailwind CSS 4 migration and verify the committed stylesheet stays current in CI
14
+ - Search monitoring tables, remember visible column choices, and keep identifier and maintenance columns visible while scrolling
15
+ - Show only the administrative actions that apply to the current PgBouncer or database state
16
+ - Sort monitoring results by any data column and reset saved column choices to their defaults
17
+ - Configure read-only administration independently for each PgBouncer, with the environment variable retained as a global override
18
+ - Emit structured Active Support notifications for successful, unavailable, failed, and policy-denied administrative commands
19
+
20
+ ## 3.1.0 - 2026-08-23
21
+
22
+ **New:**
23
+ - Read-only mode for hiding and server-side blocking PgBouncer administrative commands
24
+ - PgBouncer state visibility and Resume controls
25
+ - Integration coverage across supported PgBouncer 1.23, 1.24, and 1.25 releases
26
+ - Bounded, thread-safe connection pools per PgBouncer with configurable size and checkout timeout
27
+ - Explicit `Database#with_connection` leases for running multiple commands on one connection
28
+ - Real PgBouncer integration coverage for admin queries, summaries, reloads, authentication, and reconnection
29
+ - A dedicated CI integration job backed by the repository's Docker Compose stack
30
+ - Configurable `PgBouncerHero.config_path`, resolved from the host application's `Rails.root` by default
31
+ - `PgBouncerHero.reset!` for reloading configuration and connections safely
32
+ - Request-level engine coverage and gem-package validation in CI
33
+
34
+ **Improved:**
35
+ - Use `SHUTDOWN WAIT_FOR_CLIENTS` for graceful shutdowns initiated from the dashboard
36
+ - Reconnect stale or failed pooled connections automatically and close every pool on reset
37
+ - Pin PostgreSQL and PgBouncer development images and wait for container health before testing
38
+ - Let Dependabot maintain Docker image versions alongside gems and GitHub Actions
39
+ - Protect PgBouncer connections from concurrent use while allowing bounded parallel queries
40
+ - Resolve parameterized group and database names consistently in routes and controllers
41
+ - Load Rails engine dependencies explicitly and declare Propshaft as a runtime dependency
42
+ - Cache Appraisal dependencies directly in the Ruby/Rails CI matrix
43
+ - Pin GitHub Actions to their current immutable release commits
44
+ - Add read-only workflow permissions, cancellation of superseded runs, and job timeouts
45
+
1
46
  ## 3.0.0
2
47
 
3
48
  **Breaking Changes:**
data/README.md CHANGED
@@ -14,6 +14,7 @@ A graphical user interface for your PgBouncers.
14
14
 
15
15
  - Ruby >= 3.2
16
16
  - Rails >= 7.2
17
+ - PgBouncer >= 1.23
17
18
  - Propshaft (asset pipeline)
18
19
  - importmap-rails
19
20
 
@@ -69,6 +70,9 @@ pgbouncers:
69
70
  production:
70
71
  primary:
71
72
  url: <%= ENV["PGBOUNCER_PRODUCTION_PRIMARY_DATABASE_URL"] %>
73
+ pool_size: 5
74
+ pool_timeout: 5
75
+ read_only: true
72
76
  replica:
73
77
  url: <%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
74
78
  staging:
@@ -76,14 +80,107 @@ pgbouncers:
76
80
  url: <%= ENV["PGBOUNCER_STAGING_PRIMARY_DATABASE_URL"] %>
77
81
  replica:
78
82
  url: <%= ENV["PGBOUNCER_STAGING_REPLICA_DATABASE_URL"] %>
83
+
84
+ # Optional: allow monitoring while blocking all administrative commands
85
+ read_only: true
86
+ ```
87
+
88
+ Environment-specific top-level keys are also supported. PgBouncerHero reads
89
+ `config/pgbouncerhero.yml` from the host application's `Rails.root` by default.
90
+ An initializer can point to a different file:
91
+
92
+ ```ruby
93
+ PgBouncerHero.config_path = Rails.root.join("config/pgbouncerhero.production.yml")
94
+ ```
95
+
96
+ Changing `PgBouncerHero.env` or `config_path` resets cached groups and closes
97
+ their open connections. Call `PgBouncerHero.reset!` after changing a config
98
+ file at runtime, or `PgBouncerHero.disconnect!` when only the connections need
99
+ to be closed. Stale connections reconnect automatically.
100
+
101
+ The PostgreSQL connection timeout defaults to five seconds and can be changed
102
+ with `PGBOUNCERHERO_TIMEOUT`.
103
+
104
+ PgBouncerHero keeps a bounded, lazily created connection pool for each
105
+ configured PgBouncer. The pool defaults to five connections with a five-second
106
+ checkout timeout. Set `PGBOUNCERHERO_POOL_SIZE` and
107
+ `PGBOUNCERHERO_POOL_TIMEOUT` to change the defaults for every PgBouncer, or set
108
+ `pool_size` and `pool_timeout` on an individual database entry as shown above.
109
+ Per-database settings take precedence over environment variables.
110
+
111
+ Code that needs to issue multiple commands on the same PostgreSQL connection
112
+ can lease one explicitly:
113
+
114
+ ```ruby
115
+ database.with_connection do |connection|
116
+ connection.exec("SHOW VERSION")
117
+ connection.exec("SHOW CONFIG")
118
+ end
79
119
  ```
80
120
 
121
+ `database.connection` remains available as a connection-compatible proxy for
122
+ existing integrations. Connections are checked when borrowed, and stale or
123
+ failed connections are discarded and recreated automatically.
124
+
125
+ ### Safe Administration
126
+
127
+ The dashboard shows PgBouncer's current state and provides Reload, Suspend,
128
+ Resume, and graceful shutdown controls. Dashboard shutdown uses
129
+ `SHUTDOWN WAIT_FOR_CLIENTS`: PgBouncer stops accepting new clients and exits
130
+ after existing clients disconnect. The Ruby API keeps `database.shutdown` as
131
+ the immediate command for compatibility and also accepts `:immediate`,
132
+ `:wait_for_clients`, or `:wait_for_servers`. State visibility requires
133
+ PgBouncer 1.19 or newer; graceful shutdown modes require PgBouncer 1.23 or
134
+ newer.
135
+
136
+ Set `read_only: true` at the top level of `config/pgbouncerhero.yml`, or set
137
+ `PGBOUNCERHERO_READ_ONLY=true`, to hide and server-side reject every
138
+ administrative command. The environment variable takes precedence over YAML.
139
+ An individual PgBouncer can set `read_only: true` or `false` alongside its URL
140
+ to override the top-level YAML default. This allows production instances to be
141
+ read-only while staging instances remain writable. The environment variable is
142
+ still the highest-precedence global override.
143
+
144
+ Every administrative command emits an `admin_command.pgbouncerhero`
145
+ `ActiveSupport::Notifications` event, including commands called through the
146
+ Ruby API and dashboard attempts rejected by read-only policy. The payload
147
+ contains `group`, `database`, `action`, `target_database`, and `outcome`; a
148
+ shutdown also includes `mode`, and failed commands include `error_class` without
149
+ exposing credentials or database error messages. Notification events provide
150
+ their standard duration measurement to subscribers:
151
+
152
+ ```ruby
153
+ ActiveSupport::Notifications.subscribe("admin_command.pgbouncerhero") do |*args|
154
+ event = ActiveSupport::Notifications::Event.new(*args)
155
+ Rails.logger.info(event.payload.merge(duration_ms: event.duration).to_json)
156
+ end
157
+ ```
158
+
159
+ The Databases view provides scoped Pause, Reconnect, Wait close, and Resume
160
+ controls for planned maintenance and database failovers. Pause waits for that
161
+ database's server connections to be released and makes new client queries wait;
162
+ Reconnect replaces released server connections; Wait close confirms marked
163
+ connections have drained; and Resume restores client processing. Database names
164
+ are quoted before being sent to the PgBouncer admin console.
165
+
166
+ Monitoring views include Databases, Stats, Pools, Clients, Servers, Users,
167
+ Configuration, and State. Overview cards also show a waiting-client indicator
168
+ when any pool reports a nonzero `cl_waiting` count. Monitoring pages refresh
169
+ automatically every 60 seconds and include a manual Refresh control. Automatic
170
+ polling pauses while the browser tab is hidden and refreshes immediately when
171
+ the tab becomes visible again. Table views can be searched, sorted by column,
172
+ and customized, with each table's visible column choices remembered in local
173
+ browser storage and resettable to their defaults. Identifier and maintenance
174
+ columns remain visible while wide result sets scroll horizontally.
175
+ Administrative controls adapt to the current PgBouncer and database state so
176
+ contradictory actions are not shown.
177
+
81
178
  ## Development
82
179
 
83
180
  Start PostgreSQL and PgBouncer with Docker:
84
181
 
85
182
  ```bash
86
- docker compose up -d
183
+ docker compose up --detach --wait
87
184
  ```
88
185
 
89
186
  Run the dummy Rails app:
@@ -98,16 +195,34 @@ Then open http://localhost:3000/pgbouncerhero.
98
195
  Run the test suite:
99
196
 
100
197
  ```bash
101
- bundle exec rake # tests + rubocop + herb
102
- bundle exec appraisal rake test # tests across Rails 7.2, 8.0, 8.1
198
+ bundle exec rake # unit/integration-free tests + rubocop + herb
199
+ bundle exec appraisal rake test # tests across Rails 7.2, 8.0, and 8.1
200
+ bundle exec rake build # build and validate the gem package
201
+ bundle exec rake test:integration # real PgBouncer admin-console tests
103
202
  ```
104
203
 
204
+ The integration task expects PostgreSQL and PgBouncer from the Compose stack.
205
+ Override `PGBOUNCERHERO_INTEGRATION_URL` to test another PgBouncer instance.
206
+ The default is `postgres://pgbouncer:pgbouncer@127.0.0.1:6432/pgbouncer`.
207
+ CI runs this suite against PgBouncer 1.23, 1.24, and 1.25.
208
+
105
209
  Stop Docker when done:
106
210
 
107
211
  ```bash
108
- docker compose down
212
+ docker compose down --volumes
109
213
  ```
110
214
 
215
+ ### Releasing
216
+
217
+ Releases use a scoped RubyGems OIDC API Key Role through GitHub Actions, with no
218
+ long-lived RubyGems API key. The role is restricted to the `pgbouncerhero` gem,
219
+ the `kwent/pgbouncerhero` repository, and the `rubygems.org` audience. Store its
220
+ role token in the `RUBYGEMS_OIDC_ROLE` GitHub Actions secret. Then run the
221
+ **Release** workflow on `master` and enter the version already committed in
222
+ `lib/pgbouncerhero/version.rb`. The workflow verifies the version and test
223
+ suite, exchanges GitHub's OIDC identity for a short-lived credential, publishes
224
+ the gem, pushes the version tag, and creates the matching GitHub release.
225
+
111
226
  ## Contributing
112
227
 
113
228
  1. Fork it
@@ -1,2 +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}}
1
+ /*! tailwindcss v4.3.3 | 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;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, 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-amber-50:oklch(98.7% .022 95.277);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-800:oklch(47.3% .137 46.201);--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-300:oklch(87.2% .01 258.338);--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,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, 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:where(:not(iframe)){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}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\!visible{visibility:visible!important}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.right-0{right:0}.left-0{left:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.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}.hidden{display:none}.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-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.w-max{width:max-content}.max-w-7xl{max-width:var(--container-7xl)}.min-w-52{min-width:calc(var(--spacing) * 52)}.min-w-64{min-width:calc(var(--spacing) * 64)}.min-w-full{min-width:100%}.flex-1{flex:1}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,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-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.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(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * 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)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}: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)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.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-amber-300{border-color:var(--color-amber-300)}.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-gray-300{border-color:var(--color-gray-300)}.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-amber-50{background-color:var(--color-amber-50)}.bg-amber-500{background-color:var(--color-amber-500)}.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-3{padding:calc(var(--spacing) * 3)}.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:var(--spacing)}.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-2{padding-top:calc(var(--spacing) * 2)}.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-amber-800{color:var(--color-amber-800)}.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-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px 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)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);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-amber-300{--tw-ring-color:var(--color-amber-300)}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.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-yellow-200{--tw-ring-color:var(--color-yellow-200)}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-gray-50:is(:where(.group):hover *){background-color:var(--color-gray-50)}.hover\:bg-amber-50:hover{background-color:var(--color-amber-50)}.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\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}:where(.sm\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.sm\:divide-y-0>: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(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.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\: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}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@keyframes pulse{50%{opacity:.5}}
@@ -1 +1,11 @@
1
- @import "tailwindcss";
1
+ @import "tailwindcss" source(none);
2
+ @source "../../../views";
3
+ @source "../../../helpers";
4
+ @source "../../../javascript";
5
+
6
+ @layer base {
7
+ button:not(:disabled),
8
+ [role="button"]:not(:disabled) {
9
+ cursor: pointer;
10
+ }
11
+ }
@@ -13,10 +13,11 @@ module PgBouncerHero
13
13
  def set_database
14
14
  @groups = PgBouncerHero.groups
15
15
  if params[:group] && params[:database]
16
- @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] }
17
18
  else
18
- @group = @groups.first
19
- @database = @groups.first.last.databases.first
19
+ _group_id, @group = @groups.first
20
+ @database = @group.databases.first
20
21
  end
21
22
  end
22
23
  end
@@ -1,11 +1,13 @@
1
1
  module PgBouncerHero
2
2
  class DatabaseController < ApplicationController
3
+ DATABASE_ADMIN_ACTIONS = %i[pause_database reconnect_database wait_close_database resume_database].freeze
4
+ MONITORING_ACTIONS = %i[databases stats pools clients servers users conf state].freeze
5
+
6
+ before_action :ensure_writable!, only: [ :reload, :suspend, :resume, :shutdown, *DATABASE_ADMIN_ACTIONS ]
7
+ before_action :set_target_database, only: DATABASE_ADMIN_ACTIONS
8
+ before_action :set_admin_state, only: MONITORING_ACTIONS
9
+
3
10
  def summary
4
- if @database.connection
5
- @dbs = @database.summary
6
- else
7
- flash[:error] = "#{@database.name} does not look online."
8
- end
9
11
  end
10
12
 
11
13
  def databases
@@ -40,6 +42,22 @@ module PgBouncerHero
40
42
  end
41
43
  end
42
44
 
45
+ def servers
46
+ if @database.connection
47
+ @servers = @database.servers
48
+ else
49
+ flash[:error] = "#{@database.name} does not look online."
50
+ end
51
+ end
52
+
53
+ def users
54
+ if @database.connection
55
+ @users = @database.users
56
+ else
57
+ flash[:error] = "#{@database.name} does not look online."
58
+ end
59
+ end
60
+
43
61
  def conf
44
62
  if @database.connection
45
63
  @conf = @database.conf
@@ -48,6 +66,14 @@ module PgBouncerHero
48
66
  end
49
67
  end
50
68
 
69
+ def state
70
+ if @admin_state
71
+ @state = @admin_state
72
+ else
73
+ flash[:error] = "#{@database.name} does not look online."
74
+ end
75
+ end
76
+
51
77
  def reload
52
78
  execute_admin_command(:reload, "reloaded")
53
79
  end
@@ -56,15 +82,61 @@ module PgBouncerHero
56
82
  execute_admin_command(:suspend, "suspended")
57
83
  end
58
84
 
85
+ def resume
86
+ execute_admin_command(:resume, "resumed")
87
+ end
88
+
89
+ def pause_database
90
+ execute_database_admin_command(:pause, "paused")
91
+ end
92
+
93
+ def reconnect_database
94
+ execute_database_admin_command(:reconnect, "asked to reconnect released server connections")
95
+ end
96
+
97
+ def wait_close_database
98
+ execute_database_admin_command(:wait_close, "confirmed all marked server connections closed")
99
+ end
100
+
101
+ def resume_database
102
+ execute_database_admin_command(:resume, "resumed")
103
+ end
104
+
59
105
  def shutdown
60
- execute_admin_command(:shutdown, "shut down")
106
+ execute_admin_command(:shutdown, "asked to shut down after its clients disconnect", :wait_for_clients)
61
107
  end
62
108
 
63
109
  private
64
110
 
65
- def execute_admin_command(command, past_tense)
66
- if @database.connection
67
- @database.public_send(command)
111
+ def set_admin_state
112
+ @admin_state = @database.state
113
+ rescue PG::Error
114
+ @admin_state = nil
115
+ end
116
+
117
+ def ensure_writable!
118
+ return unless @database.read_only?
119
+
120
+ ActiveSupport::Notifications.instrument(
121
+ PgBouncerHero::ADMIN_COMMAND_EVENT,
122
+ group: @database.group.name,
123
+ database: @database.name,
124
+ action: action_name.delete_suffix("_database").to_sym,
125
+ target_database: params[:target_database].presence,
126
+ outcome: :denied
127
+ )
128
+ render plain: "PgBouncerHero is configured as read-only.", status: :forbidden
129
+ end
130
+
131
+ def set_target_database
132
+ @target_database = params[:target_database].to_s
133
+ return if @target_database.present?
134
+
135
+ render plain: "A target database is required.", status: :bad_request
136
+ end
137
+
138
+ def execute_admin_command(command, past_tense, *arguments)
139
+ if @database.public_send(command, *arguments)
68
140
  flash[:success] = "#{@database.name} has been #{past_tense}."
69
141
  else
70
142
  flash[:error] = "#{@database.name} does not look online."
@@ -74,5 +146,17 @@ module PgBouncerHero
74
146
  ensure
75
147
  redirect_back fallback_location: root_path
76
148
  end
149
+
150
+ def execute_database_admin_command(command, past_tense)
151
+ if @database.public_send(command, @target_database)
152
+ flash[:success] = "#{@target_database} on #{@database.name} has been #{past_tense}."
153
+ else
154
+ flash[:error] = "#{@database.name} does not look online."
155
+ end
156
+ rescue PG::Error => e
157
+ flash[:error] = "#{@database.name}: #{e.message.strip}"
158
+ ensure
159
+ redirect_back fallback_location: databases_path
160
+ end
77
161
  end
78
162
  end
@@ -31,5 +31,59 @@ module PgBouncerHero
31
31
  end
32
32
  }.compact.reverse.join(" ")
33
33
  end
34
+
35
+ def fleet_health(summary)
36
+ return { status: "offline", severity: 3, waiting_clients: 0, max_utilization: 0 } unless summary
37
+
38
+ pools = summary_details(summary, :pools_details)
39
+ databases = summary_details(summary, :databases_details)
40
+ waiting_clients = pools.sum { |pool| pool["cl_waiting"].to_i }
41
+ max_utilization = databases.filter_map do |database|
42
+ maximum = database["max_connections"].to_i
43
+ (database["current_connections"].to_f / maximum * 100).round if maximum.positive?
44
+ end.max.to_i
45
+
46
+ status, severity = if waiting_clients.positive?
47
+ [ "waiting", 2 ]
48
+ elsif max_utilization >= 80
49
+ [ "high_utilization", 1 ]
50
+ else
51
+ [ "healthy", 0 ]
52
+ end
53
+
54
+ { status: status, severity: severity, waiting_clients: waiting_clients, max_utilization: max_utilization }
55
+ end
56
+
57
+ def monitoring_panel(&block)
58
+ frame_id = "monitoring_#{params[:action]}"
59
+
60
+ content_tag(:div,
61
+ data: {
62
+ controller: "polling",
63
+ polling_interval_value: 60_000,
64
+ action: "turbo:frame-load->polling#refreshed"
65
+ }) do
66
+ safe_join([
67
+ monitoring_refresh_controls,
68
+ turbo_frame_tag(frame_id, data: { polling_refresh_url: request.path }, &block)
69
+ ])
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def summary_details(summary, key)
76
+ row = summary.find { |item| item.key?(key) || item.key?(key.to_s) }
77
+ Array(row && (row[key] || row[key.to_s]))
78
+ end
79
+
80
+ def monitoring_refresh_controls
81
+ content_tag(:div, class: "flex items-center justify-between gap-2 mb-3") do
82
+ safe_join([
83
+ content_tag(:span, "Updated just now", class: "text-xs text-gray-500", data: { polling_target: "status" }, aria: { live: "polite" }),
84
+ button_tag("Refresh", type: "button", class: "px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50", data: { action: "polling#refresh" })
85
+ ])
86
+ end
87
+ end
34
88
  end
35
89
  end