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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +39 -0
  4. data/app/assets/builds/pgbouncerhero/application.css +2 -2
  5. data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
  6. data/app/controllers/pg_bouncer_hero/database_controller.rb +75 -11
  7. data/app/helpers/pg_bouncer_hero/application_helper.rb +54 -0
  8. data/app/javascript/pgbouncerhero/controllers/data_table_controller.js +183 -0
  9. data/app/javascript/pgbouncerhero/controllers/fleet_controller.js +52 -0
  10. data/app/javascript/pgbouncerhero/controllers/polling_controller.js +49 -5
  11. data/app/views/layouts/pg_bouncer_hero/application.html.erb +1 -1
  12. data/app/views/pg_bouncer_hero/database/_actions.html.erb +1 -1
  13. data/app/views/pg_bouncer_hero/database/_clients.html.erb +9 -8
  14. data/app/views/pg_bouncer_hero/database/_conf.html.erb +10 -9
  15. data/app/views/pg_bouncer_hero/database/_databases.html.erb +30 -9
  16. data/app/views/pg_bouncer_hero/database/_menu.html.erb +13 -4
  17. data/app/views/pg_bouncer_hero/database/_pools.html.erb +10 -9
  18. data/app/views/pg_bouncer_hero/database/_servers.html.erb +31 -0
  19. data/app/views/pg_bouncer_hero/database/_state.html.erb +1 -1
  20. data/app/views/pg_bouncer_hero/database/_stats.html.erb +12 -11
  21. data/app/views/pg_bouncer_hero/database/_users.html.erb +31 -0
  22. data/app/views/pg_bouncer_hero/database/clients.html.erb +4 -2
  23. data/app/views/pg_bouncer_hero/database/conf.html.erb +4 -2
  24. data/app/views/pg_bouncer_hero/database/databases.html.erb +4 -2
  25. data/app/views/pg_bouncer_hero/database/pools.html.erb +4 -2
  26. data/app/views/pg_bouncer_hero/database/servers.html.erb +6 -0
  27. data/app/views/pg_bouncer_hero/database/state.html.erb +4 -2
  28. data/app/views/pg_bouncer_hero/database/stats.html.erb +4 -2
  29. data/app/views/pg_bouncer_hero/database/users.html.erb +6 -0
  30. data/app/views/pg_bouncer_hero/home/_card.html.erb +5 -2
  31. data/app/views/pg_bouncer_hero/home/_card_content.html.erb +48 -31
  32. data/app/views/pg_bouncer_hero/home/index.html.erb +28 -8
  33. data/app/views/shared/_data_table_toolbar.html.erb +13 -0
  34. data/config/routes.rb +6 -0
  35. data/lib/generators/pgbouncerhero/templates/config.yml +3 -1
  36. data/lib/pgbouncerhero/database.rb +4 -0
  37. data/lib/pgbouncerhero/methods/basics.rb +68 -8
  38. data/lib/pgbouncerhero/version.rb +1 -1
  39. data/lib/pgbouncerhero.rb +16 -0
  40. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 913492602328ce8fb2d3731212ccd7cef95c2a9429fbe6d4112d9a65d5853b35
4
- data.tar.gz: 5475f266d924090db51b7a242cc7a7e9b90e917dd485e562f346d7f471d3e9bf
3
+ metadata.gz: 6b8defbd661c257b74bb66aa9f269d9b8b0a20625f6aaa683ca3bf0db9d11668
4
+ data.tar.gz: 95cf88e91a340ea1abdcee45c6ff8e185465e241577db676c5c17b64b4a00571
5
5
  SHA512:
6
- metadata.gz: 0e6ceb49a3f0c1b28a2edeb0939fdc7bd9f4284c02c5361f879ecedd7cb9af17f1862f5943fb75b9c79e03f5d75d0242fefc4c8ee7b67cf43b62c1ee0414c345
7
- data.tar.gz: 96bc7a41d920340e9fce91c6686b6819a71393713b8e4e559e95cf790ebcae3b5d7fba13a29b62d32342aced8dceb8376c0db92b2c29f3678252199ae4c2f4e4
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.0 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-blue-50:oklch(97% .014 254.604);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.fixed{position:fixed}.static{position:static}.sticky{position:sticky}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-0{right:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.table{display:table}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-14{height:calc(var(--spacing) * 14)}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-2\/3{width:66.6667%}.w-3\/4{width:75%}.w-24{width:calc(var(--spacing) * 24)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.min-w-full{min-width:100%}.flex-1{flex:1}.table-auto{table-layout:auto}.animate-pulse{animation:var(--animate-pulse)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-yellow-200{border-color:var(--color-yellow-200)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-500{background-color:var(--color-green-500)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-inherit{color:inherit}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-white{color:var(--color-white)}.text-yellow-800{color:var(--color-yellow-800)}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-green-50:hover{background-color:var(--color-green-50)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}}@media (min-width:40rem){.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes pulse{50%{opacity:.5}}
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-blue-50:oklch(97% .014 254.604);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\!visible{visibility:visible!important}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.right-0{right:0}.left-0{left:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.table{display:table}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-14{height:calc(var(--spacing) * 14)}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-2\/3{width:66.6667%}.w-3\/4{width:75%}.w-24{width:calc(var(--spacing) * 24)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.w-max{width:max-content}.max-w-7xl{max-width:var(--container-7xl)}.min-w-52{min-width:calc(var(--spacing) * 52)}.min-w-64{min-width:calc(var(--spacing) * 64)}.min-w-full{min-width:100%}.flex-1{flex:1}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-300{border-color:var(--color-amber-300)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-yellow-200{border-color:var(--color-yellow-200)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-500{background-color:var(--color-green-500)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-20{padding-top:calc(var(--spacing) * 20)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-amber-800{color:var(--color-amber-800)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-inherit{color:inherit}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-white{color:var(--color-white)}.text-yellow-800{color:var(--color-yellow-800)}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-300{--tw-ring-color:var(--color-amber-300)}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-yellow-200{--tw-ring-color:var(--color-yellow-200)}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-gray-50:is(:where(.group):hover *){background-color:var(--color-gray-50)}.hover\:bg-amber-50:hover{background-color:var(--color-amber-50)}.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-green-50:hover{background-color:var(--color-green-50)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}:where(.sm\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.sm\:divide-y-0>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@keyframes pulse{50%{opacity:.5}}
@@ -1 +1,11 @@
1
- @import "tailwindcss";
1
+ @import "tailwindcss" source(none);
2
+ @source "../../../views";
3
+ @source "../../../helpers";
4
+ @source "../../../javascript";
5
+
6
+ @layer base {
7
+ button:not(:disabled),
8
+ [role="button"]:not(:disabled) {
9
+ cursor: pointer;
10
+ }
11
+ }
@@ -1,13 +1,13 @@
1
1
  module PgBouncerHero
2
2
  class DatabaseController < ApplicationController
3
- before_action :ensure_writable!, only: %i[reload suspend resume shutdown]
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 @database.connection
55
- @state = @database.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 PgBouncerHero.read_only?
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.connection
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
+ }