pgbouncerhero 3.1.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 +17 -0
- data/README.md +39 -0
- data/app/assets/builds/pgbouncerhero/application.css +2 -2
- data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
- data/app/controllers/pg_bouncer_hero/database_controller.rb +75 -11
- 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 +13 -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 +1 -1
- 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 +4 -2
- 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 +6 -0
- data/lib/generators/pgbouncerhero/templates/config.yml +3 -1
- data/lib/pgbouncerhero/database.rb +4 -0
- data/lib/pgbouncerhero/methods/basics.rb +68 -8
- data/lib/pgbouncerhero/version.rb +1 -1
- data/lib/pgbouncerhero.rb +16 -0
- metadata +8 -1
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,5 +1,22 @@
|
|
|
1
1
|
## Unreleased
|
|
2
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
|
+
|
|
3
20
|
## 3.1.0 - 2026-08-23
|
|
4
21
|
|
|
5
22
|
**New:**
|
data/README.md
CHANGED
|
@@ -72,6 +72,7 @@ pgbouncers:
|
|
|
72
72
|
url: <%= ENV["PGBOUNCER_PRODUCTION_PRIMARY_DATABASE_URL"] %>
|
|
73
73
|
pool_size: 5
|
|
74
74
|
pool_timeout: 5
|
|
75
|
+
read_only: true
|
|
75
76
|
replica:
|
|
76
77
|
url: <%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
|
|
77
78
|
staging:
|
|
@@ -135,6 +136,44 @@ newer.
|
|
|
135
136
|
Set `read_only: true` at the top level of `config/pgbouncerhero.yml`, or set
|
|
136
137
|
`PGBOUNCERHERO_READ_ONLY=true`, to hide and server-side reject every
|
|
137
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.
|
|
138
177
|
|
|
139
178
|
## Development
|
|
140
179
|
|
|
@@ -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}}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
module PgBouncerHero
|
|
2
2
|
class DatabaseController < ApplicationController
|
|
3
|
-
|
|
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
|
|
4
9
|
|
|
5
10
|
def summary
|
|
6
|
-
if @database.connection
|
|
7
|
-
@dbs = @database.summary
|
|
8
|
-
else
|
|
9
|
-
flash[:error] = "#{@database.name} does not look online."
|
|
10
|
-
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def databases
|
|
@@ -42,6 +42,22 @@ module PgBouncerHero
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
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
|
+
|
|
45
61
|
def conf
|
|
46
62
|
if @database.connection
|
|
47
63
|
@conf = @database.conf
|
|
@@ -51,8 +67,8 @@ module PgBouncerHero
|
|
|
51
67
|
end
|
|
52
68
|
|
|
53
69
|
def state
|
|
54
|
-
if @
|
|
55
|
-
@state = @
|
|
70
|
+
if @admin_state
|
|
71
|
+
@state = @admin_state
|
|
56
72
|
else
|
|
57
73
|
flash[:error] = "#{@database.name} does not look online."
|
|
58
74
|
end
|
|
@@ -70,21 +86,57 @@ module PgBouncerHero
|
|
|
70
86
|
execute_admin_command(:resume, "resumed")
|
|
71
87
|
end
|
|
72
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
|
+
|
|
73
105
|
def shutdown
|
|
74
106
|
execute_admin_command(:shutdown, "asked to shut down after its clients disconnect", :wait_for_clients)
|
|
75
107
|
end
|
|
76
108
|
|
|
77
109
|
private
|
|
78
110
|
|
|
111
|
+
def set_admin_state
|
|
112
|
+
@admin_state = @database.state
|
|
113
|
+
rescue PG::Error
|
|
114
|
+
@admin_state = nil
|
|
115
|
+
end
|
|
116
|
+
|
|
79
117
|
def ensure_writable!
|
|
80
|
-
return unless
|
|
118
|
+
return unless @database.read_only?
|
|
81
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
|
+
)
|
|
82
128
|
render plain: "PgBouncerHero is configured as read-only.", status: :forbidden
|
|
83
129
|
end
|
|
84
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
|
+
|
|
85
138
|
def execute_admin_command(command, past_tense, *arguments)
|
|
86
|
-
if @database.
|
|
87
|
-
@database.public_send(command, *arguments)
|
|
139
|
+
if @database.public_send(command, *arguments)
|
|
88
140
|
flash[:success] = "#{@database.name} has been #{past_tense}."
|
|
89
141
|
else
|
|
90
142
|
flash[:error] = "#{@database.name} does not look online."
|
|
@@ -94,5 +146,17 @@ module PgBouncerHero
|
|
|
94
146
|
ensure
|
|
95
147
|
redirect_back fallback_location: root_path
|
|
96
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
|
|
97
161
|
end
|
|
98
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
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["columnMenu", "empty", "query", "row", "status"]
|
|
5
|
+
static values = { defaultHidden: Array }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.columns = Array.from(this.element.querySelectorAll("thead th"))
|
|
9
|
+
this.columns.forEach(column => { column.dataset.columnLabel ||= column.textContent.trim() })
|
|
10
|
+
this.collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
|
|
11
|
+
this.columnPreferences = this.loadColumnPreferences()
|
|
12
|
+
this.buildColumnMenu()
|
|
13
|
+
this.buildSortControls()
|
|
14
|
+
this.filter()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
filter() {
|
|
18
|
+
const query = this.queryTarget.value.trim().toLowerCase()
|
|
19
|
+
let visible = 0
|
|
20
|
+
|
|
21
|
+
this.rowTargets.forEach(row => {
|
|
22
|
+
const matches = query.length === 0 || row.textContent.toLowerCase().includes(query)
|
|
23
|
+
row.hidden = !matches
|
|
24
|
+
if (matches) visible += 1
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
this.emptyTarget.hidden = visible !== 0
|
|
28
|
+
this.statusTarget.textContent = query.length === 0 ? `${visible} rows` : `${visible} of ${this.rowTargets.length} rows`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toggleColumn(event) {
|
|
32
|
+
const index = Number(event.currentTarget.dataset.columnIndex)
|
|
33
|
+
const key = event.currentTarget.dataset.columnKey
|
|
34
|
+
const visible = event.currentTarget.checked
|
|
35
|
+
|
|
36
|
+
this.setColumnVisibility(index, visible)
|
|
37
|
+
this.columnPreferences[key] = visible
|
|
38
|
+
this.saveColumnPreferences()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
resetColumns() {
|
|
42
|
+
this.columnPreferences = {}
|
|
43
|
+
|
|
44
|
+
this.columnMenuTarget.querySelectorAll("input[type='checkbox']").forEach(checkbox => {
|
|
45
|
+
const index = Number(checkbox.dataset.columnIndex)
|
|
46
|
+
const visible = !this.defaultHiddenValue.includes(checkbox.dataset.columnKey)
|
|
47
|
+
|
|
48
|
+
checkbox.checked = visible
|
|
49
|
+
this.setColumnVisibility(index, visible)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
window.localStorage.removeItem(this.storageKey)
|
|
54
|
+
} catch {
|
|
55
|
+
// The table remains usable when storage is disabled.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sort(event) {
|
|
60
|
+
const index = Number(event.currentTarget.dataset.columnIndex)
|
|
61
|
+
const direction = this.sortIndex === index && this.sortDirection === "ascending" ? "descending" : "ascending"
|
|
62
|
+
const rows = this.rowTargets.map((row, originalIndex) => ({ row, originalIndex }))
|
|
63
|
+
|
|
64
|
+
rows.sort((left, right) => {
|
|
65
|
+
const leftValue = left.row.children[index]?.textContent.trim() || ""
|
|
66
|
+
const rightValue = right.row.children[index]?.textContent.trim() || ""
|
|
67
|
+
const comparison = this.compareValues(leftValue, rightValue, direction)
|
|
68
|
+
|
|
69
|
+
return comparison || left.originalIndex - right.originalIndex
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const body = this.element.querySelector("tbody")
|
|
73
|
+
rows.forEach(({ row }) => body.append(row))
|
|
74
|
+
this.sortIndex = index
|
|
75
|
+
this.sortDirection = direction
|
|
76
|
+
this.updateSortControls(index, direction)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
buildColumnMenu() {
|
|
80
|
+
this.columnMenuTarget.replaceChildren()
|
|
81
|
+
|
|
82
|
+
this.columns.forEach((column, index) => {
|
|
83
|
+
if (column.dataset.columnLocked === "true") return
|
|
84
|
+
|
|
85
|
+
const key = column.dataset.columnKey
|
|
86
|
+
const savedVisibility = this.columnPreferences[key]
|
|
87
|
+
const checked = savedVisibility === undefined ? !this.defaultHiddenValue.includes(key) : savedVisibility
|
|
88
|
+
const label = document.createElement("label")
|
|
89
|
+
label.className = "flex items-center gap-2 whitespace-nowrap"
|
|
90
|
+
|
|
91
|
+
const checkbox = document.createElement("input")
|
|
92
|
+
checkbox.type = "checkbox"
|
|
93
|
+
checkbox.checked = checked
|
|
94
|
+
checkbox.dataset.columnIndex = index
|
|
95
|
+
checkbox.dataset.columnKey = key
|
|
96
|
+
checkbox.dataset.action = "data-table#toggleColumn"
|
|
97
|
+
|
|
98
|
+
const text = document.createElement("span")
|
|
99
|
+
text.textContent = column.dataset.columnLabel
|
|
100
|
+
|
|
101
|
+
label.append(checkbox, text)
|
|
102
|
+
this.columnMenuTarget.append(label)
|
|
103
|
+
|
|
104
|
+
this.setColumnVisibility(index, checked)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
buildSortControls() {
|
|
109
|
+
this.sortIndicators = new Map()
|
|
110
|
+
|
|
111
|
+
this.columns.forEach((column, index) => {
|
|
112
|
+
if (column.dataset.columnLocked === "true") return
|
|
113
|
+
|
|
114
|
+
const label = column.dataset.columnLabel
|
|
115
|
+
const button = document.createElement("button")
|
|
116
|
+
button.type = "button"
|
|
117
|
+
button.className = "inline-flex items-center gap-1 text-left text-inherit hover:text-gray-900"
|
|
118
|
+
button.dataset.columnIndex = index
|
|
119
|
+
button.dataset.action = "data-table#sort"
|
|
120
|
+
button.setAttribute("aria-label", `Sort by ${label} ascending`)
|
|
121
|
+
|
|
122
|
+
const text = document.createElement("span")
|
|
123
|
+
text.textContent = label
|
|
124
|
+
|
|
125
|
+
const indicator = document.createElement("span")
|
|
126
|
+
indicator.className = "text-gray-400"
|
|
127
|
+
indicator.setAttribute("aria-hidden", "true")
|
|
128
|
+
indicator.textContent = "↕"
|
|
129
|
+
|
|
130
|
+
button.append(text, indicator)
|
|
131
|
+
column.replaceChildren(button)
|
|
132
|
+
column.setAttribute("aria-sort", "none")
|
|
133
|
+
this.sortIndicators.set(index, { button, column, indicator, label })
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
updateSortControls(activeIndex, direction) {
|
|
138
|
+
this.sortIndicators.forEach(({ button, column, indicator, label }, index) => {
|
|
139
|
+
const active = index === activeIndex
|
|
140
|
+
const nextDirection = active && direction === "ascending" ? "descending" : "ascending"
|
|
141
|
+
|
|
142
|
+
column.setAttribute("aria-sort", active ? direction : "none")
|
|
143
|
+
indicator.textContent = active ? (direction === "ascending" ? "↑" : "↓") : "↕"
|
|
144
|
+
button.setAttribute("aria-label", `Sort by ${label} ${nextDirection}`)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
compareValues(left, right, direction) {
|
|
149
|
+
if (left.length === 0 && right.length > 0) return 1
|
|
150
|
+
if (right.length === 0 && left.length > 0) return -1
|
|
151
|
+
|
|
152
|
+
const comparison = this.collator.compare(left, right)
|
|
153
|
+
return direction === "ascending" ? comparison : -comparison
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
setColumnVisibility(index, visible) {
|
|
157
|
+
this.element.querySelectorAll("tr").forEach(row => {
|
|
158
|
+
const cell = row.children[index]
|
|
159
|
+
if (cell) cell.hidden = !visible
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
loadColumnPreferences() {
|
|
164
|
+
try {
|
|
165
|
+
const preferences = JSON.parse(window.localStorage.getItem(this.storageKey))
|
|
166
|
+
return preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences : {}
|
|
167
|
+
} catch {
|
|
168
|
+
return {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
saveColumnPreferences() {
|
|
173
|
+
try {
|
|
174
|
+
window.localStorage.setItem(this.storageKey, JSON.stringify(this.columnPreferences))
|
|
175
|
+
} catch {
|
|
176
|
+
// The table remains usable when storage is disabled or full.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get storageKey() {
|
|
181
|
+
return `pgbouncerhero:data-table:${this.queryTarget.id}`
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["headline", "offline", "waiting", "utilization", "group", "item"]
|
|
5
|
+
static values = { total: Number }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.refresh()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
refresh() {
|
|
12
|
+
const healthChecks = this.itemTargets.map(item => item.querySelector("[data-fleet-health]")).filter(Boolean)
|
|
13
|
+
const pending = this.totalValue - healthChecks.length
|
|
14
|
+
const offline = healthChecks.filter(health => health.dataset.status === "offline").length
|
|
15
|
+
const waiting = healthChecks.reduce((total, health) => total + Number(health.dataset.waitingClients), 0)
|
|
16
|
+
const highUtilization = healthChecks.filter(health => health.dataset.highUtilization === "true").length
|
|
17
|
+
const affected = healthChecks.filter(health => health.dataset.status !== "healthy").length
|
|
18
|
+
|
|
19
|
+
this.offlineTarget.textContent = offline
|
|
20
|
+
this.waitingTarget.textContent = waiting
|
|
21
|
+
this.utilizationTarget.textContent = highUtilization
|
|
22
|
+
|
|
23
|
+
if (pending > 0) {
|
|
24
|
+
this.headlineTarget.textContent = `Checking ${healthChecks.length} of ${this.totalValue} ${this.instances()}…`
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
this.headlineTarget.textContent = affected === 0
|
|
29
|
+
? `All ${this.totalValue} ${this.instances()} healthy`
|
|
30
|
+
: `${affected} of ${this.totalValue} ${this.instances()} ${affected === 1 ? "needs" : "need"} attention`
|
|
31
|
+
this.sortGroups()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
instances() {
|
|
35
|
+
return this.totalValue === 1 ? "instance" : "instances"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
sortGroups() {
|
|
39
|
+
this.groupTargets.forEach(group => {
|
|
40
|
+
const items = Array.from(group.querySelectorAll(":scope > [data-fleet-target~='item']"))
|
|
41
|
+
items.sort((left, right) => {
|
|
42
|
+
const severityDifference = this.severity(right) - this.severity(left)
|
|
43
|
+
return severityDifference || Number(left.dataset.fleetOriginalIndex) - Number(right.dataset.fleetOriginalIndex)
|
|
44
|
+
})
|
|
45
|
+
items.forEach(item => group.appendChild(item))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
severity(item) {
|
|
50
|
+
return Number(item.querySelector("[data-fleet-health]")?.dataset.severity || 0)
|
|
51
|
+
}
|
|
52
|
+
}
|