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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +119 -4
- data/app/assets/builds/pgbouncerhero/application.css +2 -2
- data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
- data/app/controllers/pg_bouncer_hero/application_controller.rb +4 -3
- data/app/controllers/pg_bouncer_hero/database_controller.rb +93 -9
- data/app/helpers/pg_bouncer_hero/application_helper.rb +54 -0
- data/app/javascript/pgbouncerhero/controllers/data_table_controller.js +183 -0
- data/app/javascript/pgbouncerhero/controllers/fleet_controller.js +52 -0
- data/app/javascript/pgbouncerhero/controllers/polling_controller.js +49 -5
- data/app/views/layouts/pg_bouncer_hero/application.html.erb +1 -1
- data/app/views/pg_bouncer_hero/database/_actions.html.erb +1 -1
- data/app/views/pg_bouncer_hero/database/_clients.html.erb +9 -8
- data/app/views/pg_bouncer_hero/database/_conf.html.erb +10 -9
- data/app/views/pg_bouncer_hero/database/_databases.html.erb +30 -9
- data/app/views/pg_bouncer_hero/database/_menu.html.erb +19 -4
- data/app/views/pg_bouncer_hero/database/_pools.html.erb +10 -9
- data/app/views/pg_bouncer_hero/database/_servers.html.erb +31 -0
- data/app/views/pg_bouncer_hero/database/_state.html.erb +11 -0
- data/app/views/pg_bouncer_hero/database/_stats.html.erb +12 -11
- data/app/views/pg_bouncer_hero/database/_users.html.erb +31 -0
- data/app/views/pg_bouncer_hero/database/clients.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/conf.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/databases.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/pools.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/servers.html.erb +6 -0
- data/app/views/pg_bouncer_hero/database/state.html.erb +6 -0
- data/app/views/pg_bouncer_hero/database/stats.html.erb +4 -2
- data/app/views/pg_bouncer_hero/database/users.html.erb +6 -0
- data/app/views/pg_bouncer_hero/home/_card.html.erb +5 -2
- data/app/views/pg_bouncer_hero/home/_card_content.html.erb +48 -31
- data/app/views/pg_bouncer_hero/home/index.html.erb +28 -8
- data/app/views/shared/_data_table_toolbar.html.erb +13 -0
- data/config/routes.rb +16 -2
- data/lib/generators/pgbouncerhero/templates/config.yml +7 -0
- data/lib/pgbouncerhero/connection.rb +54 -13
- data/lib/pgbouncerhero/database.rb +73 -3
- data/lib/pgbouncerhero/methods/basics.rb +86 -10
- data/lib/pgbouncerhero/version.rb +1 -1
- data/lib/pgbouncerhero.rb +139 -38
- metadata +83 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b8defbd661c257b74bb66aa9f269d9b8b0a20625f6aaa683ca3bf0db9d11668
|
|
4
|
+
data.tar.gz: 95cf88e91a340ea1abdcee45c6ff8e185465e241577db676c5c17b64b4a00571
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
@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}}
|
|
@@ -13,10 +13,11 @@ module PgBouncerHero
|
|
|
13
13
|
def set_database
|
|
14
14
|
@groups = PgBouncerHero.groups
|
|
15
15
|
if params[:group] && params[:database]
|
|
16
|
-
@
|
|
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 = @
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|