diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..5704a6851 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Guard frontend/dist/ against drift from frontend/src/. +# +# Runs only when staged changes touch frontend source. If node is available +# locally, the hook checks the source-hash committed in dist/.source-hash and +# fails with a remediation message when the bundle is stale. Without node, +# the hook stays out of the way — CI runs the same check. +set -euo pipefail + +if ! git diff --cached --name-only --diff-filter=ACMR | \ + grep -qE '^frontend/(src/|public/|index\.html$|package\.json$|package-lock\.json$|vite\.config\.ts$|tsconfig\.json$)'; then + exit 0 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "pre-commit: node not found; skipping frontend/dist/ sync check (CI will run it)" >&2 + exit 0 +fi + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT/frontend" +if ! node scripts/check-source-hash.mjs; then + echo "" >&2 + echo "pre-commit: frontend/dist/ is stale. Either" >&2 + echo " 1) cd frontend && npm run build && git add dist" >&2 + echo " 2) commit with --no-verify if you intentionally bypass the rebuild (CI will fail)" >&2 + exit 1 +fi diff --git a/.github/workflows/copr-publish.yaml b/.github/workflows/copr-publish.yaml index bbc9b53d7..7564653a6 100644 --- a/.github/workflows/copr-publish.yaml +++ b/.github/workflows/copr-publish.yaml @@ -125,9 +125,11 @@ jobs: WORK_DIR=$(mktemp -d) mkdir -p "$WORK_DIR/pg-doorman-${VERSION}" - # Define what we want to package - FILES_TO_COPY="Cargo.toml Cargo.lock src patches benches rust-toolchain.toml LICENSE" - + # Define what we want to package. `frontend/dist` carries the + # pre-built SPA referenced by include_dir!() in src/web/static_assets.rs; + # without it the rpm build fails with "frontend/dist is not a directory". + FILES_TO_COPY="Cargo.toml Cargo.lock src patches benches rust-toolchain.toml LICENSE frontend/dist" + # Debug: show what we're going to copy echo "Files/directories to copy:" for item in $FILES_TO_COPY; do @@ -137,11 +139,12 @@ jobs: echo " [MISSING] $item" fi done - - # Copy files + + # Copy files. `cp --parents` preserves intermediate directories so + # that `frontend/dist` lands at the same path inside the tarball. for item in $FILES_TO_COPY; do if [ -e "$item" ]; then - cp -r "$item" "$WORK_DIR/pg-doorman-${VERSION}/" + cp -r --parents "$item" "$WORK_DIR/pg-doorman-${VERSION}/" fi done diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 000000000..b9cd65299 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,66 @@ +name: Frontend lint, typecheck, dist sync + +on: + push: + branches: [master] + paths: + - "frontend/**" + - ".github/workflows/frontend.yml" + pull_request: + paths: + - "frontend/**" + - ".github/workflows/frontend.yml" + +permissions: + contents: read + +jobs: + lint-typecheck-build: + name: lint + typecheck + dist sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install + run: npm ci + working-directory: frontend + + - name: Lint + run: npm run lint + working-directory: frontend + + - name: Typecheck + run: npm run typecheck + working-directory: frontend + + - name: Verify committed dist matches source-hash + # The build itself is not byte-stable across machines (esbuild native + # binaries differ), so we hash the source tree instead and compare it + # against `dist/.source-hash` written by `npm run build`. A mismatch + # means a contributor changed source files without rebuilding dist. + run: npm run check-dist + working-directory: frontend + + - name: Build + run: npm run build + working-directory: frontend + + - name: Verify dist exists and is non-empty + # Pin working-directory so paths are unambiguous regardless of + # whether the step inherits the previous step's directory. The + # post-build step pre-gzips every compressible asset (.js, .css, + # .html, .svg) and removes the originals — include_dir!() + # embeds only the .gz form. The assertions therefore look for + # the .gz neighbours, not the raw files. + working-directory: frontend + run: | + test -s dist/index.html.gz + test -d dist/assets + test "$(ls dist/assets/*.js.gz 2>/dev/null | wc -l)" -ge 1 + echo "frontend/dist/ shipped with $(ls dist/assets | wc -l) assets" diff --git a/.github/workflows/launchpad-publish.yaml b/.github/workflows/launchpad-publish.yaml index b633054f2..3463693ff 100644 --- a/.github/workflows/launchpad-publish.yaml +++ b/.github/workflows/launchpad-publish.yaml @@ -210,11 +210,13 @@ jobs: mkdir -p "$WORK_DIR/pg-doorman-${VERSION}" # Define what we want to package (explicit list instead of excludes) - # These are the actual project files needed for building - # Rust tarball is included for offline installation (Launchpad has no network access) + # These are the actual project files needed for building. + # Rust tarball is included for offline installation (Launchpad has no network access). + # `frontend/dist` carries the pre-built SPA bundled into the binary + # via include_dir!() — without it cargo build fails on Launchpad. RUST_TARBALL="rust-${{ env.RUST_VERSION }}-x86_64-unknown-linux-gnu.tar.gz" - FILES_TO_COPY="Cargo.toml Cargo.lock src patches benches debian vendor.tar.gz rust-toolchain.toml $RUST_TARBALL" - + FILES_TO_COPY="Cargo.toml Cargo.lock src patches benches debian vendor.tar.gz rust-toolchain.toml frontend/dist $RUST_TARBALL" + # Debug: show what we're going to copy echo "Files/directories to copy:" for item in $FILES_TO_COPY; do @@ -224,11 +226,12 @@ jobs: echo " [MISSING] $item" fi done - - # Copy only the specified files/directories + + # Copy only the specified files/directories. `cp --parents` keeps + # nested paths like `frontend/dist` intact inside the tarball. for item in $FILES_TO_COPY; do if [ -e "$item" ]; then - cp -r "$item" "$WORK_DIR/pg-doorman-${VERSION}/" + cp -r --parents "$item" "$WORK_DIR/pg-doorman-${VERSION}/" fi done diff --git a/.gitignore b/.gitignore index faa668b3e..b44bddaac 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ build/ dist/ wheels/ *.egg-info +# Frontend build output is committed (Web UI decision #22). +!frontend/dist/ +!frontend/dist/** # Virtual environments .venv @@ -53,3 +56,8 @@ flamegraph-output/ # Superpowers brainstorm session workdir (VC mockups, server state) .superpowers/ + +# Per-session agent handoff scratchpads — not part of public history. +.local/ + +Dockerfile.ubuntu22-tls diff --git a/Cargo.lock b/Cargo.lock index 37d8106dd..ba721c1e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1323,6 +1323,25 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.12.0" @@ -1840,6 +1859,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peg" version = "0.6.3" @@ -1875,7 +1900,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pg_doorman" -version = "3.7.0" +version = "3.8.0" dependencies = [ "ahash", "arc-swap", @@ -1891,6 +1916,7 @@ dependencies = [ "futures", "hdrhistogram", "hmac", + "include_dir", "iota", "ipnet", "jwt", @@ -1930,8 +1956,10 @@ dependencies = [ "smallvec", "socket2 0.6.1", "stringprep", + "subtle", "syslog", "tempfile", + "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", "tokio-native-tls", @@ -3010,11 +3038,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tikv-jemalloc-ctl" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "661f1f6a57b3a36dc9174a2c10f19513b4866816e13425d3e418b11cc37bc24c" +dependencies = [ + "libc", + "paste", + "tikv-jemalloc-sys", +] + [[package]] name = "tikv-jemalloc-sys" -version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 3cc9a2fa0..98741d5cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pg_doorman" -version = "3.7.0" +version = "3.8.0" edition = "2021" rust-version = "1.87.0" license = "MIT" @@ -16,6 +16,7 @@ debug = true [dependencies] tikv-jemallocator = { version = "0.6.0", features = ["background_threads_runtime_support"] } +tikv-jemalloc-ctl = { version = "0.6.0", features = ["stats"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3.20", features = ["json", "env-filter", "std"]} log = "0.4.27" @@ -42,12 +43,14 @@ base64 = "0.22.1" hmac = "0.12" sha2 = "0.10" stringprep = "0.1" +subtle = "2" nix = { version = "0.30.1", features = ["process", "signal"] } sha-1 = "0.10" lru = "0.16.3" scopeguard = "1.2" parking_lot = {version = "0.12.1", features = ["hardware-lock-elision"]} libc = "0.2.172" +include_dir = "0.7" num_cpus = "1.16.0" syslog = "7.0.0" native-tls = { version = "0.2.14" } diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..0cbe6ee22 --- /dev/null +++ b/build.rs @@ -0,0 +1,33 @@ +// Tell cargo to invalidate the build whenever the embedded SPA bundle +// changes. Without this hint, cargo only watches the regular source +// tree; a frontend rebuild that produces a new `frontend/dist/...js` +// would be silently ignored and the resulting binary would still embed +// the previous bundle. Hits docker pipelines hard — they share `target/` +// across builds and the COPY layer is too late to invalidate cargo. + +use std::fs; +use std::path::Path; + +fn main() { + let dist = Path::new("frontend/dist"); + println!("cargo:rerun-if-changed=frontend/dist"); + println!("cargo:rerun-if-changed=frontend/dist/index.html"); + walk(dist); +} + +fn walk(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // Print every file so cargo's content-hash sees changes inside + // hashed asset names too (a Vite rebuild renames the JS file + // every time the source hash changes). + let path_str = path.display().to_string(); + println!("cargo:rerun-if-changed={path_str}"); + if path.is_dir() { + walk(&path); + } + } +} diff --git a/documentation/en/src/SUMMARY.md b/documentation/en/src/SUMMARY.md index 8a586a4c7..d6f149cef 100644 --- a/documentation/en/src/SUMMARY.md +++ b/documentation/en/src/SUMMARY.md @@ -47,6 +47,7 @@ # Observability - [Admin Commands](observability/admin-commands.md) +- [Web UI](guides/web-ui.md) - [JSON Structured Logging](observability/json-logging.md) - [Latency Percentiles](observability/percentiles.md) diff --git a/documentation/en/src/changelog.md b/documentation/en/src/changelog.md index 6c993b4c9..75d2b4019 100644 --- a/documentation/en/src/changelog.md +++ b/documentation/en/src/changelog.md @@ -1,5 +1,60 @@ # Changelog +### 3.8.0 + +#### Added + +**Built-in operator dashboard.** pg_doorman exposes a single-page +diagnostic console on the same port as `/metrics`, served from +inside the binary and gated on `[web].ui = true` plus a non-default +`admin_password`. Reaching the same view through the existing psql +admin console means running `SHOW POOLS`, `SHOW CLIENTS`, +`SHOW STATS` and friends in a loop, computing rates by hand between +two snapshots, and joining the rows mentally. The dashboard does +that on a 1.5 s tick. + +What it shows that the psql admin console does not: + +- **Live time-series, not snapshots.** Latency p95/p99, qps, + errors/s and connection saturation render as sparklines, so + "spiking now" is visually distinct from "always been like this". +- **Errors broken down by SQLSTATE per pool.** Plus top-N stuck + queries by `current_query_age_ms`, top-N noisy clients by + errors, top-N hottest prepared statements by hit rate. +- **Process memory by category.** RSS split into jemalloc live + allocations, jemalloc fragmentation, internal pg_doorman caches, + code + libs, stacks + page tables, swap and anonymous remainder, + with cgroup current / max alongside. Every category carries a + one-line explanation on hover. +- **Per-thread tokio-worker CPU.** Drill-down from the threads + count to per-thread utilisation, so a stuck worker is visible + without `perf top` on the host. +- **Live log tail.** An in-process LogTap activates on the first + `/api/logs` request and self-disables two minutes after the last + viewer. Level and target filters apply client-side over the + rolling buffer. +- **Sortable, filterable tables.** Pools, Clients, Apps and Caches + sort by any column and filter by substring; Prepared statements + adds a kind dropdown on top. + +The dashboard is read-only by default. Pause / Resume / Reconnect / +Reload are the four writes, scoped to one pool via +`?pool=user@db`, to every pool of a database via `?db=`, or +globally — the same semantics as the admin protocol. + +#### Notes + +- `[web].ui_anonymous` (default `false`) controls whether the + read-only `/api/*` endpoints answer without basic auth. Admin- + only endpoints (`/api/logs`, `/api/admin/*`, + `/api/prepared/text/{hash}`, `/api/interner/top`, + `/api/top/queries`) always require it regardless of that flag. +- The dashboard polls every 1.5 s, but a 250 ms shared snapshot + feeds `/api/overview`, `/api/pools`, `/api/clients`, + `/api/servers`, `/api/apps`, `/api/stats` and `/metrics`, so a + multi-tab dashboard does not multiply pool-stats work by the + number of open tabs. + ### 3.7.0 #### ACTION REQUIRED before upgrading to 3.7.0 diff --git a/documentation/en/src/comparison.md b/documentation/en/src/comparison.md index 65acff28a..6c15d726b 100644 --- a/documentation/en/src/comparison.md +++ b/documentation/en/src/comparison.md @@ -101,6 +101,7 @@ See [General settings reference](reference/general.md), [Pool settings reference | Feature | PgDoorman | PgBouncer | Odyssey | | --- | :-: | :-: | :-: | +| Built-in admin web UI (HTML console in the binary) | Yes (single-page console on the same port as `/metrics`, opt-in via `[web].ui`) | No (psql admin console only) | No (psql admin console only) | | Prometheus endpoint | Built-in `/metrics` | External (`pgbouncer_exporter`) | External (Go exporter sidecar that polls the admin console) | | Latency percentiles per pool (p50, p90, p95, p99) | Yes (HDR Histogram) | No (averages only in `SHOW STATS`) | Yes via the exporter (TDigest, requires `quantiles` rule option) | | Prepared statement counters in `SHOW STATS` | Yes | Yes (since 1.24) | No | diff --git a/documentation/en/src/guides/web-ui.md b/documentation/en/src/guides/web-ui.md new file mode 100644 index 000000000..1e19f4699 --- /dev/null +++ b/documentation/en/src/guides/web-ui.md @@ -0,0 +1,123 @@ +# Web UI + +pg_doorman ships a small operator console that runs from the same listener +as the Prometheus exporter. The bundle is embedded into the binary, so the +deployment story is identical to a UI-less build: one process, one binary, +one TCP port. + +## Enabling + +The UI lives under the `[web]` section of the config. The legacy +`[prometheus]` block is still accepted as an alias. + +```toml +[web] +enabled = true +host = "0.0.0.0" +port = 9127 + +# Operator console (default off) +ui = true +ui_anonymous = false +log_tap_max_entries = 8192 +``` + +`web.ui = true` is silently demoted to "metrics only" at startup when +`general.admin_password` is empty or the literal `"admin"`: the listener +keeps serving `/metrics`, but every admin-only endpoint would otherwise +be trivially open. Set a real password before flipping `ui = true`; you +will see `web.ui = true ignored: admin_password is default/empty` in +the log when this gate fires. + +| Option | Description | Default | +|---|---|---| +| `enabled` | Whether the listener binds at all. `/metrics` works regardless of `ui`. | `false` | +| `host` | Bind address. | `"0.0.0.0"` | +| `port` | Bind port. | `9127` | +| `ui` | Serve the operator console on `/` and the public API endpoints. | `false` | +| `ui_anonymous` | When `true`, public API endpoints (`/api/version`, `/api/overview`, `/api/pools`, ...) accept unauthenticated requests. Admin endpoints (`/api/logs`, `/api/prepared/text/...`, `/api/interner/top`, `/api/admin/...`) always require basic auth. | `false` | +| `log_tap_max_entries` | Ring buffer size for the in-memory log tap powering `/api/logs`. Set to `0` to disable the endpoint. | `8192` | + +## URL surface + +| URL | Auth | Purpose | +|---|---|---| +| `/` and any non-API path | Always public when `web.ui` is active | The SPA shell. Browsing to `/pools` directly must not trigger a browser-native basic-auth dialog before the React sign-in modal can render — `ui_anonymous` does not gate the shell. | +| `/assets/*` | Always public when `web.ui` is active | Hashed JS / CSS / font bundles. Served with `Cache-Control: public, max-age=31536000, immutable`. | +| `/metrics` | None | Prometheus exposition format. Unaffected by `ui`. | +| `/api/version`, `/api/overview`, `/api/pools`, `/api/clients`, `/api/servers`, `/api/connections`, `/api/stats`, `/api/databases`, `/api/users`, `/api/auth_query`, `/api/config`, `/api/log_level`, `/api/pool_coordinator`, `/api/pool_scaling`, `/api/sockets`, `/api/prepared`, `/api/interner`, `/api/top/clients`, `/api/top/prepared`, `/api/apps`, `/api/events` | Public when `ui_anonymous = true`, otherwise admin | Read-only JSON. Field shapes mirror `SHOW `. | +| `/api/logs`, `/api/prepared/text/{hash}`, `/api/interner/top`, `/api/top/queries` | Admin (basic auth) | Admin-only. `/api/logs` activates the in-memory tap on first request and self-disables after 2 minutes without traffic. `/api/top/queries` returns the first ~120 characters of cached SQL text — kept admin-only because previews can include literal values and tenant identifiers. | + +## Authentication + +The console uses HTTP basic auth with the `admin_username` / `admin_password` +credentials from `[general]`. The password is matched in constant time. +Browsers receive a `WWW-Authenticate: Basic` challenge on 401, so curl, gh, +and the like behave normally. Requests that advertise +`Accept: application/json` (the SPA's `fetch` wrapper) get a plain 401 +without the challenge — without that, the browser caches whatever the +operator typed at the OS-level basic-auth dialog and replays it under the +SPA modal. + +By default, credentials entered into the console live only in React state +and are lost on a hard refresh. Tick "Remember me on this device" in the +sign-in modal to persist them in the browser's `localStorage` so the +console survives a reload. Clearing the site's storage in the browser +wipes the entry. + +## Pages + +The SPA exposes: + +- **Overview** — health pill, four golden-signal sparklines (latency p95, + traffic, errors/s, saturation), connection breakdown stacked area, + pool fill heatmap, dual-axis wait + oldest-active-age, top-5 errors + per pool, and a collapsed resource detail panel. +- **Pools** — sortable table with mini-sparklines per row. +- **Pool detail** (`/pools/:poolId`) — full per-pool drill-down: SQLSTATE + breakdown, oldest-active-age, pause/resume/reconnect controls. +- **Clients** — paginated table backed by `/api/clients` with server-side + filter and sort. +- **Apps** — one row per `application_name` with err / 1k q ratio. +- **Caches** — Prepared statement table with hit rate, plus a query + interner card (named vs anonymous bytes). +- **Logs** — live tail of the LogTap with level / target filter and + pause / auto-scroll toggles. +- **Config & state** — collapsed panels covering `[general]` keys, the + active log filter, `auth_query` cache, databases, users, sockets, + pool scaling, pool coordinator. +- **War room** (`/wall`) — six huge tiles, optimized for an incident + bridge or a wall display. + +## Building from source + +The frontend bundle is checked into git under `frontend/dist/` so that +RPM/DEB/Docker pipelines do not need a node toolchain. Developers editing +the SPA must rebuild before committing: + +```bash +cd frontend +npm ci +npm run install-hooks # one-time: wires the dist-sync pre-commit hook +npm run lint +npm run typecheck +npm run build +``` + +`npm run install-hooks` is opt-in. CI does not need it: the +`frontend.yml` workflow runs `npm run check-dist` and refuses to merge +when a commit changed source files without rebuilding `dist/`. + +A separate `.github/workflows/frontend.yml` runs the same gates on every +PR that touches `frontend/`. + +## Deployment + +`/metrics` is unauthenticated on the same listener that can serve the +UI. That mirrors the historical Prometheus exporter and keeps existing +scrape configs working. If you put pg_doorman behind a reverse proxy, +remember that auth on `/api/*` does **not** propagate to `/metrics` — +metrics expose pool names, users, databases, connection pressure, +auth-query state, and workload shape. Either keep `[web]` on a private +host/port that only your scrape system reaches, or front the listener +with a proxy that adds auth on `/metrics` separately. diff --git a/documentation/en/src/index.md b/documentation/en/src/index.md index 400134d8c..40eaaca5a 100644 --- a/documentation/en/src/index.md +++ b/documentation/en/src/index.md @@ -6,6 +6,16 @@ A multi-threaded PostgreSQL connection pooler written in Rust. Drop-in replaceme ## Headline features +```admonish success title="Built-in operator dashboard" +A diagnostic console embedded in the pg_doorman binary, served on the same port as `/metrics`. What it shows: pool saturation tiles, per-pool latency p95/p99 sparklines, errors split by SQLSTATE per pool, top-N stuck queries, jemalloc memory broken into live allocations / fragmentation / internal caches / code-and-libs / stacks / swap, `/proc/self/status` fields with one-line explanations next to the numbers, per-thread tokio-worker CPU, prepared cache attribution, query interner contents, live log tail. Sortable, filterable tables on Pools / Clients / Apps / Caches; live qps and tx-per-second per app and per client. + +PgBouncer, PgCat, Odyssey, PgPool-II, RDS Proxy and Cloud SQL Auth Proxy expose `/metrics` and a psql admin console. The dashboard you would build on top of them — Prometheus + Grafana + a memory exporter + a custom panel set — is already in pg_doorman. + +Pause / Resume / Reconnect / Reload act from the same page, scoped per pool or globally. Read-only otherwise. The console activates only when `[web].ui = true` and `general.admin_password` is non-default; a fresh install with the placeholder password keeps the listener at `/metrics` only and logs a `WARN`. + +[Read more →](guides/web-ui.md) +``` + ```admonish success title="Pool Coordinator" PgDoorman caps total backend connections per database. When `max_db_connections` is reached, the coordinator evicts an idle connection from the user with the most spare capacity, ranking candidates by p95 transaction time so the slowest pools yield first. A reserve pool absorbs short bursts; per-user `min_guaranteed_pool_size` keeps critical workloads off the eviction list. diff --git a/documentation/en/src/tutorials/binary-upgrade.md b/documentation/en/src/tutorials/binary-upgrade.md index eceeaf033..eb96497c8 100644 --- a/documentation/en/src/tutorials/binary-upgrade.md +++ b/documentation/en/src/tutorials/binary-upgrade.md @@ -506,3 +506,7 @@ Before rolling out binary upgrade to production: upgrade - [ ] Confirm old process exits (check PID file or `pgrep`) - [ ] Verify Prometheus metrics show clients on the new process + +```admonish note title="Prometheus scrape during the drain window" +The web listener (which serves `/metrics`) binds with `SO_REUSEPORT`. While the old process drains and the new one accepts new clients, both share the same port; the kernel balances scrape requests between them. Counter values may appear to jump backwards on a single scrape until the old process exits. The race window lasts at most `shutdown_timeout`. +``` diff --git a/documentation/ru/src/SUMMARY.md b/documentation/ru/src/SUMMARY.md index fe43d437a..c48f80120 100644 --- a/documentation/ru/src/SUMMARY.md +++ b/documentation/ru/src/SUMMARY.md @@ -47,6 +47,7 @@ # Observability - [Admin-команды](observability/admin-commands.md) +- [Web UI](guides/web-ui.md) - [Структурированное JSON-логирование](observability/json-logging.md) - [Перцентили задержек](observability/percentiles.md) diff --git a/documentation/ru/src/authentication/jwt.md b/documentation/ru/src/authentication/jwt.md index 1967a34ae..86eba17e4 100644 --- a/documentation/ru/src/authentication/jwt.md +++ b/documentation/ru/src/authentication/jwt.md @@ -83,7 +83,7 @@ JWT — самый низкоприоритетный формат пароля: ## Оговорки -- Claim `preferred_username` должен совпадать в точности. Сопоставлений или алиасов claim'ов нет. -- Поддержки JWKS-эндпоинта нет: публичный ключ должен быть на диске. +- Claim `preferred_username` должен совпадать в точности. Псевдонимы или альтернативные имена claim не поддерживаются. +- JWKS-эндпоинт не поддерживается: публичный ключ должен быть на диске. - Проверки издателя (`iss`) или аудитории (`aud`) нет. Если нужны — терминируйте JWT в sidecar и переводите в passthrough-аутентификацию. - Если идентичность клиента должна нести информацию о роли в базе (например, `read_only` против `read_write`), смотрите [Talos](talos.md). diff --git a/documentation/ru/src/authentication/overview.md b/documentation/ru/src/authentication/overview.md index fdad066c2..a05d49a85 100644 --- a/documentation/ru/src/authentication/overview.md +++ b/documentation/ru/src/authentication/overview.md @@ -1,6 +1,6 @@ # Аутентификация -pg_doorman аутентифицирует клиентов прежде чем перенаправить их к PostgreSQL. Поддерживается шесть методов; они выбираются в порядке приоритета на основании того, что присылает клиент и что задано в конфигурации пула. +pg_doorman аутентифицирует клиентов, прежде чем перенаправить их к PostgreSQL. Поддерживаются шесть методов; они выбираются в порядке приоритета по тому, что присылает клиент и что задано в конфигурации пула. Эта страница объясняет, как pg_doorman выбирает метод аутентификации. Подробности настройки смотрите по ссылкам каждого метода ниже. diff --git a/documentation/ru/src/authentication/pam.md b/documentation/ru/src/authentication/pam.md index 6cc8f9f51..96966f788 100644 --- a/documentation/ru/src/authentication/pam.md +++ b/documentation/ru/src/authentication/pam.md @@ -53,4 +53,4 @@ PAM проверяется после Talos и HBA Trust, но до любого - PAM блокирует поток-обработчик во время вызова аутентификации. Если ваш стек PAM делает сетевые вызовы (LDAP, Kerberos), ждите эпизодических всплесков задержки. - `pam_unix.so` требует доступ на чтение к `/etc/shadow` — обычно только для `root`. Запускайте pg_doorman под пользователем с нужным членством в группе или используйте другой модуль PAM. - PAM не поддерживает passthrough SCRAM. Соединение с бэкендом всегда использует `server_username` и `server_password`. -- Для LDAP без машинерии PAM в pg_doorman нет нативной поддержки LDAP. Используйте Odyssey или PgBouncer 1.25+. +- Прямая поддержка LDAP без PAM в pg_doorman не реализована. Используйте Odyssey или PgBouncer 1.25+. diff --git a/documentation/ru/src/comparison.md b/documentation/ru/src/comparison.md index 6a517ea5b..8c73f08ba 100644 --- a/documentation/ru/src/comparison.md +++ b/documentation/ru/src/comparison.md @@ -39,7 +39,7 @@ PgCat намеренно опущен: у него центр тяжести — | Минимальная версия TLS настраивается | Да (по умолчанию TLS 1.2) | Да (`tls_protocols`, default `tlsv1.2,tlsv1.3`) | Настраивается, дефолты другие | | Direct TLS handshake (PostgreSQL 17, без `SSLRequest`) | Нет | Да (с 1.25) | Нет | | Контроль TLS 1.3 cipher suites | Нет | Да (с 1.25, `client_tls13_ciphers`/`server_tls13_ciphers`) | Нет | -| Миграция TLS-сессии при binary upgrade | Да (сборка `tls-migration`, Linux, opt-in) | Нет (TLS-соединения дропаются при online restart) | Нет | +| Миграция TLS-сессии при binary upgrade | Да (сборка `tls-migration`, Linux, по запросу) | Нет (TLS-соединения отбрасываются при online restart) | Нет | См. [TLS](guides/tls.md). @@ -101,6 +101,7 @@ PgCat намеренно опущен: у него центр тяжести — | Возможность | PgDoorman | PgBouncer | Odyssey | | --- | :-: | :-: | :-: | +| Встроенный admin web UI (HTML-консоль в бинаре) | Да (HTML-консоль на том же порту, что и `/metrics`, включается через `[web].ui`) | Нет (только psql admin-консоль) | Нет (только psql admin-консоль) | | Prometheus-эндпоинт | Встроенный `/metrics` | Внешний (`pgbouncer_exporter`) | Внешний (Go-exporter sidecar, опрашивает admin-консоль) | | Перцентили задержки на пул (p50, p90, p95, p99) | Да (HDR Histogram) | Нет (только средние в `SHOW STATS`) | Да через exporter (TDigest, требует rule-опцию `quantiles`) | | Счётчики prepared statements в `SHOW STATS` | Да | Да (с 1.24) | Нет | diff --git a/documentation/ru/src/concepts/pool-modes.md b/documentation/ru/src/concepts/pool-modes.md index 23cc546ed..bca90a64b 100644 --- a/documentation/ru/src/concepts/pool-modes.md +++ b/documentation/ru/src/concepts/pool-modes.md @@ -2,7 +2,7 @@ pg_doorman поддерживает два режима пула: `transaction` и `session`. Режим задаётся для пула, при необходимости переопределяется для конкретного пользователя. -Режима `statement` нет. В statement-пулинге backend ротируется после каждого оператора — это вынуждает клиентов отказаться от мульти-statement транзакций и полностью ломает протокол prepared statements. Свой тюнинг (кеш prepared statements, direct handoff, строгий FIFO-планировщик) pg_doorman вкладывает в транзакционный режим. PgBouncer оставляет `statement` для обратной совместимости; Odyssey его не реализует. +Режима `statement` нет. В statement-пулинге backend ротируется после каждого оператора — это вынуждает клиентов отказаться от мульти-statement транзакций и полностью ломает протокол prepared statements. Все оптимизации pg_doorman (кеш prepared statements, direct handoff, строгий FIFO-планировщик) рассчитаны на транзакционный режим. PgBouncer оставляет `statement` для обратной совместимости; Odyssey его не реализует. ## Транзакционный режим (рекомендуется) @@ -71,7 +71,7 @@ pools: ## Очистка при возврате в пул -Очистка в транзакционном режиме **трекает мутации**, а не выполняется безусловно. pg_doorman следит за каждой транзакцией на предмет `SET`, `PREPARE` и `DECLARE CURSOR`, и только когда backend уходит в пул с одним из этих флагов, отправляет соответственно `RESET ALL`, `DEALLOCATE ALL` или `CLOSE ALL`. Транзакция только на чтение пропускает очистку целиком — это измеримый выигрыш на горячих OLTP-путях. +Очистка в транзакционном режиме **отслеживает мутации**, а не выполняется безусловно. pg_doorman следит за каждой транзакцией на предмет `SET`, `PREPARE` и `DECLARE CURSOR`, и только когда backend уходит в пул с одним из этих флагов, отправляет соответственно `RESET ALL`, `DEALLOCATE ALL` или `CLOSE ALL`. Транзакция только на чтение пропускает очистку целиком — это измеримый выигрыш на горячих OLTP-путях. Что сбрасывается, когда сработал флаг: @@ -90,7 +90,7 @@ pools: cleanup_server_connections: false ``` -Делайте так только если уверены, что приложение никогда не оставляет состояние сессии. Дефолтная очистка с трекингом мутаций уже дёшева, когда мутаций не было, поэтому отключение редко стоит риска. +Делайте так только если уверены, что приложение никогда не оставляет состояние сессии. Очистка по умолчанию уже дёшева на транзакциях без мутаций, поэтому отключение редко стоит риска. ## Справочник diff --git a/documentation/ru/src/guides/web-ui.md b/documentation/ru/src/guides/web-ui.md new file mode 100644 index 000000000..895321d7d --- /dev/null +++ b/documentation/ru/src/guides/web-ui.md @@ -0,0 +1,81 @@ +# Web UI + +В pg_doorman встроена операторская консоль. HTTP-сервер тот же, что отдаёт Prometheus-метрики; собранные файлы фронтенда лежат внутри бинарника. Запуск консоли не добавляет внешних зависимостей: один процесс, один бинарь, один TCP-порт. + +## Включение + +Консоль настраивается в секции `[web]`. Старое имя секции `[prometheus]` тоже принимается. + +```toml +[web] +enabled = true +host = "0.0.0.0" +port = 9127 + +# Операторская консоль (по умолчанию выключена) +ui = true +ui_anonymous = false +log_tap_max_entries = 8192 +``` + +Если `web.ui = true`, но `general.admin_password` не задан или равен `"admin"`, консоль не запускается. HTTP-сервер продолжает отдавать `/metrics`, но веб-интерфейс и admin-эндпоинты остаются выключенными. В лог пишется `web.ui = true ignored: admin_password is default/empty`. Задайте настоящий пароль до того, как включать `ui = true`. + +| Параметр | Описание | По умолчанию | +|---|---|---| +| `enabled` | Запускать ли HTTP-сервер. `/metrics` работает независимо от `ui`. | `false` | +| `host` | Адрес для bind. | `"0.0.0.0"` | +| `port` | Порт для bind. | `9127` | +| `ui` | Отдавать веб-интерфейс по `/` и публичные API-эндпоинты. | `false` | +| `ui_anonymous` | При `true` публичные API (`/api/version`, `/api/overview`, `/api/pools`, ...) принимают запросы без авторизации. Admin-эндпоинты (`/api/logs`, `/api/prepared/text/...`, `/api/interner/top`, `/api/top/queries`, `/api/admin/...`) всегда требуют basic auth. | `false` | +| `log_tap_max_entries` | Размер кольцевого буфера в памяти, обслуживающего `/api/logs`. `0` отключает эндпоинт. | `8192` | + +## URL-карта + +| URL | Авторизация | Назначение | +|---|---|---| +| `/` и любой не-API путь | Без авторизации, когда `web.ui` активен | Оболочка SPA. Прямой переход на `/pools` открывает форму входа React, а не системный диалог браузера; `ui_anonymous` на доступ к оболочке не влияет. | +| `/assets/*` | Без авторизации, когда `web.ui` активен | Хэшированные JS, CSS и шрифты. `Cache-Control: public, max-age=31536000, immutable`. | +| `/metrics` | Без авторизации | Prometheus exposition format. От `ui` не зависит. | +| `/api/version`, `/api/overview`, `/api/pools`, `/api/clients`, `/api/servers`, `/api/connections`, `/api/stats`, `/api/databases`, `/api/users`, `/api/auth_query`, `/api/config`, `/api/log_level`, `/api/pool_coordinator`, `/api/pool_scaling`, `/api/sockets`, `/api/prepared`, `/api/interner`, `/api/top/clients`, `/api/top/prepared`, `/api/apps`, `/api/events` | Без авторизации при `ui_anonymous = true`, иначе admin | Read-only JSON. Поля повторяют формат `SHOW `. | +| `/api/logs`, `/api/prepared/text/{hash}`, `/api/interner/top`, `/api/top/queries` | Admin (basic auth) | Только для admin. `/api/logs` подключает буфер логов при первом запросе и отключает его через 2 минуты простоя. `/api/top/queries` возвращает первые ~120 символов SQL-запросов из кэша; превью могут содержать литералы и идентификаторы клиентов, поэтому admin-only. | + +## Авторизация + +Консоль использует HTTP basic auth с парой `admin_username` / `admin_password` из секции `[general]`. Пароль сравнивается за постоянное время. На 401 браузерам отдаётся `WWW-Authenticate: Basic`, чтобы `curl`, `gh` и сторонние HTTP-клиенты работали как ожидают. Запросы с заголовком `Accept: application/json` (так SPA ходит через `fetch`) получают 401 без challenge: иначе браузер закешировал бы пароль из системного диалога и подставлял его поверх формы входа React. + +По умолчанию реквизиты живут только в памяти React и пропадают при перезагрузке страницы. Если в форме входа отметить «Remember me on this device», реквизиты сохранятся в `localStorage` браузера и переживут перезагрузку. Очистка site storage в браузере удаляет эту запись. + +## Страницы + +В SPA доступны: + +- **Overview** — индикатор health, четыре sparkline по golden signals (latency p95, traffic, errors/s, saturation), stacked area по соединениям, heatmap заполнения пулов, двойная ось wait + oldest-active-age, топ-5 ошибок по пулам и свёрнутая панель Resource detail. +- **Pools** — таблица с сортировкой и mini-sparkline в строках. +- **Pool detail** (`/pools/:poolId`) — детальный разбор: разбивка по SQLSTATE, oldest-active-age, кнопки pause / resume / reconnect. +- **Clients** — таблица из `/api/clients` с пагинацией, серверной фильтрацией и сортировкой. +- **Apps** — строка на каждый `application_name` с долей ошибок на 1k запросов. +- **Caches** — таблица Prepared Statements с hit rate и карточка query interner (named / anonymous bytes). +- **Logs** — live-tail LogTap с фильтром по level / target и кнопками pause / auto-scroll. +- **Config & state** — свёрнутые панели: `[general]`, активный фильтр логов, кэш auth_query, databases, users, sockets, pool scaling, pool coordinator. +- **War room** (`/wall`) — шесть крупных плиток для incident bridge или стенда на стене. + +## Сборка из исходников + +Собранный фронтенд лежит в git по пути `frontend/dist/`, чтобы пайплайны RPM, DEB и Docker не зависели от node toolchain. Разработчикам, правящим фронтенд, нужно пересобирать его перед коммитом: + +```bash +cd frontend +npm ci +npm run install-hooks # одноразово: ставит pre-commit hook для синхронизации dist +npm run lint +npm run typecheck +npm run build +``` + +`npm run install-hooks` опционален. CI его не требует: workflow `frontend.yml` запускает `npm run check-dist` и блокирует merge, если исходники меняли без пересборки `dist/`. + +Отдельный workflow `.github/workflows/frontend.yml` запускает те же шаги на каждом PR, который трогает `frontend/`. + +## Развёртывание + +`/metrics` доступен без авторизации на том же HTTP-сервере, что и консоль. Так задумано: иначе сломались бы существующие scrape-конфиги Prometheus. Если pg_doorman стоит за reverse proxy с авторизацией на `/api/*`, эта авторизация **не** распространяется на `/metrics`. Метрики раскрывают имена пулов, пользователей и БД, давление на пул, состояние auth_query и форму нагрузки. Поэтому либо держите секцию `[web]` на приватном host:port, доступном только системе скрейпа, либо ставьте перед HTTP-сервером proxy, который добавляет авторизацию на `/metrics` отдельно. diff --git a/documentation/ru/src/index.md b/documentation/ru/src/index.md index 496d5fe00..7d588dfd6 100644 --- a/documentation/ru/src/index.md +++ b/documentation/ru/src/index.md @@ -6,6 +6,16 @@ ## Ключевые возможности +```admonish success title="Встроенный диагностический дашборд" +Диагностическая консоль, встроенная в бинарь pg_doorman и обслуживаемая тем же портом, что и `/metrics`. Что показывает: тайлы насыщения пулов, sparkline p95/p99 латентности по пулам, ошибки в разбивке по SQLSTATE на каждый пул, top-N застрявших запросов, разбор памяти jemalloc по категориям (live allocations / фрагментация / внутренние кеши / code+libs / стеки / swap), значения из `/proc/self/status` с пояснениями рядом с цифрами, per-thread CPU tokio-worker'ов, атрибуцию prepared cache, содержимое query interner, живой хвост лога. Сортируемые и фильтруемые таблицы Pools / Clients / Apps / Caches; live qps и tx-per-second на каждое приложение и каждого клиента. + +PgBouncer, PgCat, Odyssey, PgPool-II, RDS Proxy и Cloud SQL Auth Proxy отдают `/metrics` и admin-консоль через psql. Тот стек, который пришлось бы собирать поверх — Prometheus + Grafana + memory exporter + кастомный набор панелей, — у pg_doorman уже встроен. + +Pause / Resume / Reconnect / Reload запускаются с той же страницы, per-pool или глобально. В остальном read-only. Консоль включается только при `[web].ui = true` и `admin_password`, отличном от пустой строки и от значения по умолчанию `admin`; с незаданным паролем pg_doorman остаётся в режиме «только `/metrics`» и пишет `WARN` в лог. + +[Подробнее →](guides/web-ui.md) +``` + ```admonish success title="Pool Coordinator" PgDoorman ограничивает суммарное число backend-соединений к одной базе. При достижении `max_db_connections` координатор вытесняет idle-соединение у пользователя с наибольшим запасом, ранжируя кандидатов по p95 времени транзакции — медленные пулы уступают первыми. Reserve pool поглощает короткие всплески; per-user `min_guaranteed_pool_size` исключает критичные нагрузки из списка вытеснения. @@ -25,7 +35,7 @@ PgDoorman ограничивает суммарное число backend-сое ```admonish success title="Graceful Binary Upgrade" Обновляйте PgDoorman в рабочее время, без maintenance window. Приложения не получают ошибок переподключения, PostgreSQL не накрывает лавиной `auth`/SCRAM handshake-ов от одновременных reconnect-ов, идущие транзакции не падают. -По `SIGUSR2` старый процесс передаёт TCP-сокет каждого idle-клиента новому через `SCM_RIGHTS` — тот же сокет, без переподключения — вместе с cancel keys и кешем prepared statements. Клиенты внутри транзакции дорабатывают её на старом процессе и мигрируют, как только становятся idle. Со сборкой `tls-migration` (Linux, opt-in) переезжает и cipher state OpenSSL — TLS-сессии переживают upgrade без re-handshake. +По `SIGUSR2` старый процесс передаёт TCP-сокет каждого idle-клиента новому через `SCM_RIGHTS` — тот же сокет, без переподключения — вместе с cancel keys и кешем prepared statements. Клиенты внутри транзакции дорабатывают её на старом процессе и мигрируют, как только становятся idle. Со сборкой `tls-migration` (Linux, отключено по умолчанию) переезжает и cipher state OpenSSL — TLS-сессии переживают upgrade без re-handshake. Online restart в PgBouncer (`-R`, deprecated с 1.20; либо rolling restart через `so_reuseport`) и в Odyssey (`SIGUSR2` + `bindwith_reuseport`) устроены одинаково: новый процесс принимает новые соединения, старый дорабатывает до тех пор, пока его клиенты сами не отключатся. Сессии, prepared statements и TLS-состояние между процессами не переезжают. @@ -33,9 +43,9 @@ Online restart в PgBouncer (`-R`, deprecated с 1.20; либо rolling restart ``` ```admonish success title="Кеш плана для анонимных prepared statements" -PostgreSQL не кеширует план анонимных prepared statements (`Parse` с пустым именем — типичная форма для разовых параметризованных запросов в большинстве драйверов): каждый `Bind` заново запускает планировщик. PgDoorman прозрачно переписывает пустое имя в служебное `DOORMAN_` на бекенде, и план попадает в реестр именованных statement бекенда — переиспользуется между `Bind`'ами одного клиента и между клиентами одного пула. +PostgreSQL не кеширует план анонимных prepared statements (`Parse` с пустым именем — типичная форма для разовых параметризованных запросов в большинстве драйверов): каждый `Bind` заново запускает планировщик. PgDoorman прозрачно переписывает пустое имя в служебное `DOORMAN_` на бэкенде, и план попадает в реестр именованных statement бэкенда — переиспользуется между `Bind`'ами одного клиента и между клиентами одного пула. -PgBouncer (1.21+) и Odyssey поддерживают prepared statements в transaction mode, но только для **именованных** statement; анонимный `Parse` пробрасывается без изменений и каждый раз перепланируется. PgDoorman — тот, кто его переписывает. +PgBouncer (1.21+) и Odyssey поддерживают prepared statements в transaction mode, но только для **именованных** statement; анонимный `Parse` пробрасывается без изменений и каждый раз перепланируется. PgDoorman переписывает анонимный `Parse` сам. Кеш ограничен и наблюдаем. Анонимные записи истекают по бездействию, именованные освобождаются, как только на них никто не ссылается, а `SHOW INTERNER` и метрики Prometheus показывают объём в реальном времени — поток сгенерированного SQL больше не удерживает память пулера до перезапуска. @@ -48,7 +58,7 @@ PgBouncer (1.21+) и Odyssey поддерживают prepared statements в tra - **Многопоточность с одним общим пулом.** Все рабочие потоки делят один пул. PgBouncer однопоточен; рекомендованный способ масштабирования — несколько инстансов за `so_reuseport` — даёт каждому инстансу свой отдельный пул, и idle-счётчики для одной и той же базы могут расходиться между процессами. - **Подавление thundering herd.** Когда 200 клиентов борются за 4 idle-соединения, PgDoorman ограничивает число параллельно создаваемых backend-соединений (`scaling_max_parallel_creates`) и направляет возвращающиеся серверы напрямую самому давно ждущему клиенту через in-process oneshot-канал — без перекладывания через idle-очередь. - **Ограниченная хвостовая задержка.** Очередь ожидающих обслуживается строго FIFO — никто не обгоняет того, кто пришёл раньше. Опережающая замена истекающих backend-соединений (на 95% от `server_lifetime`, до 3 параллельных) удерживает пул прогретым: при ротации поколения соединений нет всплеска checkout-латентности. -- **Обнаружение мёртвого backend внутри транзакции.** Если backend умирает посреди транзакции (failover, OOM, network partition), PgDoorman сразу возвращает SQLSTATE `08006`: чтение клиента состязается с readability backend через 100-мс тик. Без этой проверки клиент завис бы до срабатывания TCP keepalive — на дефолтных настройках Linux это около двух часов плюс 9×75 с probe. +- **Обнаружение мёртвого backend внутри транзакции.** Если backend умирает посреди транзакции (failover, OOM, network partition), PgDoorman сразу возвращает SQLSTATE `08006`: чтение клиента состязается с readability backend через 100-мс тик. Без этой проверки клиент завис бы до срабатывания TCP keepalive — на стандартных настройках Linux это около двух часов плюс 9×75 с probe. - **Сделано для эксплуатации.** Конфиг в YAML или TOML с человекочитаемыми длительностями (`30s`, `5m`). `pg_doorman generate --host …` интроспектирует существующий PostgreSQL и собирает starter-конфиг. `pg_doorman -t` валидирует конфиг без запуска сервера. Prometheus-эндпоинт `/metrics` встроен. ## Сравнение @@ -61,7 +71,7 @@ PgBouncer (1.21+) и Odyssey поддерживают prepared statements в tra | Patroni-assisted fallback (встроенный) | Да | Нет | Нет | | Опережающая замена при истечении `server_lifetime` | Да | Нет | Нет | | Обнаружение мёртвого backend внутри транзакции | Да (мгновенный `08006`) | Нет (ждёт TCP keepalive) | Нет (ждёт TCP keepalive) | -| Binary upgrade с миграцией сессий | Да (`SCM_RIGHTS`, TLS state opt-in) | Нет (сессии остаются на старом процессе) | Нет (сессии остаются на старом процессе) | +| Binary upgrade с миграцией сессий | Да (`SCM_RIGHTS`, TLS state по запросу) | Нет (сессии остаются на старом процессе) | Нет (сессии остаются на старом процессе) | | Backend TLS к PostgreSQL | Да (5 режимов, hot reload по `SIGHUP`) | Да (`server_tls_*`, hot reload по `RELOAD`) | Нет | | Auth: SCRAM passthrough (без plaintext-пароля в конфиге) | Да (`ClientKey` извлекается из proof) | Да (encrypted SCRAM secret через `auth_query`/`userlist.txt`, с 1.14) | Да | | Auth: JWT (RSA-SHA256) | Да | Нет | Нет | diff --git a/documentation/ru/src/observability/json-logging.md b/documentation/ru/src/observability/json-logging.md index 65325a0f0..740571625 100644 --- a/documentation/ru/src/observability/json-logging.md +++ b/documentation/ru/src/observability/json-logging.md @@ -17,7 +17,7 @@ pg_doorman --log-format structured /etc/pg_doorman/pg_doorman.yaml LOG_FORMAT=structured pg_doorman /etc/pg_doorman/pg_doorman.yaml ``` -По умолчанию — `text` (человекочитаемый). Флаг `--log-format` принимает значения `text`, `structured` или `debug`; последнее на данный момент является алиасом для `text`. +По умолчанию — `text` (человекочитаемый). Флаг `--log-format` принимает значения `text`, `structured` или `debug`; последнее пока работает как `text`. ## Формат вывода @@ -49,7 +49,7 @@ general: ``` ```bash -pg_doorman -l debug -F Structured /etc/pg_doorman/pg_doorman.yaml +pg_doorman -l debug -F structured /etc/pg_doorman/pg_doorman.yaml ``` Изменение в рантайме через admin-базу: @@ -72,7 +72,7 @@ spec: image: ghcr.io/ozontech/pg_doorman:latest args: - "-F" - - "Structured" + - "structured" - "/etc/pg_doorman/pg_doorman.yaml" env: - name: LOG_LEVEL @@ -85,7 +85,7 @@ spec: ```ini [Service] -ExecStart=/usr/bin/pg_doorman -F Structured /etc/pg_doorman/pg_doorman.yaml +ExecStart=/usr/bin/pg_doorman -F structured /etc/pg_doorman/pg_doorman.yaml StandardOutput=journal StandardError=journal ``` @@ -94,7 +94,7 @@ StandardError=journal ## Оговорки -- Для production выбирайте `Text` (терминалы, syslog) или `Structured` (log shippers). `Debug` зарезервирован под будущее использование и сейчас равен `Text`. +- Для production выбирайте `text` (терминалы, syslog) или `structured` (log shippers). `debug` зарезервирован под будущее использование и сейчас равен `text`. - `file` и `line` берутся из мест вызова макроса `log`. Они доступны в release-сборках, потому что pg_doorman поставляется с включённой отладочной информацией. - Логгер не включает trace-идентификаторы и корреляцию запросов. Для трассировки на запрос используйте `SHOW CLIENTS` и Prometheus-метрики. diff --git a/documentation/ru/src/observability/percentiles.md b/documentation/ru/src/observability/percentiles.md index fef6186ca..6a274e34e 100644 --- a/documentation/ru/src/observability/percentiles.md +++ b/documentation/ru/src/observability/percentiles.md @@ -61,7 +61,7 @@ xacts: p50=3.8 p90=11.2 p95=18.5 p99=42.7 wait avg: 0.05ms ``` -p99 укладывается в 20× от p50 — типично для OLTP-нагрузок с редкими медленными запросами. Время ожидания — микросекунды, пул не является узким местом. +p99 укладывается в 20× от p50 — типично для OLTP-нагрузок с редкими медленными запросами. Время ожидания — микросекунды, пул узким местом не работает. ### Пул под давлением diff --git a/documentation/ru/src/reference/general.md b/documentation/ru/src/reference/general.md index 752c0f1fb..88164d48a 100644 --- a/documentation/ru/src/reference/general.md +++ b/documentation/ru/src/reference/general.md @@ -258,32 +258,29 @@ TCP backlog для входящих соединений. При значени ### tokio_global_queue_interval [Настройки Tokio runtime](https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.global_queue_interval). -Управляет тем, как часто шедулер проверяет глобальную очередь задач. -Современные версии tokio хорошо справляются с этим по умолчанию, поэтому параметр опционален. +Задаёт, как часто шедулер проверяет глобальную очередь задач. +Параметры группы `tokio_*` и `worker_stack_size`, `max_blocking_threads` опциональны: на современных версиях tokio дефолты разумны, и их обычно не нужно трогать. По умолчанию: `not set (uses tokio's default)`. ### tokio_event_interval [Настройки Tokio runtime](https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.event_interval). -Управляет тем, как часто шедулер проверяет внешние события (I/O, таймеры). -Современные версии tokio хорошо справляются с этим по умолчанию, поэтому параметр опционален. +Задаёт, как часто шедулер проверяет внешние события (I/O, таймеры). По умолчанию: `not set (uses tokio's default)`. ### worker_stack_size [Настройки Tokio runtime](https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.thread_stack_size). -Задаёт размер стека для worker-потоков. -Современные версии tokio хорошо справляются с этим по умолчанию, поэтому параметр опционален. +Размер стека для worker-потоков. По умолчанию: `not set (uses tokio's default)`. ### max_blocking_threads [Настройки Tokio runtime](https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.max_blocking_threads). -Задаёт максимальное число потоков для блокирующих операций. -Современные версии tokio хорошо справляются с этим по умолчанию, поэтому параметр опционален. +Максимальное число потоков для блокирующих операций. По умолчанию: `not set (uses tokio's default)`. @@ -356,9 +353,9 @@ TCP backlog для входящих соединений. При значени ### server_round_robin -Управляет тем, какое idle-серверное соединение выбирается для следующей транзакции. -`false` (LRU): переиспользует самое недавно возвращённое соединение. Держит горячими меньше соединений, лучше для локальности shared buffer в PostgreSQL. -`true` (Round Robin): равномерно ротирует все idle-соединения. +Задаёт, какое idle-серверное соединение выбирается для следующей транзакции. +`false` (MRU, LIFO): берётся последнее возвращённое в пул соединение. Горячих соединений меньше, локальность shared buffer в PostgreSQL выше. +`true` (Round Robin): равномерная ротация по всем idle-соединениям. Аналог `server_round_robin` из PgBouncer. По умолчанию: `false`. @@ -430,14 +427,13 @@ TCP retransmission timeout, когда keepalive не помогает (напр ### admin_username -Доступ к виртуальной admin-базе осуществляется по имени пользователя и паролю администратора. +Имя пользователя для виртуальной admin-базы. По умолчанию: `"admin"`. ### admin_password -Доступ к виртуальной admin-базе осуществляется по имени пользователя и паролю администратора. -Замените на ваш секрет. +Пароль для виртуальной admin-базы. Замените на свой секрет. По умолчанию: `"admin"`. @@ -616,13 +612,13 @@ CA-сертификат для проверки сертификатов сер ### hba -Список IP-адресов, с которых разрешено подключаться к pg-doorman. +Список IP-сетей в нотации CIDR, с которых разрешено подключение к pg_doorman. Для тонкого контроля доступа per-database и per-user используйте `pg_hba` (см. ниже). По умолчанию: `[]`. ### pg_hba -Новый стиль контроля доступа клиентов в нативном формате `pg_hba.conf` PostgreSQL. Позволяет задавать тонкие правила доступа аналогично PostgreSQL: per-database, per-user, диапазоны адресов, требования TLS. +Новый стиль контроля доступа клиентов в формате `pg_hba.conf` PostgreSQL. Позволяет задавать тонкие правила доступа аналогично PostgreSQL: per-database, per-user, диапазоны адресов, требования TLS. Указать `general.pg_hba` можно тремя способами: diff --git a/documentation/ru/src/reference/pool.md b/documentation/ru/src/reference/pool.md index ce46f0813..5932c3f44 100644 --- a/documentation/ru/src/reference/pool.md +++ b/documentation/ru/src/reference/pool.md @@ -1,6 +1,6 @@ ## Настройки пула -Каждая запись в pool — это имя виртуальной базы данных, к которой может подключиться клиент pg-doorman. +Каждая запись в pool — это имя виртуальной базы данных, к которой может подключиться клиент pg_doorman. ```toml [pools.exampledb] # Объявление базы данных 'exampledb' @@ -36,13 +36,13 @@ ### idle_timeout -Закрывать idle-соединения в этом пуле, открытые дольше указанного значения, в миллисекундах. Если не задано, используется глобальная настройка idle_timeout. +Время жизни idle-соединения в пуле в миллисекундах; соединения старше указанного значения закрываются. Если не задано, берётся глобальный `idle_timeout`. По умолчанию: `None (uses global setting)`. ### server_lifetime -Закрывать серверные соединения в этом пуле, открытые дольше указанного значения, в миллисекундах. Применяется только к idle-соединениям. Если не задано, используется глобальная настройка server_lifetime. +Время жизни серверного соединения в пуле в миллисекундах; idle-соединения старше указанного значения закрываются. Если не задано, берётся глобальный `server_lifetime`. По умолчанию: `None (uses global setting)`. @@ -62,7 +62,7 @@ ### cleanup_server_connections -Управляет тем, сбрасывает ли pg_doorman состояние сессии при возврате соединения в пул. +Сбрасывать ли состояние сессии при возврате соединения в пул. Когда параметр включён и сессия была изменена, pg_doorman отправляет: `RESET ROLE`, плюс при необходимости `RESET ALL` (если использовался SET), `DEALLOCATE ALL` (если использовался PREPARE), `CLOSE ALL` (если открывались курсоры). Замечание: `ROLLBACK` для открытых транзакций выполняется всегда, независимо diff --git a/documentation/ru/src/reference/prometheus.md b/documentation/ru/src/reference/prometheus.md index f4d511c9e..c0c019394 100644 --- a/documentation/ru/src/reference/prometheus.md +++ b/documentation/ru/src/reference/prometheus.md @@ -1,6 +1,6 @@ # Настройки Prometheus -pg_doorman содержит экспортёр метрик Prometheus, который даёт детальное представление о производительности и поведении ваших пулов соединений. В этом документе описано, как включить и использовать экспортёр метрик Prometheus, а также какие метрики доступны. +pg_doorman экспортирует метрики в формате Prometheus о производительности и состоянии пулов соединений. ## Включение метрик Prometheus @@ -60,9 +60,9 @@ pg_doorman экспортирует следующие метрики: | Метрика | Описание | |---------|----------| -| `pg_doorman_pools_clients` | Число клиентов в пулах соединений по статусу, пользователю и базе. Значения статуса: 'idle' (подключён, но не выполняет запросы), 'waiting' (ждёт серверного соединения), 'active' (выполняет запросы). Помогает мониторить загрузку пулов и распределение клиентов. | -| `pg_doorman_pools_servers` | Число серверов в пулах соединений по статусу, пользователю и базе. Значения статуса: 'active' (активно обслуживает клиентов) и 'idle' (доступен для новых соединений). Помогает мониторить доступность серверов и распределение нагрузки. | -| `pg_doorman_pools_bytes` | Общее число байт, переданных через пулы соединений, по направлению, пользователю и базе. Значения направления: 'received' (байты, полученные от клиентов) и 'sent' (байты, отправленные клиентам). Полезно для мониторинга сетевого трафика и выявления соединений с большим объёмом данных. | +| `pg_doorman_pools_clients` | Число клиентов в пулах соединений по статусу, пользователю и базе. Значения статуса: `idle` (подключён, но не выполняет запросы), `waiting` (ждёт серверного соединения), `active` (выполняет запросы). | +| `pg_doorman_pools_servers` | Число серверов в пулах соединений по статусу, пользователю и базе. Значения статуса: `active` (обслуживает клиента) и `idle` (свободен для новых соединений). | +| `pg_doorman_pools_bytes` | Сумма байт, переданных через пулы соединений, по направлению, пользователю и базе. Направления: `received` (от клиентов) и `sent` (клиентам). | | `pg_doorman_pool_size` | Сконфигурированный максимальный размер пула на пользователя и базу. Полезен для расчёта оставшейся ёмкости пула вместе с pg_doorman_pools_servers. | @@ -70,13 +70,13 @@ pg_doorman экспортирует следующие метрики: | Метрика | Описание | |---------|----------| -| `pg_doorman_pools_queries_percentile` | Перцентили времени выполнения запросов по пользователю и базе. Значения перцентилей: '99', '95', '90', '50' (медиана). Значения в миллисекундах. Помогает выявлять медленные запросы и тренды производительности по разным пользователям и базам. | -| `pg_doorman_pools_transactions_percentile` | Перцентили времени выполнения транзакций по пользователю и базе. Значения перцентилей: '99', '95', '90', '50' (медиана). Значения в миллисекундах. Помогает мониторить производительность транзакций и выявлять долгие транзакции, которые могут влиять на производительность базы. | -| `pg_doorman_pools_transactions_count` | Счётчик транзакций, выполненных в пулах соединений, по пользователю и базе. Помогает отслеживать объём транзакций и выявлять пользователей или базы с высоким темпом транзакций. | -| `pg_doorman_pools_transactions_total_time` | Общее время, потраченное на выполнение транзакций в пулах соединений, по пользователю и базе. Значения в миллисекундах. Помогает мониторить общую производительность транзакций и выявлять пользователей или базы с большим временем выполнения транзакций. | -| `pg_doorman_pools_queries_count` | Счётчик запросов, выполненных в пулах соединений, по пользователю и базе. Помогает отслеживать объём запросов и выявлять пользователей или базы с высоким темпом запросов. | -| `pg_doorman_pools_queries_total_time` | Общее время, потраченное на выполнение запросов в пулах соединений, по пользователю и базе. Значения в миллисекундах. Помогает мониторить общую производительность запросов и выявлять пользователей или базы с большим временем выполнения запросов. | -| `pg_doorman_pools_avg_wait_time` | Среднее время ожидания клиентов в пулах соединений, по пользователю и базе. Значения в миллисекундах. Помогает мониторить время ожидания клиентов и выявлять потенциальные узкие места. | +| `pg_doorman_pools_queries_percentile` | Перцентили времени выполнения запросов по пользователю и базе. Перцентили: `99`, `95`, `90`, `50` (медиана). В миллисекундах. | +| `pg_doorman_pools_transactions_percentile` | Перцентили времени выполнения транзакций по пользователю и базе. Перцентили: `99`, `95`, `90`, `50` (медиана). В миллисекундах. | +| `pg_doorman_pools_transactions_count` | Счётчик транзакций, выполненных в пулах соединений, по пользователю и базе. | +| `pg_doorman_pools_transactions_total_time` | Сумма времени выполнения транзакций в пулах соединений, по пользователю и базе. В миллисекундах. | +| `pg_doorman_pools_queries_count` | Счётчик запросов, выполненных в пулах соединений, по пользователю и базе. | +| `pg_doorman_pools_queries_total_time` | Сумма времени выполнения запросов в пулах соединений, по пользователю и базе. В миллисекундах. | +| `pg_doorman_pools_avg_wait_time` | Среднее время ожидания клиентов в пулах соединений, по пользователю и базе. В миллисекундах. | ### Метрики auth_query @@ -93,8 +93,8 @@ pg_doorman экспортирует следующие метрики: | Метрика | Описание | |---------|----------| -| `pg_doorman_servers_prepared_hits` | Счётчик попаданий prepared statements в бэкендах баз по пользователю и базе. Помогает оценить эффективность prepared statements в снижении накладных расходов на парсинг запросов. | -| `pg_doorman_servers_prepared_misses` | Счётчик промахов prepared statements в бэкендах баз по пользователю и базе. Помогает выявить запросы, которые могли бы выиграть от подготовки в prepared statements. | +| `pg_doorman_servers_prepared_hits` | Счётчик попаданий Prepared Statement в бэкендах баз по пользователю и базе. | +| `pg_doorman_servers_prepared_misses` | Счётчик промахов Prepared Statement в бэкендах баз по пользователю и базе. | ### Метрики серверного TLS @@ -110,12 +110,12 @@ pg_doorman экспортирует следующие метрики: ## Дашборд Grafana -Вы можете создать дашборд Grafana для визуализации этих метрик. Вот простой пример панелей, которые имеет смысл добавить: +Базовый набор панелей для дашборда: 1. Число соединений по типу -2. Использование памяти со временем +2. Использование памяти во времени 3. Число клиентов и серверов по пулам -4. Перцентили производительности запросов и транзакций +4. Перцентили запросов и транзакций 5. Сетевой трафик по пулам ## Примеры запросов diff --git a/documentation/ru/src/tutorials/basic-usage.md b/documentation/ru/src/tutorials/basic-usage.md index 47871869f..dd7ec95ab 100644 --- a/documentation/ru/src/tutorials/basic-usage.md +++ b/documentation/ru/src/tutorials/basic-usage.md @@ -100,7 +100,7 @@ username = "doorman" password = "SCRAM-SHA-256$4096:6nD+Ppi9rgaNyP7...MBiTld7xJipwG/X4=" ``` -Полный список параметров конфигурации можно получить командой `pg_doorman generate --reference --output ref.yaml` -- она генерирует аннотированный конфиг со всеми параметрами и значениями по умолчанию. +Полный список параметров конфигурации можно получить командой `pg_doorman generate --reference --output ref.yaml` — она генерирует аннотированный конфиг со всеми параметрами и значениями по умолчанию. ### Автоматическая генерация конфигурации @@ -152,14 +152,14 @@ pg_doorman generate --no-comments --output pg_doorman.yaml ``` `````admonish info title="Passthrough Authentication (по умолчанию)" -PgDoorman по умолчанию использует **passthrough authentication**: криптографическое доказательство клиента (MD5-хэш или SCRAM ClientKey) автоматически переиспользуется для аутентификации в backend-сервере PostgreSQL. Plaintext-пароли в конфиге не нужны -- достаточно записать в `password` хэш из `pg_shadow` / `pg_authid`. +PgDoorman по умолчанию использует **passthrough authentication**: криптографическое доказательство клиента (MD5-хеш или SCRAM ClientKey) автоматически переиспользуется для аутентификации в backend-сервере PostgreSQL. Plaintext-пароли в конфиге не нужны — достаточно записать в `password` хеш из `pg_shadow` / `pg_authid`. Указывайте `server_username` и `server_password` **только** тогда, когда backend-пользователь отличается от username пула (например, маппинг username или JWT-аутентификация): ```yaml users: - username: "app_user" # имя для клиента - password: "md5..." # хэш для аутентификации клиента + password: "md5..." # хеш для аутентификации клиента server_username: "pg_app_user" # другой backend-пользователь PostgreSQL server_password: "real_password" # plaintext-пароль для этого пользователя ``` @@ -174,9 +174,9 @@ users: ### Контроль доступа клиентов (pg_hba) PgDoorman может применять правила доступа клиентов в стиле PostgreSQL `pg_hba.conf` через параметр `general.pg_hba`. -Правила можно встроить прямо в конфиг или сослаться на путь к файлу. Полные примеры -- в [reference-разделе](../reference/general.md#pg_hba). +Правила можно встроить прямо в конфиг или сослаться на путь к файлу. Полные примеры — в [reference-разделе](../reference/general.md#pg_hba). -Trust-режим: когда правило использует `trust`, PgDoorman принимает соединения без запроса пароля у клиента -- зеркально поведению PostgreSQL. Учитываются TLS-зависимые типы правил: `hostssl` требует TLS, `hostnossl` его запрещает. +Trust-режим: когда правило использует `trust`, PgDoorman принимает соединения без запроса пароля у клиента — зеркально поведению PostgreSQL. Учитываются TLS-зависимые типы правил: `hostssl` требует TLS, `hostnossl` его запрещает. ### Запуск PgDoorman @@ -253,12 +253,12 @@ host pgdoorman admin 127.0.0.1/32 trust Admin-консоль предоставляет несколько команд для мониторинга текущего состояния PgDoorman: -- `SHOW STATS` -- посмотреть статистику производительности. -- `SHOW CLIENTS` -- список текущих клиентских соединений. -- `SHOW SERVERS` -- список текущих серверных соединений. -- `SHOW POOLS` -- состояние пулов соединений. -- `SHOW DATABASES` -- список настроенных баз данных. -- `SHOW USERS` -- список настроенных пользователей. +- `SHOW STATS` — посмотреть статистику производительности. +- `SHOW CLIENTS` — список текущих клиентских соединений. +- `SHOW SERVERS` — список текущих серверных соединений. +- `SHOW POOLS` — состояние пулов соединений. +- `SHOW DATABASES` — список настроенных баз данных. +- `SHOW USERS` — список настроенных пользователей. Эти команды подробно описаны в разделе [Команды admin-консоли](#команды-admin-консоли) ниже. @@ -512,7 +512,7 @@ pgdoorman=> SHOW LOG_LEVEL; pgdoorman=> SET log_level = 'default'; ``` -Изменения временные -- теряются при перезапуске. Допустимые уровни: `error`, `warn`, `info`, `debug`, `trace`, `off`. +Изменения временные — теряются при перезапуске. Допустимые уровни: `error`, `warn`, `info`, `debug`, `trace`, `off`. #### RELOAD @@ -535,7 +535,7 @@ pgdoorman=> RELOAD; #### PAUSE -Команда `PAUSE [db]` блокирует получение новых backend-соединений для указанной базы (или всех баз, если аргумент не задан). Активные транзакции продолжают работать -- блокируются только запросы на новые соединения. +Команда `PAUSE [db]` блокирует получение новых backend-соединений для указанной базы (или всех баз, если аргумент не задан). Активные транзакции продолжают работать — блокируются только запросы на новые соединения. ```sql -- Поставить на паузу все пулы @@ -545,9 +545,9 @@ pgdoorman=> PAUSE; pgdoorman=> PAUSE mydb; ``` -Клиенты, которые запросят новое backend-соединение во время паузы, будут ждать `RESUME` или истечения `query_wait_timeout` -- что наступит раньше. Если истечёт таймаут, клиент получит ошибку timeout. +Клиенты, которые запросят новое backend-соединение во время паузы, будут ждать `RESUME` или истечения `query_wait_timeout` — что наступит раньше. Если истечёт таймаут, клиент получит ошибку timeout. -Используйте `SHOW POOLS` для проверки состояния паузы -- колонка `paused` покажет `1` для приостановленных пулов. +Используйте `SHOW POOLS` для проверки состояния паузы — колонка `paused` покажет `1` для приостановленных пулов. ```admonish info title="Когда использовать PAUSE" PAUSE полезен во время операций обслуживания, когда нужно не пускать новые запросы на бэкенд: @@ -587,16 +587,16 @@ pgdoorman=> RECONNECT mydb; 1. Внутренний epoch-счётчик пула увеличивается. 2. Все idle-соединения сразу закрываются. -3. Активные соединения (которые сейчас обслуживают транзакцию) продолжают работать, но утилизируются при возврате в пул -- они не будут переиспользованы. +3. Активные соединения (которые сейчас обслуживают транзакцию) продолжают работать, но утилизируются при возврате в пул — они не будут переиспользованы. То есть RECONNECT **не** прерывает активные транзакции. Новые соединения создаются по запросу с текущим epoch, поэтому они будут приняты `recycle()`. ```admonish tip title="Шаблоны ротации соединений" **Постепенная ротация** (минимум воздействия): -Один RECONNECT -- idle-соединения сбрасываются сразу, активные -- по завершении текущей транзакции. Новые соединения создаются по мере необходимости. +Один RECONNECT — idle-соединения сбрасываются сразу, активные — по завершении текущей транзакции. Новые соединения создаются по мере необходимости. **Полная ротация** (гарантированно все новые соединения): -PAUSE → RECONNECT → RESUME -- сначала пауза не даёт стартовать новым транзакциям, потом RECONNECT помечает всё на утилизацию. После RESUME все последующие запросы получают свежие соединения. +PAUSE → RECONNECT → RESUME — сначала пауза не даёт стартовать новым транзакциям, потом RECONNECT помечает всё на утилизацию. После RESUME все последующие запросы получают свежие соединения. ``` ```admonish warning title="RECONNECT и min_pool_size" @@ -614,9 +614,9 @@ PAUSE → RECONNECT → RESUME -- сначала пауза не даёт ста | **RECONNECT для приостановленного пула** | Работает: idle-соединения дренируются, epoch инкрементируется. После RESUME новые соединения создаются с новым epoch. | | **PAUSE/RESUME/RECONNECT для несуществующей базы** | Возвращает ошибку: `No pool for database "xxx"`. Без аргумента-базы команда применяется ко всем пулам (ошибки нет, даже если пулов нет). | | **`query_wait_timeout` во время PAUSE** | Клиенты, ожидающие соединение, ожидаемо получают ошибку timeout. Пул остаётся на паузе. | -| **RELOAD во время PAUSE** | RELOAD пересоздаёт пулы из конфигурации, поэтому состояние паузы теряется. Это ожидаемое поведение -- новая конфигурация означает новые пулы. | +| **RELOAD во время PAUSE** | RELOAD пересоздаёт пулы из конфигурации, поэтому состояние паузы теряется. Это ожидаемое поведение — новая конфигурация означает новые пулы. | | **GC приостановленных динамических пулов** | Приостановленные динамические пулы защищены от garbage collection даже при 0 соединений. | -| **Replenish во время PAUSE** | Пулы с `min_pool_size` не дозаполняются, пока на паузе -- новые соединения не создаются. Дозаполнение возобновляется после RESUME. | +| **Replenish во время PAUSE** | Пулы с `min_pool_size` не дозаполняются, пока на паузе — новые соединения не создаются. Дозаполнение возобновляется после RESUME. | | **Время жизни соединений во время PAUSE** | Retain-задача продолжает закрывать просроченные соединения (idle timeout, server lifetime). Соединения по-прежнему стареют. | | **Несколько вызовов RECONNECT** | Каждый вызов увеличивает epoch ещё. Только соединения, созданные после последнего RECONNECT, считаются валидными. | @@ -626,7 +626,7 @@ PgDoorman реагирует на стандартные Unix-сигналы д | Сигнал | Эффект | |--------|--------| -| **SIGHUP** | Перечитывание конфигурации -- эквивалент admin-команды `RELOAD`. | +| **SIGHUP** | Перечитывание конфигурации — эквивалент admin-команды `RELOAD`. | | **SIGUSR2** | Binary upgrade и graceful shutdown. Валидирует новый бинарник флагом `-t`, запускает новый процесс, затем завершается. **Рекомендуется для обновлений.** См. [Binary upgrade](binary-upgrade.md). | | **SIGINT** | **Foreground + TTY** (Ctrl+C): только graceful shutdown (без binary upgrade). **Daemon / без TTY**: binary upgrade и graceful shutdown (legacy-поведение). | | **SIGTERM** | Немедленное завершение. Активные соединения обрываются. | diff --git a/documentation/ru/src/tutorials/binary-upgrade.md b/documentation/ru/src/tutorials/binary-upgrade.md index 040de703a..e617c0153 100644 --- a/documentation/ru/src/tutorials/binary-upgrade.md +++ b/documentation/ru/src/tutorials/binary-upgrade.md @@ -15,7 +15,7 @@ Online restart в PgBouncer (`-R`, deprecated с 1.20; либо rolling restart дорабатывает до тех пор, пока его клиенты сами не отключатся. Сессии, prepared statements и TLS-состояние между процессами не переезжают. pg_doorman передаёт живой сокет через `SCM_RIGHTS`, а со сборкой -`tls-migration` (Linux, opt-in) — и cipher state. +`tls-migration` (Linux, по запросу) — и cipher state. ``` ## Быстрый старт @@ -63,7 +63,7 @@ UPGRADE; | 2. Запуск нового | | socketpair() | | inherit-fd | - | readiness pipe | -- ожидание до 10с + | readiness pipe | — ожидание до 10с +-----------+-----------+ | +-------------+-------------+ @@ -89,7 +89,7 @@ UPGRADE; ### Фаза 1: Валидация конфига Текущий бинарник перезапускается с флагом `-t` и конфигом. -Если валидация проваливается -- upgrade отменяется, старый процесс +Если валидация проваливается — upgrade отменяется, старый процесс продолжает обслуживать трафик. В логах баннер: ``` @@ -107,7 +107,7 @@ UPGRADE; 3. Readiness pipe: родитель ждёт до 10 секунд байт от дочернего процесса. Дочерний пишет в pipe, когда начинает принимать соединения. -4. Родитель закрывает свой listener -- новые соединения идут +4. Родитель закрывает свой listener — новые соединения идут в дочерний процесс. **Daemon mode:** @@ -132,7 +132,7 @@ now`), и старый процесс выходит. подключает к нужному пулу и запускает `handle()`. Клиент не замечает миграции. Никакого reconnect, никакого error, -никакой повторной аутентификации. TCP-соединение -- тот же +никакой повторной аутентификации. TCP-соединение — тот же физический socket. ### Фаза 4: Дренирование in-transaction клиентов @@ -149,10 +149,10 @@ COMMIT, и только потом мигрирует. ### Фаза 5: Shutdown timer Shutdown timer опрашивает `CURRENT_CLIENT_COUNT` каждые 250 мс. -Когда все клиенты мигрировали или отключились -- старый процесс +Когда все клиенты мигрировали или отключились — старый процесс вызывает `process::exit(0)`. -Если `shutdown_timeout` истекает раньше -- принудительный выход, +Если `shutdown_timeout` истекает раньше — принудительный выход, оставшиеся соединения закрываются. Во время миграции `drain_all_pools()` откладывается: in-transaction @@ -171,14 +171,14 @@ Shutdown timer опрашивает `CURRENT_CLIENT_COUNT` каждые 250 мс В новом процессе: 1. Каждая запись регистрируется в pool-level shared cache (DashMap). -2. Серверные бэкенды свежие -- на них нет prepared statements. +2. Серверные бэкенды свежие — на них нет Prepared Statement. 3. При первом `Bind` к мигрированному statement pg_doorman прозрачно отправляет `Parse` на новый бэкенд. Клиент не видит дополнительного round-trip. **Ограничения:** -- Если `client_anonymous_prepared_cache_size` нового конфига меньше -- +- Если `client_anonymous_prepared_cache_size` нового конфига меньше — лишние Anonymous-записи вытесняются (LRU). Named-часть без лимита и переживает миграцию полностью. Оставшиеся entries работают нормально. - Anonymous prepared statements (`Parse` с пустым именем) переживают @@ -188,7 +188,7 @@ Shutdown timer опрашивает `CURRENT_CLIENT_COUNT` каждые 250 мс ## TLS migration -По умолчанию TLS-клиенты не мигрируют -- зашифрованная сессия +По умолчанию TLS-клиенты не мигрируют — зашифрованная сессия требует ключевой материал, который живёт внутри OpenSSL state machine. Такие клиенты дренируются при upgrade: соединение закрывается при истечении `shutdown_timeout`, клиент переподключается к новому процессу. @@ -346,8 +346,8 @@ ExecReload=/bin/kill -SIGUSR2 $MAINPID shutdown_timeout = 60000 # миллисекунды ``` -Слишком маленькое значение -- риск убить активные транзакции. Слишком -большое -- задержка выхода старого процесса при зависшем клиенте +Слишком маленькое значение — риск убить активные транзакции. Слишком +большое — задержка выхода старого процесса при зависшем клиенте (например, idle-in-transaction). Выбирайте значение, покрывающее самую длинную ожидаемую транзакцию, с запасом. @@ -377,7 +377,7 @@ Pool-level кэш prepared statements. Напрямую на миграцию н Per-client Anonymous LRU. Клиентский кэш (и Named, и Anonymous) сериализуется полностью при миграции. Если новый конфиг имеет -меньшее значение -- LRU вытесняет лишние Anonymous-записи; Named без +меньшее значение — LRU вытесняет лишние Anonymous-записи; Named без лимита и мигрирует целиком. ## Мониторинг diff --git a/documentation/ru/src/tutorials/contributing.md b/documentation/ru/src/tutorials/contributing.md index 1eff2a957..d58ad90ed 100644 --- a/documentation/ru/src/tutorials/contributing.md +++ b/documentation/ru/src/tutorials/contributing.md @@ -11,7 +11,7 @@ - [Docker](https://docs.docker.com/get-docker/) (обязательно) - [Make](https://www.gnu.org/software/make/) (обязательно) -**Установка Nix НЕ требуется** -- воспроизводимость тестового окружения обеспечивается Docker-контейнерами, собранными через Nix. +**Установка Nix НЕ требуется** — воспроизводимость тестового окружения обеспечивается Docker-контейнерами, собранными через Nix. Для локальной разработки (опционально): - [Rust](https://www.rust-lang.org/tools/install) (последняя стабильная версия) @@ -58,7 +58,7 @@ ## Интеграционное тестирование -PgDoorman использует BDD-тесты (Behavior-Driven Development) с тестовым окружением на Docker. **Воспроизводимость гарантирована** -- все тесты выполняются внутри Docker-контейнеров с одинаковым окружением. +PgDoorman использует BDD-тесты (Behavior-Driven Development) с тестовым окружением на Docker. **Воспроизводимость гарантирована** — все тесты выполняются внутри Docker-контейнеров с одинаковым окружением. ### Тестовое окружение @@ -270,7 +270,7 @@ Feature: Protocol behavior test Если в тестовом окружении нужны дополнительные пакеты, отредактируйте `tests/nix/flake.nix`: - Python-пакеты добавляются в `pythonEnv`. -- Системные пакеты -- в `runtimePackages`. +- Системные пакеты — в `runtimePackages`. После изменения `flake.nix` пересоберите образ командой `make local-build`. diff --git a/documentation/ru/src/tutorials/overview.md b/documentation/ru/src/tutorials/overview.md index 9f6ce410a..3b72d8caa 100644 --- a/documentation/ru/src/tutorials/overview.md +++ b/documentation/ru/src/tutorials/overview.md @@ -12,7 +12,7 @@ graph LR Pooler --> DB[(PostgreSQL)] ``` -Изначально PgDoorman был форкнут из [PgCat](https://github.com/postgresml/pgcat), но с тех пор переписан вокруг других целей: prepared statements в transaction mode, многопоточные общие пулы, интеграция с Patroni и binary upgrade с миграцией живых сессий. Сейчас это самостоятельный кодовый код. +Изначально PgDoorman был форкнут из [PgCat](https://github.com/postgresml/pgcat), но с тех пор переписан вокруг других целей: prepared statements в transaction mode, многопоточные общие пулы, интеграция с Patroni и binary upgrade с миграцией живых сессий. Сейчас это самостоятельная кодовая база. ## Зачем вообще пулер diff --git a/documentation/ru/src/tutorials/patroni-assisted-fallback.md b/documentation/ru/src/tutorials/patroni-assisted-fallback.md index 69f9b02de..d13facf2a 100644 --- a/documentation/ru/src/tutorials/patroni-assisted-fallback.md +++ b/documentation/ru/src/tutorials/patroni-assisted-fallback.md @@ -278,12 +278,12 @@ patroni_proxy и Patroni-assisted fallback решают разные задач **patroni_proxy** — TCP-балансировщик, разворачивается рядом с клиентскими приложениями. Маршрутизирует соединения к нужному узлу -PostgreSQL по роли (leader, sync, async). Не пулит соединения. +PostgreSQL по роли (leader, sync, async). Пулинг соединений не выполняет. **Patroni-assisted fallback** — встроен в pooler doorman, который разворачивается рядом с PostgreSQL. Обрабатывает ситуацию, когда локальный backend умер и doorman нуждается во временной альтернативе. -Пулит соединения. +Пулинг соединений выполняет. В рекомендуемой архитектуре (patroni_proxy → pg_doorman → PostgreSQL) fallback сохраняет read-трафик на уровне doorman при падении diff --git a/documentation/ru/src/tutorials/patroni-proxy.md b/documentation/ru/src/tutorials/patroni-proxy.md index 538dbb1c1..c76ac5381 100644 --- a/documentation/ru/src/tutorials/patroni-proxy.md +++ b/documentation/ru/src/tutorials/patroni-proxy.md @@ -1,6 +1,6 @@ # Patroni Proxy -`patroni_proxy` — TCP-балансировщик для кластеров PostgreSQL под управлением Patroni. Слушает один или несколько портов, спрашивает у Patroni REST API кто сейчас leader / sync / async, и направляет новые соединения на нужную роль по стратегии least-connections. Не пулит соединения, не парсит wire-протокол, не знает что за SQL внутри — это работа pg_doorman, развёрнутого ниже по цепочке. +`patroni_proxy` — TCP-балансировщик для кластеров PostgreSQL под управлением Patroni. Слушает один или несколько портов, спрашивает у Patroni REST API кто сейчас leader / sync / async, и направляет новые соединения на нужную роль по стратегии least-connections. Не выполняет пулинг соединений, не парсит wire-протокол, не знает что за SQL внутри — это работа pg_doorman, развёрнутого ниже по цепочке. ## Что он делает @@ -56,7 +56,7 @@ listen_address: "127.0.0.1:8009" clusters: my_cluster: - # Endpoint'ы Patroni API (несколько -- для отказоустойчивости) + # Endpoint'ы Patroni API (несколько — для отказоустойчивости) hosts: - "http://192.168.1.1:8008" - "http://192.168.1.2:8008" @@ -90,12 +90,12 @@ clusters: |----------|--------------|----------| | `cluster_update_interval` | 3 | Интервал в секундах между опросами Patroni API | | `listen_address` | 127.0.0.1:8009 | Адрес для HTTP API | -| `clusters..hosts` | -- | Список endpoint'ов Patroni API | -| `clusters..tls` | -- | Опциональная TLS-конфигурация для Patroni API | -| `clusters..ports..listen` | -- | Адрес для listener этого порта | -| `clusters..ports..roles` | -- | Список разрешённых ролей | -| `clusters..ports..host_port` | -- | Порт PostgreSQL на бэкенд-хостах | -| `clusters..ports..max_lag_in_bytes` | -- | Максимальный лаг репликации (опционально) | +| `clusters..hosts` | — | Список endpoint'ов Patroni API | +| `clusters..tls` | — | Опциональная TLS-конфигурация для Patroni API | +| `clusters..ports..listen` | — | Адрес для listener этого порта | +| `clusters..ports..roles` | — | Список разрешённых ролей | +| `clusters..ports..host_port` | — | Порт PostgreSQL на бэкенд-хостах | +| `clusters..ports..max_lag_in_bytes` | — | Максимальный лаг репликации (опционально) | ## Использование diff --git a/documentation/ru/src/tutorials/pool-pressure.md b/documentation/ru/src/tutorials/pool-pressure.md index 246d4508e..ea7ca3edd 100644 --- a/documentation/ru/src/tutorials/pool-pressure.md +++ b/documentation/ru/src/tutorials/pool-pressure.md @@ -77,7 +77,7 @@ backend-соединение. Второй делает то же самое, т пока пул не достигнет **порога прогрева** (warm threshold). Порог прогрева равен `pool_size × scaling_warm_pool_ratio / 100`. При -дефолтном значении 20% и `pool_size = 40` порог равен 8 соединениям. +значении по умолчанию 20% и `pool_size = 40` порог равен 8 соединениям. Ниже этого порога pg_doorman создаёт соединения без раздумий: пул холодный, цена ожидания выше цены коннекта, и клиенты не могут конкурировать за idle-соединения, которых не существует. diff --git a/documentation/ru/src/tutorials/prepared-statements.md b/documentation/ru/src/tutorials/prepared-statements.md index 665ab00e5..f3f7cda91 100644 --- a/documentation/ru/src/tutorials/prepared-statements.md +++ b/documentation/ru/src/tutorials/prepared-statements.md @@ -3,7 +3,7 @@ PostgreSQL не кеширует план анонимных prepared statements: каждый `Bind` запускает планировщик с нуля. PgDoorman кеширует план сам, прозрачно переписывая каждый анонимный `Parse` в служебное имя -`DOORMAN_` на бекенде. План попадает в реестр именованных statement бекенда и +`DOORMAN_` на бэкенде. План попадает в реестр именованных statement бэкенда и переиспользуется между `Bind`'ами одного клиента и между разными клиентами одного пула. @@ -141,7 +141,7 @@ PgDoorman держит состояние prepared statements на трёх ур наследует prepared_statements_cache_size), или AHashMap при размере 0. Выселение Anonymous локальное: Arc отбрасывается, - DOORMAN_ на бекенде остаётся. + DOORMAN_ на бэкенде остаётся. Server-level LruCache, на backend-соединение. Запоминает, какие DOORMAN_N этот backend уже держит. @@ -149,7 +149,7 @@ PgDoorman держит состояние prepared statements на трёх ур ``` При вытеснении записи из Anonymous LRU PgDoorman отбрасывает локальную -ссылку и не отправляет `Close` на бекенд. Соответствующий +ссылку и не отправляет `Close` на бэкенд. Соответствующий `DOORMAN_` будет переиспользован server-level LRU или закроется по `server_lifetime` (default 20 минут) — что наступит раньше. @@ -262,13 +262,13 @@ general: служит хеш запроса. Это верхняя граница на число различных query shape, которые пул помнит сразу для всех клиентов. Приближённый LRU: вытеснение проходит за O(N) по всему кешу и не отправляет - `Close` на бекенд (другие клиенты могут ещё держать `Arc`). + `Close` на бэкенд (другие клиенты могут ещё держать `Arc`). - `server_prepared_statements_cache_size` (по умолчанию наследует `prepared_statements_cache_size`) задаёт размер per-backend кеша — отдельный LRU на каждое серверное соединение, ключом служит имя `DOORMAN_`. Это верхняя граница на число prepared - statements, которое PgDoorman позволит держать одному бекенду - PostgreSQL. Точный LRU за O(1); при выселении в очередь бекенда + statements, которое PgDoorman позволит держать одному бэкенду + PostgreSQL. Точный LRU за O(1); при выселении в очередь бэкенда кладётся `Close`, который отправляется ближайшим Sync или Flush — представление `pg_prepared_statements` может временно показывать больше строк, чем потолок, пока не придёт следующий Sync. @@ -300,13 +300,13 @@ pools: заодно обнуляет server-level LRU. Указать `server_prepared_statements_cache_size: 0` при положительном pool-size допустимо, но смысла мало: per-backend кеш превратится -в pass-through, и каждое попадание на чужой бекенд приведёт к +в pass-through, и каждое попадание на чужой бэкенд приведёт к повторному `Parse`. Когда уменьшать `server_prepared_statements_cache_size` ниже размера pool-level кеша: -- На бекендах копится слишком много строк `DOORMAN_` +- На бэкендах копится слишком много строк `DOORMAN_` (`pg_prepared_statements` упирается в потолок, память планов растёт). - Хочется ускорить вытеснение через `Close`, не урезая попадания @@ -314,7 +314,7 @@ pool-level кеша: Когда оставить значения равными (поведение по умолчанию): -- Нет измеренной проблемы с памятью на бекендах. Достаточно +- Нет измеренной проблемы с памятью на бэкендах. Достаточно наследования. ### Размер `client_anonymous_prepared_cache_size` @@ -366,7 +366,7 @@ PostgreSQL — именованные prepared живут до конца сес `SHOW POOLS_MEMORY` и метрикой `pg_doorman_clients_prepared_named_entries` на предмет неожиданного роста. -### Окно утечки памяти на бекенде +### Окно утечки памяти на бэкенде При вытеснении записи из Anonymous LRU на стороне клиента PgDoorman отбрасывает только локальный `Arc`. Соответствующий @@ -463,14 +463,13 @@ rate(pg_doorman_clients_prepared_anonymous_evictions_total[5m]) > 10 именованным statement, под анонимным, или и так и так. `kind = mixed` означает, что одна и та же пара `(query, param_types)` была обработана хотя бы одним клиентом как named и хотя бы одним другим -как anonymous за её текущую жизнь. Большинство нагрузок не видят -строк `mixed`; пул, в котором их большинство, говорит о -гетерогенной клиентской базе (разные драйверы или конфигурации -драйверов против одной БД), и эту разнородность стоит проверить — -иногда она задумана, иногда сигналит, что один из клиентов настроен -неверно. +как anonymous за её текущую жизнь. У большинства нагрузок строк +`mixed` нет; если в пуле их большинство, у клиентов разные драйверы +или разные конфигурации драйверов против одной БД, и эту разнородность +стоит проверить — иногда она задумана, иногда сигналит, что один из +клиентов настроен неверно. -### Число prepared statements на бекенде +### Число prepared statements на бэкенде PostgreSQL отдаёт `pg_prepared_statements` для текущего backend. Если память пулера в норме, но RSS PostgreSQL backend растёт, @@ -505,8 +504,8 @@ SELECT count(*) FROM pg_prepared_statements; Когда интернер и пуловой/клиентский кеши одновременно перестали держать текст анонимного prepared, pg_doorman на следующий `Bind` отвечает `ERROR: unnamed prepared statement does not exist` -(SQLSTATE `26000`). Это та же ошибка, что нативный PostgreSQL -отдаёт в аналогичной ситуации; стандартные драйверы реагируют +(SQLSTATE `26000`). Это та же ошибка, что PostgreSQL отдаёт +напрямую в аналогичной ситуации; стандартные драйверы реагируют повторным `Parse`. Бинарное обновление (`SIGUSR2`) переносит и NAMED, и ANON в новый diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..f53a8802d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.vite/ +*.local +.eslintcache diff --git a/frontend/dist/.source-hash b/frontend/dist/.source-hash new file mode 100644 index 000000000..df9444ad9 --- /dev/null +++ b/frontend/dist/.source-hash @@ -0,0 +1 @@ +f5294b34de7dba2ad196ae933789374c96dec256037793e300a271a388c13521 diff --git a/frontend/dist/JETBRAINS_MONO_OFL.txt b/frontend/dist/JETBRAINS_MONO_OFL.txt new file mode 100644 index 000000000..8f7ed6711 --- /dev/null +++ b/frontend/dist/JETBRAINS_MONO_OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/dist/assets/index-ClUg84nv.js.gz b/frontend/dist/assets/index-ClUg84nv.js.gz new file mode 100644 index 000000000..d150b3a44 Binary files /dev/null and b/frontend/dist/assets/index-ClUg84nv.js.gz differ diff --git a/frontend/dist/assets/index-aEDZ_FDR.css.gz b/frontend/dist/assets/index-aEDZ_FDR.css.gz new file mode 100644 index 000000000..b801abe65 Binary files /dev/null and b/frontend/dist/assets/index-aEDZ_FDR.css.gz differ diff --git a/frontend/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 b/frontend/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 new file mode 100644 index 000000000..f60f0bd19 Binary files /dev/null and b/frontend/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 differ diff --git a/frontend/dist/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 b/frontend/dist/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 new file mode 100644 index 000000000..15807651c Binary files /dev/null and b/frontend/dist/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 differ diff --git a/frontend/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 b/frontend/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 new file mode 100644 index 000000000..585887339 Binary files /dev/null and b/frontend/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 differ diff --git a/frontend/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 b/frontend/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 new file mode 100644 index 000000000..be878e68f Binary files /dev/null and b/frontend/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 differ diff --git a/frontend/dist/favicon.svg.gz b/frontend/dist/favicon.svg.gz new file mode 100644 index 000000000..9d7099d8c Binary files /dev/null and b/frontend/dist/favicon.svg.gz differ diff --git a/frontend/dist/index.html.gz b/frontend/dist/index.html.gz new file mode 100644 index 000000000..f46b6cb61 Binary files /dev/null and b/frontend/dist/index.html.gz differ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 000000000..f46e61b2f --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,41 @@ +import js from "@eslint/js"; +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import reactPlugin from "eslint-plugin-react"; +import reactHooksPlugin from "eslint-plugin-react-hooks"; +import globals from "globals"; + +export default [ + { ignores: ["dist", "node_modules"] }, + js.configs.recommended, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { ...globals.node }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } }, + globals: { ...globals.browser }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + "react": reactPlugin, + "react-hooks": reactHooksPlugin, + }, + settings: { react: { version: "18.3" } }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactPlugin.configs.recommended.rules, + ...reactHooksPlugin.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, +]; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..a153b37f7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + pg_doorman + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..b033079da --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5972 @@ +{ + "name": "pg-doorman-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pg-doorman-web", + "version": "0.1.0", + "dependencies": { + "@fontsource/jetbrains-mono": "^5.2.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "uplot": "^1.6.31" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.14.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "globals": "^14.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.351", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz", + "integrity": "sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..4256172bf --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "pg-doorman-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && node scripts/post-build.mjs && node scripts/write-source-hash.mjs", + "preview": "vite preview", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "check-dist": "node scripts/check-source-hash.mjs", + "install-hooks": "node scripts/install-git-hooks.mjs" + }, + "dependencies": { + "@fontsource/jetbrains-mono": "^5.2.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "uplot": "^1.6.31" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "@vitejs/plugin-react": "^4.3.3", + "eslint": "^9.14.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "globals": "^14.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 000000000..eb12830db --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + pd + diff --git a/frontend/scripts/check-source-hash.mjs b/frontend/scripts/check-source-hash.mjs new file mode 100644 index 000000000..233d7bec9 --- /dev/null +++ b/frontend/scripts/check-source-hash.mjs @@ -0,0 +1,29 @@ +// Compare the current source hash against `dist/.source-hash`. Exits with +// status 1 and a remediation message when the two disagree, so the +// pre-commit hook and CI both fail on stale `dist/`. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { computeSourceHash } from "./source-hash.mjs"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const computed = computeSourceHash(); +let committed; +try { + committed = readFileSync(join(ROOT, "dist", ".source-hash"), "utf8").trim(); +} catch { + process.stderr.write("frontend/dist/.source-hash is missing.\n"); + process.stderr.write("Rebuild and commit dist:\n"); + process.stderr.write(" cd frontend && npm run build && git add dist\n"); + process.exit(1); +} +if (computed !== committed) { + process.stderr.write("frontend/dist/ is out of sync with frontend/src/.\n"); + process.stderr.write(` expected source-hash: ${computed}\n`); + process.stderr.write(` found in dist: ${committed}\n\n`); + process.stderr.write("Rebuild and commit dist:\n"); + process.stderr.write(" cd frontend && npm run build && git add dist\n"); + process.exit(1); +} +process.stdout.write(`source-hash OK: ${computed}\n`); diff --git a/frontend/scripts/install-git-hooks.mjs b/frontend/scripts/install-git-hooks.mjs new file mode 100644 index 000000000..49bcd7e11 --- /dev/null +++ b/frontend/scripts/install-git-hooks.mjs @@ -0,0 +1,30 @@ +// Point the parent repository's `core.hooksPath` at the project-level +// `.githooks` directory so the dist-sync pre-commit hook runs locally. +// Silent no-op when run outside a git checkout (e.g. CI cache restore, +// Docker build) — CI re-checks via `npm run check-dist`. + +import { execFileSync } from "node:child_process"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); + +try { + execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: REPO_ROOT, + stdio: "ignore", + }); +} catch { + process.exit(0); +} + +try { + execFileSync("git", ["config", "--local", "core.hooksPath", ".githooks"], { + cwd: REPO_ROOT, + stdio: "ignore", + }); + process.stdout.write("git core.hooksPath -> .githooks\n"); +} catch (err) { + process.stderr.write(`warning: failed to configure git hooks: ${err.message}\n`); + process.exit(0); +} diff --git a/frontend/scripts/post-build.mjs b/frontend/scripts/post-build.mjs new file mode 100644 index 000000000..56a051fc6 --- /dev/null +++ b/frontend/scripts/post-build.mjs @@ -0,0 +1,91 @@ +// Post-build cleanup for the SPA bundle: +// 1) drop legacy .woff font files (modern browsers support .woff2 since 2014); +// 2) strip the matching `url(...woff) format("woff")` entries from generated +// CSS so the browser does not 404-request them; +// 3) copy the JetBrains Mono OFL-1.1 license next to the bundled fonts; +// 4) replace every compressible asset (.js / .css / .html / .svg) with its +// gzipped counterpart so include_dir!() embeds only the pre-compressed +// form. Browsers that advertise gzip get the bytes verbatim; the rare +// non-gzip client gets a flate2 decompression on the server side. + +import { copyFileSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const DIST_ASSETS = join(ROOT, "dist", "assets"); + +let removed = 0; +let bytes = 0; +for (const name of readdirSync(DIST_ASSETS)) { + if (!name.endsWith(".woff")) continue; + const full = join(DIST_ASSETS, name); + bytes += statSync(full).size; + unlinkSync(full); + removed += 1; +} + +let cssTouched = 0; +for (const name of readdirSync(DIST_ASSETS)) { + if (!name.endsWith(".css")) continue; + const full = join(DIST_ASSETS, name); + const original = readFileSync(full, "utf8"); + // Match every `url(...woff) format("woff")` entry plus the comma that + // precedes or follows it inside an @font-face `src` list. Vite renders the + // pattern compactly; the regex covers both leading-comma and trailing-comma + // forms without touching the surviving woff2 entries. + const cleaned = original + .replace(/,\s*url\([^)]+\.woff\)\s*format\(["']woff["']\)/g, "") + .replace(/url\([^)]+\.woff\)\s*format\(["']woff["']\)\s*,\s*/g, ""); + if (cleaned !== original) { + writeFileSync(full, cleaned); + cssTouched += 1; + } +} + +const LICENSE_DEST = join(ROOT, "dist", "JETBRAINS_MONO_OFL.txt"); +const LICENSE_SRC = join(ROOT, "node_modules", "@fontsource", "jetbrains-mono", "LICENSE"); +try { + copyFileSync(LICENSE_SRC, LICENSE_DEST); +} catch (err) { + process.stderr.write(`warning: could not copy JetBrains Mono OFL license: ${err.message}\n`); +} + +// Pre-gzip every compressible asset and delete the original. The web +// listener serves the bytes verbatim with `Content-Encoding: gzip` to +// browsers (which all advertise gzip) and decompresses with flate2 for +// the rare client that does not. The .source-hash file stays excluded +// — it is a CI artifact and gzipping it adds noise. +const COMPRESSIBLE_EXT = [".js", ".css", ".html", ".svg"]; +const GZIP_EXCLUDE = new Set([".source-hash"]); +const DIST_ROOT = join(ROOT, "dist"); + +let gzipFiles = 0; +let gzipFromBytes = 0; +let gzipToBytes = 0; +function gzipDir(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + gzipDir(full); + continue; + } + if (GZIP_EXCLUDE.has(entry.name)) continue; + if (!COMPRESSIBLE_EXT.some((ext) => entry.name.endsWith(ext))) continue; + const raw = readFileSync(full); + const gz = gzipSync(raw, { level: 9 }); + writeFileSync(full + ".gz", gz); + unlinkSync(full); + gzipFiles += 1; + gzipFromBytes += raw.length; + gzipToBytes += gz.length; + } +} +gzipDir(DIST_ROOT); + +process.stdout.write( + `post-build: removed ${removed} .woff files (${(bytes / 1024).toFixed(1)} KB), ` + + `patched ${cssTouched} CSS files, copied OFL license, ` + + `pre-gzipped ${gzipFiles} files (${(gzipFromBytes / 1024).toFixed(1)} KB → ${(gzipToBytes / 1024).toFixed(1)} KB).\n`, +); diff --git a/frontend/scripts/source-hash.mjs b/frontend/scripts/source-hash.mjs new file mode 100644 index 000000000..68e5ea070 --- /dev/null +++ b/frontend/scripts/source-hash.mjs @@ -0,0 +1,72 @@ +// Compute a deterministic SHA-256 over all source files that influence the +// vite build output. The hash is committed alongside `dist/` as +// `dist/.source-hash` so CI and pre-commit hook can detect drift between +// source changes and the shipped bundle without relying on byte-stable +// builds (esbuild native binaries differ across machines). + +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +const TRACKED_DIRS = ["src", "public", "scripts"]; +const TRACKED_FILES = [ + "index.html", + "package.json", + "package-lock.json", + "vite.config.ts", + "tsconfig.json", +]; + +function* walk(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name === "node_modules") continue; + if (entry.name.startsWith(".")) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if (entry.isFile()) { + yield full; + } + } +} + +export function computeSourceHash() { + const files = []; + for (const dir of TRACKED_DIRS) { + for (const file of walk(join(ROOT, dir))) files.push(file); + } + for (const file of TRACKED_FILES) { + const abs = join(ROOT, file); + try { + if (statSync(abs).isFile()) files.push(abs); + } catch { + // Skip optional files. + } + } + files.sort(); + + const outer = createHash("sha256"); + for (const file of files) { + const inner = createHash("sha256"); + inner.update(readFileSync(file)); + const rel = relative(ROOT, file).split(sep).join("/"); + outer.update(rel); + outer.update("\0"); + outer.update(inner.digest("hex")); + outer.update("\n"); + } + return outer.digest("hex"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + process.stdout.write(computeSourceHash() + "\n"); +} diff --git a/frontend/scripts/write-source-hash.mjs b/frontend/scripts/write-source-hash.mjs new file mode 100644 index 000000000..55b1a95d8 --- /dev/null +++ b/frontend/scripts/write-source-hash.mjs @@ -0,0 +1,12 @@ +// Write the current source hash into `dist/.source-hash`. Invoked by +// `npm run build` after `vite build` succeeds. + +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { computeSourceHash } from "./source-hash.mjs"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const hash = computeSourceHash(); +writeFileSync(join(ROOT, "dist", ".source-hash"), hash + "\n", "utf8"); +process.stdout.write(`source-hash: ${hash}\n`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..dcd488e74 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,50 @@ +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { AuthGate } from "./components/AuthGate"; +import { Sidebar } from "./components/Sidebar"; +import { AdminAuthProvider } from "./hooks/useAdminAuth"; +import Overview from "./pages/Overview"; +import Pools from "./pages/Pools"; +import PoolDetail from "./pages/PoolDetail"; +import Clients from "./pages/Clients"; +import Apps from "./pages/Apps"; +import Caches from "./pages/Caches"; +import Wall from "./pages/Wall"; +import Logs from "./pages/Logs"; +import ConfigState from "./pages/ConfigState"; + +export default function App() { + return ( + + +
+ +
+ + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+
+
+
+ ); +} + +function NotFound() { + return ( +
+

Not found

+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 000000000..58c1a0e31 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,86 @@ +/** + * Typed fetch wrapper. Reads credentials from the AdminAuth context lazily + * via the headerProvider param, so a credential update in AuthGate + * propagates to in-flight retries without component remounting. + * + * The module also owns a single `onUnauthorized` callback that AdminAuth + * registers at mount. Any 401 response — from anywhere in the app, not just + * the AuthGate's version probe — fires the callback so the gate can pop the + * sign-in modal again. Without this hook, an admin-only page that 401's + * after AuthGate already greenlit would leave the operator stuck on a red + * error message. + */ +export class Unauthorized extends Error { + constructor() { + super("401 Unauthorized"); + this.name = "Unauthorized"; + } +} + +export class ApiError extends Error { + constructor(public readonly status: number, public readonly body: string) { + super(`api ${status}: ${body.slice(0, 200)}`); + this.name = "ApiError"; + } +} + +export type HeaderProvider = () => Record; + +let onUnauthorized: () => void = () => {}; + +export function setOnUnauthorized(cb: () => void) { + onUnauthorized = cb; +} + +export async function apiGet( + path: string, + headerProvider: HeaderProvider, + signal?: AbortSignal, +): Promise { + const provided = headerProvider(); + // When we have no credentials we still set an explicit (empty) Authorization + // header to override the browser's basic-auth cache. Without this, once the + // user has dismissed an OS-level basic-auth dialog or typed the wrong creds + // earlier, the browser keeps replaying that cached header on every fetch and + // our React modal never gets a chance to take over. + const headers: Record = { + Accept: "application/json", + Authorization: "Basic ", + ...provided, + }; + const res = await fetch(path, { + method: "GET", + credentials: "omit", + headers, + signal, + }); + if (res.status === 401) { + onUnauthorized(); + throw new Unauthorized(); + } + if (!res.ok) throw new ApiError(res.status, await res.text()); + return (await res.json()) as T; +} + +export async function apiPost( + path: string, + headerProvider: HeaderProvider, +): Promise { + const provided = headerProvider(); + const headers: Record = { + Accept: "application/json", + Authorization: "Basic ", + ...provided, + }; + const res = await fetch(path, { + method: "POST", + credentials: "omit", + headers, + }); + if (res.status === 401) { + onUnauthorized(); + throw new Unauthorized(); + } + if (!res.ok) throw new ApiError(res.status, await res.text()); + return (await res.json()) as T; +} diff --git a/frontend/src/components/AreaChart.tsx b/frontend/src/components/AreaChart.tsx new file mode 100644 index 000000000..26fd0e029 --- /dev/null +++ b/frontend/src/components/AreaChart.tsx @@ -0,0 +1,167 @@ +import { useMemo, useState } from "react"; +import type { Options } from "uplot"; +import type uPlot from "uplot"; +import { Chart } from "./Chart"; +import type { ChartEvent } from "./Sparkline"; + +interface AreaChartProps { + /** First entry is the time axis; remaining are stacked numeric series. */ + data: [number[], ...number[][]]; + /** Per-series labels in the same order as data[1..]. */ + labels: string[]; + /** Per-series fill colors. */ + fills: string[]; + height?: number; + syncKey?: string; + events?: ChartEvent[]; +} + +/** + * Stacked area chart. uPlot does not provide native stacking — we precompute + * cumulative series before passing them in, so each band paints on top of + * the previous one. + */ +export function AreaChart({ + data, + labels, + fills, + height = 200, + syncKey, + events, +}: AreaChartProps) { + const [hover, setHover] = useState<{ ts: number; values: number[] } | null>(null); + const stacked = useMemo<[number[], ...number[][]]>(() => { + const xs = data[0]; + const cumulative: number[][] = []; + for (let i = 1; i < data.length; i++) { + const prev = cumulative[cumulative.length - 1] ?? new Array(xs.length).fill(0); + const next = data[i].map((v, idx) => (prev[idx] ?? 0) + v); + cumulative.push(next); + } + return [xs, ...cumulative] as [number[], ...number[][]]; + }, [data]); + + const options: Options = useMemo( + () => ({ + width: 1024, + height, + cursor: { + sync: syncKey ? { key: syncKey } : undefined, + points: { size: 4 }, + }, + legend: { show: false }, + scales: { y: { range: (_u, _min, max) => [0, max] } }, + axes: [ + { stroke: "rgb(138 147 164)", grid: { stroke: "rgb(35 42 54 / 0.4)" } }, + { stroke: "rgb(138 147 164)", grid: { stroke: "rgb(35 42 54 / 0.4)" } }, + ], + series: [ + {}, + ...labels.map((label, i) => ({ + label, + stroke: fills[i], + // Only the bottom series fills from the X-axis baseline. + // Higher series get coloured via `bands` (between series i + // and i-1) so a top layer whose values are zero everywhere + // does not repaint the layers below it with its own colour. + fill: i === 0 ? fills[i] : undefined, + width: 1, + })), + ], + bands: labels.slice(1).map((_, idx) => ({ + // [top, bottom]: paint the area between series idx+2 and idx+1 + // with the top series' colour. + series: [idx + 2, idx + 1] as [number, number], + fill: fills[idx + 1], + })), + hooks: { + setCursor: [ + (u: uPlot) => { + const idx = u.cursor.idx; + if (idx == null || idx < 0) { + setHover(null); + return; + } + const xs = u.data[0] as number[]; + const ts = xs[idx]; + if (ts == null) { + setHover(null); + return; + } + // Reverse the cumulative stacking so each label sees its own + // value rather than the running total. + const values: number[] = []; + let prev = 0; + for (let i = 1; i < u.data.length; i++) { + const v = (u.data[i] as number[])[idx]; + values.push(v - prev); + prev = v; + } + setHover({ ts, values }); + }, + ], + draw: events && events.length > 0 + ? [ + (u: uPlot) => { + const ctx = u.ctx; + ctx.save(); + ctx.strokeStyle = "rgb(255 176 0 / 0.55)"; + ctx.setLineDash([]); + ctx.lineWidth = 1; + for (const ev of events) { + const xPx = u.valToPos(ev.ts, "x", true); + if (!Number.isFinite(xPx)) continue; + if (xPx < u.bbox.left || xPx > u.bbox.left + u.bbox.width) continue; + ctx.beginPath(); + ctx.moveTo(xPx, u.bbox.top); + ctx.lineTo(xPx, u.bbox.top + u.bbox.height); + ctx.stroke(); + } + ctx.restore(); + }, + ] + : [], + }, + }), + [labels, fills, height, syncKey, events], + ); + + // Static legend above the canvas. uPlot's built-in legend renders inline + // values which we don't need; a fixed colour-swatch row tells operators + // which band is which without forcing them to remember the stack order. + const totals = useMemo(() => data.slice(1).map((s) => s[s.length - 1] ?? 0), [data]); + + return ( +
+
+ {labels.map((l, i) => ( + + + {l} + {Math.round(totals[i] ?? 0)} + + ))} +
+ +
+ {hover ? ( + + {new Date(hover.ts * 1000).toLocaleTimeString()} + {labels.map((l, i) => ( + + {l}{" "} + {Math.round(hover.values[i] ?? 0)} + + ))} + + ) : ( + hover for per-bucket values + )} +
+
+ ); +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 000000000..4869a1dbe --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState, type FormEvent, type ReactNode } from "react"; +import { apiGet, setOnUnauthorized, Unauthorized } from "../api"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import type { VersionDto } from "../types"; + +/** + * Probes /api/version on mount and on credential change. If the probe returns + * 401, renders a basic-auth modal that locks the rest of the app until the + * user submits credentials that satisfy /api/version. Once authorised (or + * if /api/version is anonymously accessible), renders children. + * + * Phase 5 only owns the version probe. Phase 6 pages drive the same auth + * flow indirectly: every apiGet call rethrows Unauthorized; the gate shows + * the modal until a successful retry. + */ +export function AuthGate({ children }: { children: ReactNode }) { + const { creds, setCreds, authHeader, remembered } = useAdminAuth(); + const [needsAuth, setNeedsAuth] = useState(false); + const [error, setError] = useState(null); + const [probing, setProbing] = useState(true); + + // Any 401 from the rest of the app (logs, prepared text, admin actions) + // should re-arm the sign-in modal — without this hook, the operator stays + // stuck on a red error after credentials expire mid-session. + useEffect(() => { + setOnUnauthorized(() => setNeedsAuth(true)); + return () => setOnUnauthorized(() => {}); + }, []); + + useEffect(() => { + let cancelled = false; + setProbing(true); + setError(null); + apiGet("/api/version", authHeader) + .then(() => { + if (cancelled) return; + setNeedsAuth(false); + }) + .catch((e: unknown) => { + if (cancelled) return; + if (e instanceof Unauthorized) { + setNeedsAuth(true); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setProbing(false); + }); + return () => { + cancelled = true; + }; + }, [authHeader]); + + if (probing) { + return
connecting…
; + } + if (error) { + return
{error}
; + } + if (needsAuth) { + return ; + } + return <>{children}; +} + +function AuthModal({ + currentCreds, + initialRemember, + onSubmit, +}: { + currentCreds: { username: string; password: string } | null; + initialRemember: boolean; + onSubmit: (next: { username: string; password: string } | null, remember?: boolean) => void; +}) { + const [username, setUsername] = useState(currentCreds?.username ?? ""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(initialRemember); + const submit = (e: FormEvent) => { + e.preventDefault(); + onSubmit({ username, password }, remember); + }; + return ( +
+
+

Sign in

+

+ {currentCreds + ? "That user/password did not work. Check [general].admin_username and [general].admin_password in pg_doorman.toml." + : "Sign in with the admin_username / admin_password from [general] in pg_doorman.toml."} +

+ + setUsername(e.target.value)} + className="mb-3 w-full rounded border border-border-strong bg-surface-2 px-2 py-1.5 text-sm text-text" + /> + + setPassword(e.target.value)} + className="mb-3 w-full rounded border border-border-strong bg-surface-2 px-2 py-1.5 text-sm text-text" + /> + + +
+
+ ); +} diff --git a/frontend/src/components/Chart.tsx b/frontend/src/components/Chart.tsx new file mode 100644 index 000000000..6ec4f6a9f --- /dev/null +++ b/frontend/src/components/Chart.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; +import uPlot, { type AlignedData, type Options } from "uplot"; +import "uplot/dist/uPlot.min.css"; + +interface ChartProps { + data: AlignedData; + options: Options; +} + +/** + * Thin wrapper around uPlot. Re-creates the chart when options change, + * setData when data changes. The cross-hair sync key, if any, lives on + * options.cursor.sync — caller controls the group name (we use "overview" + * for all phase 6a charts so hover tracks across the strip). + */ +export function Chart({ data, options }: ChartProps) { + const containerRef = useRef(null); + const plotRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + plotRef.current = new uPlot(options, data, containerRef.current); + return () => { + plotRef.current?.destroy(); + plotRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options]); + + useEffect(() => { + plotRef.current?.setData(data); + }, [data]); + + return
; +} diff --git a/frontend/src/components/Collapsible.tsx b/frontend/src/components/Collapsible.tsx new file mode 100644 index 000000000..12da011fc --- /dev/null +++ b/frontend/src/components/Collapsible.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState, type ReactNode } from "react"; + +interface CollapsibleProps { + /** Stable key — open state persisted under `pgdoorman.collapse.${id}`. */ + id: string; + title: string; + defaultOpen?: boolean; + children: ReactNode; +} + +/** Section header that toggles its body. Open state survives a tab refresh. */ +export function Collapsible({ id, title, defaultOpen = false, children }: CollapsibleProps) { + const storageKey = `pgdoorman.collapse.${id}`; + const [open, setOpen] = useState(() => { + try { + const raw = localStorage.getItem(storageKey); + if (raw === "1") return true; + if (raw === "0") return false; + } catch { + /* private mode — fall through to default. */ + } + return defaultOpen; + }); + + useEffect(() => { + try { + localStorage.setItem(storageKey, open ? "1" : "0"); + } catch { + /* private mode — no-op. */ + } + }, [open, storageKey]); + + return ( +
+ + {open && children} +
+ ); +} diff --git a/frontend/src/components/Drawer.tsx b/frontend/src/components/Drawer.tsx new file mode 100644 index 000000000..71873a093 --- /dev/null +++ b/frontend/src/components/Drawer.tsx @@ -0,0 +1,50 @@ +import { useEffect, type ReactNode } from "react"; + +interface DrawerProps { + open: boolean; + title: string; + onClose: () => void; + children: ReactNode; +} + +/** + * Right-side overlay drawer. Click outside or press Escape to close. Used + * for per-row detail panels (pool drawer in /pools, etc.) that would push + * the layout around if rendered inline. + */ +export function Drawer({ open, title, onClose, children }: DrawerProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + return ( +
+ + +
{children}
+ +
+ ); +} diff --git a/frontend/src/components/DualAxisChart.tsx b/frontend/src/components/DualAxisChart.tsx new file mode 100644 index 000000000..ed0f0aabd --- /dev/null +++ b/frontend/src/components/DualAxisChart.tsx @@ -0,0 +1,184 @@ +import { useMemo, useState } from "react"; +import type { Options } from "uplot"; +import type uPlot from "uplot"; +import { Chart } from "./Chart"; +import type { ChartEvent } from "./Sparkline"; + +interface DualAxisChartProps { + /** [time, leftSeries, rightSeries] */ + data: [number[], number[], number[]]; + leftLabel: string; + rightLabel: string; + leftStroke: string; + rightStroke: string; + rightLogScale?: boolean; + rightWarn?: number; + rightCrit?: number; + height?: number; + syncKey?: string; + events?: ChartEvent[]; +} + +export function DualAxisChart({ + data, + leftLabel, + rightLabel, + leftStroke, + rightStroke, + rightLogScale, + rightWarn, + rightCrit, + height = 200, + syncKey, + events, +}: DualAxisChartProps) { + const [hover, setHover] = useState<{ ts: number; left: number; right: number } | null>(null); + const options: Options = useMemo( + () => ({ + width: 1024, + height, + cursor: { + sync: syncKey ? { key: syncKey } : undefined, + points: { size: 4 }, + }, + legend: { show: false }, + scales: { + y: { auto: true }, + y2: rightLogScale ? { distr: 3 } : { auto: true }, + }, + axes: [ + { stroke: "rgb(138 147 164)", grid: { stroke: "rgb(35 42 54 / 0.4)" } }, + { + stroke: leftStroke, + grid: { stroke: "rgb(35 42 54 / 0.4)" }, + scale: "y", + label: leftLabel, + }, + { stroke: rightStroke, scale: "y2", side: 1, grid: { show: false }, label: rightLabel }, + ], + series: [ + {}, + { label: leftLabel, stroke: leftStroke, width: 1.5, scale: "y" }, + { label: rightLabel, stroke: rightStroke, width: 1.5, scale: "y2" }, + ], + hooks: { + setCursor: [ + (u: uPlot) => { + const idx = u.cursor.idx; + if (idx == null || idx < 0) { + setHover(null); + return; + } + const xs = u.data[0] as number[]; + const ts = xs[idx]; + const left = (u.data[1] as number[])[idx]; + const right = (u.data[2] as number[])[idx]; + if (ts == null) { + setHover(null); + return; + } + setHover({ ts, left, right }); + }, + ], + draw: [ + (u: uPlot) => { + const ctx = u.ctx; + const drawLine = (yVal: number, color: string) => { + const yPx = u.valToPos(yVal, "y2", true); + if (!Number.isFinite(yPx)) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.setLineDash([3, 3]); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(u.bbox.left, yPx); + ctx.lineTo(u.bbox.left + u.bbox.width, yPx); + ctx.stroke(); + ctx.restore(); + }; + if (rightWarn !== undefined) drawLine(rightWarn, "rgb(245 165 36 / 0.6)"); + if (rightCrit !== undefined) drawLine(rightCrit, "rgb(229 72 77 / 0.6)"); + if (events && events.length > 0) { + ctx.save(); + ctx.strokeStyle = "rgb(255 176 0 / 0.55)"; + ctx.setLineDash([]); + ctx.lineWidth = 1; + for (const ev of events) { + const xPx = u.valToPos(ev.ts, "x", true); + if (!Number.isFinite(xPx)) continue; + if (xPx < u.bbox.left || xPx > u.bbox.left + u.bbox.width) continue; + ctx.beginPath(); + ctx.moveTo(xPx, u.bbox.top); + ctx.lineTo(xPx, u.bbox.top + u.bbox.height); + ctx.stroke(); + } + ctx.restore(); + } + }, + ], + }, + }), + [ + leftLabel, + rightLabel, + leftStroke, + rightStroke, + rightLogScale, + rightWarn, + rightCrit, + height, + syncKey, + events, + ], + ); + + const leftLatest = data[1][data[1].length - 1] ?? 0; + const rightLatest = data[2][data[2].length - 1] ?? 0; + + return ( +
+
+ + + {leftLabel} (left) + {Math.round(leftLatest)} + + + + {rightLabel} (right) + {Math.round(rightLatest)} + + {rightWarn !== undefined && ( + warn ≥ {rightWarn} + )} + {rightCrit !== undefined && ( + crit ≥ {rightCrit} + )} +
+ +
+ {hover ? ( + + {new Date(hover.ts * 1000).toLocaleTimeString()} + + {leftLabel} {Math.round(hover.left)} + + + {rightLabel} {Math.round(hover.right)} + + + ) : ( + hover for value at cursor + )} +
+
+ ); +} diff --git a/frontend/src/components/HealthPill.tsx b/frontend/src/components/HealthPill.tsx new file mode 100644 index 000000000..60028e55b --- /dev/null +++ b/frontend/src/components/HealthPill.tsx @@ -0,0 +1,117 @@ +import type { HealthState } from "../lib/thresholds"; +import type { OverviewDto, PoolsDto } from "../types"; + +const PILL_STYLES: Record = { + ok: "bg-success/20 text-success", + degraded: "bg-warning/20 text-warning", + critical: "bg-danger/20 text-danger", +}; + +const PILL_LABELS: Record = { + ok: "OK", + degraded: "DEGRADED", + critical: "CRITICAL", +}; + +interface HealthPillProps { + health: HealthState; + lastUpdated: number | null; + overview: OverviewDto | null; + pools: PoolsDto | null; + /** errors/s derived on the page side. */ + errorsPerSecond: number | null; +} + +/** + * Health bar: status pill + a compact strip of operator-relevant chips + * (pools, paused, errors/s, prepared hit rate, active clients, + * busy/total servers, waiting). Mirrors spec §15.1. Renders inline within + * a max-width container — does not stretch across the viewport. + */ +export function HealthPill({ + health, + lastUpdated, + overview, + pools, + errorsPerSecond, +}: HealthPillProps) { + const ageSeconds = + lastUpdated === null ? null : Math.max(0, Math.round((Date.now() - lastUpdated) / 1000)); + const hitRate = + overview && overview.prepared_hits_total + overview.prepared_misses_total > 0 + ? overview.prepared_hits_total / + (overview.prepared_hits_total + overview.prepared_misses_total) + : null; + const totalServers = overview ? overview.active_servers + overview.idle_servers : 0; + return ( +
+
+ + ● {PILL_LABELS[health.state]} + + {pools && } + {overview && overview.pools_paused > 0 && ( + + )} + {errorsPerSecond !== null && ( + 10 ? "crit" : errorsPerSecond > 1 ? "warn" : undefined + } + /> + )} + {hitRate !== null && ( + + )} + {overview && ( + + )} + {overview && ( + + )} + {overview && ( + 0 ? "warn" : undefined} + /> + )} + {health.reason && ( + — {health.reason} + )} + + {ageSeconds === null ? "no data" : `updated ${ageSeconds}s ago`} + +
+
+ ); +} + +function Chip({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "warn" | "crit"; +}) { + const valueColor = tone === "crit" ? "text-danger" : tone === "warn" ? "text-warning" : "text-text"; + return ( + + {label} + {value} + + ); +} diff --git a/frontend/src/components/Heatmap.tsx b/frontend/src/components/Heatmap.tsx new file mode 100644 index 000000000..26713b1b6 --- /dev/null +++ b/frontend/src/components/Heatmap.tsx @@ -0,0 +1,127 @@ +// Pool saturation heatmap — one row per pool, 60 cells × 1.5 s window. The +// previous version relied on the browser's native title="" tooltip; that +// has a ~1 s delay and cannot show the cell timestamp. The custom overlay +// renders instantly, says when the sample was taken, and turns the row +// label into a link to the pool drilldown. + +import { useState, type CSSProperties, type MouseEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +interface HeatmapRowProps { + label: string; + /** Latest 60 saturation values, oldest-first. Missing tail is rendered empty. */ + cells: (number | null)[]; + capacity: number; + pollIntervalMs: number; +} + +const CELL_WIDTH = 12; +const CELL_HEIGHT = 14; +const CELL_GAP = 1; + +function colorFor(saturation: number): string { + if (saturation >= 0.9) return "rgb(229 72 77)"; // danger + if (saturation >= 0.7) return "rgb(245 165 36)"; // warning + return "rgb(45 194 107)"; // success +} + +function HeatmapRow({ label, cells, capacity, pollIntervalMs }: HeatmapRowProps) { + const navigate = useNavigate(); + const [hover, setHover] = useState<{ idx: number; x: number; y: number } | null>(null); + + const onCellEnter = (i: number, e: MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + setHover({ idx: i, x: rect.left + rect.width / 2, y: rect.top }); + }; + + return ( +
+ + {capacity} max +
+ {cells.map((cell, i) => ( +
onCellEnter(i, e)} + onMouseLeave={() => setHover(null)} + /> + ))} + {hover && ( +
+ {(() => { + const cell = cells[hover.idx]; + const ageSec = ((cells.length - 1 - hover.idx) * pollIntervalMs) / 1000; + const ageLabel = ageSec === 0 ? "now" : `${Math.round(ageSec)} s ago`; + if (cell === null) { + return no sample · {ageLabel}; + } + return ( + + {(cell * 100).toFixed(0)}%{" "} + · {ageLabel} + + ); + })()} +
+ )} +
+
+ ); +} + +interface HeatmapProps { + /** Each row is one pool with its rolling cells. */ + rows: { label: string; cells: (number | null)[]; capacity: number }[]; + /** Truncate to the first N rows; render a footer when there are more. */ + maxRows?: number; + /** Poll interval used to label "Ns ago" on the hover overlay. */ + pollIntervalMs?: number; +} + +export function Heatmap({ rows, maxRows = 30, pollIntervalMs = 1500 }: HeatmapProps) { + const visible = rows.slice(0, maxRows); + const truncated = rows.length - visible.length; + return ( +
+
+ Pool + Capacity + + Last 60 samples · saturation + + {" "} + < 70% + {" "} + 70–89% + {" "} + ≥ 90% + + +
+ {visible.map((r) => ( + + ))} + {truncated > 0 && ( +
+{truncated} more pools (truncated)
+ )} +
+ ); +} diff --git a/frontend/src/components/HelpTip.tsx b/frontend/src/components/HelpTip.tsx new file mode 100644 index 000000000..6a7f0861b --- /dev/null +++ b/frontend/src/components/HelpTip.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +interface HelpTipProps { + title: string; + children: ReactNode; +} + +/** + * Small circular "i" trigger that opens a popover with longer-form help next + * to a section title. Click to toggle, click outside or press Escape to + * close. Hover triggers focus too so a quick mouse-over previews without a + * commit. Anchored to the trigger; the popover renders to the right by + * default, drifting left on small viewports. + */ +export function HelpTip({ title, children }: HelpTipProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + if (!ref.current) return; + if (!ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + {open && ( +
+
+
+ {title} +
+
{children}
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/InfoLabel.tsx b/frontend/src/components/InfoLabel.tsx new file mode 100644 index 000000000..0c921ff32 --- /dev/null +++ b/frontend/src/components/InfoLabel.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; + +/** + * Operator-facing tooltip label. Wraps text with a cursor-help affordance + * and a styled popover so an operator new to pg_doorman knows the value + * has an explanation behind it. Replaces native `title=` attributes which + * (a) render in the browser's chrome with no relation to the UI palette + * and (b) give no cursor cue that hover means anything. + * + * The popover is CSS-only: a sibling span that becomes visible on group- + * hover. No portal, no JS positioning — fits everywhere KV/header rows + * already render and never escapes the tile boundary in unfortunate + * directions because we let it grow upward by default. + */ +export function InfoLabel({ + tip, + children, + className = "", +}: { + tip?: string; + children: ReactNode; + className?: string; +}) { + if (!tip) return {children}; + return ( + + {children} + + + {tip} + + + ); +} diff --git a/frontend/src/components/MemoryPanel.tsx b/frontend/src/components/MemoryPanel.tsx new file mode 100644 index 000000000..3e5fdb1ca --- /dev/null +++ b/frontend/src/components/MemoryPanel.tsx @@ -0,0 +1,381 @@ +// Stacked-bar memory breakdown for the RSS tile drill-down. Operators +// looking at a climbing RSS need to see *where* it climbs — internal +// caches, jemalloc fragmentation, kernel/lib resident, stacks. The bar +// adds up to RSS; cgroup limits are displayed alongside as a "headroom" +// indicator. + +import { useEffect, useState } from "react"; +import { apiGet } from "../api"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { tip } from "../lib/tooltips"; +import type { MemoryBreakdownDto } from "../types"; +import { InfoLabel } from "./InfoLabel"; + +const POLL_MS = 5000; + +export function MemoryPanel({ open, onClose }: { open: boolean; onClose: () => void }) { + const { authHeader } = useAdminAuth(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + const ctrl = new AbortController(); + const tick = () => { + apiGet("/api/process/memory", authHeader, ctrl.signal) + .then((d) => { + if (cancelled) return; + setData(d); + setError(null); + }) + .catch((e: unknown) => { + if (cancelled) return; + if (e instanceof DOMException && e.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }); + }; + tick(); + const id = window.setInterval(tick, POLL_MS); + return () => { + cancelled = true; + ctrl.abort(); + window.clearInterval(id); + }; + }, [open, authHeader]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +
+
+
panel
+

Process memory breakdown

+
+ +
+
+ {error &&

memory fetch failed: {error}

} + {!data && !error &&

loading…

} + {data && } +
+
+
+ ); +} + +function Body({ data }: { data: MemoryBreakdownDto }) { + const fmt = (n: number) => { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`; + }; + const fmtOpt = (n: number | null) => (n === null ? "—" : fmt(n)); + + // Bar widths scale to RSS (the header value), not to the sum of + // categories. jemalloc.allocated tracks "live allocations" by the + // allocator's own bookkeeping; the kernel may have reclaimed pages + // (MADV_DONTNEED, swap) while jemalloc still considers the objects + // live, so categories can sum to more than the kernel-reported RSS. + // Without scaling to RSS the bar visually says "100 % of categories" + // while the header says "RSS = X" — the operator reads two numbers + // that disagree. Render against `rss_bytes` and clip late segments + // to the remaining bar width; the table below keeps every category's + // honest byte count. + const attributedTotal = data.categories.reduce((s, c) => s + c.bytes, 0); + const denom = data.rss_bytes > 0 ? data.rss_bytes : attributedTotal; + let usedPct = 0; + const segments = data.categories.map((c) => { + const wanted = denom > 0 ? (c.bytes / denom) * 100 : 0; + const remaining = Math.max(0, 100 - usedPct); + const pct = Math.min(wanted, remaining); + const midPct = usedPct + pct / 2; + usedPct += pct; + // Flip the popover's anchor side once the segment sits in the right + // half of the bar — without this the rightmost categories ("Stacks + // + page tables", "Other") render their tooltip past the viewport's + // right edge. Left-anchored popovers on left segments extend right + // and stay on screen; right-anchored popovers on right segments + // extend left and stay on screen. + const anchorRight = midPct > 60; + return { ...c, pct, anchorRight }; + }); + const overAttributed = attributedTotal > data.rss_bytes && data.rss_bytes > 0; + const palette: Record = { + app_caches: "rgb(255 176 0)", + jemalloc_live: "rgb(0 212 255)", + jemalloc_fragmentation: "rgb(154 148 133)", + code_and_libs: "rgb(57 211 83)", + stacks_and_pagetables: "rgb(177 140 245)", + swap: "rgb(255 77 77)", + other: "rgb(91 140 255)", + }; + + return ( +
+
+
+
RSS
+
+ {fmt(data.rss_bytes)} +
+
+ {data.cgroup && ( +
+
+ cgroup v{data.cgroup.version} +
+
+ {fmt(data.cgroup.current_bytes)} /{" "} + {data.cgroup.max_bytes !== null ? fmt(data.cgroup.max_bytes) : "uncapped"} +
+ {data.cgroup.peak_bytes !== null && ( +
peak {fmt(data.cgroup.peak_bytes)}
+ )} +
+ )} +
+ +
+
+ breakdown +
+ {/* + Each segment carries its own styled tooltip. Drop overflow-hidden + from the bar so the popover can escape upward; the segments sum to + 100% width and the bar's border still frames them cleanly. + */} +
+ {segments.map((c) => ( +
+
+ {/* + Anchor the popover to the segment's left or right edge + depending on which half of the bar the segment lives in. + A centered popover (`left-1/2 -translate-x-1/2`) clips + on the left for the bar's leftmost segments; a left- + anchored one clips on the right for the rightmost. The + inline `left` / `right` attributes on the parent flip + between `0` and `auto`, keeping the popover on-screen + regardless of where the operator hovers. + */} +
+
{c.label}
+
+ {fmt(c.bytes)} · {denom > 0 ? ((c.bytes / denom) * 100).toFixed(1) : "0"}% of RSS +
+
{c.explain}
+
+
+ ))} +
+ {overAttributed && ( +

+ Attributed total {fmt(attributedTotal)} exceeds kernel-reported RSS {fmt(data.rss_bytes)} — + jemalloc.allocated counts live objects whose pages the kernel may have reclaimed. + Bar widths clip to RSS; the table below keeps each category’s full byte count. +

+ )} + + + {data.categories.map((c) => ( + + + + + + ))} + +
+ + + {c.label} + + {fmt(c.bytes)}{c.explain}
+
+ + {data.jemalloc && ( +
+
+ jemalloc +
+ + + + + + + + + + +
+
+ )} + +
+
+ /proc/self/status +
+ + + + + + + + + + + + + + +
+
+ +
+
+ pg_doorman caches +
+ + + + + +
+
+
+ ); +} + +function Row({ k, v, tip }: { k: string; v: string; tip?: string }) { + return ( + + + {tip ? {k} : k} + + {v} + + ); +} diff --git a/frontend/src/components/MiniSparkline.tsx b/frontend/src/components/MiniSparkline.tsx new file mode 100644 index 000000000..d5517134c --- /dev/null +++ b/frontend/src/components/MiniSparkline.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; + +interface MiniSparklineProps { + values: number[]; + width?: number; + height?: number; + /** Resolved hex / rgb stroke color (no Tailwind classes — the canvas needs a literal). */ + stroke: string; + /** Optional fixed range; otherwise auto-fit. */ + min?: number; + max?: number; +} + +/** + * Tiny canvas sparkline meant to be inlined into a table cell. Avoids uPlot + * because each row would otherwise spin up a dedicated chart instance — fine + * for the four golden-signals cards but expensive across dozens of rows. + */ +export function MiniSparkline({ + values, + width = 80, + height = 16, + stroke, + min, + max, +}: MiniSparklineProps) { + const ref = useRef(null); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + if (values.length === 0) return; + const lo = min ?? Math.min(...values); + const hi = max ?? Math.max(...values); + const span = hi - lo || 1; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + values.forEach((v, i) => { + const x = values.length === 1 ? width / 2 : (i / (values.length - 1)) * (width - 1); + const y = height - 1 - ((v - lo) / span) * (height - 2); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + }, [values, width, height, stroke, min, max]); + + return ; +} diff --git a/frontend/src/components/PageHero.tsx b/frontend/src/components/PageHero.tsx new file mode 100644 index 000000000..136246e9c --- /dev/null +++ b/frontend/src/components/PageHero.tsx @@ -0,0 +1,22 @@ +/** + * Page-level header. A clean title above a one-paragraph description that + * explains what the page is and how often the data refreshes. Reused at the + * top of every routed page so the visual hierarchy stays consistent without + * resorting to gimmicky chrome. + */ +export function PageHero({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+

{title}

+

+ {description} +

+
+ ); +} diff --git a/frontend/src/components/PanelView.tsx b/frontend/src/components/PanelView.tsx new file mode 100644 index 000000000..714539a68 --- /dev/null +++ b/frontend/src/components/PanelView.tsx @@ -0,0 +1,393 @@ +// Fullscreen drill-down modal for any chart in the dashboard. Opens via +// `?panel=` on the route (so deep-linking and browser back work); +// closes on Escape, the backdrop, or the explicit ✕ button. Operators +// asked for "Grafana view" — large canvas, percentile table over the +// visible window, cross-hair readout, event annotations from /api/events. +// +// The component takes the same `series` shape Sparkline / AreaChart use +// (`[xs, ys, ...]`) so callers do not have to reshape data; PanelView is +// content-agnostic and just picks the right mode per `kind`. + +import { useEffect, useMemo, useRef, useState } from "react"; +import uPlot, { type Options } from "uplot"; +import "uplot/dist/uPlot.min.css"; +import type { ChartEvent } from "./Sparkline"; +import { summaryStats } from "../lib/quantile"; + +export type PanelKind = "line" | "stackedArea" | "dualAxis"; + +export interface PanelViewProps { + open: boolean; + title: string; + kind: PanelKind; + /// Series in uPlot shape: first entry is the time axis (seconds), rest + /// are numeric series (one per legend label). + data: [number[], ...number[][]]; + labels: string[]; + fills?: string[]; + /// Right-axis series indices (only for `kind = "dualAxis"`). + rightSeries?: number[]; + rightLogScale?: boolean; + warn?: number; + crit?: number; + units?: string; + events?: ChartEvent[]; + onClose: () => void; +} + +const TIME_RANGES: { label: string; ms: number }[] = [ + { label: "1m", ms: 60_000 }, + { label: "5m", ms: 5 * 60_000 }, + { label: "15m", ms: 15 * 60_000 }, + { label: "1h", ms: 60 * 60_000 }, + { label: "all", ms: 0 }, +]; + +export function PanelView({ + open, + title, + kind, + data, + labels, + fills, + rightSeries, + rightLogScale, + warn, + crit, + units, + events, + onClose, +}: PanelViewProps) { + // Esc closes the modal regardless of focus location. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const containerRef = useRef(null); + const wrapRef = useRef(null); + const plotRef = useRef(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [hoverIdx, setHoverIdx] = useState(null); + const [rangeMs, setRangeMs] = useState(0); // 0 = all + + // Window the data to the selected range. xs in seconds — convert ms→s. + const windowed = useMemo<[number[], ...number[][]]>(() => { + if (rangeMs === 0) return data; + const xs = data[0]; + if (xs.length === 0) return data; + const cutoff = xs[xs.length - 1] - rangeMs / 1000; + let startIdx = xs.findIndex((t) => t >= cutoff); + if (startIdx < 0) startIdx = 0; + const slicedXs = xs.slice(startIdx); + const sliced = data.slice(1).map((s) => s.slice(startIdx)); + return [slicedXs, ...sliced] as [number[], ...number[][]]; + }, [data, rangeMs]); + + // Stack for stackedArea: cumulative sum so each series paints on top of + // the previous. Same trick as AreaChart.tsx. + const series = useMemo<[number[], ...number[][]]>(() => { + if (kind !== "stackedArea") return windowed; + const xs = windowed[0]; + const cumulative: number[][] = []; + for (let i = 1; i < windowed.length; i++) { + const prev = cumulative[cumulative.length - 1] ?? new Array(xs.length).fill(0); + const next = windowed[i].map((v, idx) => (prev[idx] ?? 0) + v); + cumulative.push(next); + } + return [xs, ...cumulative] as [number[], ...number[][]]; + }, [windowed, kind]); + + // Resize observer — sized against the modal container. + useEffect(() => { + if (!open || !wrapRef.current) return; + const ro = new ResizeObserver((entries) => { + const r = entries[0].contentRect; + setWidth(Math.floor(r.width)); + setHeight(Math.max(220, Math.floor(r.height) - 280)); + }); + ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, [open]); + + const options: Options = useMemo(() => { + const isDual = kind === "dualAxis"; + const isStack = kind === "stackedArea"; + const opt: Options = { + width: width || 600, + height: height || 300, + cursor: { points: { size: 5 } }, + legend: { show: false }, + scales: isDual + ? { + y: { auto: true }, + y2: rightLogScale ? { distr: 3 } : { auto: true }, + } + : { y: { auto: true } }, + axes: [ + { stroke: "rgb(154 148 133)", grid: { stroke: "rgb(31 31 31)" } }, + { + stroke: "rgb(154 148 133)", + grid: { stroke: "rgb(31 31 31)" }, + scale: "y", + }, + ...(isDual + ? [ + { + stroke: "rgb(154 148 133)", + grid: { show: false }, + scale: "y2", + side: 1, + }, + ] + : []), + ], + series: [ + {}, + ...labels.map((label, i) => { + const stroke = fills?.[i] ?? "rgb(255 176 0)"; + const onRight = rightSeries?.includes(i + 1); + // Stacked area: only the bottom series fills from the X-axis + // baseline. Higher series get their colour via `bands` below + // (band between series i and i-1) so a top series whose + // values collapse to zero never repaints the layers under it. + const fill = isStack && i === 0 ? stroke : undefined; + return { + label, + stroke, + fill, + width: 1.5, + scale: onRight ? "y2" : "y", + }; + }), + ], + bands: isStack + ? labels.slice(1).map((_, idx) => ({ + // [top, bottom] — uPlot fills the area between the two + // cumulative series with the top series' colour. + series: [idx + 2, idx + 1] as [number, number], + fill: fills?.[idx + 1] ?? "rgb(255 176 0)", + })) + : undefined, + hooks: { + setCursor: [ + (u: uPlot) => { + const idx = u.cursor.idx; + if (idx == null || idx < 0) { + setHoverIdx(null); + return; + } + setHoverIdx(idx); + }, + ], + draw: [ + (u: uPlot) => { + const ctx = u.ctx; + // Threshold lines (only for line/single-axis kinds where warn/crit + // map to the y axis cleanly). + if (warn !== undefined && kind === "line") { + const yPx = u.valToPos(warn, "y", true); + if (Number.isFinite(yPx)) { + ctx.save(); + ctx.strokeStyle = "rgb(255 176 0 / 0.6)"; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(u.bbox.left, yPx); + ctx.lineTo(u.bbox.left + u.bbox.width, yPx); + ctx.stroke(); + ctx.restore(); + } + } + if (crit !== undefined && kind === "line") { + const yPx = u.valToPos(crit, "y", true); + if (Number.isFinite(yPx)) { + ctx.save(); + ctx.strokeStyle = "rgb(255 77 77 / 0.6)"; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(u.bbox.left, yPx); + ctx.lineTo(u.bbox.left + u.bbox.width, yPx); + ctx.stroke(); + ctx.restore(); + } + } + // Event vertical lines. + if (events && events.length > 0) { + ctx.save(); + ctx.strokeStyle = "rgb(255 176 0 / 0.55)"; + ctx.setLineDash([]); + ctx.lineWidth = 1; + for (const ev of events) { + const xPx = u.valToPos(ev.ts, "x", true); + if (!Number.isFinite(xPx)) continue; + if (xPx < u.bbox.left || xPx > u.bbox.left + u.bbox.width) continue; + ctx.beginPath(); + ctx.moveTo(xPx, u.bbox.top); + ctx.lineTo(xPx, u.bbox.top + u.bbox.height); + ctx.stroke(); + } + ctx.restore(); + } + }, + ], + }, + }; + return opt; + }, [width, height, kind, labels, fills, rightSeries, rightLogScale, warn, crit, events]); + + // (Re)create the plot on width/height/options change. + useEffect(() => { + if (!open) return; + if (!containerRef.current) return; + if (width === 0 || height === 0) return; + plotRef.current = new uPlot(options, series, containerRef.current); + return () => { + plotRef.current?.destroy(); + plotRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, options, width, height]); + + useEffect(() => { + plotRef.current?.setData(series); + }, [series]); + + // Compute summary stats per series over the visible window. + const summaries = useMemo(() => { + return labels.map((_, i) => summaryStats(windowed[i + 1] ?? [])); + }, [labels, windowed]); + + if (!open) return null; + + const hoverTs = hoverIdx !== null ? series[0][hoverIdx] : null; + const hoverValues = + hoverIdx !== null + ? labels.map((_, i) => { + if (kind === "stackedArea") { + const cur = series[i + 1][hoverIdx]; + const prev = i === 0 ? 0 : series[i][hoverIdx]; + return cur - prev; + } + return series[i + 1][hoverIdx]; + }) + : null; + + const fmt = (v: number | null) => { + if (v === null) return "—"; + if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (Math.abs(v) >= 10_000) return `${(v / 1000).toFixed(0)}k`; + if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`; + if (Math.abs(v) >= 10) return v.toFixed(0); + return v.toFixed(2); + }; + + return ( +
+
e.stopPropagation()} + > +
+
+
panel
+

{title}

+
+
+
+ {TIME_RANGES.map((r) => ( + + ))} +
+ +
+
+ +
+ +
+ {hoverTs !== null && hoverValues ? ( +
+ {new Date(hoverTs * 1000).toLocaleTimeString()} + {labels.map((l, i) => ( + + {l} {fmt(hoverValues[i])} + {units ? ` ${units}` : ""} + + ))} +
+ ) : ( + hover for value at cursor + )} +
+ +
+
+ summary over the visible window +
+ + + + + + + + + + + + + + + {labels.map((l, i) => { + const s = summaries[i]; + return ( + + + + + + + + + + + ); + })} + +
seriescountminavgp50p95p99max
+ ● {l} + {s.count}{fmt(s.min)}{fmt(s.avg)}{fmt(s.p50)}{fmt(s.p95)}{fmt(s.p99)}{fmt(s.max)}
+
+
+
+ ); +} diff --git a/frontend/src/components/SectionHeader.tsx b/frontend/src/components/SectionHeader.tsx new file mode 100644 index 000000000..ef4fed0a3 --- /dev/null +++ b/frontend/src/components/SectionHeader.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { HelpTip } from "./HelpTip"; + +interface SectionHeaderProps { + title: string; + /** What is rendered in the section. Shown as the first line in the popover. */ + what?: string; + /** How / where the data comes from and how often it refreshes. */ + how?: string; + /** Threshold or healthy-range note. */ + normal?: string; + /** Optional right-aligned slot (status pill, last-updated chip, button). */ + right?: ReactNode; + /** When set, the title becomes a clickable button (used for chart cards + * that open a full-screen PanelView on click). */ + onTitleClick?: () => void; +} + +/** + * Section header used above every chart, table, and panel. Shows just the + * title; full guidance lives in a popover behind the "i" icon so the layout + * stays clean. The popover answers three questions: what is on screen, how + * the numbers update, and what counts as healthy. + */ +export function SectionHeader({ + title, + what, + how, + normal, + right, + onTitleClick, +}: SectionHeaderProps) { + const hasHelp = Boolean(what || how || normal); + return ( +
+ {onTitleClick ? ( + + ) : ( +

{title}

+ )} + {hasHelp && ( + + {what && ( +

+ What. {what} +

+ )} + {how && ( +

+ How. {how} +

+ )} + {normal && ( +

+ Healthy. {normal} +

+ )} +
+ )} + + {right} +
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 000000000..7428d3718 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { NavLink } from "react-router-dom"; +import { apiGet } from "../api"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import type { VersionDto } from "../types"; + +const NAV: { to: string; label: string }[] = [ + { to: "/overview", label: "Overview" }, + { to: "/pools", label: "Pools" }, + { to: "/clients", label: "Clients" }, + { to: "/apps", label: "Apps" }, + { to: "/caches", label: "Caches" }, + { to: "/logs", label: "Logs" }, + { to: "/config", label: "Config" }, + { to: "/wall", label: "War room" }, +]; + +export function Sidebar() { + const { authHeader, creds, setCreds } = useAdminAuth(); + const [version, setVersion] = useState(null); + useEffect(() => { + let cancelled = false; + apiGet("/api/version", authHeader) + .then((d) => { + if (!cancelled) setVersion(d.version); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [authHeader]); + return ( + + ); +} diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx new file mode 100644 index 000000000..c2d27fd7b --- /dev/null +++ b/frontend/src/components/Sparkline.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import uPlot, { type Options } from "uplot"; +import "uplot/dist/uPlot.min.css"; +import { InfoLabel } from "./InfoLabel"; + +export interface ChartEvent { + /// Unix timestamp in seconds (uPlot convention). Frontend converts ms → s. + ts: number; + /// Short label drawn near the line (e.g. "RELOAD"). Optional. + label?: string; +} + +interface SparklineProps { + label: string; + valueText: string; + series: [number[], number[]]; + warn?: number; + crit?: number; + logY?: boolean; + syncKey?: string; + events?: ChartEvent[]; + /// Optional one-sentence explanation, rendered as a hover tooltip on the + /// title via InfoLabel. Operators new to pg_doorman want to know what + /// the sparkline measures and what healthy looks like without leaving + /// for the docs. + tip?: string; +} + +const HEIGHT_PX = 64; +const STROKE = "rgb(34 184 207)"; +const WARN_STROKE = "rgb(245 165 36 / 0.55)"; +const CRIT_STROKE = "rgb(229 72 77 / 0.55)"; + +function formatHoverValue(v: number): string { + if (!Number.isFinite(v)) return "—"; + if (Math.abs(v) >= 1000) return v.toFixed(0); + if (Math.abs(v) >= 10) return v.toFixed(1); + return v.toFixed(2); +} + +/** + * uPlot-backed sparkline that fills its container width via ResizeObserver. + * The label and value sit above the chart; threshold lines are painted in + * the draw hook so they survive setData without a chart rebuild. + */ +export function Sparkline({ + label, + valueText, + series, + warn, + crit, + logY, + syncKey, + events, + tip, +}: SparklineProps) { + const wrapRef = useRef(null); + const plotRef = useRef(null); + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + // Cursor readout — populated by uPlot's setCursor hook on every mouse + // move. Null means the cursor left the canvas. + const [hover, setHover] = useState<{ ts: number; value: number } | null>(null); + + useEffect(() => { + if (!wrapRef.current) return; + const ro = new ResizeObserver((entries) => { + const w = Math.max(40, Math.floor(entries[0].contentRect.width)); + setWidth(w); + }); + ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const options: Options = useMemo( + () => ({ + width: width || 200, + height: HEIGHT_PX, + cursor: { + sync: syncKey ? { key: syncKey } : undefined, + points: { size: 5 }, + }, + legend: { show: false }, + scales: { y: logY ? { distr: 3 } : { auto: true } }, + axes: [{ show: false }, { show: false }], + series: [{}, { stroke: STROKE, width: 1.5 }], + hooks: { + setCursor: [ + (u: uPlot) => { + const idx = u.cursor.idx; + if (idx == null || idx < 0) { + setHover(null); + return; + } + const xs = u.data[0] as number[]; + const ys = u.data[1] as number[]; + const ts = xs[idx]; + const value = ys[idx]; + if (ts == null || !Number.isFinite(value)) { + setHover(null); + return; + } + setHover({ ts, value }); + }, + ], + draw: [ + (u: uPlot) => { + const ctx = u.ctx; + const drawLine = (yVal: number, color: string) => { + const yPx = u.valToPos(yVal, "y", true); + if (!Number.isFinite(yPx)) return; + ctx.save(); + ctx.strokeStyle = color; + ctx.setLineDash([3, 3]); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(u.bbox.left, yPx); + ctx.lineTo(u.bbox.left + u.bbox.width, yPx); + ctx.stroke(); + ctx.restore(); + }; + if (warn !== undefined) drawLine(warn, WARN_STROKE); + if (crit !== undefined) drawLine(crit, CRIT_STROKE); + // Event annotations: thin amber vertical line per /api/events + // entry inside the visible window. + if (events && events.length > 0) { + ctx.save(); + ctx.strokeStyle = "rgb(255 176 0 / 0.55)"; + ctx.setLineDash([]); + ctx.lineWidth = 1; + for (const ev of events) { + const xPx = u.valToPos(ev.ts, "x", true); + if (!Number.isFinite(xPx)) continue; + if (xPx < u.bbox.left || xPx > u.bbox.left + u.bbox.width) continue; + ctx.beginPath(); + ctx.moveTo(xPx, u.bbox.top); + ctx.lineTo(xPx, u.bbox.top + u.bbox.height); + ctx.stroke(); + } + ctx.restore(); + } + }, + ], + }, + }), + [width, warn, crit, logY, syncKey, events], + ); + + // Create plot only after width is known. + useEffect(() => { + if (!containerRef.current) return; + if (width === 0) return; + plotRef.current = new uPlot(options, series, containerRef.current); + return () => { + plotRef.current?.destroy(); + plotRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options]); + + useEffect(() => { + plotRef.current?.setData(series); + }, [series]); + + return ( + // `min-w-0` keeps the flex container from auto-expanding to fit its + // longest child's intrinsic width — without it the footer's idle vs + // hover text could push the wrap wider, ResizeObserver would refire, + // options/chart would rebuild, and the operator would see the page + // jitter on every mouse move. +
+ {/* + The label flex-shrinks (min-w-0) and the value never does (shrink-0). + The number is always the operator's primary read; if the tile is + narrow, the title abbreviates with an ellipsis instead of squeezing + the number out. + */} +
+ {tip ? ( + + {label} + + ) : ( + + {label} + + )} + + {valueText} + +
+
+ {/* + Fixed-height single-line footer. Idle and hover states use the + same h/leading so swapping content cannot bump the card by a + pixel — the trigger of the page-wide jitter the operator hits + when sweeping the mouse across multiple sparklines on Overview. + */} +
+ {hover ? ( + <> + {new Date(hover.ts * 1000).toLocaleTimeString()} + {formatHoverValue(hover.value)} + + ) : ( + <> + {label.toLowerCase()} · last {valueText} + hover for point + + )} +
+
+ ); +} diff --git a/frontend/src/hooks/useAdminAuth.tsx b/frontend/src/hooks/useAdminAuth.tsx new file mode 100644 index 000000000..78cd6c66f --- /dev/null +++ b/frontend/src/hooks/useAdminAuth.tsx @@ -0,0 +1,101 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { setOnUnauthorized } from "../api"; + +interface Credentials { + username: string; + password: string; +} + +interface AdminAuthValue { + creds: Credentials | null; + setCreds: (next: Credentials | null, remember?: boolean) => void; + authHeader: () => Record; + /** Bumps every time api.ts saw a 401. AuthGate watches this. */ + unauthorizedAt: number | null; + clearUnauthorized: () => void; + /** True when credentials were loaded from localStorage on mount, or saved + * via `setCreds(_, remember=true)`. The AuthGate checkbox reflects this. */ + remembered: boolean; +} + +const AdminAuthContext = createContext(null); + +const STORAGE_KEY = "pgdoorman.admin-auth"; + +function loadStored(): Credentials | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + "username" in parsed && + "password" in parsed && + typeof (parsed as Credentials).username === "string" && + typeof (parsed as Credentials).password === "string" + ) { + return parsed as Credentials; + } + return null; + } catch { + return null; + } +} + +export function AdminAuthProvider({ children }: { children: ReactNode }) { + const initial = loadStored(); + const [creds, setCredsState] = useState(initial); + const [remembered, setRemembered] = useState(initial !== null); + const [unauthorizedAt, setUnauthorizedAt] = useState(null); + + useEffect(() => { + setOnUnauthorized(() => setUnauthorizedAt(Date.now())); + return () => setOnUnauthorized(() => {}); + }, []); + + const setCreds = useCallback((next: Credentials | null, remember = false) => { + setCredsState(next); + setRemembered(remember && next !== null); + try { + if (next && remember) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } else { + // Either user cleared creds, or chose "do not remember" — wipe any + // earlier persisted copy so a shared workstation does not leak. + localStorage.removeItem(STORAGE_KEY); + } + } catch { + /* private mode / quota / disabled — non-fatal */ + } + }, []); + + const authHeader = useCallback((): Record => { + if (!creds) return {}; + const token = btoa(`${creds.username}:${creds.password}`); + return { Authorization: `Basic ${token}` }; + }, [creds]); + + const clearUnauthorized = useCallback(() => setUnauthorizedAt(null), []); + + return ( + + {children} + + ); +} + +export function useAdminAuth(): AdminAuthValue { + const ctx = useContext(AdminAuthContext); + if (!ctx) throw new Error("useAdminAuth must be used inside AdminAuthProvider"); + return ctx; +} diff --git a/frontend/src/hooks/useHistory.ts b/frontend/src/hooks/useHistory.ts new file mode 100644 index 000000000..412d29398 --- /dev/null +++ b/frontend/src/hooks/useHistory.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; + +const DEFAULT_MAX_POINTS = 120; // 120 × 1.5 s polling = 3 min window per parent spec §10.2. + +export interface HistoryHandle { + history: T[]; + push: (value: T) => void; + /// Replace the rolling window with `next`. Used to clear the buffer when a + /// stale-tab gap is detected so the chart does not bridge it with a flat + /// line. + replace: (next: T[]) => void; +} + +/** + * Rolling window of the latest `maxPoints` values keyed by `key`. Persisted + * in sessionStorage so a tab refresh keeps the recent context. Storage write + * failures (private mode, quota) are silent — the in-memory history still + * works. + */ +export function useHistory(key: string, maxPoints = DEFAULT_MAX_POINTS): HistoryHandle { + const storageKey = `pgdoorman.history.${key}`; + const [history, setHistory] = useState(() => { + try { + const raw = sessionStorage.getItem(storageKey); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } + }); + + useEffect(() => { + try { + sessionStorage.setItem(storageKey, JSON.stringify(history)); + } catch { + /* storage quota or private mode — no-op. */ + } + }, [history, storageKey]); + + const push = (value: T) => { + setHistory((prev) => { + const next = + prev.length >= maxPoints ? prev.slice(prev.length - maxPoints + 1) : prev.slice(); + next.push(value); + return next; + }); + }; + + const replace = (next: T[]) => { + setHistory(next.slice(0, maxPoints)); + }; + + return { history, push, replace }; +} diff --git a/frontend/src/hooks/usePoll.ts b/frontend/src/hooks/usePoll.ts new file mode 100644 index 000000000..7790eef37 --- /dev/null +++ b/frontend/src/hooks/usePoll.ts @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; + +interface PollState { + data: T | null; + error: Error | null; + lastUpdated: number | null; +} + +/** + * Calls fetcher on mount and every intervalMs ms. Cancels the in-flight + * request via AbortController on unmount and on dependency change. Phase 5 + * does not call this hook from any page; it is here so phase 6 has the + * primitive ready. + */ +export function usePoll( + fetcher: (signal: AbortSignal) => Promise, + intervalMs = 1500, +): PollState { + const [state, setState] = useState>({ + data: null, + error: null, + lastUpdated: null, + }); + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + let intervalId: number | null = null; + // Sequence id stamps every dispatched request. A response is committed + // only when its stamp matches `seq` at completion time; older in-flight + // responses lose the race and are discarded. Without this, a slow + // /api/logs or /api/clients can land after a fresher one and overwrite + // the UI with stale rows (and, for /api/logs, double-emit entries by + // re-using the `since` cursor). + let seq = 0; + let inflight = 0; + + const tick = () => { + // Skip background ticks: when the tab is hidden, browsers throttle + // setInterval (often to 1 Hz minimum) and abort/clear pending fetches + // anyway. A skipped tick keeps the user-visible last sample fresh + // without faking new history points. + if (typeof document !== "undefined" && document.hidden) return; + // Drop ticks while a request is in flight. The visible interval is + // intervalMs from the last completion, not from the last dispatch — + // matters for slow endpoints (/api/logs at 1.5 s with a 600 ms + // round-trip skipped one out of every two ticks otherwise). + if (inflight > 0) return; + seq += 1; + const mySeq = seq; + inflight += 1; + fetcherRef + .current(controller.signal) + .then((data) => { + if (cancelled || mySeq !== seq) return; + setState({ data, error: null, lastUpdated: Date.now() }); + }) + .catch((e: unknown) => { + if (cancelled || mySeq !== seq) return; + if (e instanceof DOMException && e.name === "AbortError") return; + setState((prev) => ({ + ...prev, + error: e instanceof Error ? e : new Error(String(e)), + })); + }) + .finally(() => { + inflight -= 1; + }); + }; + + const startInterval = () => { + if (intervalId !== null) return; + intervalId = window.setInterval(tick, intervalMs); + }; + const stopInterval = () => { + if (intervalId === null) return; + window.clearInterval(intervalId); + intervalId = null; + }; + const onVisibility = () => { + if (document.hidden) { + stopInterval(); + } else { + // Resume immediately so the user does not wait a full interval + // for the first sample after returning to the tab. + tick(); + startInterval(); + } + }; + + tick(); + if (typeof document === "undefined" || !document.hidden) startInterval(); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibility); + } + + return () => { + cancelled = true; + controller.abort(); + stopInterval(); + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibility); + } + }; + }, [intervalMs]); + + return state; +} diff --git a/frontend/src/lib/prettySql.ts b/frontend/src/lib/prettySql.ts new file mode 100644 index 000000000..f0368f246 --- /dev/null +++ b/frontend/src/lib/prettySql.ts @@ -0,0 +1,79 @@ +// Lightweight SQL pretty-printer for the Prepared expand row. We do not +// pull a real SQL parser into the bundle — a 50-line keyword-driven +// reformatter is enough for the prepared statements pg_doorman caches +// (they're issued by ORMs and pgbench-style scripts, not hand-written +// reports). Behaviour: +// +// - Trim and collapse runs of whitespace down to a single space. +// - Insert a newline before every "major clause" keyword +// (SELECT, FROM, WHERE, JOIN variants, AND, OR, GROUP BY, ORDER BY, +// LIMIT, OFFSET, RETURNING, VALUES, ON CONFLICT, UNION...). +// - Continuation lines indent two spaces so the eye picks up the +// hierarchy. +// - Keywords stay in whatever case the SQL ships with — uppercasing +// would mangle quoted identifiers. +// +// The split is regex-only and case-insensitive. Quoted strings, identifiers, +// and column names are left alone because the regex requires whitespace +// boundaries and the SQL we receive is already paramaterised. + +const MAJOR_CLAUSES = [ + "SELECT", + "INSERT INTO", + "UPDATE", + "DELETE FROM", + "FROM", + "WHERE", + "GROUP BY", + "ORDER BY", + "HAVING", + "LIMIT", + "OFFSET", + "RETURNING", + "VALUES", + "SET", + "ON CONFLICT", + "UNION", + "UNION ALL", + "INTERSECT", + "EXCEPT", + "INNER JOIN", + "LEFT JOIN", + "LEFT OUTER JOIN", + "RIGHT JOIN", + "RIGHT OUTER JOIN", + "FULL JOIN", + "FULL OUTER JOIN", + "CROSS JOIN", + "JOIN", + "WITH", +]; + +const MINOR_CONNECTORS = ["AND", "OR"]; + +export function prettySql(raw: string): string { + if (!raw) return ""; + // Collapse whitespace runs, drop leading/trailing blanks. + const collapsed = raw.replace(/\s+/g, " ").trim(); + if (collapsed.length === 0) return ""; + + let result = collapsed; + + // Major clauses → newline + zero indent. + for (const kw of MAJOR_CLAUSES) { + const re = new RegExp(`(\\s|^)${kw.replace(/ /g, "\\s+")}\\b`, "gi"); + result = result.replace(re, (_match, lead) => { + const prefix = lead === "" ? "" : "\n"; + return `${prefix}${kw}`; + }); + } + + // Minor connectors (AND / OR) inside WHERE / ON → newline + 2-space indent. + for (const kw of MINOR_CONNECTORS) { + const re = new RegExp(`\\s${kw}\\s`, "gi"); + result = result.replace(re, `\n ${kw} `); + } + + // Trim leading newline if the very first token was a clause keyword. + return result.replace(/^\n/, ""); +} diff --git a/frontend/src/lib/quantile.ts b/frontend/src/lib/quantile.ts new file mode 100644 index 000000000..67f098ffb --- /dev/null +++ b/frontend/src/lib/quantile.ts @@ -0,0 +1,50 @@ +// Linear-interpolation quantile over an unsorted numeric array. Used by +// PanelView's summary table — operators reading the panel expect the +// p50/p95/p99 of the visible window without a backend HDR snapshot. The +// implementation is the standard "type 7" (R default), good enough for +// 200-point sparklines. + +export function quantile(values: number[], q: number): number | null { + if (values.length === 0) return null; + const sorted = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); + if (sorted.length === 0) return null; + if (sorted.length === 1) return sorted[0]; + const pos = (sorted.length - 1) * q; + const base = Math.floor(pos); + const rest = pos - base; + const next = sorted[base + 1]; + if (next === undefined) return sorted[base]; + return sorted[base] + rest * (next - sorted[base]); +} + +export function summaryStats(values: number[]): { + count: number; + min: number | null; + max: number | null; + avg: number | null; + p50: number | null; + p95: number | null; + p99: number | null; +} { + const finite = values.filter((v) => Number.isFinite(v)); + if (finite.length === 0) { + return { count: 0, min: null, max: null, avg: null, p50: null, p95: null, p99: null }; + } + let sum = 0; + let min = Infinity; + let max = -Infinity; + for (const v of finite) { + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + return { + count: finite.length, + min, + max, + avg: sum / finite.length, + p50: quantile(finite, 0.5), + p95: quantile(finite, 0.95), + p99: quantile(finite, 0.99), + }; +} diff --git a/frontend/src/lib/sqlstate.ts b/frontend/src/lib/sqlstate.ts new file mode 100644 index 000000000..9bfb66f6e --- /dev/null +++ b/frontend/src/lib/sqlstate.ts @@ -0,0 +1,101 @@ +// Human-readable PostgreSQL SQLSTATE labels. Covers the codes that operators +// see day-to-day plus the pg_doorman-side ones (53300 on checkout failure, +// 26000 on synthetic prepared-statement miss). Unknown codes fall back to +// their two-character class prefix. + +const STATEMENTS: Record = { + // pg_doorman side + "53300": "too_many_connections (pg_doorman checkout fail)", + "26000": "invalid_sql_statement_name (synthetic miss)", + "57P01": "admin_shutdown", + "57P02": "crash_shutdown", + "57P03": "cannot_connect_now", + "58006": "internal_error (pg_doorman shutdown)", + // PostgreSQL — common + "08000": "connection_exception", + "08003": "connection_does_not_exist", + "08006": "connection_failure", + "22000": "data_exception", + "22001": "string_data_right_truncation", + "22003": "numeric_value_out_of_range", + "22P02": "invalid_text_representation", + "23000": "integrity_constraint_violation", + "23502": "not_null_violation", + "23503": "foreign_key_violation", + "23505": "unique_violation", + "23514": "check_violation", + "25000": "invalid_transaction_state", + "25P02": "in_failed_sql_transaction", + "28000": "invalid_authorization_specification", + "28P01": "invalid_password", + "40000": "transaction_rollback", + "40001": "serialization_failure", + "40P01": "deadlock_detected", + "42000": "syntax_error_or_access_rule_violation", + "42501": "insufficient_privilege", + "42601": "syntax_error", + "42703": "undefined_column", + "42P01": "undefined_table", + "53000": "insufficient_resources", + "53100": "disk_full", + "53200": "out_of_memory", + "55P03": "lock_not_available", + "57000": "operator_intervention", + "57014": "query_canceled", + "XX000": "internal_error", +}; + +const CLASSES: Record = { + "00": "Successful Completion", + "01": "Warning", + "02": "No Data", + "03": "SQL Statement Not Yet Complete", + "08": "Connection Exception", + "09": "Triggered Action Exception", + "0A": "Feature Not Supported", + "0B": "Invalid Transaction Initiation", + "0F": "Locator Exception", + "0L": "Invalid Grantor", + "0P": "Invalid Role Specification", + "0Z": "Diagnostics Exception", + "20": "Case Not Found", + "21": "Cardinality Violation", + "22": "Data Exception", + "23": "Integrity Constraint Violation", + "24": "Invalid Cursor State", + "25": "Invalid Transaction State", + "26": "Invalid SQL Statement Name", + "27": "Triggered Data Change Violation", + "28": "Invalid Authorization Specification", + "2B": "Dependent Privilege Descriptors Still Exist", + "2D": "Invalid Transaction Termination", + "2F": "SQL Routine Exception", + "34": "Invalid Cursor Name", + "38": "External Routine Exception", + "39": "External Routine Invocation Exception", + "3B": "Savepoint Exception", + "3D": "Invalid Catalog Name", + "3F": "Invalid Schema Name", + "40": "Transaction Rollback", + "42": "Syntax or Access Rule Violation", + "44": "WITH CHECK OPTION Violation", + "53": "Insufficient Resources", + "54": "Program Limit Exceeded", + "55": "Object Not in Prerequisite State", + "57": "Operator Intervention", + "58": "System Error", + "72": "Snapshot Too Old", + F0: "Configuration File Error", + HV: "Foreign Data Wrapper Error", + P0: "PL/pgSQL Error", + XX: "Internal Error", +}; + +export function describeSqlstate(code: string): string { + const exact = STATEMENTS[code]; + if (exact) return exact; + const klass = code.slice(0, 2); + const cls = CLASSES[klass]; + if (cls) return `class ${klass}: ${cls}`; + return "unknown SQLSTATE"; +} diff --git a/frontend/src/lib/thresholds.ts b/frontend/src/lib/thresholds.ts new file mode 100644 index 000000000..39f0f28c3 --- /dev/null +++ b/frontend/src/lib/thresholds.ts @@ -0,0 +1,310 @@ +import type { AuthQueryDto, OverviewDto, PoolDto, Severity } from "../types"; + +export interface PoolEvaluation { + poolId: string; + severity: Severity; + reasons: string[]; +} + +export interface HealthState { + state: Severity; + reason: string | null; + perPool: PoolEvaluation[]; + authQuery?: GlobalEvaluation; +} + +export interface GlobalEvaluation { + severity: Severity; + reasons: string[]; +} + +export interface PoolHistoryPoint { + ts: number; + errors_total: number; + queries_total: number; + // Cumulative `pool_scaling.creates` for the pool (reconnect rate input). + creates_total?: number; + // Cumulative `pool_scaling.gate_budget_ex` for the pool. + gate_budget_ex_total?: number; + // Cumulative `pool_coordinator.exhaustions` for the pool's database. + // Pools sharing a database see the same series. + coordinator_exhaustions_total?: number; +} + +export type PoolHistory = Map; + +const SUSTAIN_30S_POINTS = 20; // 20 × 1.5 s = 30 s rolling window per parent spec §10.2. +const SUSTAIN_60S_POINTS = 40; // 40 × 1.5 s = 60 s rolling window for slower-cadence rules. +const SUSTAIN_10S_POINTS = 7; // 7 × 1.5 s = 10.5 s — smallest window covering 10 s sustain. +const ERRORS_PER_SEC_WARN = 0.1; +const ERRORS_PER_SEC_CRIT = 1.0; +const SATURATION_WARN = 0.7; +const SATURATION_CRIT = 0.9; +const QUERY_P95_WARN_MS = 100; +const QUERY_P95_CRIT_MS = 500; +const QUERY_P99_WARN_MS = 500; +const QUERY_P99_CRIT_MS = 2000; +const ACTIVE_AGE_WARN_MS = 30_000; +const ACTIVE_AGE_CRIT_MS = 300_000; +const WAIT_AVG_WARN_MS = 5; +const WAIT_AVG_CRIT_MS = 50; +const WAIT_P95_WARN_MS = 50; +const WAIT_P95_CRIT_MS = 500; +// Spec §15.4: per-pool waiting thresholds. +const WAITING_WARN_COUNT = 1; +const WAITING_CRIT_COUNT_FLOOR = 10; // applied as max(10, 0.10×max_connections). +// Spec §15.4: reconnect rate per pool, scaled to the pool's max_connections. +const RECONNECT_WARN_FACTOR = 0.1; // ≥ 0.10 × max_connections / s +const RECONNECT_CRIT_FACTOR = 0.3; // ≥ 0.30 × max_connections / s +// Spec §15.4: per-pool burst-gate budget exhaustion rate. +const GATE_BUDGET_EX_WARN = 0; // > 0 / s sustained +const GATE_BUDGET_EX_CRIT = 0.1; // > 0.1 / s sustained +// Spec §15.4: per-database coordinator exhaustion rate. +const COORD_EXHAUSTIONS_WARN = 0; // > 0 / s sustained +const COORD_EXHAUSTIONS_CRIT = 1.0; // > 1 / s sustained +// Spec §15.4: per-database auth-failure rate (instantaneous ratio over total). +const AUTH_FAIL_WARN_RATIO = 0.005; // 0.5 % +const AUTH_FAIL_CRIT_RATIO = 0.05; // 5 % +// Below this many attempts the failure ratio is dominated by single-event +// noise; the rule stays silent until enough samples accumulate. +const AUTH_MIN_ATTEMPTS = 100; + +function rank(s: Severity): number { + switch (s) { + case "ok": + return 0; + case "degraded": + return 1; + case "critical": + return 2; + } +} + +function maxSeverity(a: Severity, b: Severity): Severity { + return rank(a) >= rank(b) ? a : b; +} + +function ratePerSecond( + history: PoolHistoryPoint[], + field: (p: PoolHistoryPoint) => number | undefined, +): number | null { + if (history.length < 2) return null; + const first = history[0]; + const last = history[history.length - 1]; + const dt = (last.ts - first.ts) / 1000; + if (dt <= 0) return null; + const f = field(first); + const l = field(last); + if (f === undefined || l === undefined) return null; + return Math.max(0, (l - f) / dt); +} + +function errorsPerSecond(history: PoolHistoryPoint[]): number | null { + return ratePerSecond(history, (p) => p.errors_total); +} + +function sustainedAbove( + history: PoolHistoryPoint[], + points: number, + predicate: (p: PoolHistoryPoint) => boolean, +): boolean { + if (history.length < points) return false; + return history.slice(-points).every(predicate); +} + +export function evaluatePool( + pool: PoolDto, + history: PoolHistoryPoint[] | undefined, +): PoolEvaluation { + let severity: Severity = "ok"; + const reasons: string[] = []; + const note = (lvl: Severity, msg: string) => { + severity = maxSeverity(severity, lvl); + reasons.push(msg); + }; + + if (pool.max_connections > 0) { + // Saturation = active / max_connections, not connections / max. + // `connections` counts every held backend (active + idle + used + + // login). After a traffic spike the pool sits with many warm idle + // backends that have not yet hit `server_idle_timeout`; they are + // not pressure, so flagging the pool as CRITICAL because of held + // count is a false alarm. The real overload signal is "active + // backends high relative to capacity" plus the WAITING threshold + // below. + const sat = pool.active / pool.max_connections; + if (sat >= SATURATION_CRIT) note("critical", `active ${(sat * 100).toFixed(0)}% ≥ 90%`); + else if (sat >= SATURATION_WARN) note("degraded", `active ${(sat * 100).toFixed(0)}% ≥ 70%`); + } + + // Backend-origin signals (query latency, oldest active query, error rate) + // are reported as DEGRADED max. The pooler is the messenger here, not the + // cause — clients are slow because PostgreSQL is slow, holding locks, or + // returning errors. Painting the pool CRITICAL on these would make the + // /overview pill red for problems the operator must fix on the database + // side, not in pg_doorman. Saturation, waiting count, reconnect, gate + // budget, and coordinator exhaustion stay CRITICAL: those are the signals + // where the pooler itself is in trouble (clients queueing, can't add + // capacity, internal admission control hitting limits). + if (pool.max_active_age_ms > ACTIVE_AGE_CRIT_MS) { + note("degraded", `backend oldest-active ${pool.max_active_age_ms} ms > 300 s`); + } else if (pool.max_active_age_ms > ACTIVE_AGE_WARN_MS) { + note("degraded", `backend oldest-active ${pool.max_active_age_ms} ms > 30 s`); + } + + if (pool.query_p95_ms > QUERY_P95_CRIT_MS) + note("degraded", `backend p95 ${pool.query_p95_ms} ms > 500`); + else if (pool.query_p95_ms > QUERY_P95_WARN_MS) + note("degraded", `backend p95 ${pool.query_p95_ms} ms > 100`); + if (pool.query_p99_ms > QUERY_P99_CRIT_MS) + note("degraded", `backend p99 ${pool.query_p99_ms} ms > 2000`); + else if (pool.query_p99_ms > QUERY_P99_WARN_MS) + note("degraded", `backend p99 ${pool.query_p99_ms} ms > 500`); + + // wait_avg_ms / wait_p95_ms are pooler-side: they measure how long a + // client sat waiting for an idle backend. Real CRITICAL signals. + if (pool.wait_avg_ms > WAIT_AVG_CRIT_MS) + note("critical", `wait avg ${pool.wait_avg_ms} ms > 50`); + else if (pool.wait_avg_ms > WAIT_AVG_WARN_MS) + note("degraded", `wait avg ${pool.wait_avg_ms} ms > 5`); + if (pool.wait_p95_ms > WAIT_P95_CRIT_MS) + note("critical", `wait p95 ${pool.wait_p95_ms} ms > 500`); + else if (pool.wait_p95_ms > WAIT_P95_WARN_MS) + note("degraded", `wait p95 ${pool.wait_p95_ms} ms > 50`); + + const eps = history ? errorsPerSecond(history) : null; + if (eps !== null && history) { + if (sustainedAbove(history, SUSTAIN_30S_POINTS, () => eps > ERRORS_PER_SEC_CRIT)) { + note("degraded", `backend errors ${eps.toFixed(2)}/s > 1.0 sustained`); + } else if (sustainedAbove(history, SUSTAIN_30S_POINTS, () => eps > ERRORS_PER_SEC_WARN)) { + note("degraded", `backend errors ${eps.toFixed(2)}/s > 0.1 sustained`); + } + } + + // pool.waiting — sustained 10 s per spec §15.4. + const waitingCrit = Math.max( + WAITING_CRIT_COUNT_FLOOR, + Math.floor(0.1 * pool.max_connections), + ); + if (history) { + if ( + sustainedAbove(history, SUSTAIN_10S_POINTS, () => pool.waiting >= waitingCrit) + ) { + note("critical", `waiting ${pool.waiting} ≥ ${waitingCrit}`); + } else if ( + sustainedAbove(history, SUSTAIN_10S_POINTS, () => pool.waiting >= WAITING_WARN_COUNT) + ) { + note("degraded", `waiting ${pool.waiting} ≥ ${WAITING_WARN_COUNT}`); + } + } + + // Reconnect rate — pool_scaling.creates delta scaled to max_connections. + const reconnectPs = history ? ratePerSecond(history, (p) => p.creates_total) : null; + if (reconnectPs !== null && history && pool.max_connections > 0) { + const warn = RECONNECT_WARN_FACTOR * pool.max_connections; + const crit = RECONNECT_CRIT_FACTOR * pool.max_connections; + if (sustainedAbove(history, SUSTAIN_30S_POINTS, () => reconnectPs >= crit)) { + note( + "critical", + `reconnect ${reconnectPs.toFixed(2)}/s ≥ ${crit.toFixed(2)} (0.30×max_connections)`, + ); + } else if (sustainedAbove(history, SUSTAIN_30S_POINTS, () => reconnectPs >= warn)) { + note( + "degraded", + `reconnect ${reconnectPs.toFixed(2)}/s ≥ ${warn.toFixed(2)} (0.10×max_connections)`, + ); + } + } + + // Burst-gate budget exhaustion rate — pool_scaling.gate_budget_ex delta. + const gatePs = history ? ratePerSecond(history, (p) => p.gate_budget_ex_total) : null; + if (gatePs !== null && history) { + if (sustainedAbove(history, SUSTAIN_60S_POINTS, () => gatePs > GATE_BUDGET_EX_CRIT)) { + note("critical", `burst-gate budget exhausted ${gatePs.toFixed(2)}/s > 0.1`); + } else if ( + sustainedAbove(history, SUSTAIN_60S_POINTS, () => gatePs > GATE_BUDGET_EX_WARN) + ) { + note("degraded", `burst-gate budget exhausted ${gatePs.toFixed(2)}/s sustained`); + } + } + + // Coordinator exhaustions rate — pool_coordinator.exhaustions delta for the + // pool's database. Pools sharing a database evaluate the same series. + const coordPs = history + ? ratePerSecond(history, (p) => p.coordinator_exhaustions_total) + : null; + if (coordPs !== null && history) { + if ( + sustainedAbove(history, SUSTAIN_60S_POINTS, () => coordPs > COORD_EXHAUSTIONS_CRIT) + ) { + note("critical", `coordinator exhaustions ${coordPs.toFixed(2)}/s > 1.0`); + } else if ( + sustainedAbove(history, SUSTAIN_60S_POINTS, () => coordPs > COORD_EXHAUSTIONS_WARN) + ) { + note("degraded", `coordinator exhaustions ${coordPs.toFixed(2)}/s sustained`); + } + } + + // Backend gaps that block the rest of spec §15.4: + // - TLS handshake error rate: only `pg_doorman_server_tls_handshake_errors_total` + // in Prometheus today; needs a counter on PoolDto or a /api/tls endpoint. + // - Anonymous LRU evictions: only Prometheus + // `pg_doorman_clients_prepared_anonymous_evictions_total`; PoolDto needs + // a per-pool eviction counter to support the rule. + // - Synthetic misses (SQLSTATE 26000): backend does not classify errors yet + // (task #4 in handoff — Top-5 errors with SQLSTATE breakdown). + // - fallback_active: the pool exposes neither a boolean nor an + // "in-fallback-since" timestamp; PoolDto needs the gauge. + // - Patroni API: lives in the standalone patroni_proxy binary, no JSON + // over /api today. + // - Process RSS vs cgroup limit: backend has no cgroup awareness yet. + + return { poolId: pool.id, severity, reasons }; +} + +/** + * Evaluate per-database auth-failure rate over `/api/auth_query` snapshots. + * Returns the worst severity across all databases that already accumulated + * at least `AUTH_MIN_ATTEMPTS` attempts. + */ +export function evaluateAuthQuery(authQuery: AuthQueryDto | null): GlobalEvaluation { + if (!authQuery) return { severity: "ok", reasons: [] }; + let severity: Severity = "ok"; + const reasons: string[] = []; + for (const row of authQuery.pools) { + const total = row.auth_success + row.auth_failure; + if (total < AUTH_MIN_ATTEMPTS) continue; + const ratio = row.auth_failure / total; + if (ratio > AUTH_FAIL_CRIT_RATIO) { + severity = maxSeverity(severity, "critical"); + reasons.push(`auth failure ${(ratio * 100).toFixed(1)} % > 5 % on db ${row.database}`); + } else if (ratio > AUTH_FAIL_WARN_RATIO) { + severity = maxSeverity(severity, "degraded"); + reasons.push(`auth failure ${(ratio * 100).toFixed(2)} % > 0.5 % on db ${row.database}`); + } + } + return { severity, reasons }; +} + +export function aggregateHealth( + _overview: OverviewDto, + pools: PoolDto[], + history: PoolHistory, + authQuery: AuthQueryDto | null = null, +): HealthState { + const perPool = pools.map((p) => evaluatePool(p, history.get(p.id))); + const authQ = evaluateAuthQuery(authQuery); + let state: Severity = "ok"; + let reason: string | null = null; + for (const e of perPool) { + if (rank(e.severity) > rank(state)) { + state = e.severity; + reason = e.reasons[0] ?? null; + } + } + if (rank(authQ.severity) > rank(state)) { + state = authQ.severity; + reason = authQ.reasons[0] ?? null; + } + return { state, reason, perPool, authQuery: authQ }; +} diff --git a/frontend/src/lib/tooltips.ts b/frontend/src/lib/tooltips.ts new file mode 100644 index 000000000..5e9afafa5 --- /dev/null +++ b/frontend/src/lib/tooltips.ts @@ -0,0 +1,135 @@ +/** + * Operator-facing tooltip strings. Keys map to UI elements identified by + * "what is this column / tile / row" rather than the raw DTO field name, + * so a future rename of an internal struct does not break the dictionary. + * + * Each string answers three questions an on-call operator asks the first + * time they see a value: "what is it", "what's normal", "what's bad". + * + * The full dictionary with code references lives in tooltip-research.md; + * the short forms below are what fit into a `title=` attribute without + * forcing the operator into a side panel. + */ + +export const tip = { + // --- PoolDto --------------------------------------------------------- + poolId: + "Stable user@database identifier. Same key the admin protocol and Prometheus labels use.", + poolMode: + "transaction = backend returns to pool on commit/rollback. session = backend pinned for the client's lifetime (legacy / LISTEN). statement = returns after every statement (autocommit only).", + saturation: + "active backends / max_connections. Amber ≥ 70%, red ≥ 90%. At 100% new checkouts queue for query_wait_timeout. Idle backends still held from a prior burst do not count — they are not pressure.", + connectionsTotal: + "sv_active + sv_idle + sv_used + sv_login at snapshot time. Includes backends still in the SCRAM/LOGIN phase.", + connectionsActiveIdle: + "Server-side split. active = backend executing a query. idle = ready for next checkout. For the client side use Overview tiles.", + waiting: + "Clients past the burst gate but no backend yet. 0 is healthy. Sustained > pool_size/4 = scale or coordinator exhaustion blocking.", + oldestActive: + "Wall-clock age of the single longest-running checkout. Counts client think-time inside an open BEGIN, not just query runtime. > 30s = stuck transaction.", + queryP95: + "95th percentile of query duration over the last 60 s. Amber > 100 ms, red > 500 ms.", + queryP99: "99th percentile of query duration over the last 60 s.", + txP95: + "95th percentile of transaction duration over the last 60 s. > 1 s = either slow queries or client think-time.", + txP99: "99th percentile of transaction duration over the last 60 s.", + waitAvg: "Average time a client waited for a backend. > 0 means the pool is queueing.", + waitP95: "95th percentile of wait time. > 100 ms means most checkouts queue.", + queriesTotal: "Total queries since pg_doorman started. Counter — not a rate.", + txTotal: "Total transactions since pg_doorman started. Counter — not a rate.", + errorsTotal: + "Total errors with a SQLSTATE since pg_doorman started. See SQLSTATE breakdown for the codes.", + errorsBySqlstate: + "Cumulative error count grouped by PostgreSQL SQLSTATE. Click a row in Pools for the full breakdown.", + paused: + "yes = pool is rejecting new checkouts (PAUSE / RECONNECT in progress). Existing transactions keep running until commit.", + epoch: + "RECONNECT bumps this counter. After running RECONNECT, this should increment for every touched pool — confirms cached backends were invalidated.", + fallbackActive: + "yes = the local backend is in cooldown and pg_doorman is routing to a Patroni-discovered fallback host. Database-scoped.", + tlsHandshakeErrors: + "Failed TLS handshakes to backends, by database. Sustained growth = cert rotation incident or wrong CA on the server.", + tlsBackendConnections: + "Live backend connections currently using TLS, by database. Should equal pool.connections when server_tls_mode is required.", + // --- PoolCoordinatorRowDto ------------------------------------------- + coordMaxDbConn: + "Database-level cap shared across every user@db pool. Set by general.max_db_connections; 0 = unlimited.", + coordCurrent: + "Backends checked out from this database right now (sum across users). Approaching max_db_conn means the next checkout will hit the coordinator gate.", + coordReserveSize: + "Reserve permits held back for high-priority traffic. Sized by reserve_pool_size.", + coordReserveUsed: + "Reserve permits in use right now. Non-zero = a low-priority client borrowed from reserve.", + coordEvictions: + "Idle backends evicted to make room for a higher-priority checkout. Spikes during burst handovers.", + coordReserveAcq: + "Successful reserve acquisitions. Each one means the regular cap was full and a high-priority client used a reserve permit instead.", + coordExhaustions: + "Times the coordinator failed to grant any permit (regular OR reserve). > 0 = clients are waiting because the database cap is hit.", + // --- PoolScalingRowDto ----------------------------------------------- + scalingInflight: + "Backend connections being established right now. Spikes during anticipation or burst growth; should drop back to 0 within a second.", + scalingCreates: + "Total backends created since process start. Steady ramp under load = healthy. Flat while waiting > 0 = create_fallback / gate is throttling.", + scalingGateWaits: + "Times a checkout waited at the burst-gate (max_concurrent_creates). Brief spikes are fine; sustained = the per-process create cap is the bottleneck.", + scalingGateBudgetEx: + "Burst-gate budget exhaustions. > 0 means the gate is dropping requests rather than queueing — usually a sign of explosive client growth.", + scalingAnticNotify: + "Anticipation: pool created a connection ahead of demand based on xact_p99. Healthy churn keeps this incrementing.", + scalingAnticTimeout: + "Anticipation request did not produce a backend within the deadline. > 10% of antic_notify = backend is slow to accept TCP.", + scalingCreateFallback: + "Times pg_doorman fell back to the synchronous create path. Common during cold-start; sustained > 0 = anticipation cannot keep up.", + scalingReplenishDef: + "Times replenishment was deferred (cooldown after a backend failure). > 0 right after an outage; should drain to 0.", + // --- Overview / Process / Memory ------------------------------------- + rss: "Resident memory of the pg_doorman process. Click for the meminfo-style breakdown (caches, jemalloc, code, swap).", + cpu: "Aggregate CPU time. 100% = one core saturated; (cpu_cores × 100)% = every core busy.", + threads: + "Total OS threads in the pg_doorman process. Click to see per-thread CPU; tokio workers + accept threads + metrics.", + fdOpen: + "Open file descriptors. Mostly client and backend sockets. Amber at 70% of the soft cap, red at 90%.", + fdLimit: "Soft FD cap (RLIMIT_NOFILE). Below 65k → check systemd LimitNOFILE; below 8k = will run out.", + jemallocAllocated: + "Bytes the application has actually requested. Smallest of the jemalloc numbers; rises and falls with workload.", + jemallocActive: + "Pages jemalloc has handed out to size classes. Slightly above allocated due to internal padding.", + jemallocResident: "Pages backed by physical RAM. This is what RSS counts.", + jemallocMapped: + "Address-space pages reserved by jemalloc (mostly virtual). Includes retained pages still mmaped but not in RAM.", + jemallocRetained: + "Pages jemalloc keeps mmaped to avoid syscalls on the next allocation. Reclaimable on memory pressure.", + jemallocMetadata: + "Bytes jemalloc itself uses for arena bookkeeping. Should be a tiny fraction of allocated.", + jemallocFragmentation: + "resident − allocated. The bigger this gap relative to allocated, the more slabs sit half-empty. > 50% of allocated = consider arena.purge.", + cgroupCurrent: + "Memory the cgroup currently accounts to this process. Includes RSS, page cache attributable to us, kernel stacks.", + cgroupPeak: + "High-water mark since cgroup creation. > current = there was a transient spike — check binary upgrade or burst load.", + cgroupMax: + "Hard memory limit (cgroup v2 memory.max). Hitting it triggers OOM kill on the next allocation.", + cgroupHigh: + "Soft throttle threshold (cgroup v2 memory.high). Above this the kernel reclaims aggressively, slowing the process.", + // --- AuthQueryRowDto ------------------------------------------------- + authCacheEntries: + "Cached username→password rows. Caps at auth_query_cache_capacity per database.", + authCacheHits: + "Auth attempts served from the cache. Goal: > 99% of (hits + misses) once the cache warms.", + authCacheMisses: + "Auth attempts that had to query the backend auth table. Each miss counts a real round-trip.", + authCacheRefetches: + "Background refreshes of stale entries. Healthy = matches your auth_query_cache_ttl cadence.", + authCacheRateLimited: + "Auth attempts blocked by the per-user rate limit. > 0 = a client is bruteforcing or has a stale password.", + authSuccess: "Successful auth_query lookups since process start.", + authFailure: "Failed auth_query lookups (wrong password or unknown user). Spikes = credentials issue.", + authExecQueries: "Real SQL roundtrips to the backend auth table. Should be minuscule vs auth_success.", + authExecErrors: + "Backend errors during auth_query (SQL fail, network). Sustained > 0 means auth is degraded for new clients.", + dynPoolsCurrent: "Live dynamic pools (created on-demand from a wildcard pool config).", + dynPoolsCreated: "Total dynamic pools created since process start.", + dynPoolsDestroyed: + "Total dynamic pools removed (idle GC). created − destroyed should equal current ± in-flight.", +} as const; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 000000000..11129e51e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles/tailwind.css"; + +const rootEl = document.getElementById("root"); +if (!rootEl) { + throw new Error("missing #root in index.html"); +} +createRoot(rootEl).render( + + + , +); diff --git a/frontend/src/pages/Apps.tsx b/frontend/src/pages/Apps.tsx new file mode 100644 index 000000000..c2cc1759c --- /dev/null +++ b/frontend/src/pages/Apps.tsx @@ -0,0 +1,267 @@ +// /api/apps already aggregated client counters by `application_name` on the +// backend; the JSON DTO has been there since phase 3d-1 but no frontend +// page rendered it. This file fixes that — operators looking for "which +// app holds 30 connections / generates the error spike / churns reconnects" +// now have a single sortable table instead of grepping the Clients view by +// application_name substring. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { apiGet } from "../api"; +import { InfoLabel } from "../components/InfoLabel"; +import { PageHero } from "../components/PageHero"; +import { SectionHeader } from "../components/SectionHeader"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { usePoll } from "../hooks/usePoll"; +import type { AppsDto } from "../types"; + +const POLL_MS = 1500; + +type SortKey = + | "application_name" + | "clients" + | "qps" + | "tps" + | "queries_total" + | "transactions_total" + | "errors_total"; +type SortDir = "asc" | "desc"; + +type AppTotals = Record; +type AppRates = Record; + +// Computes per-application qps / tps from the delta between the current +// `/api/apps` snapshot and the previous one. The endpoint only ships +// cumulative counters; without this hook the operator could not answer +// "which app is busy right now" without doing the math by eye. +function useAppRates(data: AppsDto | null): AppRates { + const [rates, setRates] = useState({}); + const prevRef = useRef<{ ts: number; apps: AppTotals } | null>(null); + useEffect(() => { + if (!data) return; + const cur: AppTotals = {}; + for (const a of data.apps) { + cur[a.application_name] = { + queries: a.queries_total, + transactions: a.transactions_total, + }; + } + const prev = prevRef.current; + if (prev && prev.ts !== data.ts) { + const dt = (data.ts - prev.ts) / 1000; + if (dt > 0) { + const next: AppRates = {}; + for (const [name, totals] of Object.entries(cur)) { + const p = prev.apps[name]; + if (p) { + next[name] = { + qps: Math.max(0, (totals.queries - p.queries) / dt), + tps: Math.max(0, (totals.transactions - p.transactions) / dt), + }; + } + } + setRates(next); + } + } + prevRef.current = { ts: data.ts, apps: cur }; + }, [data]); + return rates; +} + +export default function Apps() { + const { authHeader } = useAdminAuth(); + const poll = usePoll( + (signal) => apiGet("/api/apps", authHeader, signal), + POLL_MS, + ); + const rates = useAppRates(poll.data); + const [filter, setFilter] = useState(""); + const [sortKey, setSortKey] = useState("qps"); + const [sortDir, setSortDir] = useState("desc"); + + const rows = useMemo(() => { + if (!poll.data) return []; + const flt = filter.trim().toLowerCase(); + let list = poll.data.apps; + if (flt) list = list.filter((r) => r.application_name.toLowerCase().includes(flt)); + list = list.slice().sort((a, b) => { + const dir = sortDir === "asc" ? 1 : -1; + switch (sortKey) { + case "application_name": + return a.application_name.localeCompare(b.application_name) * dir; + case "clients": + return (a.clients - b.clients) * dir; + case "qps": + return ((rates[a.application_name]?.qps ?? 0) - (rates[b.application_name]?.qps ?? 0)) * dir; + case "tps": + return ((rates[a.application_name]?.tps ?? 0) - (rates[b.application_name]?.tps ?? 0)) * dir; + case "queries_total": + return (a.queries_total - b.queries_total) * dir; + case "transactions_total": + return (a.transactions_total - b.transactions_total) * dir; + case "errors_total": + return (a.errors_total - b.errors_total) * dir; + } + }); + return list; + }, [poll.data, rates, filter, sortKey, sortDir]); + + const onSort = (key: SortKey) => { + if (key === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(key === "application_name" ? "asc" : "desc"); + } + }; + const sortIndicator = (key: SortKey) => + sortKey === key ? (sortDir === "asc" ? " ▲" : " ▼") : ""; + + if (poll.error) { + return ( +
+

Apps

+

+ Could not load apps: {poll.error.message}. Try Sign out → Sign in to refresh credentials, or check whether pg_doorman is running. +

+
+ ); + } + + return ( +
+ + +
+ setFilter(e.target.value)} + className="w-64 rounded border border-border-strong bg-surface-2 px-2 py-1 text-sm text-text font-mono" + /> + + {rows.length} app{rows.length === 1 ? "" : "s"} + +
+ + + + + + + + + + + + + + + {rows.map((r) => { + const errPerK = r.queries_total > 0 ? (r.errors_total * 1000) / r.queries_total : 0; + const errTone = + errPerK > 10 + ? "text-danger" + : errPerK > 1 + ? "text-warning" + : "text-text-muted"; + return ( + + + + + + + + + + + ); + })} + +
+ + onSort("application_name")} + > + application_name{sortIndicator("application_name")} + + + + + onSort("clients")}> + clients{sortIndicator("clients")} + + + + + onSort("qps")}> + qps{sortIndicator("qps")} + + + + + onSort("tps")}> + tx/s{sortIndicator("tps")} + + + + + onSort("queries_total")}> + queries{sortIndicator("queries_total")} + + + + + onSort("transactions_total")} + > + transactions{sortIndicator("transactions_total")} + + + + + onSort("errors_total")}> + errors{sortIndicator("errors_total")} + + + + + err / 1k q + +
+ {r.application_name || (unknown)} + {r.clients} + {rates[r.application_name] + ? rates[r.application_name].qps.toFixed(1) + : "—"} + + {rates[r.application_name] + ? rates[r.application_name].tps.toFixed(1) + : "—"} + {r.queries_total.toLocaleString()}{r.transactions_total.toLocaleString()} 0 ? "text-warning" : "" + }`} + > + {r.errors_total.toLocaleString()} + {errPerK.toFixed(2)}
+ {!poll.data &&

Loading apps…

} + {poll.data && rows.length === 0 && ( +

No application_name matches that fragment. Try a shorter or different substring.

+ )} +
+ ); +} diff --git a/frontend/src/pages/Caches.tsx b/frontend/src/pages/Caches.tsx new file mode 100644 index 000000000..eaa471267 --- /dev/null +++ b/frontend/src/pages/Caches.tsx @@ -0,0 +1,571 @@ +import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { apiGet } from "../api"; +import { InfoLabel } from "../components/InfoLabel"; +import { PageHero } from "../components/PageHero"; +import { SectionHeader } from "../components/SectionHeader"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { usePoll } from "../hooks/usePoll"; +import { prettySql } from "../lib/prettySql"; +import type { + InternerDto, + InternerTopDto, + PreparedDto, + PreparedRowDto, + PreparedTextDto, +} from "../types"; + +const POLL_MS = 3000; + +type Tab = "prepared" | "interner"; + +export default function Caches() { + const [tab, setTab] = useState("prepared"); + return ( +
+ +
+ setTab("prepared")}>Prepared + setTab("interner")}>Query cache +
+ {tab === "prepared" ? : } +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +interface TextCell { + loading: boolean; + text?: string; + error?: string; +} + +type PreparedSortKey = + | "pool" + | "kind" + | "name" + | "hash" + | "count_used" + | "hits" + | "misses" + | "hit_rate" + | "refs_per_s"; +type SortDir = "asc" | "desc"; + +type PreparedRefsTotals = Record; +type PreparedRefsRates = Record; + +// Per-(pool, hash) refs/s — delta of (hits + misses) between consecutive +// /api/prepared snapshots, divided by the snapshot interval. The hits +// and misses counters bump on every Parse-time reference, so this rate +// shows which statements are touched right now versus which sit cold. +function usePreparedRefsRate(data: PreparedDto | null): PreparedRefsRates { + const [rates, setRates] = useState({}); + const prevRef = useRef<{ ts: number; totals: PreparedRefsTotals } | null>(null); + useEffect(() => { + if (!data) return; + const totals: PreparedRefsTotals = {}; + for (const r of data.prepared) { + totals[`${r.pool}|${r.hash}`] = r.hits + r.misses; + } + const prev = prevRef.current; + if (prev && prev.ts !== data.ts) { + const dt = (data.ts - prev.ts) / 1000; + if (dt > 0) { + const next: PreparedRefsRates = {}; + for (const [key, total] of Object.entries(totals)) { + const p = prev.totals[key]; + if (p !== undefined) { + next[key] = Math.max(0, (total - p) / dt); + } + } + setRates(next); + } + } + prevRef.current = { ts: data.ts, totals }; + }, [data]); + return rates; +} + +/// Hit-rate sentinel used to keep "no traffic yet" rows below real data +/// regardless of asc/desc — `hits + misses == 0` rows have nothing to +/// compare and dragging them to the top would bury the rows operators +/// actually care about. +const HIT_RATE_NO_DATA = -1; + +function hitRateOrSentinel(r: PreparedRowDto): number { + const total = r.hits + r.misses; + return total > 0 ? r.hits / total : HIT_RATE_NO_DATA; +} + +function comparePrepared( + a: PreparedRowDto, + b: PreparedRowDto, + key: PreparedSortKey, + rates: PreparedRefsRates, +): number { + switch (key) { + case "pool": + return a.pool.localeCompare(b.pool); + case "kind": + return a.kind.localeCompare(b.kind); + case "name": + return (a.name || "").localeCompare(b.name || ""); + case "hash": + return a.hash.localeCompare(b.hash); + case "count_used": + return a.count_used - b.count_used; + case "hits": + return a.hits - b.hits; + case "misses": + return a.misses - b.misses; + case "hit_rate": + return hitRateOrSentinel(a) - hitRateOrSentinel(b); + case "refs_per_s": + return (rates[`${a.pool}|${a.hash}`] ?? 0) - (rates[`${b.pool}|${b.hash}`] ?? 0); + } +} + +interface PreparedFilters { + pool: string; + name: string; + hash: string; + // "any" matches all kinds. The remaining values are exact matches against + // the row's `kind` field as serialised by the backend. + kind: "any" | "named" | "anonymous" | "mixed"; +} + +const EMPTY_FILTERS: PreparedFilters = { pool: "", name: "", hash: "", kind: "any" }; + +function matchesFilters(r: PreparedRowDto, f: PreparedFilters): boolean { + if (f.pool && !r.pool.toLowerCase().includes(f.pool.toLowerCase())) return false; + if (f.name && !(r.name || "").toLowerCase().includes(f.name.toLowerCase())) return false; + if (f.hash && !r.hash.toLowerCase().includes(f.hash.toLowerCase())) return false; + if (f.kind !== "any" && r.kind !== f.kind) return false; + return true; +} + +function PreparedTab() { + const { authHeader } = useAdminAuth(); + const poll = usePoll( + (signal) => apiGet("/api/prepared", authHeader, signal), + POLL_MS, + ); + // Lazy-loaded SQL text per (pool, hash). The /api/prepared response + // omits the text on purpose (anonymous-safe public endpoint); admins + // fetch it row-by-row via /api/prepared/text/{hash}. + const [texts, setTexts] = useState>({}); + const refsRate = usePreparedRefsRate(poll.data); + // Default to "most-used statements first" — the question an operator + // opens this page to answer is which statements drive cache pressure. + const [sortKey, setSortKey] = useState("count_used"); + const [sortDir, setSortDir] = useState("desc"); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const filterActive = + filters.pool !== "" || + filters.name !== "" || + filters.hash !== "" || + filters.kind !== "any"; + const onSort = (k: PreparedSortKey) => { + if (k === sortKey) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(k); + setSortDir("desc"); + } + }; + const sortIndicator = (k: PreparedSortKey) => + sortKey === k ? (sortDir === "asc" ? " ▲" : " ▼") : ""; + const sorted = useMemo(() => { + if (!poll.data) return []; + const arr = poll.data.prepared.filter((r) => matchesFilters(r, filters)); + arr.sort((a, b) => { + const cmp = comparePrepared(a, b, sortKey, refsRate); + return sortDir === "asc" ? cmp : -cmp; + }); + return arr; + }, [poll.data, filters, sortKey, sortDir, refsRate]); + + const toggle = (pool: string, hash: string) => { + const key = `${pool}-${hash}`; + setTexts((prev) => { + const cur = prev[key]; + if (cur && (cur.text || cur.error)) { + // Already loaded — collapse. + const next = { ...prev }; + delete next[key]; + return next; + } + if (cur?.loading) return prev; + return { ...prev, [key]: { loading: true } }; + }); + // Avoid double-fetching on re-toggle. + if (texts[key]?.text || texts[key]?.error) return; + apiGet(`/api/prepared/text/${hash}`, authHeader) + .then((dto) => { + setTexts((prev) => ({ ...prev, [key]: { loading: false, text: dto.query } })); + }) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setTexts((prev) => ({ ...prev, [key]: { loading: false, error: msg } })); + }); + }; + + if (poll.error) return

{poll.error.message}

; + if (!poll.data) return

Loading prepared statements…

; + + return ( + <> + +
+ setFilters((f) => ({ ...f, pool: e.target.value }))} + className="w-44 rounded border border-border-strong bg-surface-2 px-2 py-1 font-mono text-xs text-text" + /> + setFilters((f) => ({ ...f, name: e.target.value }))} + className="w-56 rounded border border-border-strong bg-surface-2 px-2 py-1 font-mono text-xs text-text" + /> + setFilters((f) => ({ ...f, hash: e.target.value }))} + className="w-44 rounded border border-border-strong bg-surface-2 px-2 py-1 font-mono text-xs text-text" + /> + + {filterActive && ( + + )} + + {sorted.length} of {poll.data.prepared.length} statements + +
+ + + + + + + + + + + + + + + + {sorted.map((r) => { + const total = r.hits + r.misses; + const hitRate = total > 0 ? r.hits / total : null; + const key = `${r.pool}-${r.hash}`; + const cell = texts[key]; + return ( + + toggle(r.pool, r.hash)} + title="Click to fetch the SQL body" + > + + + + + + + + + + + {cell && ( + + + + )} + + ); + })} + +
+ + onSort("pool")}> + Pool{sortIndicator("pool")} + + + + + onSort("kind")}> + Kind{sortIndicator("kind")} + + + + + onSort("name")}> + Name{sortIndicator("name")} + + + + + onSort("hash")}> + Hash{sortIndicator("hash")} + + + + + onSort("count_used")}> + Used{sortIndicator("count_used")} + + + + + onSort("refs_per_s")}> + Refs/s{sortIndicator("refs_per_s")} + + + + + onSort("hits")}> + Hits{sortIndicator("hits")} + + + + + onSort("misses")}> + Misses{sortIndicator("misses")} + + + + + onSort("hit_rate")}> + Hit rate{sortIndicator("hit_rate")} + + +
{r.pool}{r.kind}{r.name || "—"}{r.hash}{r.count_used} + {(() => { + const v = refsRate[`${r.pool}|${r.hash}`]; + return v === undefined ? "—" : v.toFixed(1); + })()} + {r.hits}{r.misses} + {hitRate === null ? "—" : `${(hitRate * 100).toFixed(1)}%`} +
+ {cell.loading && loading SQL…} + {cell.error && ( + SQL fetch failed: {cell.error} + )} + {cell.text && ( +
+
+ SQL · {key} + +
+
+{prettySql(cell.text)}
+                          
+
+ )} +
+ {poll.data.prepared.length === 0 && ( +

No prepared statements yet. The cache fills as clients send Parse over the wire — open the Clients page to confirm traffic is flowing.

+ )} + {poll.data.prepared.length > 0 && sorted.length === 0 && ( +

No statements match these filters. Click clear to see them all again.

+ )} + + ); +} + +function InternerTab() { + const { authHeader } = useAdminAuth(); + const poll = usePoll( + (signal) => apiGet("/api/interner", authHeader, signal), + POLL_MS, + ); + // Admin-only top-N from /api/interner/top — needed to show *which* + // entries dominate the cache. Without it the tab is just two summary + // cards and offers no actionable information. + const topPoll = usePoll( + (signal) => apiGet("/api/interner/top?n=20", authHeader, signal), + POLL_MS, + ); + + if (poll.error) return

{poll.error.message}

; + if (!poll.data) return

Loading interner stats…

; + + const fmtBytes = (n: number) => { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + return `${(n / 1024 / 1024).toFixed(2)} MiB`; + }; + + return ( + <> + +
+ + +
+ + {topPoll.error && ( +

{topPoll.error.message}

+ )} + {topPoll.data && ( +
+ + + + + + + + + + + + {topPoll.data.entries.map((e) => ( + + + + + + + + ))} + +
+ + Hash + + + + Kind + + + + Bytes + + + + Idle ms + + + + Preview + +
{e.hash}{e.kind}{fmtBytes(e.bytes)} + {e.idle_ms < 0 ? "—" : e.idle_ms} + {e.preview}
+ {topPoll.data.entries.length === 0 && ( +

Interner is empty. Either no SQL has been seen yet, or the build was compiled without the interner.

+ )} +
+ )} + + ); +} + +function Card({ + title, + entries, + bytes, + fmtBytes, +}: { + title: string; + entries: number; + bytes: number; + fmtBytes: (n: number) => string; +}) { + return ( +
+

{title}

+
+
+ + Entries + +
+
{entries}
+
+ + Total bytes + +
+
{fmtBytes(bytes)}
+
+ + Avg bytes / entry + +
+
{entries > 0 ? fmtBytes(Math.round(bytes / entries)) : "—"}
+
+
+ ); +} diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx new file mode 100644 index 000000000..b785eb88f --- /dev/null +++ b/frontend/src/pages/Clients.tsx @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { apiGet } from "../api"; +import { InfoLabel } from "../components/InfoLabel"; +import { PageHero } from "../components/PageHero"; +import { SectionHeader } from "../components/SectionHeader"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { usePoll } from "../hooks/usePoll"; +import type { ClientsDto } from "../types"; + +const POLL_MS = 1500; +const PAGE_SIZE = 50; + +type ClientTotals = Record; +type ClientRates = Record; + +// Computes per-client qps / tps from the delta between the current /api/clients +// snapshot and the previous one. Mirrors `useAppRates` on the Apps page; +// /api/clients only ships lifetime counters, so without this hook the operator +// has no way to see which client session is busy *right now* short of +// watching the queries column tick. +function useClientRates(data: ClientsDto | null): ClientRates { + const [rates, setRates] = useState({}); + const prevRef = useRef<{ ts: number; clients: ClientTotals } | null>(null); + useEffect(() => { + if (!data) return; + const cur: ClientTotals = {}; + for (const c of data.clients) { + cur[c.client_id] = { + queries: c.queries_total, + transactions: c.transactions_total, + }; + } + const prev = prevRef.current; + if (prev && prev.ts !== data.ts) { + const dt = (data.ts - prev.ts) / 1000; + if (dt > 0) { + const next: ClientRates = {}; + for (const [id, totals] of Object.entries(cur)) { + const p = prev.clients[id]; + if (p) { + next[id] = { + qps: Math.max(0, (totals.queries - p.queries) / dt), + tps: Math.max(0, (totals.transactions - p.transactions) / dt), + }; + } + } + setRates(next); + } + } + prevRef.current = { ts: data.ts, clients: cur }; + }, [data]); + return rates; +} + +type SortKey = "queries_total" | "errors_total" | "age_seconds" | "current_query_age_ms"; +type SortDir = "asc" | "desc"; + +interface Filters { + pool: string; + database: string; + user: string; + state: string; + appName: string; + addr: string; +} + +const STATE_OPTIONS = ["", "active", "idle", "waiting", "closing"]; + +// Single labelled text input. Browsers turn the label into a click target for +// the field, and operators see the placeholder *and* the field name even +// after they start typing — placeholder-as-label loses the field name the +// moment you type one character. +function FilterField({ + label, + value, + onChange, + width, + mono, +}: { + label: string; + value: string; + onChange: (v: string) => void; + width: string; + mono?: boolean; +}) { + const id = `clients-filter-${label.replace(/\W+/g, "-")}`; + return ( +
+ + onChange(e.target.value)} + className={`${width} rounded border border-border-strong bg-surface-2 px-2 py-1 text-sm text-text${mono ? " font-mono" : ""}`} + /> +
+ ); +} + +function buildQuery(filters: Filters, sort: SortKey, dir: SortDir, offset: number): string { + const params = new URLSearchParams(); + params.set("limit", String(PAGE_SIZE)); + params.set("offset", String(offset)); + params.set("sort", sort); + params.set("order", dir); + if (filters.pool) params.set("pool", filters.pool); + if (filters.database) params.set("database", filters.database); + if (filters.user) params.set("user", filters.user); + if (filters.state) params.set("state", filters.state); + if (filters.appName) params.set("application_name", filters.appName); + if (filters.addr) params.set("addr", filters.addr); + return params.toString(); +} + +export default function Clients() { + const { authHeader } = useAdminAuth(); + const [filters, setFilters] = useState({ + pool: "", + database: "", + user: "", + state: "", + appName: "", + addr: "", + }); + const [sort, setSort] = useState("queries_total"); + const [dir, setDir] = useState("desc"); + const [offset, setOffset] = useState(0); + + const query = useMemo(() => buildQuery(filters, sort, dir, offset), [filters, sort, dir, offset]); + const fetcher = useCallback( + (signal: AbortSignal) => apiGet(`/api/clients?${query}`, authHeader, signal), + [authHeader, query], + ); + const poll = usePoll(fetcher, POLL_MS); + const rates = useClientRates(poll.data); + // Client-side sort over the visible page only. /api/clients still + // paginates by lifetime counters (the server has no notion of qps), so + // the operator sees "the 50 clients the server picked, re-ordered by + // current rate". Tooltip on the header explains the scope. + const [pageSort, setPageSort] = useState<"qps" | "tps" | null>(null); + const [pageSortDir, setPageSortDir] = useState("desc"); + const onPageSort = (k: "qps" | "tps") => { + if (pageSort === k) { + setPageSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setPageSort(k); + setPageSortDir("desc"); + } + }; + const pageSortIndicator = (k: "qps" | "tps") => + pageSort === k ? (pageSortDir === "asc" ? " ▲" : " ▼") : ""; + const visibleClients = useMemo(() => { + if (!poll.data) return []; + if (!pageSort) return poll.data.clients; + const arr = [...poll.data.clients]; + arr.sort((a, b) => { + const av = rates[a.client_id]?.[pageSort] ?? 0; + const bv = rates[b.client_id]?.[pageSort] ?? 0; + return pageSortDir === "asc" ? av - bv : bv - av; + }); + return arr; + }, [poll.data, rates, pageSort, pageSortDir]); + const filterActive = + filters.pool !== "" || + filters.database !== "" || + filters.user !== "" || + filters.state !== "" || + filters.appName !== "" || + filters.addr !== ""; + const clearFilters = () => { + setFilters({ pool: "", database: "", user: "", state: "", appName: "", addr: "" }); + setOffset(0); + }; + + const onSort = (key: SortKey) => { + if (key === sort) { + setDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSort(key); + setDir("desc"); + } + setOffset(0); + }; + const sortIndicator = (key: SortKey) => (sort === key ? (dir === "asc" ? " ▲" : " ▼") : ""); + const updateFilter = (k: keyof Filters, v: string) => { + setFilters((f) => ({ ...f, [k]: v })); + setOffset(0); + }; + + const total = poll.data?.total ?? 0; + const showingFrom = total === 0 ? 0 : offset + 1; + const showingTo = Math.min(total, offset + (poll.data?.clients.length ?? 0)); + const canPrev = offset > 0; + const canNext = offset + PAGE_SIZE < total; + + if (poll.error) { + return ( +
+

Clients

+

+ Could not load clients: {poll.error.message}. Try Sign out → Sign in to refresh credentials, or check whether pg_doorman is running. +

+
+ ); + } + + return ( +
+ + +
+ updateFilter("pool", v)} /> + updateFilter("database", v)} /> + updateFilter("user", v)} /> + updateFilter("appName", v)} /> + updateFilter("addr", v)} /> +
+ + +
+ {filterActive && ( + + )} + + {showingFrom}–{showingTo} of {total} + +
+ + + + + + + + + + + + + + + + + + + + {visibleClients.map((c) => ( + + + + + + + + + + + + + + + + ))} + +
+ + Client + + + + Addr + + + + Pool + + + + App + + + + State + + + Wait + + + onSort("current_query_age_ms")} + > + Q age ms{sortIndicator("current_query_age_ms")} + + + + + onSort("age_seconds")}> + Age s{sortIndicator("age_seconds")} + + + + + onPageSort("qps")}> + Q/s{pageSortIndicator("qps")} + + + + + onPageSort("tps")}> + T/s{pageSortIndicator("tps")} + + + + + onSort("queries_total")}> + Queries{sortIndicator("queries_total")} + + + + + onSort("errors_total")}> + Errors{sortIndicator("errors_total")} + + + TLS
{c.client_id}{c.addr || "—"}{c.user}@{c.database}{c.application_name || "—"} + + {c.state} + + + {c.wait && c.wait !== "none" ? ( + {c.wait_ms ? `${c.wait_ms} ms` : c.wait} + ) : ( + "—" + )} + {c.current_query_age_ms || "—"}{c.age_seconds} + {rates[c.client_id] ? rates[c.client_id].qps.toFixed(1) : "—"} + + {rates[c.client_id] ? rates[c.client_id].tps.toFixed(1) : "—"} + {c.queries_total} 0 ? "text-warning" : ""}`}> + {c.errors_total} + + {c.tls ? "✓" : ""} +
+
+ + + {!poll.data && loading…} +
+
+ ); +} diff --git a/frontend/src/pages/ConfigState.tsx b/frontend/src/pages/ConfigState.tsx new file mode 100644 index 000000000..7cb98fd61 --- /dev/null +++ b/frontend/src/pages/ConfigState.tsx @@ -0,0 +1,491 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { apiGet } from "../api"; +import { Collapsible } from "../components/Collapsible"; +import { InfoLabel } from "../components/InfoLabel"; +import { PageHero } from "../components/PageHero"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { usePoll } from "../hooks/usePoll"; +import type { + AuthQueryDto, + ConfigDto, + DatabasesDto, + LogLevelDto, + PoolCoordinatorDto, + PoolScalingDto, + SocketsDto, + UsersDto, +} from "../types"; + +const FAST_MS = 5000; +const SLOW_MS = 15_000; + +export default function ConfigState() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function useEndpoint(endpoint: string, intervalMs: number) { + const { authHeader } = useAdminAuth(); + const fetcher = useCallback( + (signal: AbortSignal) => apiGet(endpoint, authHeader, signal), + [authHeader, endpoint], + ); + return usePoll(fetcher, intervalMs); +} + +function PanelShell({ + loading, + error, + children, +}: { + loading: boolean; + error: Error | null; + children: ReactNode; +}) { + if (error) return

{error.message}

; + if (loading) return

loading…

; + return <>{children}; +} + +function ConfigPanel() { + const poll = useEndpoint("/api/config", SLOW_MS); + const [filter, setFilter] = useState(""); + const filtered = useMemo(() => { + if (!poll.data) return []; + const q = filter.trim().toLowerCase(); + if (!q) return poll.data.config; + return poll.data.config.filter( + (e) => e.key.toLowerCase().includes(q) || e.value.toLowerCase().includes(q), + ); + }, [poll.data, filter]); + + return ( + +
+ setFilter(e.target.value)} + className="w-72 rounded border border-border-strong bg-surface-2 px-2 py-1 text-sm text-text" + /> + + {filtered.length} of {poll.data?.config.length ?? 0} keys + +
+ + + + + + + + + + + {filtered.map((e) => { + const changed = e.default !== "-" && e.default !== e.value; + return ( + + + + + + + ); + })} + +
KeyDefaultCurrentReload-able
+ {e.key} + + + {e.changeable === "yes" ? "yes" : "restart"} + +
+
+ ); +} + +/// Truncates long config values so a single outlier (e.g. `talos.keys` with +/// a comma-joined list of `.pem` paths) cannot blow the table column past +/// the viewport. The full value stays one hover away in the styled tooltip. +const VALUE_TRUNCATE_AT = 64; + +function ConfigValueCell({ + value, + className, + baseTip, +}: { + value: string; + className: string; + baseTip?: string; +}) { + const truncated = value.length > VALUE_TRUNCATE_AT; + const display = truncated ? `${value.slice(0, VALUE_TRUNCATE_AT)}…` : value; + const tip = truncated ? value : baseTip; + return ( + + {tip ? {display} : display} + + ); +} + +function LogLevelPanel() { + const poll = useEndpoint("/api/log_level", FAST_MS); + return ( + +
+
Active filter
+
{poll.data?.log_level}
+

+ RUST_LOG-style filter. Change at runtime with{" "} + {`SET log_level = '…'`}{" "} + on the admin protocol;{" "} + {`'default'`}{" "} + resets to the startup level. +

+
+
+ ); +} + +function AuthQueryPanel() { + const poll = useEndpoint("/api/auth_query", SLOW_MS); + return ( + + {poll.data?.pools.length === 0 ? ( +

No pool uses auth_query in this config. Set auth_query = ‘...’ under [databases.X] to enable per-database password lookups.

+ ) : ( + + + + + + + + + + + + + + {poll.data?.pools.map((row) => { + const total = row.cache_hits + row.cache_misses; + const hr = total > 0 ? row.cache_hits / total : null; + const failRate = + row.auth_success + row.auth_failure > 0 + ? row.auth_failure / (row.auth_success + row.auth_failure) + : 0; + return ( + + + + + + + + + + ); + })} + +
DatabaseEntriesHit rateAuth okAuth failExec errDyn pools
{row.database}{row.cache_entries} + {hr === null ? "—" : `${(hr * 100).toFixed(1)}%`} + {row.auth_success} 0.05 ? "text-danger" : failRate > 0.005 ? "text-warning" : "" + }`} + > + {row.auth_failure} + 0 ? "text-warning" : "" + }`} + > + {row.executor_errors} + {row.dynamic_pools_current}
+ )} +
+ ); +} + +function DatabasesPanel() { + const poll = useEndpoint("/api/databases", SLOW_MS); + return ( + + + + + + + + + + + + + + + {poll.data?.databases.map((d) => ( + + + + + + + + + + ))} + +
PoolHost:PortForce userModePool sizeMinConnections
{d.name} + {d.host}:{d.port} + {d.database && d.database !== d.name && ( + → {d.database} + )} + {d.force_user || "—"}{d.pool_mode}{d.pool_size}{d.min_pool_size} + {d.current_connections} / {d.max_connections} +
+
+ ); +} + +function UsersPanel() { + const poll = useEndpoint("/api/users", SLOW_MS); + return ( + + + + + + + + + + {poll.data?.users.map((u, i) => ( + + + + + ))} + +
UserPool mode
{u.name}{u.pool_mode}
+
+ ); +} + +function SocketsPanel() { + const poll = useEndpoint("/api/sockets", FAST_MS); + if (!poll.data && !poll.error) { + return

loading…

; + } + if (poll.error) { + return ( +

+ Socket counts are Linux-only. On macOS/Windows builds this card stays empty; if you are on Linux and seeing this, the request failed: {poll.error.message} +

+ ); + } + const s = poll.data!; + return ( +
+ + + + 0 ? "warn" : null} /> + + + + + + + + + + + + + +
+ ); +} + +function SocketCard({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+ {title} +
+
{children}
+
+ ); +} + +function KV({ + label, + value, + highlight, +}: { + label: string; + value: number; + highlight?: "warn" | "crit" | null; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function PoolScalingPanel() { + const poll = useEndpoint("/api/pool_scaling", FAST_MS); + return ( + + + + + + + + + + + + + + + + {poll.data?.pools.map((row) => ( + + + + + + + + + + + ))} + +
PoolIn-flightCreatesGate waitsBudget exAntic notifyAntic timeoutFallback
+ {row.user}@{row.database} + {row.inflight}{row.creates}{row.gate_waits} 0 ? "text-warning" : ""}`} + > + {row.gate_budget_ex} + {row.antic_notify} 0 ? "text-warning" : ""}`} + > + {row.antic_timeout} + 0 ? "text-warning" : ""}`} + > + {row.create_fallback} +
+
+ ); +} + +function PoolCoordinatorPanel() { + const poll = useEndpoint("/api/pool_coordinator", FAST_MS); + return ( + + + + + + + + + + + + + + {poll.data?.databases.map((row) => { + const sat = row.max_db_conn > 0 ? row.current / row.max_db_conn : 0; + return ( + + + + + + + + + ); + })} + +
DatabaseConnectionsReserve used / sizeReserve acqEvictionsExhaustions
{row.database}= 0.9 ? "text-danger" : sat >= 0.7 ? "text-warning" : "" + }`} + > + {row.current} / {row.max_db_conn}{" "} + ({(sat * 100).toFixed(0)}%) + + {row.reserve_used} / {row.reserve_size} + {row.reserve_acq}{row.evictions} 0 ? "text-danger" : ""}`} + > + {row.exhaustions} +
+
+ ); +} diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 000000000..f1c0b274f --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiGet } from "../api"; +import { PageHero } from "../components/PageHero"; +import { SectionHeader } from "../components/SectionHeader"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { usePoll } from "../hooks/usePoll"; +import type { LogEntryDto, LogsDto } from "../types"; + +const POLL_MS = 1500; +const MAX_KEEP = 500; +const LEVEL_OPTIONS = ["", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"]; + +const LEVEL_COLOR: Record = { + ERROR: "text-danger", + WARN: "text-warning", + INFO: "text-text", + DEBUG: "text-text-muted", + TRACE: "text-text-dim", +}; + +export default function Logs() { + const { authHeader } = useAdminAuth(); + const [level, setLevel] = useState(""); + const [target, setTarget] = useState(""); + const [autoScroll, setAutoScroll] = useState(true); + const [paused, setPaused] = useState(false); + const [lines, setLines] = useState([]); + const [meta, setMeta] = useState<{ + tap_active: boolean; + used: number; + capacity: number; + dropped_before: number; + dropped_total: number; + } | null>(null); + const sinceRef = useRef(0); + const tailRef = useRef(null); + + // Reset stream when level changes (full refetch from seq 0). The text + // filter stays client-side so changing it doesn't drop the buffer. + useEffect(() => { + sinceRef.current = 0; + setLines([]); + }, [level]); + + const fetcher = useCallback( + async (signal: AbortSignal): Promise => { + const params = new URLSearchParams(); + params.set("since", String(sinceRef.current)); + params.set("max", "200"); + if (level) params.set("level", level); + return apiGet(`/api/logs?${params.toString()}`, authHeader, signal); + }, + [authHeader, level], + ); + + const poll = usePoll(fetcher, paused ? 60_000 : POLL_MS); + + // Append new entries on each successful poll. + useEffect(() => { + if (!poll.data) return; + setMeta({ + tap_active: poll.data.tap_active, + used: poll.data.tap_used_entries, + capacity: poll.data.tap_capacity_entries, + dropped_before: poll.data.dropped_before, + dropped_total: poll.data.dropped_total, + }); + if (poll.data.entries.length > 0) { + sinceRef.current = poll.data.next_seq; + setLines((prev) => { + const next = [...prev, ...poll.data!.entries]; + if (next.length > MAX_KEEP) return next.slice(next.length - MAX_KEEP); + return next; + }); + } else if (poll.data.next_seq > sinceRef.current) { + // server moved forward without entries (e.g. filter rejected all). + sinceRef.current = poll.data.next_seq; + } + }, [poll.data]); + + useEffect(() => { + if (autoScroll && tailRef.current) { + tailRef.current.scrollIntoView({ behavior: "auto" }); + } + }, [lines, autoScroll]); + + if (poll.error) { + return ( +
+

Logs

+

+ Could not load logs: {poll.error.message}. Try Sign out → Sign in to refresh credentials, or check whether pg_doorman is running. +

+
+ ); + } + + const fmtTs = (ms: number) => { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`; + }; + + return ( +
+ + +
+ + setTarget(e.target.value)} + className="w-96 rounded border border-border-strong bg-surface-2 px-2 py-1 text-sm text-text font-mono" + /> + + + {meta && ( + + {meta.tap_active ? "tap on" : "tap off"} · {meta.used}/{meta.capacity} buffered · + {" "}drops {meta.dropped_total} + {meta.dropped_before > 0 && ` · ${meta.dropped_before} lost before since`} + + )} +
+
+ {(() => { + const filtered = target + ? lines.filter( + (e) => e.target.includes(target) || e.message.includes(target), + ) + : lines; + if (filtered.length === 0) return ; + return ( + + + {filtered.map((e) => ( + + + + + + + ))} + +
+ {fmtTs(e.ts_ms)} + {e.level}{e.target}{e.message}
+ ); + })()} +
+
+
+ ); +} + +function LogsEmpty({ + meta, +}: { + meta: { + tap_active: boolean; + used: number; + capacity: number; + dropped_before: number; + dropped_total: number; + } | null; +}) { + if (!meta) { + return

Opening log tap…

; + } + if (meta.capacity === 0) { + return ( +
+

Log streaming is turned off in the running config.

+

+ Set [web].log_tap_max_entries{" "} + to a positive number (8192 is a good default) and restart pg_doorman. Until then, the daemon ignores tap requests. +

+
+ ); + } + if (!meta.tap_active) { + return ( +
+

LogTap is currently off.

+

+ The tap shuts off 2 minutes after the last viewer. It should re-arm on the next poll — if it stays off after a few seconds, reload the page. +

+
+ ); + } + return ( +
+

+ Tap is open ({meta.used}/{meta.capacity} buffered) and the filter excludes everything in the buffer. +

+

+ Either the pooler is idle, or your filter is too narrow — clear the search box, pick ‘all levels’, and run any query against pg_doorman. Drops since the tap opened: {meta.dropped_total}. +

+
+ ); +} diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx new file mode 100644 index 000000000..ccdec5475 --- /dev/null +++ b/frontend/src/pages/Overview.tsx @@ -0,0 +1,1219 @@ +import { useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useSearchParams } from "react-router-dom"; +import { apiGet } from "../api"; +import { AreaChart } from "../components/AreaChart"; +import { Collapsible } from "../components/Collapsible"; +import { DualAxisChart } from "../components/DualAxisChart"; +import { HealthPill } from "../components/HealthPill"; +import { Heatmap } from "../components/Heatmap"; +import { PageHero } from "../components/PageHero"; +import { MemoryPanel } from "../components/MemoryPanel"; +import { PanelView, type PanelKind } from "../components/PanelView"; +import { SectionHeader } from "../components/SectionHeader"; +import { Sparkline } from "../components/Sparkline"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { useHistory } from "../hooks/useHistory"; +import { usePoll } from "../hooks/usePoll"; +import { + aggregateHealth, + type HealthState, + type PoolHistory, + type PoolHistoryPoint, +} from "../lib/thresholds"; +import type { ChartEvent } from "../components/Sparkline"; +import type { + AuthQueryDto, + EventsDto, + InternerDto, + OverviewDto, + PoolCoordinatorDto, + PoolScalingDto, + PoolsDto, + ProcessDto, + SocketsDto, +} from "../types"; + +const POLL_MS = 1500; +const HISTORY_KEY = "overview"; +const HEATMAP_CELLS = 60; + +interface OverviewSamplePoint { + ts: number; + query_p95_max_ms: number; + qps: number; + tps: number; + errors_per_s: number; + saturation_max_pct: number; + active_clients: number; + idle_clients: number; + waiting_clients: number; + oldest_active_age_max_ms: number; +} + +interface RawTotals { + ts: number; + query_count_total: number; + transaction_count_total: number; + errors_count_total: number; +} + +type PoolSnap = Record; +type PoolSatSnap = Record; + +const EMPTY_HEALTH: HealthState = { state: "ok", reason: null, perPool: [] }; + +export default function Overview() { + const { authHeader } = useAdminAuth(); + const overviewPoll = usePoll( + (signal) => apiGet("/api/overview", authHeader, signal), + POLL_MS, + ); + const poolsPoll = usePoll( + (signal) => apiGet("/api/pools", authHeader, signal), + POLL_MS, + ); + // Resource detail polls — slower cadence (3 s) since the data is not + // hot-path and the section is collapsed by default. + const socketsPoll = usePoll( + (signal) => apiGet("/api/sockets", authHeader, signal), + 3000, + ); + const internerPoll = usePoll( + (signal) => apiGet("/api/interner", authHeader, signal), + 3000, + ); + // Threshold-only polls for §15.4 reconnect-rate, gate-budget, coordinator, + // and auth-failure rules. Not rendered, but their counters feed the + // per-pool history that the threshold engine reads. + const scalingPoll = usePoll( + (signal) => apiGet("/api/pool_scaling", authHeader, signal), + POLL_MS, + ); + const coordPoll = usePoll( + (signal) => apiGet("/api/pool_coordinator", authHeader, signal), + POLL_MS, + ); + const authPoll = usePoll( + (signal) => apiGet("/api/auth_query", authHeader, signal), + 3000, + ); + // Process resources (RSS, CPU, FDs, threads). Slow cadence (3 s) — the + // process card is informational, not alerting, and these /proc reads are + // not free at 1.5 s. + const processPoll = usePoll( + (signal) => apiGet("/api/process", authHeader, signal), + 3000, + ); + // Admin event ring (RELOAD/PAUSE/RESUME/RECONNECT) — paint vertical + // annotation lines on every chart so a metric spike correlates with the + // operator action that caused it. + const eventsPoll = usePoll( + (signal) => apiGet("/api/events", authHeader, signal), + 3000, + ); + const chartEvents: ChartEvent[] = useMemo(() => { + const list = eventsPoll.data?.events ?? []; + return list.map((e) => ({ ts: e.ts_ms / 1000, label: e.target })); + }, [eventsPoll.data]); + + // Per-thread CPU% history. Each successful /api/process poll computes a + // delta against the previous snapshot and pushes a row into a rolling + // 120-point window. The threads PanelView reads this history to render + // a line per tokio worker so an imbalanced runtime is visible as one + // line at 100% while the others stay near 0. + const processSnapshotsRef = useRef([]); + const threadHistoryRef = useRef< + Array<{ + ts: number; + // pct keyed by tid for the *whole* known set at this snapshot. Threads + // that disappeared or just appeared are filled with NaN so uPlot + // breaks the line at the join. + pcts: Map; + names: Map; + }> + >([]); + if (processPoll.data) { + const snapshots = processSnapshotsRef.current; + const last = snapshots[snapshots.length - 1]; + if (!last || last.ts !== processPoll.data.ts) { + const cur = processPoll.data; + if (last && cur.ts > last.ts) { + const dtSec = (cur.ts - last.ts) / 1000; + if (dtSec > 0) { + const pcts = new Map(); + const names = new Map(); + const lastByTid = new Map( + last.threads_breakdown.map((t) => [t.tid, t.cpu_user_us + t.cpu_system_us]), + ); + for (const t of cur.threads_breakdown) { + const prevTotal = lastByTid.get(t.tid); + const curTotal = t.cpu_user_us + t.cpu_system_us; + const pct = + prevTotal === undefined + ? 0 + : Math.max(0, ((curTotal - prevTotal) / 1_000_000 / dtSec) * 100); + pcts.set(t.tid, pct); + names.set(t.tid, t.name); + } + threadHistoryRef.current.push({ ts: cur.ts, pcts, names }); + if (threadHistoryRef.current.length > 240) threadHistoryRef.current.shift(); + } + } + snapshots.push(cur); + if (snapshots.length > 240) snapshots.shift(); + } + } + + const rawHistory = useHistory(`${HISTORY_KEY}.raw`); + const sampleHistory = useHistory(HISTORY_KEY); + const poolErrorsHistory = useHistory(`${HISTORY_KEY}.poolerrs`); + const poolSatHistory = useHistory(`${HISTORY_KEY}.poolsat`); + + useEffect(() => { + if (!overviewPoll.data || !poolsPoll.data) return; + const ov = overviewPoll.data; + const pools = poolsPoll.data; + const prevRaw = rawHistory.history[rawHistory.history.length - 1]; + // Stale-tab guard: if the gap since the last poll is bigger than five + // intervals (browser throttled the timer or the laptop slept), drop the + // history. Otherwise the chart bridges the gap with a flat line and + // misrepresents the state during the pause as steady-state activity. + if (prevRaw && ov.ts - prevRaw.ts > 5 * POLL_MS) { + rawHistory.replace([]); + sampleHistory.replace([]); + poolErrorsHistory.replace([]); + poolSatHistory.replace([]); + return; + } + rawHistory.push({ + ts: ov.ts, + query_count_total: ov.query_count_total, + transaction_count_total: ov.transaction_count_total, + errors_count_total: ov.errors_count_total, + }); + let qps = 0; + let tps = 0; + let errPs = 0; + if (prevRaw) { + const dt = (ov.ts - prevRaw.ts) / 1000; + if (dt > 0) { + qps = Math.max(0, (ov.query_count_total - prevRaw.query_count_total) / dt); + tps = Math.max(0, (ov.transaction_count_total - prevRaw.transaction_count_total) / dt); + errPs = Math.max(0, (ov.errors_count_total - prevRaw.errors_count_total) / dt); + } + } + sampleHistory.push({ + ts: ov.ts, + query_p95_max_ms: pools.pools.reduce((m, p) => Math.max(m, p.query_p95_ms), 0), + qps, + tps, + errors_per_s: errPs, + saturation_max_pct: + pools.pools.reduce((m, p) => { + const s = p.max_connections > 0 ? p.active / p.max_connections : 0; + return Math.max(m, s); + }, 0) * 100, + active_clients: ov.active_clients, + idle_clients: ov.idle_clients, + waiting_clients: ov.waiting_clients, + oldest_active_age_max_ms: pools.pools.reduce( + (m, p) => Math.max(m, p.max_active_age_ms), + 0, + ), + }); + const errSnap: PoolSnap = {}; + const satSnap: PoolSatSnap = {}; + // Index sibling-endpoint rows for O(1) lookup per pool id below. + const scalingByKey = new Map(); + if (scalingPoll.data) { + for (const r of scalingPoll.data.pools) { + scalingByKey.set(`${r.user}@${r.database}`, { + creates: r.creates, + gate_budget_ex: r.gate_budget_ex, + }); + } + } + const coordByDb = new Map(); + if (coordPoll.data) { + for (const r of coordPoll.data.databases) { + coordByDb.set(r.database, r.exhaustions); + } + } + for (const p of pools.pools) { + const scaling = scalingByKey.get(p.id); + errSnap[p.id] = { + ts: ov.ts, + errors_total: p.errors_total, + queries_total: p.queries_total, + creates_total: scaling?.creates, + gate_budget_ex_total: scaling?.gate_budget_ex, + coordinator_exhaustions_total: coordByDb.get(p.database), + }; + satSnap[p.id] = { + saturation: p.max_connections > 0 ? p.active / p.max_connections : 0, + max_connections: p.max_connections, + label: p.id, + }; + } + poolErrorsHistory.push(errSnap); + poolSatHistory.push(satSnap); + // The effect keys on the overview timestamp only. Pools, scaling, + // coordinator, and auth-query polls all run independently and their + // timestamps drift relative to the overview cadence — including any of + // them in the dep array makes the effect fire mid-interval with + // `dt ≈ 200 ms` and a tiny `delta`, which the sparkline draws as a + // sawtooth wave dropping to zero between each real overview tick. + // pools/scaling/coord data is still read snapshot-style at each fire + // so the per-pool history retains the threshold-engine fields. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overviewPoll.data?.ts]); + + const poolHistoryForEngine: PoolHistory = useMemo(() => { + const map: PoolHistory = new Map(); + for (const snap of poolErrorsHistory.history) { + for (const id of Object.keys(snap)) { + const list = map.get(id) ?? []; + list.push(snap[id]); + map.set(id, list); + } + } + return map; + }, [poolErrorsHistory.history]); + + const health = useMemo(() => { + if (!overviewPoll.data || !poolsPoll.data) return EMPTY_HEALTH; + return aggregateHealth( + overviewPoll.data, + poolsPoll.data.pools, + poolHistoryForEngine, + authPoll.data ?? null, + ); + }, [overviewPoll.data, poolsPoll.data, poolHistoryForEngine, authPoll.data]); + + const seriesXs = useMemo( + () => sampleHistory.history.map((s) => s.ts / 1000), + [sampleHistory.history], + ); + + const sigSeries = (extract: (s: OverviewSamplePoint) => number): [number[], number[]] => [ + seriesXs, + sampleHistory.history.map(extract), + ]; + + // Panel drill-down state. The currently-open panel is encoded in the + // route query string so a deep-link to e.g. ?panel=traffic survives a + // page reload and can be shared during an incident handover. + const [searchParams, setSearchParams] = useSearchParams(); + const openPanel = searchParams.get("panel"); + const closePanel = () => { + const next = new URLSearchParams(searchParams); + next.delete("panel"); + setSearchParams(next, { replace: true }); + }; + const openPanelById = (id: string) => { + const next = new URLSearchParams(searchParams); + next.set("panel", id); + setSearchParams(next, { replace: false }); + }; + + const latest = sampleHistory.history[sampleHistory.history.length - 1]; + + // Connection breakdown: stacked area active / idle / waiting over the + // sample window. Three separate (non-cumulative) series; AreaChart stacks + // them internally. + const connBreakdown: [number[], ...number[][]] = useMemo(() => { + const xs = sampleHistory.history.map((s) => s.ts / 1000); + const active = sampleHistory.history.map((s) => s.active_clients); + const idle = sampleHistory.history.map((s) => s.idle_clients); + const waiting = sampleHistory.history.map((s) => s.waiting_clients); + return [xs, active, idle, waiting]; + }, [sampleHistory.history]); + + // Top 5 pools by errors-per-second over the last 30 s window. We compute + // each pool's eps by walking poolErrorsHistory and taking the average over + // the last SUSTAIN points; the resulting AreaChart paints the last sample + // window with each pool's eps as a stacked band. + const top5Errors = useMemo(() => { + const ids = poolsPoll.data ? poolsPoll.data.pools.map((p) => p.id) : []; + const recent = poolErrorsHistory.history.slice(-20); + const epsById = new Map(); + for (const id of ids) { + let prev: PoolHistoryPoint | undefined; + let max = 0; + for (const snap of recent) { + const cur = snap[id]; + if (!cur) continue; + if (prev) { + const dt = (cur.ts - prev.ts) / 1000; + if (dt > 0) { + const eps = Math.max(0, (cur.errors_total - prev.errors_total) / dt); + if (eps > max) max = eps; + } + } + prev = cur; + } + epsById.set(id, max); + } + const top = ids + .map((id) => ({ id, eps: epsById.get(id) ?? 0 })) + .filter((x) => x.eps > 0) + .sort((a, b) => b.eps - a.eps) + .slice(0, 5); + if (top.length === 0) return { labels: [] as string[], data: [[]] as [number[], ...number[][]] }; + const xs = poolErrorsHistory.history.map((snap) => { + const anyKey = top.find((t) => snap[t.id])?.id; + return anyKey ? snap[anyKey].ts / 1000 : 0; + }); + const series = top.map(({ id }) => { + const out: number[] = []; + let prev: PoolHistoryPoint | undefined; + for (const snap of poolErrorsHistory.history) { + const cur = snap[id]; + if (!cur || !prev) { + out.push(0); + prev = cur ?? prev; + continue; + } + const dt = (cur.ts - prev.ts) / 1000; + const eps = dt > 0 ? Math.max(0, (cur.errors_total - prev.errors_total) / dt) : 0; + out.push(eps); + prev = cur; + } + return out; + }); + return { + labels: top.map((t) => t.id), + data: [xs, ...series] as [number[], ...number[][]], + }; + }, [poolErrorsHistory.history, poolsPoll.data]); + + // Pool fill heatmap rows: one per current pool, last HEATMAP_CELLS cells of + // saturation. Pads with `null` on the left when history is shorter. + const heatmapRows = useMemo(() => { + const ids = poolsPoll.data ? poolsPoll.data.pools.map((p) => p.id) : []; + const capacities = new Map(); + if (poolsPoll.data) { + for (const p of poolsPoll.data.pools) capacities.set(p.id, p.max_connections); + } + const recent = poolSatHistory.history.slice(-HEATMAP_CELLS); + return ids.map((id) => { + const cells: (number | null)[] = new Array(HEATMAP_CELLS).fill(null); + const offset = HEATMAP_CELLS - recent.length; + for (let i = 0; i < recent.length; i++) { + const cell = recent[i][id]; + cells[offset + i] = cell ? cell.saturation : null; + } + return { label: id, cells, capacity: capacities.get(id) ?? 0 }; + }); + }, [poolsPoll.data, poolSatHistory.history]); + + // Compact human-friendly duration. Tile widths are tight; the suffix + // sits right after the number with no space so the whole string fits + // a 6-character mono cell at any magnitude. "87ms" / "1.2s" / "1m29s" + // / "1h42m". Operators read this as a number-plus-magnitude word, the + // exact precision is in the hover tooltip. + const fmtMs = (n: number | undefined): string => { + if (n === undefined) return "—"; + // Sub-millisecond values arrive as fractions (e.g. 0.42 ms). Show + // two decimals so an operator does not see "0ms" for a pool that + // actually serves 420-microsecond p95. + if (n > 0 && n < 1) return `${n.toFixed(2)}ms`; + if (n < 10) return `${n.toFixed(1)}ms`; + if (n < 1000) return `${Math.round(n)}ms`; + if (n < 10_000) return `${(n / 1000).toFixed(1)}s`; + if (n < 60_000) return `${Math.round(n / 1000)}s`; + if (n < 3_600_000) { + const m = Math.floor(n / 60_000); + const s = Math.floor((n % 60_000) / 1000); + return `${m}m${s.toString().padStart(2, "0")}s`; + } + const h = Math.floor(n / 3_600_000); + const m = Math.floor((n % 3_600_000) / 60_000); + return `${h}h${m.toString().padStart(2, "0")}m`; + }; + // Compact rate formatter — number + k/M suffix, no whitespace. The + // separate `unit` argument lives in the tile label, not the value, so + // the value column stays wide enough to render two numbers when the + // caller composes (qps + tps). + const fmtRate = (n: number | undefined, unit?: string): string => { + if (n === undefined) return "—"; + const abs = Math.abs(n); + let body: string; + if (abs >= 1_000_000) body = `${(n / 1_000_000).toFixed(1)}M`; + else if (abs >= 10_000) body = `${(n / 1000).toFixed(0)}k`; + else if (abs >= 1000) body = `${(n / 1000).toFixed(1)}k`; + else if (abs >= 10) body = n.toFixed(0); + else body = n.toFixed(2); + return unit ? `${body}${unit}` : body; + }; + const fmtPct = (n: number | undefined) => (n === undefined ? "—" : `${Math.round(n)}%`); + + if (overviewPoll.error || poolsPoll.error) { + const err = overviewPoll.error?.message ?? poolsPoll.error?.message ?? "fetch failed"; + return ( +
+

Overview

+

+ Could not read overview/pools: {err}. The pooler may be unreachable, or admin credentials may have been rotated. +

+
+ ); + } + + return ( +
+ +
+ + openPanelById("threads")} onOpenRss={() => openPanelById("rss")} /> + +
+ openPanelById("latency")}> + s.query_p95_max_ms)} + warn={100} + crit={500} + logY + syncKey="overview" + events={chartEvents} + tip="Worst per-pool query p95 across all pools, in milliseconds. Amber dashed line at 100 ms, red at 500 ms. Click the tile for the 1-hour panel with p50/p95/p99." + /> + + openPanelById("traffic")}> + s.qps)} + syncKey="overview" + events={chartEvents} + tip="Aggregate rate across all pools. The two numbers are queries-per-second (left) and transactions-per-second (right); the sparkline tracks q/s. Footer line spells out which is which." + /> + + openPanelById("errors")}> + s.errors_per_s)} + warn={1} + crit={10} + syncKey="overview" + events={chartEvents} + tip="Aggregate errors per second across all pools (any non-zero SQLSTATE). Amber at 1/s, red at 10/s. Click the tile for the SQLSTATE breakdown." + /> + + openPanelById("saturation")}> + s.saturation_max_pct)} + warn={70} + crit={90} + syncKey="overview" + events={chartEvents} + tip="Highest single-pool saturation right now, in percent of pool_size. Amber at 70 %, red at 90 %. The heatmap below identifies which pool is hot." + /> + +
+
+ openPanelById("conn_breakdown")} + help={{ + what: "Stacked clients in active / idle / waiting state.", + how: "Stacked over the 3 min window. No threshold — the shape is the signal. Active rising while idle stays low = good throughput; waiting rising = backends are full and clients are queueing.", + }} + > + + + {heatmapRows.length > 0 && ( + + + + )} + openPanelById("wait_oldest")} + help={{ + what: "Left axis: clients currently waiting for a backend. Right axis (log ms): worst single in-flight query age across pools.", + how: "Both lines move together when traffic is fine. They diverge when one client holds a transaction open and others queue behind it — that is the pattern to look for during a stall.", + normal: "Waiting near 0; oldest active < 30 s. Sustained > 5 min = stuck connection.", + }} + > + s.waiting_clients), + sampleHistory.history.map((s) => Math.max(1, s.oldest_active_age_max_ms)), + ]} + leftLabel="waiting" + rightLabel="oldest-active ms" + leftStroke="rgb(91 140 255)" + rightStroke="rgb(245 165 36)" + rightLogScale + rightWarn={30_000} + rightCrit={300_000} + syncKey="overview" + events={chartEvents} + /> + + {top5Errors.labels.length > 0 && ( + openPanelById("top_errors")} + help={{ + what: `The ${top5Errors.labels.length} pools with the highest errors-per-second over the last 30 s.`, + how: "Each band is one pool. Empty = no errors in the last 30 s. A band sustained above 1 err/s for ten samples in a row is the pool that needs the SQLSTATE drill-down.", + normal: "Bands hovering at 0 = no errors. Sustained > 1 / s on one band = investigate that pool.", + }} + > + + + )} + + + +
+ + {openPanel === "rss" && } + {openPanel && openPanel !== "rss" && ( + + )} +
+ ); +} + +function Card({ + title, + help, + children, + onTitleClick, +}: { + title: string; + help?: { what?: string; how?: string; normal?: string }; + children: ReactNode; + onTitleClick?: () => void; +}) { + // When the section is bound to a PanelView (`onTitleClick` set) we make + // the whole card clickable, not just the title text. Operators expected + // the chart-title arrow ↗ to be a clue that the card opens, but they + // kept clicking the canvas instead of the heading. The wrapper button + // takes the click anywhere; the canvas itself still owns mouse-move for + // hover readout. + const inner = ( + <> + +
{children}
+ + ); + if (onTitleClick) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onTitleClick(); + } + }} + className="cursor-pointer rounded-md border border-border bg-surface transition-colors hover:border-border-strong" + title="Open in panel view (1h history, p50/p95/p99 table)." + > + {inner} +
+ ); + } + return
{inner}
; +} + +// Wrapper that turns a Sparkline card into a button-like region: any click +// inside (other than on the cursor itself, which uPlot prevents from +// bubbling) opens the matching PanelView. Keyboard activation via Enter +// keeps the affordance accessible for non-mouse users. +function ChartLink({ + onClick, + children, +}: { + onClick: () => void; + children: ReactNode; +}) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className="cursor-pointer transition-colors hover:bg-surface-2" + title="Open in panel view (1h history, p50/p95/p99 table)." + > + {children} +
+ ); +} + +interface PanelDescriptor { + open: true; + title: string; + kind: PanelKind; + data: [number[], ...number[][]]; + labels: string[]; + fills?: string[]; + rightSeries?: number[]; + rightLogScale?: boolean; + warn?: number; + crit?: number; + units?: string; + events?: import("../components/Sparkline").ChartEvent[]; +} + +function panelDescriptor( + id: string, + seriesXs: number[], + history: OverviewSamplePoint[], + connBreakdown: [number[], ...number[][]], + top5Errors: { labels: string[]; data: [number[], ...number[][]] }, + events: import("../components/Sparkline").ChartEvent[], + threadHistory: Array<{ + ts: number; + pcts: Map; + names: Map; + }>, + processSnapshots: ProcessDto[], +): PanelDescriptor { + switch (id) { + case "latency": + return { + open: true, + title: "Latency P95 (max across pools)", + kind: "line", + data: [seriesXs, history.map((s) => s.query_p95_max_ms)] as [number[], ...number[][]], + labels: ["query p95"], + fills: ["rgb(255 176 0)"], + warn: 100, + crit: 500, + units: "ms", + events, + }; + case "traffic": + return { + open: true, + title: "Traffic — qps and tps", + kind: "line", + data: [ + seriesXs, + history.map((s) => s.qps), + history.map((s) => s.tps), + ] as [number[], ...number[][]], + labels: ["queries / s", "transactions / s"], + fills: ["rgb(255 176 0)", "rgb(0 212 255)"], + units: "/s", + events, + }; + case "errors": + return { + open: true, + title: "Errors per second", + kind: "line", + data: [seriesXs, history.map((s) => s.errors_per_s)] as [number[], ...number[][]], + labels: ["errors / s"], + fills: ["rgb(255 77 77)"], + warn: 1, + crit: 10, + units: "/s", + events, + }; + case "saturation": + return { + open: true, + title: "Worst pool saturation %", + kind: "line", + data: [seriesXs, history.map((s) => s.saturation_max_pct)] as [number[], ...number[][]], + labels: ["saturation max %"], + fills: ["rgb(57 211 83)"], + warn: 70, + crit: 90, + units: "%", + events, + }; + case "conn_breakdown": + return { + open: true, + title: "Connection breakdown — active / idle / waiting", + kind: "stackedArea", + data: connBreakdown, + labels: ["active", "idle", "waiting"], + fills: ["rgb(57 211 83)", "rgb(154 148 133)", "rgb(255 176 0)"], + events, + }; + case "wait_oldest": + return { + open: true, + title: "Wait queue vs oldest active query", + kind: "dualAxis", + data: [ + seriesXs, + history.map((s) => s.waiting_clients), + history.map((s) => Math.max(1, s.oldest_active_age_max_ms)), + ] as [number[], ...number[][]], + labels: ["waiting clients", "oldest active ms"], + fills: ["rgb(0 212 255)", "rgb(255 176 0)"], + rightSeries: [2], + rightLogScale: true, + events, + }; + case "threads": { + // Per-thread CPU over the rolling window. We keep only threads that + // ever exceeded 1% in the window (the rest are bookkeeping overhead + // — jemalloc background workers idling at 0 — and they bury the + // signal in the legend). Series order: highest peak first so the + // legend matches the panel summary. + const xs = threadHistory.map((s) => s.ts / 1000); + const peakByTid = new Map(); + const nameByTid = new Map(); + for (const snap of threadHistory) { + for (const [tid, pct] of snap.pcts.entries()) { + if (pct > (peakByTid.get(tid) ?? 0)) peakByTid.set(tid, pct); + if (!nameByTid.has(tid)) nameByTid.set(tid, snap.names.get(tid) ?? `tid${tid}`); + } + } + const tids = [...peakByTid.entries()] + .filter(([, peak]) => peak >= 1) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([tid]) => tid); + const seriesPalette = [ + "rgb(255 176 0)", + "rgb(0 212 255)", + "rgb(57 211 83)", + "rgb(255 77 77)", + "rgb(177 140 245)", + "rgb(91 140 255)", + "rgb(245 165 36)", + "rgb(154 148 133)", + ]; + const series: number[][] = tids.map((tid) => + threadHistory.map((snap) => { + const v = snap.pcts.get(tid); + return v === undefined ? NaN : v; + }), + ); + const labels = tids.map((tid) => `${nameByTid.get(tid) ?? "tid"}#${tid}`); + return { + open: true, + title: "Per-thread CPU% (active threads only, ≥ 1% peak)", + kind: "line", + data: [xs, ...series] as [number[], ...number[][]], + labels, + fills: seriesPalette.slice(0, tids.length), + warn: 60, + crit: 90, + units: "% of 1 core", + events, + }; + } + case "rss": { + // RSS over time + cumulative CPU as secondary line. Memory + // breakdown research is in flight; until that lands we plot what we + // already have — the operator at least sees the growth curve. + const xs = processSnapshots.map((s) => s.ts / 1000); + const rss = processSnapshots.map((s) => s.rss_bytes / (1024 * 1024)); + const vm = processSnapshots.map((s) => s.vm_size_bytes / (1024 * 1024)); + return { + open: true, + title: "Process memory — RSS / VM", + kind: "line", + data: [xs, rss, vm] as [number[], ...number[][]], + labels: ["RSS MiB", "VM MiB"], + fills: ["rgb(255 176 0)", "rgb(154 148 133 / 0.7)"], + units: "MiB", + events, + }; + } + case "top_errors": + return { + open: true, + title: `Top ${top5Errors.labels.length} pools by error rate`, + kind: "stackedArea", + data: top5Errors.data, + labels: top5Errors.labels, + fills: [ + "rgb(255 77 77 / 0.7)", + "rgb(255 176 0 / 0.7)", + "rgb(177 140 245 / 0.7)", + "rgb(91 140 255 / 0.65)", + "rgb(57 211 83 / 0.6)", + ], + events, + }; + default: + return { + open: true, + title: id, + kind: "line", + data: [seriesXs] as [number[], ...number[][]], + labels: [], + events, + }; + } +} + + +function ResourceDetail({ + sockets, + interner, +}: { + sockets: SocketsDto | null; + interner: InternerDto | null; +}) { + const fmtBytes = (n: number) => { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`; + }; + return ( +
+
+

Sockets

+ {sockets ? ( + + + + + + + + + +
tcp established{sockets.tcp.established}
tcp time-wait{sockets.tcp.time_wait}
tcp close-wait{sockets.tcp.close_wait}
tcp listen{sockets.tcp.listen}
tcp6 established{sockets.tcp6.established}
unix-stream connected{sockets.unix_stream.connected}
+ ) : ( +

linux only — no data on this platform.

+ )} +
+
+

Query interner

+ {interner ? ( + + + + + + + +
named entries{interner.named.entries}
named bytes{fmtBytes(interner.named.bytes)}
anonymous entries{interner.anonymous.entries}
anonymous bytes{fmtBytes(interner.anonymous.bytes)}
+ ) : ( +

loading…

+ )} +
+
+ ); +} + +// Process resource bar — RSS, CPU%, FDs, threads, uptime, hostname/pid. +// CPU% is computed from successive snapshots: every poll we record the +// previous (cpu_user_us + cpu_system_us) and the latest, divide by the +// elapsed wall-clock and the core count to get a percentage of one core. +// "100%" means one core saturated; with N cores fully busy you'd see +// N×100%. The bar paints amber when total CPU > 60% of cpu_cores and red +// when > 90%; FDs paint amber > 70% of limit, red > 90%. +function ProcessBar({ + process, + onOpenThreads, + onOpenRss, +}: { + process: ProcessDto | null; + onOpenThreads?: () => void; + onOpenRss?: () => void; +}) { + // Two refs: the previous snapshot we computed against, and the most + // recent percentage. Re-renders that don't bring a new ts (a sibling + // poll updated state) reuse the cached delta instead of nulling it + // out — without that we'd flicker "sampling…" between every real poll. + const prevRef = useRef(null); + const cachedPctRef = useRef<{ + cpuPct: number | null; + threadDeltas: { tid: number; name: string; pct: number }[]; + forTs: number; + } | null>(null); + + let cpuPct: number | null = null; + let threadDeltas: { tid: number; name: string; pct: number }[] = []; + const last = prevRef.current; + if (process && cachedPctRef.current && cachedPctRef.current.forTs === process.ts) { + // Same poll snapshot we already computed against — reuse cached values. + cpuPct = cachedPctRef.current.cpuPct; + threadDeltas = cachedPctRef.current.threadDeltas; + } else if (process && last && last.ts !== process.ts) { + const dtSec = (process.ts - last.ts) / 1000; + if (dtSec > 0 && process.cpu_cores > 0) { + const usDelta = + process.cpu_user_us + + process.cpu_system_us - + (last.cpu_user_us + last.cpu_system_us); + cpuPct = (usDelta / 1_000_000 / dtSec) * 100; + + // Per-thread CPU% deltas. Operators care about the hottest tokio + // worker — a single worker pinned to 100% means the runtime is + // imbalanced even when the global CPU number looks fine. + const lastByTid = new Map( + last.threads_breakdown.map((t) => [t.tid, t.cpu_user_us + t.cpu_system_us]), + ); + threadDeltas = process.threads_breakdown + .map((t): { tid: number; name: string; pct: number } | null => { + const prevTotal = lastByTid.get(t.tid); + const cur = t.cpu_user_us + t.cpu_system_us; + if (prevTotal === undefined) return null; + const deltaUs = cur - prevTotal; + if (deltaUs <= 0) return { tid: t.tid, name: t.name, pct: 0 }; + return { tid: t.tid, name: t.name, pct: (deltaUs / 1_000_000 / dtSec) * 100 }; + }) + .filter((x: { tid: number; name: string; pct: number } | null): x is { tid: number; name: string; pct: number } => x !== null) + .sort((a: { pct: number }, b: { pct: number }) => b.pct - a.pct); + } + } + // Stash *after* computing the delta — but only when we actually advanced + // to a new poll snapshot. Skipping stale re-renders keeps the cached + // values usable on the next paint. + if (process && (!last || last.ts !== process.ts)) { + prevRef.current = process; + cachedPctRef.current = { cpuPct, threadDeltas, forTs: process.ts }; + } + + if (!process) return null; + + const maxThreadPct = threadDeltas[0]?.pct ?? null; + const minThreadPct = + threadDeltas.length > 0 ? threadDeltas[threadDeltas.length - 1].pct : null; + const avgThreadPct = + threadDeltas.length > 0 + ? threadDeltas.reduce((s, t) => s + t.pct, 0) / threadDeltas.length + : null; + + const fmtBytes = (n: number) => { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MiB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GiB`; + }; + const fmtUptime = (s: number): string => { + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${m % 60}m`; + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; + }; + // FD limits land at 2^30+ on modern Linux containers — formatting them + // as raw integers turned the tile into "66/1073741816" which truncated + // and read as gibberish. Operators care that the limit is "effectively + // infinite", not the exact number; if the limit is sane (< 1M) we show + // the raw figure, otherwise we abbreviate. + const fmtFdLimit = (n: number): string => { + if (n <= 0) return "?"; + if (n < 1_000_000) return n.toLocaleString(); + if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(0)}M`; + return "∞"; + }; + + const cpuTone = + cpuPct === null + ? "text-text-muted" + : cpuPct > 90 * process.cpu_cores + ? "text-danger" + : cpuPct > 60 * process.cpu_cores + ? "text-warning" + : "text-text"; + const fdRatio = process.fd_limit > 0 ? process.fd_open / process.fd_limit : 0; + const fdTone = + fdRatio > 0.9 ? "text-danger" : fdRatio > 0.7 ? "text-warning" : "text-text"; + const maxThreadTone = + maxThreadPct === null + ? "text-text-muted" + : maxThreadPct > 90 + ? "text-danger" + : maxThreadPct > 60 + ? "text-warning" + : "text-text"; + + return ( +
+
+ Process + + {process.hostname || "host"} · pid {process.pid} + +
+
+ ${60 * process.cpu_cores} % is amber, > ${90 * process.cpu_cores} % is red.`} + /> + + `${t.pct.toFixed(0).padStart(3, " ")}% ${t.name}#${t.tid}`) + .join("\n") + } + onClick={onOpenThreads} + /> + 0 + ? `${process.fd_open}/${fmtFdLimit(process.fd_limit)}` + : String(process.fd_open) + } + tone={fdTone} + hint={`Open FDs vs soft cap (${process.fd_limit.toLocaleString()}). Amber at 70 % means you are running out before LimitNOFILE bites; red at 90 % means clients will start failing accept().`} + /> + 0 + ? `Started ${new Date(process.started_at_ms).toLocaleString()}` + : "Process start timestamp not yet captured" + } + /> +
+
+ ); +} + +function ProcStat({ + label, + value, + tone, + hint, + onClick, +}: { + label: string; + value: string; + tone: string; + hint: string; + onClick?: () => void; +}) { + const cls = `border border-border bg-surface-2 px-3 py-2 ${onClick ? "cursor-pointer hover:border-border-strong" : ""}`; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ProcStatTwoLine({ + label, + primary, + secondary, + tone, + hint, + onClick, +}: { + label: string; + primary: string; + secondary: string; + tone: string; + hint: string; + onClick?: () => void; +}) { + const cls = `border border-border bg-surface-2 px-3 py-2 ${onClick ? "cursor-pointer hover:border-border-strong" : ""}`; + return ( +
+
{label}
+
{primary}
+ {secondary &&
{secondary}
} +
+ ); +} diff --git a/frontend/src/pages/PoolDetail.tsx b/frontend/src/pages/PoolDetail.tsx new file mode 100644 index 000000000..868ccabf1 --- /dev/null +++ b/frontend/src/pages/PoolDetail.tsx @@ -0,0 +1,690 @@ +// Full-route pool detail. The right-side drawer that lived inside `Pools.tsx` +// was 28rem wide and forced a 7-block vertical scroll for sparklines, KV +// pairs, threshold reasons, and the SQLSTATE breakdown. Operators flagged it +// as unusable; the comparable patterns in Datadog/Stripe/Lens/GitHub all use +// a full route, so this page takes that shape: identity bar, KPI tile strip, +// tabbed body. The route is `/pools/:poolId` and reuses `/api/pools` — +// nothing on the backend had to move for this view to exist. + +import type { ReactNode } from "react"; +import { useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { apiGet, apiPost } from "../api"; +import { InfoLabel } from "../components/InfoLabel"; +import { MiniSparkline } from "../components/MiniSparkline"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { useHistory } from "../hooks/useHistory"; +import { usePoll } from "../hooks/usePoll"; +import { describeSqlstate } from "../lib/sqlstate"; +import { evaluatePool } from "../lib/thresholds"; +import { tip } from "../lib/tooltips"; +import type { + PoolCoordinatorDto, + PoolCoordinatorRowDto, + PoolDto, + PoolScalingDto, + PoolScalingRowDto, + PoolsDto, +} from "../types"; + +interface AdminActionResponse { + ts: number; + action: string; + // List of pool ids the action ran against, e.g. ["app_user@app_db"]. + // `affected_pools` retains the count for compatibility. + affected?: string[]; + affected_pools?: number; + error?: string; + // Set on no_matching_db / no_matching_pool 404s. + db?: string; + user?: string; +} + +const POLL_MS = 1500; +const HISTORY_KEY_PREFIX = "pools.detail"; + +interface RowSnap { + ts: number; + saturation: number; + query_p95_ms: number; + errors_total: number; + queries_total: number; + waiting: number; + qps: number; + errors_per_s: number; +} + +export default function PoolDetail() { + const { poolId: rawId } = useParams<{ poolId: string }>(); + const poolId = decodeURIComponent(rawId ?? ""); + const { authHeader } = useAdminAuth(); + + const poll = usePoll( + (signal) => apiGet("/api/pools", authHeader, signal), + POLL_MS, + ); + // Coordinator + scaling are admin-only globals; operators following a + // saturation alert from the Pool detail page need to see "is the + // database-level cap also pinned" and "is the scaler stuck on + // anticipation timeouts" without flipping over to the Config tab. + const coordPoll = usePoll( + (signal) => apiGet("/api/pool_coordinator", authHeader, signal), + POLL_MS * 2, + ); + const scalingPoll = usePoll( + (signal) => apiGet("/api/pool_scaling", authHeader, signal), + POLL_MS * 2, + ); + const history = useHistory(`${HISTORY_KEY_PREFIX}.${poolId}`, 240); + + const pool: PoolDto | undefined = useMemo( + () => poll.data?.pools.find((p) => p.id === poolId), + [poll.data, poolId], + ); + + // Push history on each successful poll. Computes per-second rates from the + // previous snapshot so the tile strip can show a live qps and errors/s + // without the operator having to math the cumulative counters. + const evalResult = useMemo(() => (pool ? evaluatePool(pool, undefined) : null), [pool]); + + if (!pool && poll.error) { + return ( +
+ +

{poll.error.message}

+
+ ); + } + if (!pool) { + return ( +
+ +

Loading pool {poolId}…

+
+ ); + } + + // Push history snapshot (single line — kept inside the render path because + // we only have data once `pool` is non-null and the cadence is set by + // `usePoll`, so this is safe and matches the pattern used in Overview.tsx). + const last = history.history[history.history.length - 1]; + if (!last || last.ts !== poll.data!.ts) { + const saturation = pool.max_connections > 0 ? pool.active / pool.max_connections : 0; + let qps = 0; + let eps = 0; + if (last) { + const dt = (poll.data!.ts - last.ts) / 1000; + if (dt > 0) { + qps = Math.max(0, (pool.queries_total - last.queries_total) / dt); + eps = Math.max(0, (pool.errors_total - last.errors_total) / dt); + } + } + history.push({ + ts: poll.data!.ts, + saturation, + query_p95_ms: pool.query_p95_ms, + errors_total: pool.errors_total, + queries_total: pool.queries_total, + waiting: pool.waiting, + qps, + errors_per_s: eps, + }); + } + + const series = (extract: (s: RowSnap) => number) => history.history.map(extract); + const saturation = pool.max_connections > 0 ? pool.active / pool.max_connections : 0; + const latestQps = history.history[history.history.length - 1]?.qps ?? 0; + const latestEps = history.history[history.history.length - 1]?.errors_per_s ?? 0; + + return ( +
+
+
+
+ +

{pool.id}

+

+ {pool.user} → {pool.database} on {pool.host}:{pool.port} · mode {pool.pool_mode} + {pool.paused && ( + + PAUSED + + )} + {evalResult?.severity === "critical" && ( + + CRITICAL + + )} + {evalResult?.severity === "degraded" && ( + + DEGRADED + + )} +

+
+ +
+
+ +
+ = 0.9 ? "danger" : saturation >= 0.7 ? "warning" : "ok"} + spark={series((s) => s.saturation * 100)} + sparkMin={0} + sparkMax={100} + /> + 500 ? "danger" : pool.query_p95_ms > 100 ? "warning" : "ok"} + spark={series((s) => s.query_p95_ms)} + /> + 0 ? "warning" : "ok"} + spark={series((s) => s.waiting)} + /> + 1 ? "danger" : latestEps > 0.1 ? "warning" : "ok"} + spark={series((s) => s.errors_per_s)} + /> + 300_000 + ? "danger" + : pool.max_active_age_ms > 30_000 + ? "warning" + : "ok" + } + spark={[]} + /> + s.qps)} /> +
+ + {/* + Two-column grid keeps short KV stacks (Latency, Throughput, Connections, + TLS) side-by-side so the eye does not have to jump across half the + viewport between label and value. Wider blocks (Coordinator, Pool + scaling, Errors-by-SQLSTATE, Threshold reasons) span both columns + because their contents already render as multi-column tables or lists. + */} +
+
+ + + + +
+ +
+ + + +
+ +
+ + + + + + +
+ +
+ + + +
+ +
+ d.database === pool.database) ?? null} + /> +
+ +
+ p.user === pool.user && p.database === pool.database, + ) ?? null + } + /> +
+ +
+ +
+ + {evalResult && evalResult.reasons.length > 0 && ( +
+
    + {evalResult.reasons.map((r) => ( +
  • · {r}
  • + ))} +
+
+ )} +
+
+ ); +} + +// Admin action bar — Pause / Resume / Reconnect target this single +// user@db pool via POST /api/admin/{action}?pool=, plus the +// global Reload at the right edge. Pool-scoped is the default since +// pg_doorman 3.7: scoping by ?db= still works for tools that need +// database-wide blast radius, but the UI's most precise click should +// not surprise an operator with cross-tenant impact. +function PoolActions({ pool }: { pool: PoolDto }) { + const { authHeader } = useAdminAuth(); + const [pending, setPending] = useState(null); + const [confirm, setConfirm] = useState(null); + const [feedback, setFeedback] = useState(null); + + const trigger = async (action: string, scope: "pool" | "global") => { + setPending(action); + setFeedback(null); + try { + const url = + scope === "pool" + ? `/api/admin/${action}?pool=${encodeURIComponent(pool.id)}` + : `/api/admin/${action}`; + const res = await apiPost(url, authHeader); + if (res.error) { + setFeedback({ tone: "err", text: `${action} failed: ${res.error}` }); + } else { + const ids = res.affected ?? []; + const label = + ids.length > 0 + ? `${action} done · ${ids.join(", ")}` + : `${action} done · 0 pools touched`; + setFeedback({ tone: "ok", text: label }); + } + } catch (e) { + setFeedback({ tone: "err", text: `${action} failed: ${e instanceof Error ? e.message : String(e)}` }); + } finally { + setPending(null); + setConfirm(null); + } + }; + + const buttonClass = (variant: "default" | "danger" | "warning") => { + const base = + "border px-3 py-1 text-xs font-mono uppercase tracking-wider transition-colors disabled:opacity-50"; + if (variant === "danger") return `${base} border-danger text-danger hover:bg-danger/10`; + if (variant === "warning") return `${base} border-warning text-warning hover:bg-warning/10`; + return `${base} border-border-strong text-text-muted hover:text-accent`; + }; + + return ( +
+
+ + + + +
+ {feedback && ( +
+ {feedback.text} +
+ )} + {confirm && ( + setConfirm(null)} + onConfirm={() => trigger(confirm.action, confirm.scope)} + /> + )} +
+ ); +} + +function ConfirmModal({ + action, + database, + pending, + onCancel, + onConfirm, +}: { + action: string; + database: string; + pending: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + const [typed, setTyped] = useState(""); + const required = action.toUpperCase(); + const body = (() => { + switch (action) { + case "pause": + return `Pause stops new checkouts on the '${database}' pool of this user only. Existing transactions continue.`; + case "resume": + return `Resume re-enables checkouts on the '${database}' pool of this user.`; + case "reconnect": + return `Reconnect drops idle backends on the '${database}' pool of this user and refuses the active ones when they return. Use after a role or grant change.`; + case "reload": + return "Reload re-reads pg_doorman.toml on every pool. Pool sizes shrink via natural drain."; + default: + return ""; + } + })(); + const title = action === "reload" ? "RELOAD pg_doorman?" : `${required} ${database}?`; + return ( +
+
+

{title}

+

+ {body} Type {required} to confirm. +

+ setTyped(e.target.value)} + className="mb-4 w-full border border-border-strong bg-surface-2 px-2 py-1 font-mono text-sm text-text" + placeholder={required} + /> +
+ + +
+
+
+ ); +} + +function BackLink() { + return ( + + ← all pools + + ); +} + +function Tile({ + label, + value, + tone, + spark, + sparkMin, + sparkMax, +}: { + label: string; + value: string; + tone: "ok" | "warning" | "danger"; + spark: number[]; + sparkMin?: number; + sparkMax?: number; +}) { + const stroke = + tone === "danger" ? "rgb(255 77 77)" : tone === "warning" ? "rgb(255 176 0)" : "rgb(57 211 83)"; + const valueClass = + tone === "danger" ? "text-danger" : tone === "warning" ? "text-warning" : "text-text"; + return ( +
+
{label}
+
{value}
+ {spark.length > 0 && ( +
+ +
+ )} +
+ ); +} + +function Section({ + title, + wide, + children, +}: { + title: string; + // Span both grid columns. Used for blocks whose internal layout is + // already multi-column (Coordinator, Pool scaling) or whose content is + // a list (Threshold reasons, SQLSTATE breakdown) where two side-by-side + // copies would be visually noisy. + wide?: boolean; + children: ReactNode; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function KV({ label, value, tip }: { label: string; value: string; tip?: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +function CoordinatorBlock({ row }: { row: PoolCoordinatorRowDto | null }) { + if (!row) { + return ( +

+ No coordinator row for this database. The coordinator only tracks + databases with an active backend connection cap; if you do not see a + row, max_db_conn is unlimited. +

+ ); + } + const free = row.max_db_conn > row.current ? row.max_db_conn - row.current : 0; + const reserve_free = row.reserve_size > row.reserve_used ? row.reserve_size - row.reserve_used : 0; + return ( + <> + + + + + + + + ); +} + +function ScalingBlock({ row }: { row: PoolScalingRowDto | null }) { + if (!row) { + return ( +

+ No scaling counters for this pool yet. The first checkout will create + the row. +

+ ); + } + return ( + <> + + + + + + + + + ); +} + +function SqlstateBreakdown({ errors }: { errors?: Record }) { + const entries = errors ? Object.entries(errors).sort((a, b) => b[1] - a[1]) : []; + if (entries.length === 0) + return

No errors recorded for this pool yet. The SQLSTATE breakdown fills in the moment a query returns one.

; + return ( +
    + {entries.map(([code, count]) => ( +
  • + + {code} + {describeSqlstate(code)} + + {count} +
  • + ))} +
+ ); +} + diff --git a/frontend/src/pages/Pools.tsx b/frontend/src/pages/Pools.tsx new file mode 100644 index 000000000..b3a160dbb --- /dev/null +++ b/frontend/src/pages/Pools.tsx @@ -0,0 +1,386 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { InfoLabel } from "../components/InfoLabel"; +import { tip } from "../lib/tooltips"; +import { apiGet } from "../api"; +import { MiniSparkline } from "../components/MiniSparkline"; +import { PageHero } from "../components/PageHero"; +import { SectionHeader } from "../components/SectionHeader"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { useHistory } from "../hooks/useHistory"; +import { usePoll } from "../hooks/usePoll"; +import { evaluatePool, type PoolEvaluation } from "../lib/thresholds"; +import type { PoolDto, PoolsDto, Severity } from "../types"; + +const POLL_MS = 1500; +const HISTORY_KEY = "pools"; + +type SortKey = "id" | "saturation" | "waiting" | "query_p95_ms" | "errors_total"; +type SortDir = "asc" | "desc"; + +const SEV_COLOR: Record = { + ok: "border-l-transparent", + degraded: "border-l-warning", + critical: "border-l-danger", +}; + +const SEV_LABEL: Record = { + ok: "ok", + degraded: "degraded", + critical: "critical", +}; + +const SEV_TEXT: Record = { + ok: "text-text-dim", + degraded: "text-warning", + critical: "text-danger", +}; + +interface Filters { + query: string; + severity: Severity | "all"; +} + +interface RowSnap { + ts: number; + saturation: number; + query_p95_ms: number; + errors_total: number; + waiting: number; +} + +interface Row { + pool: PoolDto; + eval: PoolEvaluation; + saturation: number; +} + +export default function Pools() { + const { authHeader } = useAdminAuth(); + const poll = usePoll( + (signal) => apiGet("/api/pools", authHeader, signal), + POLL_MS, + ); + const snapHistory = useHistory>(HISTORY_KEY); + + useEffect(() => { + if (!poll.data) return; + const ts = poll.data.ts; + const snap: Record = {}; + for (const p of poll.data.pools) { + snap[p.id] = { + ts, + saturation: p.max_connections > 0 ? p.active / p.max_connections : 0, + query_p95_ms: p.query_p95_ms, + errors_total: p.errors_total, + waiting: p.waiting, + }; + } + snapHistory.push(snap); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poll.data?.ts]); + + const navigate = useNavigate(); + // URL-state for filters/sort. Operators can paste "/pools?severity=critical&q=app@db" + // straight into Slack during an incident — every control on the page is a + // search-param. Local React state mirrors the URL so the inputs stay + // responsive while the URL updates on commit. + const [searchParams, setSearchParams] = useSearchParams(); + const initialFilters: Filters = { + query: searchParams.get("q") ?? "", + severity: ((searchParams.get("severity") as Severity | null) ?? + ("all" as const)) as Filters["severity"], + }; + const initialSortKey = (searchParams.get("sort") as SortKey | null) ?? "saturation"; + const initialSortDir = (searchParams.get("dir") as SortDir | null) ?? "desc"; + const [filters, setFiltersState] = useState(initialFilters); + const [sortKey, setSortKeyState] = useState(initialSortKey); + const [sortDir, setSortDirState] = useState(initialSortDir); + + // Single source of truth for URL ↔ state sync. Callers always pass the + // full target state for the dimensions they want to change; the helper + // re-derives the URL from those values plus the still-current ones for + // unchanged dimensions. Without this shape, two consecutive + // setSearchParams calls in one handler (e.g. flipping sortKey then + // sortDir) read stale closures and the URL ends up sorted by one key + // while the UI is sorted by another. + const writeUrl = (f: Filters, sk: SortKey, sd: SortDir) => { + const sp = new URLSearchParams(searchParams); + if (f.query) sp.set("q", f.query); + else sp.delete("q"); + if (f.severity !== "all") sp.set("severity", f.severity); + else sp.delete("severity"); + if (sk !== "saturation") sp.set("sort", sk); + else sp.delete("sort"); + if (sd !== "desc") sp.set("dir", sd); + else sp.delete("dir"); + setSearchParams(sp, { replace: true }); + }; + const setFilters = ( + update: Filters | ((prev: Filters) => Filters), + ) => { + setFiltersState((prev) => { + const value = typeof update === "function" ? update(prev) : update; + writeUrl(value, sortKey, sortDir); + return value; + }); + }; + + const evaluated: Row[] = useMemo(() => { + if (!poll.data) return []; + return poll.data.pools.map((p) => ({ + pool: p, + eval: evaluatePool(p, undefined), + saturation: p.max_connections > 0 ? p.active / p.max_connections : 0, + })); + }, [poll.data]); + + const filtered = useMemo(() => { + return evaluated + .filter((row) => { + if (filters.query && !row.pool.id.toLowerCase().includes(filters.query.toLowerCase())) return false; + if (filters.severity !== "all" && row.eval.severity !== filters.severity) return false; + return true; + }) + .sort((a, b) => { + let av: number | string; + let bv: number | string; + switch (sortKey) { + case "id": + av = a.pool.id; + bv = b.pool.id; + break; + case "saturation": + av = a.saturation; + bv = b.saturation; + break; + case "waiting": + av = a.pool.waiting; + bv = b.pool.waiting; + break; + case "query_p95_ms": + av = a.pool.query_p95_ms; + bv = b.pool.query_p95_ms; + break; + case "errors_total": + av = a.pool.errors_total; + bv = b.pool.errors_total; + break; + } + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sortDir === "asc" ? cmp : -cmp; + }); + }, [evaluated, filters, sortKey, sortDir]); + + const onSort = (key: SortKey) => { + const nextDir: SortDir = + key === sortKey ? (sortDir === "asc" ? "desc" : "asc") : key === "id" ? "asc" : "desc"; + setSortKeyState(key); + setSortDirState(nextDir); + writeUrl(filters, key, nextDir); + }; + const sortIndicator = (key: SortKey) => + sortKey === key ? (sortDir === "asc" ? " ▲" : " ▼") : ""; + + const seriesFor = (poolId: string, extract: (s: RowSnap) => number): number[] => + snapHistory.history.map((snap) => snap[poolId] ?? null).filter((v): v is RowSnap => v !== null).map(extract); + + if (poll.error) { + return ( +
+

Pools

+

+ Could not load pools: {poll.error.message}. Try Sign out → Sign in to refresh credentials, or check whether pg_doorman is running. +

+
+ ); + } + + return ( +
+ + +
+ setFilters((f) => ({ ...f, query: e.target.value }))} + className="rounded border border-border-strong bg-surface-2 px-2 py-1 text-sm text-text" + /> + + + {filtered.length} of {evaluated.length} pools + +
+ + + + + + + + + + + + + + + {filtered.map((row) => ( + s.saturation * 100)} + p95Series={seriesFor(row.pool.id, (s) => s.query_p95_ms)} + onOpen={() => navigate(`/pools/${encodeURIComponent(row.pool.id)}`)} + /> + ))} + +
+ + onSort("id")}> + Pool{sortIndicator("id")} + + + + Mode + + + onSort("saturation")}> + Saturation{sortIndicator("saturation")} + + + + + Trend + + + + onSort("waiting")}> + Waiting{sortIndicator("waiting")} + + + + + onSort("query_p95_ms")}> + p95 ms{sortIndicator("query_p95_ms")} + + + + + onSort("errors_total")}> + Errors{sortIndicator("errors_total")} + + + + + State + +
+ {filtered.length === 0 && evaluated.length > 0 && ( +

Nothing matches the current filter. Clear the search box or pick ‘all severities’ to widen.

+ )} + {!poll.data &&

Reading pool list…

} +
+ ); +} + +function PoolRowView({ + row, + satSeries, + p95Series, + onOpen, +}: { + row: Row; + satSeries: number[]; + p95Series: number[]; + onOpen: () => void; +}) { + const { pool, saturation } = row; + const sev = row.eval.severity; + const satColor = + saturation >= 0.9 ? "rgb(229 72 77)" : saturation >= 0.7 ? "rgb(245 165 36)" : "rgb(45 194 107)"; + return ( + + {pool.id} + {pool.pool_mode} + + = 0.9 ? "text-danger" : saturation >= 0.7 ? "text-warning" : "" + } + > + {pool.active}/{pool.max_connections} + {" "} + + ({(saturation * 100).toFixed(0)}%) + + + +
+ + + + 100 ms = degraded backend; > 500 ms = something is stuck.`}> + 500 ? "rgb(229 72 77)" : pool.query_p95_ms > 100 ? "rgb(245 165 36)" : "rgb(34 184 207)"} + /> + +
+ + 0 ? "text-warning" : ""}`} + title={`${pool.waiting} client(s) queued for a backend. Anything sustained above zero for 10 s is degraded. Above ${Math.max(10, Math.round(pool.max_connections * 0.1))} for 10 s is critical for this pool.`} + > + {pool.waiting} + + 500 ? "text-danger" : pool.query_p95_ms > 100 ? "text-warning" : "" + }`} + title={`p95 = ${pool.query_p95_ms} ms, p99 = ${pool.query_p99_ms} ms over last 60 s. > 100 ms is amber, > 500 ms is red.`} + > + {pool.query_p95_ms} + + + {pool.errors_total} + + + + ● {SEV_LABEL[sev].toUpperCase()} + + + + ); +} + diff --git a/frontend/src/pages/Wall.tsx b/frontend/src/pages/Wall.tsx new file mode 100644 index 000000000..1da9051eb --- /dev/null +++ b/frontend/src/pages/Wall.tsx @@ -0,0 +1,194 @@ +// War-room route. The same data as Overview, painted as six oversize +// tiles with no chrome — meant for a sidebar TV, a war-room panel, or a +// fullscreen window during an outage. Auto-refreshes via the existing +// usePoll hook; cells flash amber on threshold breaches so the operator +// sees the change without reading the digit. + +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { apiGet } from "../api"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { usePoll } from "../hooks/usePoll"; +import type { OverviewDto, PoolsDto } from "../types"; + +const POLL_MS = 1500; + +interface TileInput { + label: string; + value: string; + detail?: string; + tone: "ok" | "warning" | "danger"; +} + +export default function Wall() { + const { authHeader } = useAdminAuth(); + const overview = usePoll( + (signal) => apiGet("/api/overview", authHeader, signal), + POLL_MS, + ); + const pools = usePoll( + (signal) => apiGet("/api/pools", authHeader, signal), + POLL_MS, + ); + + const tiles = useMemo(() => { + if (!overview.data || !pools.data) return []; + let maxP95 = 0; + let maxSat = 0; + let maxOldest = 0; + let waitingTotal = 0; + let critical = 0; + let degraded = 0; + let paused = 0; + for (const p of pools.data.pools) { + if (p.query_p95_ms > maxP95) maxP95 = p.query_p95_ms; + // Saturation = active / max_connections to match the threshold engine + // in lib/thresholds.ts. Idle backends still held from a prior burst + // are not pressure on the pool, so they do not count here either. + if (p.max_connections > 0) { + const s = (p.active / p.max_connections) * 100; + if (s > maxSat) maxSat = s; + } + if (p.max_active_age_ms > maxOldest) maxOldest = p.max_active_age_ms; + waitingTotal += p.waiting; + if (p.paused) paused += 1; + // Pool counted CRITICAL only on pooler-side pressure (high active or + // a backlog of waiting clients). Backend latency / errors stay in the + // DEGRADED bucket — slow PostgreSQL is not a pooler-critical signal. + const sat = p.max_connections > 0 ? p.active / p.max_connections : 0; + if (sat >= 0.9 || p.waiting >= 10) critical += 1; + else if (sat >= 0.7 || p.waiting > 0 || p.query_p95_ms > 100 || p.errors_total > 0) + degraded += 1; + } + + const fmtMs = (n: number) => { + if (n > 0 && n < 1) return `${n.toFixed(2)}ms`; + if (n < 10) return `${n.toFixed(1)}ms`; + if (n < 1000) return `${Math.round(n)}ms`; + if (n < 60_000) return `${(n / 1000).toFixed(0)}s`; + const m = Math.floor(n / 60_000); + const s = Math.floor((n % 60_000) / 1000); + return `${m}m${s.toString().padStart(2, "0")}`; + }; + + return [ + // Backend latency tiles cap at "warning" tone — slow PostgreSQL is not + // a pooler-critical signal. The pooler tiles below (max sat, waiting) + // own the "danger" colour. + { + label: "max p95", + value: fmtMs(maxP95), + detail: maxP95 > 500 ? "backend slow > 500ms" : maxP95 > 100 ? "backend warn > 100ms" : "healthy", + tone: maxP95 > 100 ? "warning" : "ok", + }, + { + label: "errors / s", + value: overview.data.errors_count_total.toLocaleString(), + detail: "cumulative", + tone: overview.data.errors_count_total > 0 ? "warning" : "ok", + }, + { + label: "max sat %", + value: `${maxSat.toFixed(0)}%`, + detail: maxSat > 90 ? "crit ≥ 90%" : maxSat > 70 ? "warn ≥ 70%" : "healthy", + tone: maxSat > 90 ? "danger" : maxSat > 70 ? "warning" : "ok", + }, + { + label: "waiting", + value: String(waitingTotal), + detail: waitingTotal > 0 ? `${critical}c · ${degraded}d` : "queue empty", + tone: waitingTotal >= 10 ? "danger" : waitingTotal > 0 ? "warning" : "ok", + }, + { + label: "oldest active", + value: fmtMs(maxOldest), + detail: maxOldest > 300_000 ? "backend stuck > 5m" : maxOldest > 30_000 ? "backend warn > 30s" : "healthy", + tone: maxOldest > 30_000 ? "warning" : "ok", + }, + { + label: "pools", + value: `${pools.data.pools.length}`, + detail: + paused > 0 + ? `${paused} paused · ${critical} crit · ${degraded} deg` + : `${critical} crit · ${degraded} deg`, + tone: critical > 0 ? "danger" : degraded > 0 ? "warning" : paused > 0 ? "warning" : "ok", + }, + ]; + }, [overview.data, pools.data]); + + if (overview.error || pools.error) { + return ( +
+
+

{overview.error?.message ?? pools.error?.message}

+ + back to overview + +
+
+ ); + } + if (!overview.data || !pools.data) { + return ( +
+ Reading overview and pool snapshots… +
+ ); + } + + const anyCritical = tiles.some((t) => t.tone === "danger"); + return ( +
+
+
+
pg_doorman war room
+
+ {pools.data.pools.length} pools · last update {fmtAge(overview.lastUpdated)} +
+
+
+ + back + +
+
+
+ {tiles.map((t) => ( + + ))} +
+
+ ); +} + +function Tile({ label, value, detail, tone }: TileInput) { + const toneClass = + tone === "danger" + ? "border-danger text-danger bg-danger/5" + : tone === "warning" + ? "border-warning text-warning bg-warning/5" + : "border-border text-text bg-surface-2"; + return ( +
+
{label}
+
{value}
+ {detail &&
{detail}
} +
+ ); +} + +function fmtAge(ts: number | null): string { + if (!ts) return "—"; + const ageSec = Math.round((Date.now() - ts) / 1000); + if (ageSec < 5) return "now"; + if (ageSec < 60) return `${ageSec}s ago`; + return `${Math.round(ageSec / 60)}m ago`; +} diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css new file mode 100644 index 000000000..dec799cf1 --- /dev/null +++ b/frontend/src/styles/tailwind.css @@ -0,0 +1,131 @@ +@import "tailwindcss"; + +/* Bloomberg-Terminal direction: JetBrains Mono is the only typeface — + display, body, numerics. Subset trim: latin + cyrillic, weights 400 + + 500, woff2 only (`font-bold` aka 700 is not used anywhere in the app — + `font-semibold`/600 is the heaviest weight rendered, and the browser + synthesises that from 500 with no visible artefacting in this face). + Result: 4 woff2 files in the bundle, ~150 KB across all subsets. */ +@import "@fontsource/jetbrains-mono/latin-400.css"; +@import "@fontsource/jetbrains-mono/latin-500.css"; +@import "@fontsource/jetbrains-mono/cyrillic-400.css"; +@import "@fontsource/jetbrains-mono/cyrillic-500.css"; + +@theme { + /* Surface — Bloomberg-flat. The whole canvas is one plane carved by + hairline borders rather than four near-identical near-black layers. */ + --color-bg: #000000; + --color-surface: #000000; + --color-surface-2: #0b0b0b; + --color-surface-3: #0b0b0b; + + /* Borders — single hairline tone, never doubled. */ + --color-border: #1f1f1f; + --color-border-strong: #2a2a2a; + + /* Text — warm paper-white, not bluish. */ + --color-text: #e8e3d6; + --color-text-muted: #9a9485; + --color-text-dim: #5a564b; + + /* Accent — Bloomberg amber. Used for call-to-action, focus rings, + selection, and the live-tick underline animation below. */ + --color-accent: #ffb000; + --color-accent-hover: #ffc233; + --color-accent-fg: #000000; + + /* Semantic — high-contrast trader palette. */ + --color-success: #39d353; + --color-warning: #ffb000; + --color-danger: #ff4d4d; + --color-info: #00d4ff; + + /* Chart palette (uPlot lines) — distinct hues, no muddy gradients. */ + --color-chart-1: #ffb000; + --color-chart-2: #00d4ff; + --color-chart-3: #39d353; + --color-chart-4: #ff4d4d; + + /* Typography. JetBrains Mono is the only typeface; system fallbacks + cover the rare case where the woff2 fails to load. */ + --font-sans: "JetBrains Mono", ui-monospace, monospace; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + --font-size-xs: 11px; + --font-size-sm: 13px; + --font-size-base: 14px; + --font-size-md: 16px; + --font-size-lg: 20px; + --font-size-xl: 28px; + + /* Geometry. Bloomberg discipline: zero radius, no shadow, depth via + hairlines. Setting --radius-* to 0 collapses every rounded-* class. */ + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; + --radius-2xl: 0; + --radius-3xl: 0; +} + +/* Tabular figures helper. JetBrains Mono is tabular by default; the legacy + CSS still references this class so we keep the rule. */ +.tabular { + font-variant-numeric: tabular-nums slashed-zero; +} + +/* Focus ring — single hairline amber, no offset. */ +*:focus-visible { + outline: 1px solid var(--color-accent); + outline-offset: 0; +} + +/* Selection — invert to amber-on-black. */ +::selection { + background: var(--color-accent); + color: var(--color-accent-fg); +} + +/* Reserve space for the vertical scrollbar at all times so a sub-pixel + layout shift inside any tile cannot make the document briefly tall + enough to summon the scrollbar — that summon/dismiss cycle was the + page-wide horizontal jitter operators saw when sweeping the mouse + across the Overview sparklines. */ +html { + scrollbar-gutter: stable; +} + +/* Scrollbar — narrow, monochrome, no rounded thumb. */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-border); +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-border-strong); +} + +/* Tick-flash animation. Live cells (numbers retickering each poll cycle) + wear this for ~160ms after each update so the dashboard feels like a + tickertape without any actual motion of content. */ +@keyframes tick-amber { + 0% { background-color: rgb(255 176 0 / 0.20); } + 100% { background-color: transparent; } +} +@keyframes tick-cyan { + 0% { background-color: rgb(0 212 255 / 0.20); } + 100% { background-color: transparent; } +} +.tick-up { animation: tick-amber 160ms linear; } +.tick-down { animation: tick-cyan 160ms linear; } + +@media (prefers-reduced-motion: reduce) { + .tick-up, + .tick-down { + animation: none; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 000000000..230be2ae5 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,411 @@ +/** + * DTO mirrors. Phase 5 only exposes the types phase 5 actually uses; phase 6 + * adds the rest as pages need them. Source of truth is `src/web/routes/dto.rs` + * — keep these manual until divergence becomes painful. + */ +export interface VersionDto { + version: string; + git_commit: string; + build_date: string; + ts: number; +} + +export interface OverviewDto { + ts: number; + active_clients: number; + idle_clients: number; + waiting_clients: number; + active_servers: number; + idle_servers: number; + connections_total: number; + connections_tls_total: number; + connections_plain_total: number; + connections_cancel_total: number; + query_count_total: number; + transaction_count_total: number; + errors_count_total: number; + prepared_hits_total: number; + prepared_misses_total: number; + pools_total: number; + pools_paused: number; + // Process tile fields (added in the backend P0 inventory wedge). + rss_bytes: number; + uptime_seconds: number; + pid: number; + current_clients: number; + clients_in_transactions: number; + shutdown_in_progress: boolean; + migration_in_progress: boolean; +} + +export interface PoolDto { + id: string; + user: string; + database: string; + host: string; + port: number; + pool_mode: string; + max_connections: number; + min_connections: number; + connections: number; + idle: number; + active: number; + waiting: number; + max_active_age_ms: number; + query_p95_ms: number; + query_p99_ms: number; + transactions_p95_ms: number; + transactions_p99_ms: number; + wait_avg_ms: number; + wait_p95_ms: number; + queries_total: number; + transactions_total: number; + errors_total: number; + // Cumulative error breakdown by PostgreSQL SQLSTATE. Optional — backend + // omits the field when no errors have been classified yet. + errors_by_sqlstate?: Record; + paused: boolean; + epoch: number; + // Patroni-assisted fallback flag (mirror of the prometheus gauge). + fallback_active: boolean; + // Cumulative count of failed backend TLS handshakes for this pool. + tls_handshake_errors_total: number; + // Live TLS-encrypted backend connections held by the pool. + tls_backend_connections: number; +} + +export interface PoolsDto { + ts: number; + pools: PoolDto[]; +} + +export type Severity = "ok" | "degraded" | "critical"; + +export interface EventEntryDto { + seq: number; + ts_ms: number; + // RELOAD, PAUSE, RESUME, RECONNECT (admin commands). + target: string; + message: string; +} + +export interface EventsDto { + ts: number; + next_seq: number; + events: EventEntryDto[]; +} + +export interface AppRowDto { + application_name: string; + clients: number; + queries_total: number; + transactions_total: number; + errors_total: number; +} + +export interface AppsDto { + ts: number; + apps: AppRowDto[]; +} + +export interface JemallocStatsDto { + allocated_bytes: number; + active_bytes: number; + resident_bytes: number; + mapped_bytes: number; + retained_bytes: number; + metadata_bytes: number; + fragmentation_bytes: number; +} + +export interface CgroupMemoryDto { + version: number; + current_bytes: number; + peak_bytes: number | null; + max_bytes: number | null; + high_bytes: number | null; +} + +export interface MemoryCategoryDto { + key: string; + label: string; + bytes: number; + explain: string; +} + +export interface MemoryBreakdownDto { + ts: number; + rss_bytes: number; + vm_peak_bytes: number | null; + vm_hwm_bytes: number | null; + vm_data_bytes: number | null; + vm_stack_bytes: number | null; + vm_exe_bytes: number | null; + vm_lib_bytes: number | null; + vm_pte_bytes: number | null; + vm_swap_bytes: number | null; + rss_anon_bytes: number | null; + rss_file_bytes: number | null; + rss_shmem_bytes: number | null; + jemalloc: JemallocStatsDto | null; + cgroup: CgroupMemoryDto | null; + interner_named_bytes: number; + interner_anonymous_bytes: number; + categories: MemoryCategoryDto[]; +} + +export interface ProcessThreadDto { + tid: number; + name: string; + cpu_user_us: number; + cpu_system_us: number; +} + +export interface ProcessDto { + ts: number; + pid: number; + hostname: string; + uptime_seconds: number; + started_at_ms: number; + rss_bytes: number; + vm_size_bytes: number; + threads: number; + fd_open: number; + fd_limit: number; + cpu_user_us: number; + cpu_system_us: number; + cpu_cores: number; + threads_breakdown: ProcessThreadDto[]; +} + +export interface InternerKindDto { + entries: number; + bytes: number; +} + +export interface InternerDto { + ts: number; + named: InternerKindDto; + anonymous: InternerKindDto; +} + +export interface TcpCounts { + established: number; + time_wait: number; + close_wait: number; + listen: number; + // Other states exist (syn_sent/recv, fin_wait1/2, close, last_ack, etc.) — + // phase 6a-4 surfaces only the four operators most often look at; they can + // be added later without an api change. +} + +export interface UnixStreamCounts { + // Mirrors `/proc/net/unix` SOCK_STREAM state column. `connected` is the + // analogue of TCP `established`; `unconnected` covers listening server + // sockets and sockets without a peer yet. + free: number; + unconnected: number; + connecting: number; + connected: number; + disconnecting: number; +} + +export interface SocketsDto { + ts: number; + tcp: TcpCounts; + tcp6: TcpCounts; + unix_stream: UnixStreamCounts; +} + +export interface ClientDto { + client_id: string; + database: string; + user: string; + application_name: string; + addr: string; + tls: boolean; + state: string; + wait: string; + wait_ms: number; + transactions_total: number; + queries_total: number; + errors_total: number; + age_seconds: number; + current_query_age_ms: number; +} + +export interface ClientsDto { + ts: number; + total: number; + limit: number; + offset: number; + clients: ClientDto[]; +} + +export interface PreparedRowDto { + pool: string; + hash: string; + name: string; + count_used: number; + hits: number; + misses: number; + kind: string; +} + +export interface PreparedDto { + ts: number; + prepared: PreparedRowDto[]; +} + +// Admin-only response for /api/prepared/text/{hash}. +export interface PreparedTextDto { + ts: number; + hash: string; + pool: string; + name: string; + query: string; + kind: string; +} + +// Admin-only response for /api/interner/top. +export interface InternerTopRowDto { + hash: string; + kind: string; + bytes: number; + // Idle milliseconds for anonymous entries; -1 for named entries. + idle_ms: number; + // First 120 chars of the SQL text, truncated by chars (not bytes). + preview: string; +} + +export interface InternerTopDto { + ts: number; + n: number; + entries: InternerTopRowDto[]; +} + +export interface LogEntryDto { + seq: number; + ts_ms: number; + level: string; + target: string; + message: string; +} + +export interface LogsDto { + ts: number; + tap_active: boolean; + tap_capacity_entries: number; + tap_used_entries: number; + next_seq: number; + dropped_before: number; + dropped_total: number; + entries: LogEntryDto[]; +} + +export interface ConfigEntry { + key: string; + /// Currently effective value. `"***"` for secret keys (password / token). + value: string; + /// Built-in default from `Config::default()`. `"-"` when no default + /// representation exists (user-defined pools, talos keys, etc.). + default: string; + /// `"yes"` when the key takes effect on RELOAD, `"no"` when restart-only. + changeable: string; + /// EN description from `fields.yaml`. Empty for keys without docs. + doc: string; +} + +export interface ConfigDto { + ts: number; + config: ConfigEntry[]; +} + +export interface LogLevelDto { + ts: number; + log_level: string; +} + +export interface AuthQueryRowDto { + database: string; + cache_entries: number; + cache_hits: number; + cache_misses: number; + cache_refetches: number; + cache_rate_limited: number; + auth_success: number; + auth_failure: number; + executor_queries: number; + executor_errors: number; + dynamic_pools_current: number; + dynamic_pools_created: number; + dynamic_pools_destroyed: number; +} + +export interface AuthQueryDto { + ts: number; + pools: AuthQueryRowDto[]; +} + +export interface DatabaseDto { + name: string; + host: string; + port: number; + database: string; + force_user: string; + pool_size: number; + min_pool_size: number; + reserve_pool: number; + pool_mode: string; + max_connections: number; + current_connections: number; +} + +export interface DatabasesDto { + ts: number; + databases: DatabaseDto[]; +} + +export interface UserDto { + name: string; + pool_mode: string; +} + +export interface UsersDto { + ts: number; + users: UserDto[]; +} + +export interface PoolScalingRowDto { + user: string; + database: string; + inflight: number; + creates: number; + gate_waits: number; + gate_budget_ex: number; + antic_notify: number; + antic_timeout: number; + create_fallback: number; + replenish_def: number; +} + +export interface PoolScalingDto { + ts: number; + pools: PoolScalingRowDto[]; +} + +export interface PoolCoordinatorRowDto { + database: string; + max_db_conn: number; + current: number; + reserve_size: number; + reserve_used: number; + evictions: number; + reserve_acq: number; + exhaustions: number; +} + +export interface PoolCoordinatorDto { + ts: number; + databases: PoolCoordinatorRowDto[]; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 000000000..cc6bd451e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 000000000..aca7df645 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": "http://127.0.0.1:9127", + "/metrics": "http://127.0.0.1:9127", + }, + }, + build: { + outDir: "dist", + sourcemap: false, + chunkSizeWarningLimit: 500, + }, +}); diff --git a/grafana/README.md b/grafana/README.md index ed8f02057..8f60135d3 100644 --- a/grafana/README.md +++ b/grafana/README.md @@ -74,12 +74,15 @@ The first command builds the image referenced by `docker-compose.yml`. The second regenerates the dashboard with the provisioned datasource UID `prometheus` matching `grafana/provisioning/datasources/prometheus.yml`. The third brings -up Postgres, pg_doorman, Prometheus, Grafana, and two pgbench load +up Postgres, pg_doorman, Prometheus, Grafana, two pgbench load generators (`pgbench.sh`, `pgbench2.sh`) hammering the pool with -distinct user identities. +distinct user identities, and a `listener` sidecar that holds three +LISTEN sessions on a session-mode pool — so the Web UI shows +`pool_mode = session` alongside the transaction-mode pgbench traffic. - Grafana: http://localhost:3000 (anonymous admin login). - Prometheus: http://localhost:19090. +- pg_doorman Web UI: http://localhost:9127 (admin / `doorman_demo`). ```bash docker compose down -v diff --git a/grafana/demo/docker-compose.yml b/grafana/demo/docker-compose.yml index 73702ba8a..8ddf2e9c3 100644 --- a/grafana/demo/docker-compose.yml +++ b/grafana/demo/docker-compose.yml @@ -78,3 +78,32 @@ services: postgres: condition: service_healthy restart: always + + # Session-mode demo: 3 LISTEN sessions + a slow producer of NOTIFY events. + # Demonstrates pool_mode = "session" alongside the transaction-mode pgbench + # workload so the Web UI shows both modes side-by-side. + listener: + image: postgres:16 + volumes: + - ./listener.sh:/listener.sh + entrypoint: ["bash", "/listener.sh"] + depends_on: + postgres: + condition: service_healthy + restart: always + + # Session-mode pgbench: 4 long-lived clients hammering session_user with + # SELECT-only traffic so the Web UI shows the session pool actually busy + # (active connections, query latency, qps) instead of just three idle + # LISTENers. + pgbench-session: + image: postgres:16 + environment: + PGPASSWORD: session_pass + volumes: + - ./pgbench-session.sh:/pgbench-session.sh + entrypoint: ["bash", "/pgbench-session.sh"] + depends_on: + postgres: + condition: service_healthy + restart: always diff --git a/grafana/demo/grafana/provisioning/dashboards/pg_doorman.json b/grafana/demo/grafana/provisioning/dashboards/pg_doorman.json new file mode 100644 index 000000000..311e82b8b --- /dev/null +++ b/grafana/demo/grafana/provisioning/dashboards/pg_doorman.json @@ -0,0 +1,2518 @@ +{ + "style": "dark", + "editable": true, + "graphTooltip": 0, + "schemaVersion": 36, + "templating": { + "list": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "type": "datasource", + "name": "DS_PROMETHEUS", + "hide": 2, + "skipUrlSync": false, + "label": "Prometheus", + "query": "prometheus", + "multi": false, + "includeAll": false, + "auto": false, + "auto_min": "10s", + "auto_count": 30 + }, + { + "id": "00000000-0000-0000-0000-000000000000", + "type": "query", + "name": "instance", + "hide": 0, + "skipUrlSync": false, + "label": "Instance", + "query": "label_values(pg_doorman_total_memory, instance)", + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "refresh": 2, + "includeAll": true, + "auto": false, + "auto_min": "10s", + "auto_count": 30 + }, + { + "id": "00000000-0000-0000-0000-000000000000", + "type": "query", + "name": "database", + "hide": 0, + "skipUrlSync": false, + "label": "Database", + "query": "label_values(pg_doorman_pools_clients{instance=~\"$instance\"}, database)", + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "refresh": 2, + "includeAll": true, + "auto": false, + "auto_min": "10s", + "auto_count": 30 + }, + { + "id": "00000000-0000-0000-0000-000000000000", + "type": "query", + "name": "user", + "hide": 0, + "skipUrlSync": false, + "label": "User", + "query": "label_values(pg_doorman_pools_clients{instance=~\"$instance\", database=~\"$database\"}, user)", + "datasource": { + "uid": "prometheus" + }, + "multi": true, + "refresh": 2, + "includeAll": true, + "auto": false, + "auto_min": "10s", + "auto_count": 30 + } + ] + }, + "annotations": {}, + "uid": "pg-doorman-overview", + "title": "pg_doorman", + "tags": [ + "pg_doorman", + "postgresql", + "connection-pooler" + ], + "timezone": "browser", + "time": { + "from": "now-30m", + "to": "now" + }, + "fiscalYearStartMonth": 0, + "refresh": "10s", + "panels": [ + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "Overview", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + } + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 1, + "color": "yellow" + }, + { + "value": 10, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "sum(pg_doorman_pools_clients{status=\"waiting\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "" + } + ], + "title": "Waiting Clients", + "description": "Clients queued for a server connection. Sustained >0 means pool_size is insufficient \u2014 increase pool_size or reduce query duration.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 1 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 5, + "color": "yellow" + }, + { + "value": 50, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "max(pg_doorman_pools_avg_wait_time{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "" + } + ], + "title": "Avg Wait Time", + "description": "Max across pools of average queue wait \u2014 adds directly to application latency. Above 50ms: check Pool Utilization and raise pool_size.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 1 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 50, + "color": "yellow" + }, + { + "value": 200, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "max(pg_doorman_pools_queries_percentile{percentile=\"99\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "" + } + ], + "title": "Query p99", + "description": "99th percentile server-side query time (excludes queue wait). Spike without QPS increase \u2014 check pg_stat_activity for locks or vacuum.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 1 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 70, + "color": "yellow" + }, + { + "value": 90, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "sum(pg_doorman_pools_servers{status=\"active\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}) / sum(pg_doorman_pool_size{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}) * 100", + "refId": "" + } + ], + "title": "Pool Utilization", + "description": "Active server connections / pool_size. Above 70%: anticipate saturation. Above 90%: clients are queuing.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 1 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 536870912, + "color": "yellow" + }, + { + "value": 1073741824, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_total_memory{instance=~\"$instance\"}", + "refId": "" + } + ], + "title": "Memory", + "description": "Process RSS. Sudden growth without new connections usually means unbounded prepared statement cache \u2014 check Pool Cache Entries.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 1 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "none", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "blue" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_connection_count{type=\"total\", instance=~\"$instance\"}", + "refId": "" + } + ], + "title": "Total Connections", + "description": "Current client connections (all pools). Compare with pool_size for multiplexing ratio \u2014 100:1+ is normal in transaction mode.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 1 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "Client Load", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "sum by (status) (pg_doorman_pools_clients{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "{{status}}" + } + ], + "title": "Clients by State", + "description": "Active (has server), idle (between transactions), waiting (queued). Growing 'waiting' area means pool_size is the bottleneck.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 6 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pools_clients{status=\"waiting\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Waiting Clients", + "description": "Waiting clients by user@database. Pinpoints which pool needs pool_size increase or query optimization.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 6 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pools_avg_wait_time{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Avg Wait Time", + "description": "Average queue time per pool. Pool with low wait count but high wait time has slow query turnover.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 6 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "Server Pool", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "sum by (status) (pg_doorman_pools_servers{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "{{status}}" + } + ], + "title": "Servers by State", + "description": "Backend connections: active (executing query), idle (available for checkout). Idle approaching zero means no headroom for bursts.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 15 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pool_size{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "pool_size {{user}}@{{database}}" + }, + { + "expr": "pg_doorman_pools_servers{status=\"active\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "active {{user}}@{{database}}" + } + ], + "title": "Pool Size vs Active Servers", + "description": "Active servers overlaid with pool_size ceiling. Gap between lines is spare capacity. When they converge, clients start queuing.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 15 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pools_servers{status=\"active\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"} / pg_doorman_pool_size{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"} * 100", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Pool Utilization %", + "description": "Active/pool_size ratio over time. Sustained above 70% warrants pool_size increase; above 90% clients are already waiting.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 15 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "Query Latency", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "max by (database) (pg_doorman_pools_queries_percentile{percentile=\"50\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p50" + }, + { + "expr": "max by (database) (pg_doorman_pools_queries_percentile{percentile=\"90\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p90" + }, + { + "expr": "max by (database) (pg_doorman_pools_queries_percentile{percentile=\"95\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p95" + }, + { + "expr": "max by (database) (pg_doorman_pools_queries_percentile{percentile=\"99\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p99" + } + ], + "title": "Query Latency Percentiles", + "description": "Server-side query time at p50/p90/p95/p99. p99 diverging from p50 \u2014 check pg_stat_activity for lock waits or long-running queries.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ops" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_pools_queries_count{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Queries per Second", + "description": "Query throughput per pool. Flat QPS with rising latency signals PostgreSQL saturation. Rising QPS with stable latency is healthy growth.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "Transaction Latency", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "max by (database) (pg_doorman_pools_transactions_percentile{percentile=\"50\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p50" + }, + { + "expr": "max by (database) (pg_doorman_pools_transactions_percentile{percentile=\"90\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p90" + }, + { + "expr": "max by (database) (pg_doorman_pools_transactions_percentile{percentile=\"95\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p95" + }, + { + "expr": "max by (database) (pg_doorman_pools_transactions_percentile{percentile=\"99\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"})", + "refId": "", + "legendFormat": "p99" + } + ], + "title": "Transaction Latency Percentiles", + "description": "End-to-end transaction time including all queries and inter-query gaps. High values with low query latency indicate application-side delays between queries.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 33 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ops" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_pools_transactions_count{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Transactions per Second", + "description": "Transaction throughput per pool. Drop with rising latency indicates lock contention or long transactions holding server connections.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 33 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Traffic", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 41 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "Bps" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_pools_bytes{direction=\"received\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Bytes Received", + "description": "Data rate from clients. Spikes correlate with bulk INSERTs/COPYs.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 42 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "Bps" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_pools_bytes{direction=\"sent\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Bytes Sent", + "description": "Data rate to clients. Large spikes indicate fat result sets \u2014 consider LIMIT if unexpected.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 42 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Pool Coordinator", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 50 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pool_coordinator{type=\"connections\", instance=~\"$instance\", database=~\"$database\"}", + "refId": "", + "legendFormat": "current" + }, + { + "expr": "pg_doorman_pool_coordinator{type=\"max_connections\", instance=~\"$instance\", database=~\"$database\"}", + "refId": "", + "legendFormat": "max" + } + ], + "title": "Connections vs Max", + "description": "Total backend connections across all user pools vs max_db_connections. When current approaches max, coordinator evicts idle connections from lower-priority pools.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 51 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pool_coordinator{type=\"reserve_in_use\", instance=~\"$instance\", database=~\"$database\"}", + "refId": "", + "legendFormat": "in_use" + }, + { + "expr": "pg_doorman_pool_coordinator{type=\"reserve_pool_size\", instance=~\"$instance\", database=~\"$database\"}", + "refId": "", + "legendFormat": "size" + } + ], + "title": "Reserve Pool", + "description": "Reserve connections activated when all max_db_connections slots are full. Any usage means primary capacity exhausted \u2014 raise max_db_connections.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 51 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_pool_coordinator_total{type=\"evictions\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "evictions/s" + }, + { + "expr": "rate(pg_doorman_pool_coordinator_total{type=\"reserve_acquisitions\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "reserve_acq/s" + }, + { + "expr": "rate(pg_doorman_pool_coordinator_total{type=\"exhaustions\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "exhaustions/s" + } + ], + "title": "Coordinator Events", + "description": "Evictions: idle connections reclaimed across pools (normal). Exhaustions: client errors, no connection available (critical \u2014 increase capacity).", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 51 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Pool Scaling", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 59 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pool_scaling{type=\"inflight_creates\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Inflight Creates", + "description": "Connections mid-handshake (TCP + auth + startup). Sustained high count \u2014 PostgreSQL slow to accept, check auth method or backend CPU.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 60 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "sum by (database) (rate(pg_doorman_pool_scaling_total{type=\"creates_started\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval]))", + "refId": "", + "legendFormat": "creates/s" + }, + { + "expr": "sum by (database) (rate(pg_doorman_pool_scaling_total{type=\"burst_gate_waits\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval]))", + "refId": "", + "legendFormat": "gate_waits/s" + }, + { + "expr": "sum by (database) (rate(pg_doorman_pool_scaling_total{type=\"create_fallback\", instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval]))", + "refId": "", + "legendFormat": "fallback/s" + } + ], + "title": "Scaling Events", + "description": "creates/s: new connections. gate_waits/s: throttled by burst limiter. fallback/s: anticipation missed, created on-demand.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 60 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_connection_count{type=\"plain\", instance=~\"$instance\"}", + "refId": "", + "legendFormat": "plain" + }, + { + "expr": "pg_doorman_connection_count{type=\"tls\", instance=~\"$instance\"}", + "refId": "", + "legendFormat": "tls" + }, + { + "expr": "pg_doorman_connection_count{type=\"cancel\", instance=~\"$instance\"}", + "refId": "", + "legendFormat": "cancel" + } + ], + "title": "Connections by Type", + "description": "Connections by protocol. Track TLS adoption. Elevated cancel count indicates application timeouts.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 60 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Prepared Statements", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 68 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pool_prepared_cache_entries{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Pool Cache Entries", + "description": "Unique prepared statements per pool. Unbounded growth means dynamic statement names \u2014 fix the app or cap with prepared_statements_cache_size.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 69 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "bytes" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_pool_prepared_cache_bytes{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "pool {{user}}@{{database}}" + }, + { + "expr": "pg_doorman_clients_prepared_cache_bytes{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "client {{user}}@{{database}}" + } + ], + "title": "Cache Memory (Pool + Client)", + "description": "Memory in prepared caches (pool + client). When this dominates total memory, reduce cache size or fix dynamic statement names.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 69 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "clamp_max(sum by (user, database) (pg_doorman_servers_prepared_hits{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}) / clamp_min(sum by (user, database) (pg_doorman_servers_prepared_hits{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}) + sum by (user, database) (pg_doorman_servers_prepared_misses{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}), 1), 1)", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Prepared Statement Hit Ratio", + "description": "Cache hits / total lookups. Below 90%: servers frequently re-parse after multiplexing. Ensure consistent statement names.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 69 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_clients_prepared_named_entries{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Client Named Entries", + "description": "Sum of Named entries across all clients in the pool. Named is unbounded \u2014 drivers that mint per-query named statements (some pgjdbc / Hibernate / Npgsql configurations) drive this up without limit. Application is responsible for DEALLOCATE or name reuse.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 77 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_clients_prepared_anonymous_entries{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Client Anonymous Entries", + "description": "Sum of Anonymous entries across all clients. Bounded per client by client_anonymous_prepared_cache_size (default 256). Approaches at most connected_clients * cache_size.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 77 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ops" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_clients_prepared_anonymous_evictions_total{instance=~\"$instance\", user=~\"$user\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{user}}@{{database}}" + } + ], + "title": "Anonymous LRU Eviction Rate", + "description": "Rate of evictions on the per-client Anonymous LRU. Sustained non-zero rate means client_anonymous_prepared_cache_size is too small for the workload, or the application generates unique queries on the hot path. Alert template: > 10/s for 10m.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 77 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Auth Query", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 85 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "clamp_max(rate(pg_doorman_auth_query_cache{type=\"hits\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval]) / clamp_min(rate(pg_doorman_auth_query_cache{type=\"hits\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval]) + rate(pg_doorman_auth_query_cache{type=\"misses\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval]), 0.001), 1)", + "refId": "", + "legendFormat": "{{database}}" + } + ], + "title": "Auth Cache Hit Rate", + "description": "Auth lookups served from cache vs PostgreSQL query. Low rate adds latency to every new connection \u2014 increase cache_ttl.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 86 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_auth_query_auth{result=\"success\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "success/s" + }, + { + "expr": "rate(pg_doorman_auth_query_auth{result=\"failure\", instance=~\"$instance\", database=~\"$database\"}[$__rate_interval])", + "refId": "", + "legendFormat": "failure/s" + } + ], + "title": "Auth Outcomes", + "description": "Auth success vs failure rate. Failure spike after deploy = credential mismatch. Sustained failures = check source IPs in logs.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 86 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_auth_query_dynamic_pools{type=\"current\", instance=~\"$instance\", database=~\"$database\"}", + "refId": "", + "legendFormat": "current" + } + ], + "title": "Dynamic Pools", + "description": "Auto-created pools for auth_query users. Unexpected growth indicates wrong database names or unplanned user sprawl.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 86 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "System", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 94 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "bytes" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_total_memory{instance=~\"$instance\"}", + "refId": "", + "legendFormat": "{{instance}}" + } + ], + "title": "Process Memory", + "description": "Process RSS over time. Correlate with Total Connections and Cache Memory to isolate growth driver.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 95 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_sockets{type=\"tcp\", instance=~\"$instance\"}", + "refId": "", + "legendFormat": "tcp" + }, + { + "expr": "pg_doorman_sockets{type=\"tcp6\", instance=~\"$instance\"}", + "refId": "", + "legendFormat": "tcp6" + }, + { + "expr": "pg_doorman_sockets{type=\"unix\", instance=~\"$instance\"}", + "refId": "", + "legendFormat": "unix" + } + ], + "title": "Sockets by Type", + "description": "Open sockets by protocol. Count growing faster than connections indicates FD leak \u2014 check CLOSE_WAIT via SHOW SOCKETS.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 95 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Patroni-assisted fallback", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 103 + } + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 1, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "max(pg_doorman_fallback_active{instance=~\"$instance\"})", + "refId": "" + } + ], + "title": "Local backend in cooldown", + "description": "1 if any pool is currently using a fallback host. Sustained = local backend not recovering.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 104 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "none", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "blue" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "sum(increase(pg_doorman_fallback_connections_total{instance=~\"$instance\"}[$__rate_interval]))", + "refId": "" + } + ], + "title": "Fallback Connections", + "description": "Connections routed to fallback hosts in the current window.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 104 + }, + "repeatDirection": "h" + }, + { + "type": "stat", + "transparent": false, + "transformations": [], + "options": { + "graphMode": "area", + "colorMode": "background", + "justifyMode": "auto", + "reduceOptions": { + "calcs": [] + }, + "textMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 1, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "sum(increase(pg_doorman_patroni_api_errors_total{instance=~\"$instance\"}[$__rate_interval]))", + "refId": "" + } + ], + "title": "Patroni API Errors", + "description": "Failed /cluster requests (all Patroni URLs unreachable).", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 104 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_patroni_api_requests_total{instance=~\"$instance\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{pool}} requests/s" + }, + { + "expr": "rate(pg_doorman_fallback_connections_total{instance=~\"$instance\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{pool}} connections/s" + }, + { + "expr": "rate(pg_doorman_patroni_api_errors_total{instance=~\"$instance\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{pool}} errors/s" + } + ], + "title": "Patroni API Rate", + "description": "Patroni API calls, fallback connections, and errors per second. Errors without connections = all Patroni URLs or all candidates unreachable.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 12, + "y": 104 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "histogram_quantile(0.50, rate(pg_doorman_patroni_api_duration_seconds_bucket{instance=~\"$instance\"}[$__rate_interval]))", + "refId": "", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.99, rate(pg_doorman_patroni_api_duration_seconds_bucket{instance=~\"$instance\"}[$__rate_interval]))", + "refId": "", + "legendFormat": "p99" + } + ], + "title": "Patroni API Duration", + "description": "Time to fetch /cluster from Patroni API. p99 above 1s = network issues or overloaded Patroni nodes.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 20, + "y": 104 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_fallback_cache_hits_total{instance=~\"$instance\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{pool}}" + } + ], + "title": "Fallback Cache Hits", + "description": "Fallback host served from cache without querying Patroni API. High rate during cooldown = cache working correctly.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 112 + }, + "repeatDirection": "h" + }, + { + "type": "row", + "collapsed": true, + "id": 0, + "panels": [], + "title": "Query Interner", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 120 + } + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ops" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_query_interner_synthetic_misses_total{instance=~\"$instance\"}[$__rate_interval])", + "refId": "", + "legendFormat": "synthetic 26000/s" + } + ], + "title": "Synthetic SQLSTATE 26000 Rate", + "description": "Bind referencing an anonymous prepared whose text is no longer in any cache. Flat zero is the normal case. Sustained > 1/s = TTL too short for the workload, or a driver depending on cross-batch unnamed prepared statements.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 121 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "bytes" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_query_interner_bytes{instance=~\"$instance\"}", + "refId": "", + "legendFormat": "{{kind}}" + } + ], + "title": "Interner Bytes by Kind", + "description": "Total length of interned Parse text per kind. ANON > 1.5 GiB is the alert threshold for the bundled prometheus rule (PgDoormanAnonInternerMemoryHigh).", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 12, + "y": 121 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "targets": [ + { + "expr": "pg_doorman_query_interner_entries{instance=~\"$instance\"}", + "refId": "", + "legendFormat": "{{kind}}" + } + ], + "title": "Interner Entries by Kind", + "description": "Live entries in the global query interner. NAMED is bounded by passive Arc::strong_count GC; ANON is bounded by query_interner_anon_idle_ttl_seconds. Sustained growth on either line points to either a long TTL on a unique-anon workload or an Arc leak on the named side.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 20, + "y": 121 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "ops" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(pg_doorman_query_interner_evictions_total{instance=~\"$instance\"}[$__rate_interval])", + "refId": "", + "legendFormat": "{{kind}}/{{reason}}" + } + ], + "title": "Interner Eviction Rate", + "description": "Evictions per second, split by kind (named|anonymous) and reason (gc_passive for named, ttl_expired for anonymous). Steady ANON eviction is normal; steady NAMED eviction is unusual unless a flood of unique named statements just landed.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 129 + }, + "repeatDirection": "h" + }, + { + "type": "timeseries", + "transparent": false, + "transformations": [], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "min", + "max", + "lastNotNull" + ] + }, + "tooltip": { + "mode": "single", + "sort": "asc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(pg_doorman_query_interner_gc_duration_seconds_bucket{instance=~\"$instance\"}[$__rate_interval])))", + "refId": "", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(pg_doorman_query_interner_gc_duration_seconds_bucket{instance=~\"$instance\"}[$__rate_interval])))", + "refId": "", + "legendFormat": "p99" + } + ], + "title": "GC Sweep Duration", + "description": "Wall-clock time of one GC sweep cycle (named + anonymous combined). P99 above 50 ms means the sweep is starting to bite into request latency tails \u2014 increase query_interner_gc_interval_seconds or shrink the interner via RESET INTERNER plus cache-size tuning.", + "datasource": { + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 8, + "y": 129 + }, + "repeatDirection": "h" + } + ] +} diff --git a/grafana/demo/init.sql b/grafana/demo/init.sql index ffed61b6d..f5ea48f6a 100644 --- a/grafana/demo/init.sql +++ b/grafana/demo/init.sql @@ -2,6 +2,8 @@ SET password_encryption = 'md5'; CREATE USER app_user WITH PASSWORD 'app_pass' SUPERUSER; CREATE USER app_user_2 WITH PASSWORD 'app_pass_2' SUPERUSER; CREATE USER doorman_auth WITH PASSWORD 'auth_secret'; +-- Session-mode demo user. Owns no objects; only LISTEN/NOTIFY rights. +CREATE USER app_session WITH PASSWORD 'session_pass'; CREATE DATABASE app_db OWNER app_user; -- Auth query function @@ -13,3 +15,47 @@ BEGIN END; $$ LANGUAGE plpgsql SECURITY DEFINER; GRANT EXECUTE ON FUNCTION pg_doorman_auth(TEXT) TO doorman_auth; + +-- Notification table + trigger for the session-mode listener sidecar. +-- LISTEN/NOTIFY requires session pool mode because the notification arrives +-- outside any transaction, and a transaction-pooled backend would have been +-- handed off to another client by then. +CREATE TABLE notify_queue ( + id SERIAL PRIMARY KEY, + payload TEXT NOT NULL, + ts TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION notify_queue_event() RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('app_events', NEW.payload); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER notify_queue_t + AFTER INSERT ON notify_queue + FOR EACH ROW EXECUTE FUNCTION notify_queue_event(); + +GRANT INSERT, SELECT ON notify_queue TO app_session, app_user_2; +GRANT USAGE, SELECT ON SEQUENCE notify_queue_id_seq TO app_session, app_user_2; + +-- app_session runs read-only pgbench against the tables that app_user +-- creates with `pgbench -i`. We use both ALTER DEFAULT PRIVILEGES (covers +-- future objects) and an explicit GRANT (covers any tables that already +-- exist before the bench user is created — the order between init.sql +-- and pgbench.sh is timing-dependent on a cold start). +ALTER DEFAULT PRIVILEGES FOR USER app_user IN SCHEMA public + GRANT SELECT ON TABLES TO app_session; + +-- pgbench's startup vacuum is unconditional unless --no-vacuum is set, +-- and it touches all four pgbench tables; granting ALL keeps the +-- session-mode load running even if a future pgbench flavour issues +-- writes against the history table during startup. The bench logic +-- itself stays SELECT-only. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'pgbench_accounts') THEN + EXECUTE 'GRANT ALL ON pgbench_accounts, pgbench_branches, pgbench_history, pgbench_tellers TO app_session'; + END IF; +END $$; diff --git a/grafana/demo/listener.sh b/grafana/demo/listener.sh new file mode 100755 index 000000000..4a0ccc02e --- /dev/null +++ b/grafana/demo/listener.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Session-mode demo workload. Holds three long-lived sessions LISTENing on +# `app_events` (only possible in session pool mode) and a fourth session +# inserting one row into notify_queue every five seconds. The trigger on +# notify_queue raises NOTIFY, which the LISTEN sessions receive — keeping +# `pg_doorman_pools_clients{user="app_session"}` non-zero on idle for the +# Web UI and Grafana to surface. +set -e + +until pg_isready -h pg_doorman -p 6432 -U app_session -d app_db; do + echo "waiting for pg_doorman ..." + sleep 2 +done + +trap 'kill 0' EXIT INT TERM + +for i in 1 2 3; do + PGPASSWORD=session_pass psql \ + -h pg_doorman -p 6432 -U app_session -d app_db \ + -c "LISTEN app_events;" \ + -c "SELECT pg_sleep(86400);" \ + >"/tmp/listener-$i.log" 2>&1 & +done + +while true; do + PGPASSWORD=session_pass psql \ + -h pg_doorman -p 6432 -U app_session -d app_db \ + -c "INSERT INTO notify_queue(payload) VALUES ('event-' || now()::TEXT);" \ + >/dev/null 2>&1 || true + sleep 5 +done diff --git a/grafana/demo/pg_doorman.toml b/grafana/demo/pg_doorman.toml index c024e42a2..1121f6d54 100644 --- a/grafana/demo/pg_doorman.toml +++ b/grafana/demo/pg_doorman.toml @@ -2,20 +2,26 @@ host = "0.0.0.0" port = 6432 admin_username = "admin" -admin_password = "admin" +# Non-default password is required to enable the Web UI; with the default +# `admin` value pg_doorman warns and leaves the SPA disabled. +admin_password = "doorman_demo" pool_mode = "transaction" shutdown_timeout = 10000 pg_hba = { content = "host all all 0.0.0.0/0 trust" } -[prometheus] +[web] enabled = true host = "0.0.0.0" port = 9127 +ui = true +ui_anonymous = false +log_tap_max_entries = 2048 [pools.app_db] server_host = "postgres" server_port = 5432 -max_db_connections = 15 +# 10 (app_user) + 10 (app_user_2) + 5 (session_user, long-lived backends). +max_db_connections = 25 [[pools.app_db.users]] username = "app_user" @@ -27,6 +33,15 @@ username = "app_user_2" password = "md5a05c39aae7f2198b1f06c4291434ee85" pool_size = 10 +# Session-mode pool for LISTEN/NOTIFY workloads. pg_doorman keeps each +# client pinned to its own backend for the lifetime of the session, so +# pool_size = concurrent client count. +[[pools.app_db.users]] +username = "app_session" +password = "md5ca9e0f8ef284b8b27348eee420aae511" +pool_size = 5 +pool_mode = "session" + # Patroni-assisted fallback (uncomment to enable) # patroni_api_urls = ["http://10.0.0.1:8008", "http://10.0.0.2:8008"] # fallback_cooldown = "30s" diff --git a/grafana/demo/pgbench-session.sh b/grafana/demo/pgbench-session.sh new file mode 100755 index 000000000..b0375f5b8 --- /dev/null +++ b/grafana/demo/pgbench-session.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Session-mode pgbench: keeps 4 long-lived clients pinned to backends through +# the app_session pool. Lower concurrency than the transaction-mode pgbench +# scripts because each client holds a backend for the full session lifetime — +# pool_size = 5, leave 1 slot free for the listener producer. +set -e + +until pg_isready -h pg_doorman -p 6432 -U app_session -d app_db 2>/dev/null; do + sleep 1 +done + +# pgbench tables already initialised by pgbench.sh on the postgres backend; +# the session pool reuses them. +exec pgbench \ + -h pg_doorman -p 6432 -U app_session \ + -c 4 -j 1 -T 999999 -P 30 \ + -M simple \ + --select-only \ + --no-vacuum \ + app_db diff --git a/pg_doorman.toml b/pg_doorman.toml index 90e69bd0e..22bb2de5e 100644 --- a/pg_doorman.toml +++ b/pg_doorman.toml @@ -407,9 +407,9 @@ hba = [] # """ # ############################################################################ -# PROMETHEUS METRICS +# WEB UI / METRICS # ############################################################################ -[prometheus] +[web] # Enable Prometheus metrics exporter. # Default: false enabled = false @@ -422,6 +422,18 @@ host = "0.0.0.0" # Default: 9127 port = 9127 +# Enable the web UI. +# Default: false +ui = false + +# Allow unauthenticated access to the public read-only API endpoints. +# Default: false +ui_anonymous = false + +# Maximum number of log entries held in the in-memory log tap. +# Default: 8192 +log_tap_max_entries = 8192 + # ############################################################################ # TALOS AUTHENTICATION (Optional) # ############################################################################ diff --git a/pg_doorman.yaml b/pg_doorman.yaml index 90ab0a7cc..9903c82e1 100644 --- a/pg_doorman.yaml +++ b/pg_doorman.yaml @@ -447,9 +447,9 @@ general: # host all all 0.0.0.0/0 reject # ############################################################################ -# PROMETHEUS METRICS +# WEB UI / METRICS # ############################################################################ -prometheus: +web: # Enable Prometheus metrics exporter. # Default: false enabled: false @@ -462,6 +462,18 @@ prometheus: # Default: 9127 port: 9127 + # Enable the web UI. + # Default: false + ui: false + + # Allow unauthenticated access to the public read-only API endpoints. + # Default: false + ui_anonymous: false + + # Maximum number of log entries held in the in-memory log tap. + # Default: 8192 + log_tap_max_entries: 8192 + # ############################################################################ # TALOS AUTHENTICATION (Optional) # ############################################################################ diff --git a/src/admin/commands.rs b/src/admin/commands.rs index b8d1a24b3..ae61efa7d 100644 --- a/src/admin/commands.rs +++ b/src/admin/commands.rs @@ -5,12 +5,13 @@ use log::{error, info}; use nix::sys::signal::{self, Signal}; use nix::unistd::Pid; +use crate::admin::operations::{pause_now, reconnect_now, resume_now, AdminEffect, AdminScope}; use crate::config::{get_config, reload_config}; use crate::errors::Error; use crate::messages::protocol::{command_complete, data_row, row_description}; use crate::messages::socket::write_all_half; use crate::messages::types::DataType; -use crate::pool::{get_all_pools, ClientServerMap, PoolMap}; +use crate::pool::ClientServerMap; /// Reload the configuration file without restarting the process. pub async fn reload(stream: &mut T, client_server_map: ClientServerMap) -> Result<(), Error> @@ -20,6 +21,7 @@ where info!("Reloading config"); reload_config(client_server_map).await?; + crate::admin::events::push_event("RELOAD", "config reloaded".to_string()); get_config().show(); @@ -122,29 +124,44 @@ where write_all_half(stream, &res).await } -/// Check that the specified database has at least one pool. -/// Returns `Ok(true)` if pools exist (or no db filter was given). -/// Returns `Ok(false)` after sending an error response if db was specified but no pools matched. -async fn check_db_has_pools( - stream: &mut T, - db: &Option, - pools: &PoolMap, -) -> Result +/// Map an [`AdminEffect`] to either a postgres-protocol error response +/// (for `NoMatchingDb`) or a `CommandComplete` reply (for `Applied`). +async fn render_effect(stream: &mut T, command: &str, effect: AdminEffect) -> Result<(), Error> where T: tokio::io::AsyncWrite + std::marker::Unpin, { - if let Some(ref db_name) = db { - if !pools.keys().any(|id| id.db == *db_name) { + match effect { + AdminEffect::NoMatchingDb { db } => { + admin_error_response(stream, &format!("No pool for database \"{db}\""), "3D000").await + } + // The PG admin protocol path only ever passes Database / AllPools + // scopes, so NoMatchingPool cannot land here in practice. Surface + // it the same way as NoMatchingDb in case a future caller wires + // pool-level scoping into this transport too. + AdminEffect::NoMatchingPool { user, db } => { admin_error_response( stream, - &format!("No pool for database \"{}\"", db_name), + &format!("No pool for user \"{user}\"@database \"{db}\""), "3D000", ) - .await?; - return Ok(false); + .await + } + AdminEffect::Applied { .. } => { + let mut res = BytesMut::new(); + res.put(command_complete(command)); + res.put_u8(b'Z'); + res.put_i32(5); + res.put_u8(b'I'); + write_all_half(stream, &res).await } } - Ok(true) +} + +fn db_scope(db: Option) -> AdminScope { + match db { + Some(name) => AdminScope::Database(name), + None => AdminScope::AllPools, + } } /// Pause connection pools — blocks new backend connection acquisition. @@ -154,26 +171,7 @@ pub async fn pause(stream: &mut T, db: Option) -> Result<(), Error> where T: tokio::io::AsyncWrite + std::marker::Unpin, { - let pools = get_all_pools(); - if !check_db_has_pools(stream, &db, &pools).await? { - return Ok(()); - } - for (identifier, pool) in pools.iter() { - if let Some(ref db_name) = db { - if identifier.db != *db_name { - continue; - } - } - pool.database.pause(); - info!("PAUSE: paused pool {}", identifier); - } - - let mut res = BytesMut::new(); - res.put(command_complete("PAUSE")); - res.put_u8(b'Z'); - res.put_i32(5); - res.put_u8(b'I'); - write_all_half(stream, &res).await + render_effect(stream, "PAUSE", pause_now(db_scope(db))).await } /// Resume connection pools — unblocks clients waiting due to PAUSE. @@ -182,26 +180,7 @@ pub async fn resume(stream: &mut T, db: Option) -> Result<(), Error> where T: tokio::io::AsyncWrite + std::marker::Unpin, { - let pools = get_all_pools(); - if !check_db_has_pools(stream, &db, &pools).await? { - return Ok(()); - } - for (identifier, pool) in pools.iter() { - if let Some(ref db_name) = db { - if identifier.db != *db_name { - continue; - } - } - pool.database.resume(); - info!("RESUME: resumed pool {}", identifier); - } - - let mut res = BytesMut::new(); - res.put(command_complete("RESUME")); - res.put_u8(b'Z'); - res.put_i32(5); - res.put_u8(b'I'); - write_all_half(stream, &res).await + render_effect(stream, "RESUME", resume_now(db_scope(db))).await } /// Reconnect connection pools — bumps epoch and drains idle connections. @@ -211,27 +190,5 @@ pub async fn reconnect(stream: &mut T, db: Option) -> Result<(), Erro where T: tokio::io::AsyncWrite + std::marker::Unpin, { - let pools = get_all_pools(); - if !check_db_has_pools(stream, &db, &pools).await? { - return Ok(()); - } - for (identifier, pool) in pools.iter() { - if let Some(ref db_name) = db { - if identifier.db != *db_name { - continue; - } - } - let new_epoch = pool.database.reconnect(); - info!( - "RECONNECT: reconnected pool {} (new epoch: {})", - identifier, new_epoch - ); - } - - let mut res = BytesMut::new(); - res.put(command_complete("RECONNECT")); - res.put_u8(b'Z'); - res.put_i32(5); - res.put_u8(b'I'); - write_all_half(stream, &res).await + render_effect(stream, "RECONNECT", reconnect_now(db_scope(db))).await } diff --git a/src/admin/events.rs b/src/admin/events.rs new file mode 100644 index 000000000..504ce1cf4 --- /dev/null +++ b/src/admin/events.rs @@ -0,0 +1,149 @@ +//! Bounded ring buffer of admin command events (RELOAD, PAUSE, RESUME, +//! RECONNECT). Frontend reads via `/api/events?since=&max=` to +//! render vertical-line annotations on Overview graphs. +//! +//! This module is intentionally simple: a single Mutex, no +//! lock-free fancy. Admin commands fire on the order of one per few +//! minutes per cluster; contention is negligible. Reads come from a +//! handful of Web UI operators per hour. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Maximum number of events retained in the ring. At one admin command +/// every few minutes this is well over a day of history; oldest events +/// are dropped silently when the buffer fills. +const BUFFER_CAPACITY: usize = 1024; + +#[derive(Debug, Clone)] +pub struct EventEntry { + /// Monotonically increasing sequence number assigned at push time. + pub seq: u64, + /// Wall-clock timestamp in milliseconds since unix epoch. + pub ts_ms: u64, + /// One of `"RELOAD"`, `"PAUSE"`, `"RESUME"`, `"RECONNECT"`. + pub target: &'static str, + /// Human-readable description of the event (e.g. `"pool main@db1 paused"`). + pub message: String, +} + +static SEQ_COUNTER: AtomicU64 = AtomicU64::new(1); + +fn buffer() -> &'static Mutex> { + static BUFFER: OnceLock>> = OnceLock::new(); + BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(BUFFER_CAPACITY))) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Records an admin event in the ring. Called from `src/admin/commands.rs` +/// after a successful command. +pub fn push_event(target: &'static str, message: String) { + let entry = EventEntry { + seq: SEQ_COUNTER.fetch_add(1, Ordering::Relaxed), + ts_ms: now_ms(), + target, + message, + }; + if let Ok(mut buf) = buffer().lock() { + if buf.len() >= BUFFER_CAPACITY { + buf.pop_front(); + } + buf.push_back(entry); + } +} + +/// Returns all events with `seq > since`, capped at `max` entries, plus the +/// next sequence number an operator should poll with. Empty when no events +/// have been pushed. +pub fn get_events_since(since: u64, max: usize) -> (Vec, u64) { + let buf = match buffer().lock() { + Ok(b) => b, + Err(_) => return (Vec::new(), since), + }; + let mut next_seq = since; + let entries: Vec = buf + .iter() + .filter(|e| e.seq > since) + .take(max) + .map(|e| { + next_seq = next_seq.max(e.seq); + e.clone() + }) + .collect(); + (entries, next_seq) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + /// Tests touch the global SEQ_COUNTER and BUFFER, so serialise them. + fn lock() -> std::sync::MutexGuard<'static, ()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| StdMutex::new(())).lock().unwrap() + } + + fn drain() { + let _ = buffer().lock().map(|mut b| b.clear()); + } + + #[test] + fn push_event_appends_with_increasing_seq() { + let _g = lock(); + drain(); + push_event("RELOAD", "config reloaded".into()); + push_event("PAUSE", "pool main@db1 paused".into()); + let (events, next) = get_events_since(0, 100); + assert_eq!(events.len(), 2); + assert!(events[0].seq < events[1].seq); + assert_eq!(next, events[1].seq); + assert_eq!(events[0].target, "RELOAD"); + assert_eq!(events[1].target, "PAUSE"); + } + + #[test] + fn get_events_since_filters_by_seq() { + let _g = lock(); + drain(); + push_event("RELOAD", "first".into()); + push_event("PAUSE", "second".into()); + let (first_batch, next1) = get_events_since(0, 100); + let (after_first, next2) = get_events_since(first_batch[0].seq, 100); + assert_eq!(after_first.len(), 1); + assert_eq!(after_first[0].message, "second"); + assert_eq!(next2, next1); + } + + #[test] + fn get_events_since_respects_max() { + let _g = lock(); + drain(); + for i in 0..10 { + push_event("RELOAD", format!("event {i}")); + } + let (events, _) = get_events_since(0, 3); + assert_eq!(events.len(), 3); + } + + #[test] + fn buffer_drops_oldest_when_full() { + let _g = lock(); + drain(); + for i in 0..(BUFFER_CAPACITY + 50) { + push_event("RELOAD", format!("event {i}")); + } + let (events, _) = get_events_since(0, BUFFER_CAPACITY * 2); + assert_eq!(events.len(), BUFFER_CAPACITY); + // Earliest 50 dropped — first surviving event is index 50 (message "event 50"). + assert_eq!(events[0].message, "event 50"); + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 512c24209..ab7cf12fd 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -6,6 +6,9 @@ mod commands; mod show; +pub mod events; +pub mod operations; + use bytes::{Buf, BufMut, BytesMut}; use log::{debug, warn}; diff --git a/src/admin/operations.rs b/src/admin/operations.rs new file mode 100644 index 000000000..dff6867f3 --- /dev/null +++ b/src/admin/operations.rs @@ -0,0 +1,135 @@ +//! Single source of truth for the database-scoped admin actions. Both the +//! postgres-protocol admin socket (`crate::admin::commands::{pause,resume, +//! reconnect}`) and the REST surface (`POST /api/admin/{pause,resume, +//! reconnect}`) call into the helpers here and translate the typed +//! [`AdminEffect`] into their own response envelopes. That way the two +//! transports cannot diverge: a `db` filter that matches no pool is +//! reported as a `NoMatchingDb` outcome to both, instead of an SQLSTATE +//! error in one path and a silent `affected: 0` in the other. +//! +//! `events::push_event` is emitted from here, so the Web UI's events +//! overlay paints a marker on every successful action regardless of +//! origin. + +use log::info; + +use crate::config::reload_config; +use crate::errors::Error; +use crate::pool::{get_all_pools, get_client_server_map, ConnectionPool, PoolIdentifier}; + +/// Scope filter for `pause` / `resume` / `reconnect`. The REST surface +/// accepts both `?db=` (every user@db pool of one database) and +/// `?pool=@` (one specific pool); the admin protocol path +/// historically only takes a database name, so it always passes +/// [`AdminScope::Database`] or [`AdminScope::AllPools`]. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum AdminScope { + AllPools, + Database(String), + Pool { user: String, db: String }, +} + +impl AdminScope { + fn matches(&self, identifier: &PoolIdentifier) -> bool { + match self { + AdminScope::AllPools => true, + AdminScope::Database(name) => identifier.db == *name, + AdminScope::Pool { user, db } => identifier.user == *user && identifier.db == *db, + } + } +} + +/// Outcome of an admin action. +/// +/// `Applied { affected: [] }` is legitimate when [`AdminScope::AllPools`] +/// is used but the pooler holds no pools yet. `NoMatchingDb` / +/// `NoMatchingPool` are reserved for the case where the caller did pass +/// a scope filter and no pool matches — transports turn these into a +/// 404 / SQLSTATE 3D000 so the operator gets a clear signal that the +/// typo / stale name took no effect. +/// +/// `affected` is the list of touched pools, not just a count, so DBAs +/// can see exactly which `user@db` rows the action ran against — useful +/// when the same database has several users. +#[derive(Debug, PartialEq, Eq)] +pub enum AdminEffect { + NoMatchingDb { db: String }, + NoMatchingPool { user: String, db: String }, + Applied { affected: Vec }, +} + +/// Reload the configuration file. Equivalent to `RELOAD` on the admin +/// protocol; emits the same RELOAD event. Returns `true` when the config +/// actually changed and pools were reconciled, `false` when the file +/// re-parsed identically to the live config (a no-op reload). +pub async fn reload_now() -> Result { + let csm = get_client_server_map() + .ok_or_else(|| Error::SocketError("client_server_map not initialised".into()))?; + info!("Reloading config (via /api/admin/reload)"); + let changed = reload_config(csm).await?; + crate::admin::events::push_event("RELOAD", "config reloaded".to_string()); + crate::config::get_config().show(); + Ok(changed) +} + +/// Pause every pool the scope selects. +pub fn pause_now(scope: AdminScope) -> AdminEffect { + apply_per_pool(scope, |identifier, pool| { + pool.database.pause(); + crate::admin::events::push_event("PAUSE", format!("pool {identifier} paused")); + info!("PAUSE: paused pool {identifier}"); + }) +} + +/// Resume — mirror of [`pause_now`]. +pub fn resume_now(scope: AdminScope) -> AdminEffect { + apply_per_pool(scope, |identifier, pool| { + pool.database.resume(); + crate::admin::events::push_event("RESUME", format!("pool {identifier} resumed")); + info!("RESUME: resumed pool {identifier}"); + }) +} + +/// Reconnect — bumps the pool epoch and drains idle connections. Active +/// connections are refused on return. +pub fn reconnect_now(scope: AdminScope) -> AdminEffect { + apply_per_pool(scope, |identifier, pool| { + let new_epoch = pool.database.reconnect(); + crate::admin::events::push_event( + "RECONNECT", + format!("pool {identifier} reconnected (epoch={new_epoch})"), + ); + info!("RECONNECT: reconnected pool {identifier} (new epoch: {new_epoch})"); + }) +} + +/// Iterate the pool table once: skip pools that do not match the scope, +/// return `NoMatchingDb` / `NoMatchingPool` if the scope's filter +/// matched nothing, otherwise return the list of touched pool ids. +fn apply_per_pool(scope: AdminScope, mut act: F) -> AdminEffect +where + F: FnMut(&PoolIdentifier, &ConnectionPool), +{ + let pools = get_all_pools(); + if !pools + .iter() + .any(|(identifier, _)| scope.matches(identifier)) + { + return match scope { + AdminScope::AllPools => AdminEffect::Applied { + affected: Vec::new(), + }, + AdminScope::Database(db) => AdminEffect::NoMatchingDb { db }, + AdminScope::Pool { user, db } => AdminEffect::NoMatchingPool { user, db }, + }; + } + let mut affected = Vec::new(); + for (identifier, pool) in pools.iter() { + if !scope.matches(identifier) { + continue; + } + act(identifier, pool); + affected.push(identifier.clone()); + } + AdminEffect::Applied { affected } +} diff --git a/src/admin/show.rs b/src/admin/show.rs index 595fd6fc6..4833fe616 100644 --- a/src/admin/show.rs +++ b/src/admin/show.rs @@ -175,7 +175,7 @@ where for (identifier, pool) in get_all_pools().iter() { if let Some(cache) = pool.prepared_statement_cache.as_ref() { let entries = cache.get_entries(); - for (hash, parse, last_used, kind) in entries { + for (hash, parse, last_used, kind, _hits, _misses) in entries { res.put(data_row(&[ identifier.to_string(), hash.to_string(), @@ -506,13 +506,13 @@ where for (_, client) in new_map { let row = vec![ format!("#c{}", client.connection_id()), - client.pool_name(), - client.username(), - client.application_name(), - client.ipaddr(), + client.pool_name().to_string(), + client.username().to_string(), + client.application_name().to_string(), + client.ipaddr().to_string(), client.tls().to_string(), - client.state_to_string(), - client.wait_to_string(), + client.state_str().to_string(), + client.wait_str().to_string(), client.transaction_count.load(Ordering::Relaxed).to_string(), client.query_count.load(Ordering::Relaxed).to_string(), client.error_count.load(Ordering::Relaxed).to_string(), @@ -594,12 +594,12 @@ where let row = vec![ format!("{:#010X}", server.server_id()), server.process_id().to_string(), - server.pool_name(), - server.username(), + server.pool_name().to_string(), + server.username().to_string(), application_name.clone(), server.tls().to_string(), - server.state_to_string(), - server.wait_to_string(), + server.state_str().to_string(), + server.wait_str().to_string(), server.transaction_count.load(Ordering::Relaxed).to_string(), server.query_count.load(Ordering::Relaxed).to_string(), server.bytes_sent.load(Ordering::Relaxed).to_string(), diff --git a/src/app/generate/annotated.rs b/src/app/generate/annotated.rs index 39d547e6b..26e53d9ae 100644 --- a/src/app/generate/annotated.rs +++ b/src/app/generate/annotated.rs @@ -10,7 +10,7 @@ use std::sync::LazyLock; use serde::Deserialize; -use crate::config::{Config, ConfigFormat, Pool, PoolMode, Prometheus, User}; +use crate::config::{Config, ConfigFormat, Pool, PoolMode, User, Web}; // --------------------------------------------------------------------------- // YAML field descriptions — single source of truth @@ -49,7 +49,7 @@ pub(crate) struct FieldsMap { pub pool: HashMap, pub user: HashMap, pub auth_query: HashMap, - pub prometheus: HashMap, + pub web: HashMap, } #[derive(Deserialize)] @@ -60,13 +60,29 @@ pub(crate) struct FieldsData { } impl FieldsData { + /// Same lookup as [`Self::field`] but returns `None` instead of + /// panicking on an unknown section/field. Used by the web layer to + /// surface field descriptions in `/api/config`, where unknown keys + /// are normal (operator-defined pool names, talos role names, etc.). + pub(crate) fn try_field(&self, section: &str, name: &str) -> Option<&FieldDesc> { + let map = match section { + "general" => &self.fields.general, + "pool" => &self.fields.pool, + "user" => &self.fields.user, + "auth_query" => &self.fields.auth_query, + "web" => &self.fields.web, + _ => return None, + }; + map.get(name) + } + pub(crate) fn field(&self, section: &str, name: &str) -> &FieldDesc { let map = match section { "general" => &self.fields.general, "pool" => &self.fields.pool, "user" => &self.fields.user, "auth_query" => &self.fields.auth_query, - "prometheus" => &self.fields.prometheus, + "web" => &self.fields.web, _ => panic!("Unknown section: {section}"), }; map.get(name) @@ -186,7 +202,7 @@ pub fn generate_annotated_config(config: &Config, format: ConfigFormat, russian: write_header(&mut w); write_include_section(&mut w); write_general_section(&mut w, config); - write_prometheus_section(&mut w, &config.prometheus); + write_web_section(&mut w, &config.web); write_talos_section(&mut w); write_pools_section(&mut w, config); @@ -1081,22 +1097,38 @@ fn write_pg_hba_rule_examples(w: &mut ConfigWriter, fi: usize) { } } -fn write_prometheus_section(w: &mut ConfigWriter, prom: &Prometheus) { +fn write_web_section(w: &mut ConfigWriter, web: &Web) { let f = &*FIELDS; - w.major_separator(f.text("prometheus_title").get(w.russian)); - w.section(0, "prometheus"); + w.major_separator(f.text("web_title").get(w.russian)); + w.section(0, "web"); let fi = w.field_indent(); - write_field_comment(w, fi, "prometheus", "enabled"); - w.kv(fi, "enabled", &w.bool_val(prom.enabled)); + write_field_comment(w, fi, "web", "enabled"); + w.kv(fi, "enabled", &w.bool_val(web.enabled)); w.blank(); - write_field_comment(w, fi, "prometheus", "host"); - w.kv(fi, "host", &w.str_val(&prom.host)); + write_field_comment(w, fi, "web", "host"); + w.kv(fi, "host", &w.str_val(&web.host)); w.blank(); - write_field_comment(w, fi, "prometheus", "port"); - w.kv(fi, "port", &w.num_val(prom.port)); + write_field_comment(w, fi, "web", "port"); + w.kv(fi, "port", &w.num_val(web.port)); + w.blank(); + + write_field_comment(w, fi, "web", "ui"); + w.kv(fi, "ui", &w.bool_val(web.ui)); + w.blank(); + + write_field_comment(w, fi, "web", "ui_anonymous"); + w.kv(fi, "ui_anonymous", &w.bool_val(web.ui_anonymous)); + w.blank(); + + write_field_comment(w, fi, "web", "log_tap_max_entries"); + w.kv( + fi, + "log_tap_max_entries", + &w.num_val(web.log_tap_max_entries), + ); w.blank(); } @@ -2037,7 +2069,7 @@ mod tests { ("General", include_str!("../../config/general.rs")), ("Pool", include_str!("../../config/pool.rs")), ("User", include_str!("../../config/user.rs")), - ("Prometheus", include_str!("../../config/prometheus.rs")), + ("Web", include_str!("../../config/web.rs")), ("Talos", include_str!("../../config/talos.rs")), ("Include", include_str!("../../config/include.rs")), ]; @@ -2121,7 +2153,7 @@ mod tests { ("General", include_str!("../../config/general.rs")), ("Pool", include_str!("../../config/pool.rs")), ("User", include_str!("../../config/user.rs")), - ("Prometheus", include_str!("../../config/prometheus.rs")), + ("Web", include_str!("../../config/web.rs")), ("AuthQueryConfig", include_str!("../../config/pool.rs")), ]; @@ -2129,7 +2161,7 @@ mod tests { ("General", "general"), ("Pool", "pool"), ("User", "user"), - ("Prometheus", "prometheus"), + ("Web", "web"), ("AuthQueryConfig", "auth_query"), ]; @@ -2162,7 +2194,7 @@ mod tests { "pool" => &fields.fields.pool, "user" => &fields.fields.user, "auth_query" => &fields.fields.auth_query, - "prometheus" => &fields.fields.prometheus, + "web" => &fields.fields.web, _ => unreachable!(), }; map.contains_key(field.as_str()) diff --git a/src/app/generate/docs.rs b/src/app/generate/docs.rs index b113073bf..3873c8087 100644 --- a/src/app/generate/docs.rs +++ b/src/app/generate/docs.rs @@ -370,28 +370,29 @@ fn write_user_fields(out: &mut String, f: &FieldsData) { // --------------------------------------------------------------------------- fn write_prometheus_fields(out: &mut String, f: &FieldsData) { - let _ = writeln!(out, "## Enabling Prometheus Metrics\n"); - let _ = writeln!(out, "To enable the Prometheus metrics exporter, add the following to your configuration file:\n"); - let _ = writeln!(out, "```yaml\nprometheus:\n enabled: true\n host: \"0.0.0.0\" # The host on which the metrics server will listen\n port: 9127 # The port on which the metrics server will listen\n```\n"); + let _ = writeln!(out, "## Enabling the Web Listener\n"); + let _ = writeln!(out, "Both the Prometheus metrics endpoint (`/metrics`) and the optional operator console (the SPA on `/`, `/api/*`) are served by the same `[web]` listener. The legacy `prometheus.*` config keys are accepted as aliases for `web.*`.\n"); + let _ = writeln!(out, "```yaml\nweb:\n enabled: true # Bind the listener (Prometheus only)\n host: \"0.0.0.0\"\n port: 9127\n # Operator console (off by default; see the Web UI guide)\n ui: false\n ui_anonymous: false\n```\n"); let _ = writeln!(out, "### Configuration Options\n"); + let _ = writeln!(out, "Full list — including UI-related fields — lives in [Web Settings](web.md). The minimum to expose `/metrics` is:\n"); let _ = writeln!(out, "| Option | Description | Default |"); let _ = writeln!(out, "|--------|-------------|---------|"); let _ = writeln!( out, "| `enabled` | {} | `false` |", - field_doc(f, "prometheus", "enabled") + field_doc(f, "web", "enabled") ); let _ = writeln!( out, "| `host` | {} | `\"0.0.0.0\"` |", - field_doc(f, "prometheus", "host") + field_doc(f, "web", "host") ); let _ = writeln!( out, "| `port` | {} | `9127` |\n", - field_doc(f, "prometheus", "port") + field_doc(f, "web", "port") ); } diff --git a/src/app/generate/fields.yaml b/src/app/generate/fields.yaml index 02a6bd3a7..50572612b 100644 --- a/src/app/generate/fields.yaml +++ b/src/app/generate/fields.yaml @@ -151,9 +151,9 @@ texts: general_title: en: "GENERAL SETTINGS" ru: "ОСНОВНЫЕ НАСТРОЙКИ" - prometheus_title: - en: "PROMETHEUS METRICS" - ru: "МЕТРИКИ PROMETHEUS" + web_title: + en: "WEB UI / METRICS" + ru: "ВЕБ-ИНТЕРФЕЙС / МЕТРИКИ" talos_title: en: "TALOS AUTHENTICATION (Optional)" ru: "АУТЕНТИФИКАЦИЯ TALOS (Опционально)" @@ -1675,7 +1675,7 @@ fields: doc: "Minimum interval between re-fetches for the same username after an authentication failure. Protects the backend from excessive queries during brute-force attempts." default: '"1s"' - prometheus: + web: enabled: config: en: "Enable Prometheus metrics exporter." @@ -1696,3 +1696,24 @@ fields: ru: "Порт HTTP-эндпоинта для метрик." doc: "The port on which the Prometheus metrics exporter will listen." default: "9127" + + ui: + config: + en: "Enable the web UI." + ru: "Включить веб-интерфейс." + doc: "When true, the web listener also serves the SPA (static assets) and `/api/*` routes alongside `/metrics`. Requires `admin_password` to be set to a non-default value." + default: "false" + + ui_anonymous: + config: + en: "Allow unauthenticated access to the public read-only API endpoints." + ru: "Разрешить анонимный доступ к публичным read-only API-эндпоинтам." + doc: "When true, public read-only routes (overview, pools, clients, config) are accessible without authentication; admin-only routes (logs, prepared statement bodies, interner top, top queries, /api/admin/*) always require basic-auth regardless. Note: the SPA shell on `/` is always served anonymously when `web.ui` is active so the React sign-in modal can render before any browser-native basic-auth dialog." + default: "false" + + log_tap_max_entries: + config: + en: "Maximum number of log entries held in the in-memory log tap." + ru: "Максимальное количество записей в буфере лога." + doc: "Cap on how many recent log lines the web UI log tap retains in memory." + default: "8192" diff --git a/src/app/log_level.rs b/src/app/log_level.rs index 23301393f..b7b9facb7 100644 --- a/src/app/log_level.rs +++ b/src/app/log_level.rs @@ -68,6 +68,7 @@ impl Log for LogLevelController { fn log(&self, record: &Record) { if self.enabled(record.metadata()) { self.inner.log(record); + crate::web::log_tap::push(record); } } diff --git a/src/app/server.rs b/src/app/server.rs index a563275a5..9573ebb69 100644 --- a/src/app/server.rs +++ b/src/app/server.rs @@ -19,11 +19,12 @@ use crate::config::{get_config, reload_config, Config}; use crate::daemon; use crate::messages::{configure_tcp_socket, configure_unix_socket}; use crate::pool::{retain, ClientServerMap, ConnectionPool}; -use crate::prometheus::{record_interner_gc, start_prometheus_server}; use crate::server::{gc_sweep_anon, gc_sweep_named}; use crate::stats::{Collector, Reporter, REPORTER, TOTAL_CONNECTION_COUNTER}; use crate::utils::core_affinity; use crate::utils::format_duration; +use crate::web::metrics::record_interner_gc; +use crate::web::WebServerOptions; use socket2::SockRef; #[cfg(not(windows))] use std::os::fd::{AsRawFd, FromRawFd}; @@ -47,6 +48,14 @@ pub static CLIENTS_IN_TRANSACTIONS: AtomicI64 = AtomicI64::new(0); /// Global flag: migration to new process is active. Clients should self-migrate at idle points. pub static MIGRATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false); +/// Process start time. Captured the first time `STARTED_AT` is read (i.e. +/// during the first poll into `OverviewDto.uptime_seconds`); for the +/// foreground listener path that happens within a few hundred milliseconds +/// of `main()` so the value approximates the binary's real boot time +/// closely enough for an operator console. +pub static STARTED_AT: std::sync::LazyLock = + std::sync::LazyLock::new(std::time::SystemTime::now); + /// Channel sender for migration payloads. Set once when migration starts. pub static MIGRATION_TX: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -357,14 +366,48 @@ pub fn run_server(args: Args, config: Config) -> Result<(), Box l, + Err(e) => { + error!("web listener bind failed on {host}: {e}"); + std::process::exit(exitcode::OSERR); + } + }; tokio::task::spawn(async move { - start_prometheus_server( - format!("{}:{}", config.prometheus.host, config.prometheus.port).as_str(), - ) - .await; + crate::web::serve_on(web_listener, opts).await; }); + // LogTap stays off until /api/logs is hit; the reaper turns it + // back off when nobody is polling, so spawn it once here. + if ui_active && config.web.log_tap_max_entries > 0 { + tokio::task::spawn(crate::web::log_tap::run_reaper()); + } } // Signal readiness to parent process (for binary upgrade in foreground mode) diff --git a/src/client/core.rs b/src/client/core.rs index 24c39e882..d7daa3f11 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -388,6 +388,12 @@ pub struct PreparedStatementState { /// Hash of the last anonymous prepared statement (for Bind to find the corresponding Parse) pub last_anonymous_hash: Option, + /// Hash of the last Bind in the current batch, plus the anonymous flag. + /// Cleared on Sync completion. Used by /api/top/queries duration + /// instrumentation to attribute the batch's elapsed time to a single + /// interner entry. + pub last_bound_for_top: Option<(u64, bool)>, + /// Tracks skipped Parse messages that need synthetic ParseComplete responses. /// Each entry contains the statement name and what response we're waiting for. pub skipped_parses: Vec, @@ -424,6 +430,7 @@ impl PreparedStatementState { async_client: false, cache: PreparedStatementCache::new(anon_cache_size), last_anonymous_hash: None, + last_bound_for_top: None, skipped_parses: Vec::new(), batch_operations: Vec::new(), parses_sent_in_batch: 0, diff --git a/src/client/protocol.rs b/src/client/protocol.rs index df3d5f4f3..314c03da9 100644 --- a/src/client/protocol.rs +++ b/src/client/protocol.rs @@ -249,7 +249,7 @@ where // eviction rate at zero capacity pressure. if let PutOutcome::Evicted(_) = self.prepared.cache.put(cache_key, cached) { self.prepared.anonymous_evictions += 1; - crate::prometheus::observe_anonymous_eviction(&self.username, &self.pool_name); + crate::web::metrics::observe_anonymous_eviction(&self.username, &self.pool_name); } // Update prepared cache stats after modification @@ -263,7 +263,18 @@ where // Check if server already has this prepared statement // For async clients with unique names, this will always be false (new unique name) - if server.has_prepared_statement(&server_stmt_name) { + let server_has_it = server.has_prepared_statement(&server_stmt_name); + if let Some(cache) = pool.prepared_statement_cache.as_ref() { + // Per-CacheEntry hit/miss for /api/top/prepared. Silent no-op + // when the entry was evicted between register_parse_to_cache and + // here — same lock-free policy as /api/top/queries. + if server_has_it { + cache.record_hit(hash); + } else { + cache.record_miss(hash); + } + } + if server_has_it { // For async clients, always send Parse to get real ParseComplete from server if self.prepared.async_client { debug!( @@ -371,7 +382,7 @@ where self.username, self.pool_name, self.connection_id, ); } - crate::prometheus::record_synthetic_miss(); + crate::web::metrics::record_synthetic_miss(); // SQLSTATE 26000 (invalid_sql_statement_name) matches the // error native PostgreSQL raises for the same condition; // see src/backend/tcop/postgres.c exec_bind_message. @@ -462,6 +473,14 @@ where statement_name: server_name, }); + // /api/top/queries instrumentation. Accept the cache miss / + // race where the interner entry has been GC'd between intern + // and Bind — the no-op behaviour in record_query_count is + // intended to keep the hot path lock-free. + let is_anonymous = client_given_name.is_empty(); + crate::server::record_query_count(cached.hash, is_anonymous); + self.prepared.last_bound_for_top = Some((cached.hash, is_anonymous)); + Ok(()) } None => { @@ -485,7 +504,7 @@ where self.username, self.pool_name, self.connection_id, ); } - crate::prometheus::record_synthetic_miss(); + crate::web::metrics::record_synthetic_miss(); error_response( &mut self.write, "unnamed prepared statement does not exist", @@ -639,7 +658,7 @@ where self.username, self.pool_name, self.connection_id, ); } - crate::prometheus::record_synthetic_miss(); + crate::web::metrics::record_synthetic_miss(); error_response( &mut self.write, "unnamed prepared statement does not exist", diff --git a/src/client/transaction.rs b/src/client/transaction.rs index 1c22e505f..903fbe520 100644 --- a/src/client/transaction.rs +++ b/src/client/transaction.rs @@ -384,6 +384,12 @@ where server.set_async_mode(false); server.set_expected_responses(0); + // Defensively clear any pending extended-protocol attribution. + // A simple query is opaque to the interner; whatever last_bound_for_top + // held was from a prior extended batch and would otherwise leak its + // hash into the next Sync. + self.prepared.last_bound_for_top = None; + self.execute_server_roundtrip(Some(message), server).await?; self.stats.query(); server.stats.query( @@ -485,10 +491,16 @@ where server.has_pending_cache_entries = false; self.stats.query(); - server.stats.query( - query_start_at.elapsed().as_micros() as u64, - self.server_parameters.get_application_name(), - ); + // /api/top/queries duration accounting. The whole batch's elapsed + // time is attributed to the last Bind's hash; multi-Bind batches + // give the duration to whichever Bind was last (approximation). + let micros = query_start_at.elapsed().as_micros() as u64; + if let Some((hash, anon)) = self.prepared.last_bound_for_top.take() { + crate::server::record_query_duration_us(hash, anon, micros); + } + server + .stats + .query(micros, self.server_parameters.get_application_name()); self.buffer.clear(); // Reset batch state for next batch @@ -744,7 +756,10 @@ where // We'll send back an error message and clean the extended // protocol buffer self.stats.idle_read(); - current_pool.address.stats.error(); + // Mirrors the SQLSTATE in the ErrorResponse below + // so the per-pool breakdown reflects checkout + // failures alongside PG-side errors. + current_pool.address.stats.error_with_sqlstate("53300"); self.stats.checkout_error(); if message[0] as char == 'S' { @@ -767,11 +782,13 @@ where }; }; let server = conn.deref_mut(); - server.stats.active(self.stats.application_name()); + server + .stats + .active(self.stats.application_name().to_string()); let checkout_us = connecting_at.elapsed().as_micros() as u64; server .stats - .checkout_time(checkout_us, self.stats.application_name()); + .checkout_time(checkout_us, self.stats.application_name().to_string()); // Update client-side wait tracking so SHOW POOLS maxwait // reflects real checkout peaks, not the zero from init. self.stats diff --git a/src/config/mod.rs b/src/config/mod.rs index 6c5015d2c..8c7d033bc 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -27,10 +27,10 @@ mod duration; mod general; mod include; mod pool; -mod prometheus; mod talos; pub mod tls; mod user; +mod web; #[cfg(test)] mod tests; @@ -42,10 +42,10 @@ pub use duration::Duration; pub use general::General; pub use include::{GeneralWithInclude, Include, ServerConfig}; pub use pool::{AuthQueryConfig, Pool}; -pub use prometheus::Prometheus; pub use talos::Talos; pub use tls::{ServerTlsConfig, ServerTlsMode}; pub use user::User; +pub use web::Web; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -213,9 +213,9 @@ pub struct Config { // General and global settings. pub general: General, - // Prometheus settings. - #[serde(default = "Prometheus::empty")] - pub prometheus: Prometheus, + // Web UI / metrics settings. + #[serde(default = "Web::empty", alias = "prometheus")] + pub web: Web, // Talos settings. #[serde(default = "Talos::empty", skip_serializing_if = "Talos::is_empty")] @@ -243,7 +243,7 @@ impl Default for Config { Config { path: Self::default_path(), general: General::default(), - prometheus: Prometheus::empty(), + web: Web::empty(), pools: HashMap::default(), talos: Talos { keys: vec![], @@ -791,6 +791,13 @@ pub async fn reload_config(client_server_map: ClientServerMap) -> Result Prometheus { - Prometheus { - host: Self::default_host(), - port: Self::default_port(), - enabled: Self::default_enable(), - } - } - pub fn default_host() -> String { - "0.0.0.0".to_string() - } - pub fn default_port() -> u16 { - 9127 - } - pub fn default_enable() -> bool { - false - } -} diff --git a/src/config/tests.rs b/src/config/tests.rs index d4796e693..4c3e6b22e 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1828,3 +1828,159 @@ client_anonymous_prepared_cache_size = 2048 let found = find_deprecated_general_keys_toml(&value); assert!(found.is_empty()); } + +#[tokio::test] +#[serial] +async fn test_config_web_section() { + let config_content = r#" +[general] +host = "127.0.0.1" +port = 6432 +admin_username = "admin" +admin_password = "admin_password" + +[web] +enabled = true +host = "127.0.0.1" +port = 9128 +ui = true +ui_anonymous = false +log_tap_max_entries = 4096 + +[pools.example_db] +server_host = "localhost" +server_port = 5432 + +[[pools.example_db.users]] +username = "u" +password = "p" +pool_size = 5 +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(config_content.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + parse(temp_file.path().to_str().unwrap()).await.unwrap(); + + let cfg = get_config(); + assert!(cfg.web.enabled); + assert_eq!(cfg.web.host, "127.0.0.1"); + assert_eq!(cfg.web.port, 9128); + assert!(cfg.web.ui); + assert!(!cfg.web.ui_anonymous); + assert_eq!(cfg.web.log_tap_max_entries, 4096); +} + +#[tokio::test] +#[serial] +async fn test_config_prometheus_alias() { + let config_content = r#" +[general] +host = "127.0.0.1" +port = 6432 +admin_username = "admin" +admin_password = "admin_password" + +[prometheus] +enabled = true +host = "127.0.0.1" +port = 9128 + +[pools.example_db] +server_host = "localhost" +server_port = 5432 + +[[pools.example_db.users]] +username = "u" +password = "p" +pool_size = 5 +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(config_content.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + parse(temp_file.path().to_str().unwrap()).await.unwrap(); + + let cfg = get_config(); + assert!(cfg.web.enabled); + assert_eq!(cfg.web.host, "127.0.0.1"); + assert_eq!(cfg.web.port, 9128); + // New-field defaults are preserved when the legacy [prometheus] alias is used. + assert!(!cfg.web.ui); + assert!(!cfg.web.ui_anonymous); + assert_eq!(cfg.web.log_tap_max_entries, 8192); +} + +#[tokio::test] +#[serial] +async fn test_config_web_and_prometheus_both_rejected() { + let config_content = r#" +[general] +host = "127.0.0.1" +port = 6432 +admin_username = "admin" +admin_password = "admin_password" + +[web] +enabled = true + +[prometheus] +enabled = false + +[pools.example_db] +server_host = "localhost" +server_port = 5432 + +[[pools.example_db.users]] +username = "u" +password = "p" +pool_size = 5 +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(config_content.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = parse(temp_file.path().to_str().unwrap()).await; + assert!( + result.is_err(), + "Expected parse to fail when both [web] and [prometheus] are present, but it succeeded" + ); +} + +#[tokio::test] +#[serial] +async fn test_config_web_section_partial() { + let config_content = r#" +[general] +host = "127.0.0.1" +port = 6432 +admin_username = "admin" +admin_password = "admin_password" + +[web] +enabled = true + +[pools.example_db] +server_host = "localhost" +server_port = 5432 + +[[pools.example_db.users]] +username = "u" +password = "p" +pool_size = 5 +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(config_content.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + parse(temp_file.path().to_str().unwrap()).await.unwrap(); + + let cfg = get_config(); + assert!(cfg.web.enabled); + // All other fields fall back to defaults. + assert_eq!(cfg.web.host, "0.0.0.0"); + assert_eq!(cfg.web.port, 9127); + assert!(!cfg.web.ui); + assert!(!cfg.web.ui_anonymous); + assert_eq!(cfg.web.log_tap_max_entries, 8192); +} diff --git a/src/config/web.rs b/src/config/web.rs new file mode 100644 index 000000000..fb0a86486 --- /dev/null +++ b/src/config/web.rs @@ -0,0 +1,70 @@ +//! Web UI and metrics endpoint configuration. + +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Web { + #[serde(default = "Web::default_host")] + pub host: String, + #[serde(default = "Web::default_port")] + pub port: u16, + #[serde(default = "Web::default_enabled")] + pub enabled: bool, + #[serde(default = "Web::default_ui")] + pub ui: bool, + #[serde(default = "Web::default_ui_anonymous")] + pub ui_anonymous: bool, + #[serde(default = "Web::default_log_tap_max_entries")] + pub log_tap_max_entries: u32, +} + +impl Web { + pub fn empty() -> Web { + Web { + host: Self::default_host(), + port: Self::default_port(), + enabled: Self::default_enabled(), + ui: Self::default_ui(), + ui_anonymous: Self::default_ui_anonymous(), + log_tap_max_entries: Self::default_log_tap_max_entries(), + } + } + + /// Default host/port match the legacy prometheus listener so existing deployments + /// and Grafana scrape configs keep working without changes. + pub fn default_host() -> String { + "0.0.0.0".to_string() + } + + pub fn default_port() -> u16 { + 9127 + } + + /// Whole HTTP listener is opt-in. Matches the legacy `prometheus.enabled = false` default. + pub fn default_enabled() -> bool { + false + } + + /// Web UI is opt-in. Listener exists for /metrics by default; SPA and /api/* are gated behind this flag + /// plus a non-default admin_password. + pub fn default_ui() -> bool { + false + } + + /// Public read-only routes accessible without auth. Defaults to `false` — + /// `/api/clients` exposes per-client peer addresses and application names, + /// `/api/top/queries` exposes statement text, and other endpoints leak + /// pool topology that is more sensitive than the aggregate `/metrics` + /// counters. The operator who wants public read-only access flips this + /// flag deliberately. + pub fn default_ui_anonymous() -> bool { + false + } + + /// Capacity of in-memory log ring buffer (entries, not bytes). 8192 ≈ 1.5–2 minutes of history + /// at info-level under 5kTPS, ~2 MB RSS at 250 bytes/entry. Smaller values (e.g., 1024) lose + /// live-tail usefulness on a hot pooler; larger values waste RSS for the default. + pub fn default_log_tap_max_entries() -> u32 { + 8192 + } +} diff --git a/src/lib.rs b/src/lib.rs index 0b6f778bd..df7d9b23d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,9 +13,9 @@ pub mod logger { } pub mod messages; pub mod pool; -pub mod prometheus; pub mod server; pub mod stats; pub mod transport; pub mod utils; +pub mod web; pub use config::tls; diff --git a/src/pool/fallback.rs b/src/pool/fallback.rs index 0e76bceb0..525afb4c3 100644 --- a/src/pool/fallback.rs +++ b/src/pool/fallback.rs @@ -186,7 +186,7 @@ impl FallbackState { *guard = None; drop(guard); - crate::prometheus::FALLBACK_ACTIVE + crate::web::metrics::FALLBACK_ACTIVE .with_label_values(&[&self.pool_name]) .set(0.0); self.blacklist_logged.store(false, Ordering::Relaxed); @@ -197,7 +197,7 @@ impl FallbackState { wl.take() }; if let Some((host, port, _)) = old_host { - let _ = crate::prometheus::FALLBACK_HOST.remove_label_values(&[ + let _ = crate::web::metrics::FALLBACK_HOST.remove_label_values(&[ &self.pool_name, &host, &port.to_string(), @@ -231,7 +231,7 @@ impl FallbackState { guard.take() }; if let Some((host, port, _)) = old_host { - let _ = crate::prometheus::FALLBACK_HOST.remove_label_values(&[ + let _ = crate::web::metrics::FALLBACK_HOST.remove_label_values(&[ &self.pool_name, &host, &port.to_string(), @@ -239,7 +239,7 @@ impl FallbackState { } self.unhealthy_candidates.lock().clear(); self.blacklist_logged.store(false, Ordering::Relaxed); - crate::prometheus::FALLBACK_ACTIVE + crate::web::metrics::FALLBACK_ACTIVE .with_label_values(&[&self.pool_name]) .set(0.0); } @@ -267,7 +267,7 @@ impl FallbackState { guard.take() }; if let Some((host, port, _)) = old { - let _ = crate::prometheus::FALLBACK_HOST.remove_label_values(&[ + let _ = crate::web::metrics::FALLBACK_HOST.remove_label_values(&[ &self.pool_name, &host, &port.to_string(), @@ -292,7 +292,7 @@ impl FallbackState { /// `COOLDOWN_MAX = 60s` puts a hard ceiling on how long any one entry /// stays active. pub fn mark_unhealthy(&self, host: &str, port: u16, reason: FailureReason) { - crate::prometheus::FALLBACK_CANDIDATE_FAILURES_TOTAL + crate::web::metrics::FALLBACK_CANDIDATE_FAILURES_TOTAL .with_label_values(&[self.pool_name.as_str(), reason.as_str()]) .inc(); @@ -408,7 +408,7 @@ impl FallbackState { "[pool: {}] fallback: returning whitelisted host {}:{}", self.pool_name, host_owned, port ); - crate::prometheus::FALLBACK_CACHE_HITS_TOTAL + crate::web::metrics::FALLBACK_CACHE_HITS_TOTAL .with_label_values(&[&self.pool_name]) .inc(); return Ok(( @@ -484,7 +484,7 @@ impl FallbackState { }; if let Some((old_host, old_port, _)) = old { if (old_host.as_str(), old_port) != (host.as_str(), port) { - let _ = crate::prometheus::FALLBACK_HOST.remove_label_values(&[ + let _ = crate::web::metrics::FALLBACK_HOST.remove_label_values(&[ &self.pool_name, &old_host, &old_port.to_string(), @@ -495,7 +495,7 @@ impl FallbackState { "[pool: {}] fallback: whitelisted {}:{} (role: {:?})", self.pool_name, host, port, role ); - crate::prometheus::FALLBACK_HOST + crate::web::metrics::FALLBACK_HOST .with_label_values(&[&self.pool_name, &host, &port.to_string()]) .set(1.0); } @@ -520,7 +520,7 @@ impl FallbackState { }; if is_creator { - crate::prometheus::PATRONI_API_REQUESTS_TOTAL + crate::web::metrics::PATRONI_API_REQUESTS_TOTAL .with_label_values(&[&self.pool_name]) .inc(); } @@ -530,7 +530,7 @@ impl FallbackState { // Joiners measure wait time, not discovery time — only the creator records it. if is_creator { - crate::prometheus::PATRONI_API_DURATION + crate::web::metrics::PATRONI_API_DURATION .with_label_values(&[&self.pool_name]) .observe(start.elapsed().as_secs_f64()); } @@ -903,20 +903,20 @@ mod tests { .unwrap(); state.set_whitelisted("10.0.0.1".to_string(), 5432, Role::SyncStandby); - let v1 = crate::prometheus::FALLBACK_HOST + let v1 = crate::web::metrics::FALLBACK_HOST .with_label_values(&[pool, "10.0.0.1", "5432"]) .get(); assert_eq!(v1, 1.0); state.set_whitelisted("10.0.0.2".to_string(), 5432, Role::SyncStandby); // Old label cleared during overwrite. - let v_old = crate::prometheus::FALLBACK_HOST + let v_old = crate::web::metrics::FALLBACK_HOST .with_label_values(&[pool, "10.0.0.1", "5432"]) .get(); // remove_label_values drops the metric entirely; reading again // recreates it at default 0.0. Either way, never 1.0 here. assert_eq!(v_old, 0.0); - let v_new = crate::prometheus::FALLBACK_HOST + let v_new = crate::web::metrics::FALLBACK_HOST .with_label_values(&[pool, "10.0.0.2", "5432"]) .get(); assert_eq!(v_new, 1.0); @@ -1167,12 +1167,12 @@ mod tests { ) .unwrap(); - let before = crate::prometheus::PATRONI_API_REQUESTS_TOTAL + let before = crate::web::metrics::PATRONI_API_REQUESTS_TOTAL .with_label_values(&["test_pool_inflight_fail"]) .get(); let _ = state.fetch_cluster_coalesced().await; let _ = state.fetch_cluster_coalesced().await; - let after = crate::prometheus::PATRONI_API_REQUESTS_TOTAL + let after = crate::web::metrics::PATRONI_API_REQUESTS_TOTAL .with_label_values(&["test_pool_inflight_fail"]) .get(); @@ -1324,12 +1324,12 @@ mod tests { ) .unwrap(); - let before = crate::prometheus::PATRONI_API_REQUESTS_TOTAL + let before = crate::web::metrics::PATRONI_API_REQUESTS_TOTAL .with_label_values(&["test_pool_inflight_ok_coalesce"]) .get(); let r1 = state.fetch_cluster_coalesced().await; let r2 = state.fetch_cluster_coalesced().await; - let after = crate::prometheus::PATRONI_API_REQUESTS_TOTAL + let after = crate::web::metrics::PATRONI_API_REQUESTS_TOTAL .with_label_values(&["test_pool_inflight_ok_coalesce"]) .get(); diff --git a/src/pool/mod.rs b/src/pool/mod.rs index b2b380bb2..83b03d225 100644 --- a/src/pool/mod.rs +++ b/src/pool/mod.rs @@ -88,7 +88,7 @@ fn set_client_server_map(csm: ClientServerMap) { CLIENT_SERVER_MAP.set(csm).ok(); } -fn get_client_server_map() -> Option { +pub fn get_client_server_map() -> Option { CLIENT_SERVER_MAP.get().cloned() } diff --git a/src/pool/server_pool.rs b/src/pool/server_pool.rs index 70f70c66b..55772b798 100644 --- a/src/pool/server_pool.rs +++ b/src/pool/server_pool.rs @@ -307,7 +307,7 @@ impl ServerPool { if is_backend_unreachable(&err) { if let Some(ref fallback) = self.fallback_state { fallback.blacklist(); - crate::prometheus::FALLBACK_ACTIVE + crate::web::metrics::FALLBACK_ACTIVE .with_label_values(&[&self.address.pool_name]) .set(1.0); info!( @@ -431,7 +431,7 @@ impl ServerPool { let (targets, source) = match fallback.get_fallback_targets().await { Ok(pair) => pair, Err(e) => { - crate::prometheus::PATRONI_API_ERRORS_TOTAL + crate::web::metrics::PATRONI_API_ERRORS_TOTAL .with_label_values(&[&self.address.pool_name]) .inc(); warn!( @@ -472,7 +472,7 @@ impl ServerPool { target.port, target.role ); - crate::prometheus::FALLBACK_CONNECTIONS_TOTAL + crate::web::metrics::FALLBACK_CONNECTIONS_TOTAL .with_label_values(&[&self.address.pool_name]) .inc(); return match self.try_fallback_target(&target).await { @@ -570,7 +570,7 @@ impl ServerPool { // entry — not per candidate. The metric measures fallback usage // pressure, not per-host attempt counts (those live in // `_candidate_failures_total`). - crate::prometheus::FALLBACK_CONNECTIONS_TOTAL + crate::web::metrics::FALLBACK_CONNECTIONS_TOTAL .with_label_values(&[&self.address.pool_name]) .inc(); let _ = source; // reserved for future wave-source-specific logic diff --git a/src/prometheus/server.rs b/src/prometheus/server.rs deleted file mode 100644 index 22b1180fe..000000000 --- a/src/prometheus/server.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! HTTP server for Prometheus metrics endpoint. - -use flate2::write::GzEncoder; -use flate2::Compression; -use log::{error, info}; -use prometheus::{Encoder, TextEncoder}; -use std::io::Write; -use std::net::SocketAddr; -use tokio::net::TcpSocket; - -use super::metrics::update_metrics; -use super::REGISTRY; - -/// Handles HTTP requests for metrics -pub async fn handle_metrics_request(stream: tokio::net::TcpStream) { - // Clone the stream for reading - let (read_half, write_half) = stream.into_split(); - let mut stream_reader = tokio::io::BufReader::new(read_half); - let mut connection = tokio::io::BufWriter::new(write_half); - let mut headers = [0; 1024]; - - // Read HTTP request headers - let n = match tokio::io::AsyncReadExt::read(&mut stream_reader, &mut headers).await { - Ok(n) => n, - Err(e) => { - error!("Failed to read HTTP request: {e}"); - return; - } - }; - - let headers_str = match std::str::from_utf8(&headers[..n]) { - Ok(s) => s, - Err(e) => { - error!("Failed to parse HTTP headers: {e}"); - return; - } - }; - - // Check if client accepts gzip encoding - let accepts_gzip = - headers_str.contains("Accept-Encoding") && headers_str.to_lowercase().contains("gzip"); - - // Update metrics before serving - update_metrics(); - - // Encode metrics to the Prometheus text format - let encoder = TextEncoder::new(); - let metric_families = REGISTRY.gather(); - let mut buffer = Vec::new(); - - if let Err(e) = encoder.encode(&metric_families, &mut buffer) { - error!("Failed to encode metrics: {e}"); - return; - } - - let content_type = encoder.format_type(); - - // Prepare response body (compressed or not) - let (response_body, content_encoding) = if accepts_gzip { - // Compress the buffer with gzip - let mut compressed = Vec::new(); - { - let mut encoder = GzEncoder::new(&mut compressed, Compression::default()); - if let Err(e) = encoder.write_all(&buffer) { - error!("Failed to compress metrics data: {e}"); - return; - } - if let Err(e) = encoder.finish() { - error!("Failed to finish gzip compression: {e}"); - return; - } - } - (compressed, "Content-Encoding: gzip\r\n") - } else { - (buffer, "") - }; - - // Prepare HTTP response - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {}\r\n{}Content-Length: {}\r\n\r\n", - content_type, - content_encoding, - response_body.len() - ); - - // Send response - if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut connection, response.as_bytes()).await - { - error!("Failed to write HTTP response header: {e}"); - return; - } - - if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut connection, &response_body).await { - error!("Failed to write metrics data: {e}"); - return; - } - - if let Err(e) = tokio::io::AsyncWriteExt::flush(&mut connection).await { - error!("Failed to flush connection: {e}"); - } -} - -/// Starts the prometheus exporter -pub async fn start_prometheus_server(host: &str) { - info!("Starting prometheus exporter on {host}"); - let addr: SocketAddr = match host.parse() { - Ok(addr) => addr, - Err(e) => { - panic!("Failed to parse socket address '{host}': {e}"); - } - }; - let listen_socket = if addr.is_ipv4() { - match TcpSocket::new_v4() { - Ok(socket) => socket, - Err(e) => { - panic!("Failed to create IPv4 socket: {e}"); - } - } - } else { - match TcpSocket::new_v6() { - Ok(socket) => socket, - Err(e) => { - panic!("Failed to create IPv6 socket: {e}"); - } - } - }; - if let Err(e) = listen_socket.set_reuseaddr(true) { - panic!("Failed to set SO_REUSEADDR: {e}"); - } - - if let Err(e) = listen_socket.set_reuseport(true) { - panic!("Failed to set SO_REUSEPORT: {e}"); - } - - if let Err(e) = listen_socket.bind(addr) { - panic!("Failed to bind to address {addr}: {e}"); - } - match listen_socket.listen(1024) { - Ok(listener) => { - info!("prometheus exporter listening on {addr}"); - - loop { - match listener.accept().await { - Ok((stream, _)) => { - tokio::spawn(async move { - handle_metrics_request(stream).await; - }); - } - Err(e) => { - error!("Failed to accept connection: {e}"); - } - } - } - } - Err(e) => { - panic!("Failed to bind Prometheus metrics server to {addr}: {e}"); - } - } -} diff --git a/src/server/mod.rs b/src/server/mod.rs index 75af8eb9d..16e937948 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -15,8 +15,9 @@ mod server_backend; pub use parameters::ServerParameters; pub use prepared_statement_cache::{ anon_len, anon_snapshot, gc_sweep_anon, gc_sweep_named, intern_query, named_len, - named_snapshot, now_monotonic_ms, reset_interners_force, set_interner_worker_threads, - AnonEntry, CacheEntryKind, GcStats, NamedEntry, PreparedStatementCache, + named_snapshot, now_monotonic_ms, record_query_count, record_query_duration_us, + reset_interners_force, set_interner_worker_threads, AnonEntry, CacheEntryKind, GcStats, + NamedEntry, PreparedStatementCache, }; #[cfg(test)] diff --git a/src/server/prepared_statement_cache.rs b/src/server/prepared_statement_cache.rs index 5363e6af8..6dbe524e6 100644 --- a/src/server/prepared_statement_cache.rs +++ b/src/server/prepared_statement_cache.rs @@ -41,6 +41,14 @@ const GC_STATE_MARKED: u8 = 1; pub struct NamedEntry { text: Arc, gc_state: AtomicU8, + /// Cumulative count of Bind events that referenced this hash. + /// Used by `/api/top/queries?by=count`. Approximate: see plan. + count: AtomicU64, + /// Cumulative microseconds spent across all Sync's that ended a batch + /// whose last Bind referenced this hash. Approximate per-batch + /// attribution — multi-Bind batches give the entire duration to the + /// last hash. See plan for the trade-off. + total_duration_us: AtomicU64, } impl NamedEntry { @@ -48,6 +56,8 @@ impl NamedEntry { Self { text, gc_state: AtomicU8::new(GC_STATE_ACTIVE), + count: AtomicU64::new(0), + total_duration_us: AtomicU64::new(0), } } @@ -58,6 +68,16 @@ impl NamedEntry { pub fn text(&self) -> &Arc { &self.text } + + /// Approximate count of Bind references. Used by `/api/top/queries`. + pub fn count(&self) -> u64 { + self.count.load(Ordering::Relaxed) + } + + /// Approximate cumulative execution time in microseconds. + pub fn total_duration_us(&self) -> u64 { + self.total_duration_us.load(Ordering::Relaxed) + } } /// Entry in the anonymous interner. Bounded by per-entry TTL over @@ -66,6 +86,8 @@ pub struct AnonEntry { text: Arc, last_used: AtomicU64, gc_state: AtomicU8, + count: AtomicU64, + total_duration_us: AtomicU64, } impl AnonEntry { @@ -74,6 +96,8 @@ impl AnonEntry { text, last_used: AtomicU64::new(now_ms), gc_state: AtomicU8::new(GC_STATE_ACTIVE), + count: AtomicU64::new(0), + total_duration_us: AtomicU64::new(0), } } @@ -90,6 +114,14 @@ impl AnonEntry { now_ms.saturating_sub(self.last_used.load(Ordering::Relaxed)) } + pub fn count(&self) -> u64 { + self.count.load(Ordering::Relaxed) + } + + pub fn total_duration_us(&self) -> u64 { + self.total_duration_us.load(Ordering::Relaxed) + } + #[cfg(test)] pub fn last_used_for_test(&self) -> u64 { self.last_used.load(Ordering::Relaxed) @@ -114,6 +146,31 @@ pub fn now_monotonic_ms() -> u64 { START.elapsed().as_millis() as u64 } +/// Increments the Bind-count atomic on the interner entry that owns `hash`. +/// No-op if the entry has been GC'd or not yet inserted; we accept the +/// resulting count gap to keep the hot path lock-free. +pub fn record_query_count(hash: u64, is_anonymous: bool) { + if is_anonymous { + if let Some(entry) = ANON_INTERNER.get(&hash) { + entry.count.fetch_add(1, Ordering::Relaxed); + } + } else if let Some(entry) = NAMED_INTERNER.get(&hash) { + entry.count.fetch_add(1, Ordering::Relaxed); + } +} + +/// Adds `micros` to the cumulative duration on the interner entry. Same +/// no-op-on-miss policy as `record_query_count`. +pub fn record_query_duration_us(hash: u64, is_anonymous: bool, micros: u64) { + if is_anonymous { + if let Some(entry) = ANON_INTERNER.get(&hash) { + entry.total_duration_us.fetch_add(micros, Ordering::Relaxed); + } + } else if let Some(entry) = NAMED_INTERNER.get(&hash) { + entry.total_duration_us.fetch_add(micros, Ordering::Relaxed); + } +} + /// Interns the query string into the matching half of the interner. /// `is_anonymous` reflects how *this* Parse uses the hash — empty Parse /// name = anonymous. @@ -300,6 +357,13 @@ struct CacheEntry { /// bit 1 = seen as anonymous. At least one bit is always set after /// construction (`CacheEntry::new`); bits only ever flip from 0 to 1. kind_flags: AtomicU8, + /// Cumulative count of Parse-time has_prepared_statement(server_name) hits + /// for this hash. Approximate per-pool counter — see plan for the LRU + /// eviction caveat. + hit_count: AtomicU64, + /// Cumulative count of Parse-time has_prepared_statement(server_name) + /// misses for this hash. + miss_count: AtomicU64, } impl CacheEntry { @@ -316,6 +380,8 @@ impl CacheEntry { parse, count_used, kind_flags: AtomicU8::new(bits), + hit_count: AtomicU64::new(0), + miss_count: AtomicU64::new(0), } } @@ -386,6 +452,30 @@ pub struct PreparedStatementCache { max_size: usize, /// Global counter for LRU ordering counter: AtomicU64, + /// Live cumulative byte cost of every entry currently in `cache`. Kept + /// in sync with insert/evict so `memory_usage()` answers in one atomic + /// load instead of walking every entry; the walk version was an O(N) + /// hotspot on every `/api/pools` poll for instances with large + /// per-pool prepared caches. + /// + /// Approximate, not exact: two threads racing the slow path on the + /// same hash both `fetch_add` while DashMap keeps a single entry, + /// leaving a phantom-entry overshoot until the slot eventually + /// evicts. The pre-existing walk was also racy under concurrent + /// inserts; this counter trades one shape of approximation for one + /// that is far cheaper to read. + total_memory_bytes: AtomicU64, +} + +/// Per-entry overhead independent of the Parse content (DashMap key + the +/// CacheEntry record itself). Variable part is `parse.memory_usage()`. +const ENTRY_OVERHEAD_BYTES: usize = std::mem::size_of::() + std::mem::size_of::(); + +/// Byte cost of a single cache entry built around `parse`. Same shape as +/// the original walk in `memory_usage` so the new incremental counter +/// converges to identical totals. +fn entry_bytes(parse: &Parse) -> u64 { + (parse.memory_usage() + ENTRY_OVERHEAD_BYTES) as u64 } impl std::fmt::Debug for PreparedStatementCache { @@ -408,6 +498,7 @@ impl PreparedStatementCache { cache: new_dashmap_with_capacity(size, worker_threads), max_size: size, counter: AtomicU64::new(0), + total_memory_bytes: AtomicU64::new(0), } } @@ -455,10 +546,13 @@ impl PreparedStatementCache { // Insert first, then evict excess. Reversing the order closes // the race where N concurrent callers all pass len() >= max_size // before any eviction runs, pushing the cache far above the limit. + let inserted_bytes = entry_bytes(&new_parse); self.cache.insert( hash, CacheEntry::new(new_parse.clone(), timestamp, initial_kind), ); + self.total_memory_bytes + .fetch_add(inserted_bytes, Ordering::Relaxed); while self.cache.len() > self.max_size { self.evict_oldest(); @@ -477,21 +571,27 @@ impl PreparedStatementCache { self.cache.is_empty() } - /// Approximate memory usage of the cache in bytes + /// Approximate memory usage of the cache in bytes. Single atomic load + /// — kept in sync with `get_or_insert` and `evict_oldest` so the + /// dashboard polling path does not pay an O(N) walk on every snapshot. pub fn memory_usage(&self) -> usize { - let mut total = 0; - for entry in self.cache.iter() { - total += entry.parse.memory_usage(); - total += std::mem::size_of::(); // Key - total += std::mem::size_of::(); - } - total + self.total_memory_bytes.load(Ordering::Relaxed) as usize + } + + /// Direct hash lookup. Used by `/api/prepared/text/{hash}` to fetch + /// one statement without paying for a `get_entries()` clone of every + /// row in every pool — the prior implementation walked all entries + /// linearly per pool and allocated a Vec along the way for what was + /// always a single-row answer. + pub fn lookup_by_hash(&self, hash: u64) -> Option<(Arc, CacheEntryKind)> { + self.cache + .get(&hash) + .map(|entry| (entry.parse.clone(), entry.kind())) } - /// Returns a list of all entries in the cache, including the derived - /// `CacheEntryKind` reflecting whether clients have used this hash via - /// named statements, anonymous statements, or both. - pub fn get_entries(&self) -> Vec<(u64, Arc, u64, CacheEntryKind)> { + /// Returns all entries with stats. Tuple is + /// `(hash, parse, count_used, kind, hit_count, miss_count)`. + pub fn get_entries(&self) -> Vec<(u64, Arc, u64, CacheEntryKind, u64, u64)> { self.cache .iter() .map(|entry| { @@ -500,11 +600,29 @@ impl PreparedStatementCache { entry.parse.clone(), entry.count_used, entry.kind(), + entry.hit_count.load(Ordering::Relaxed), + entry.miss_count.load(Ordering::Relaxed), ) }) .collect() } + /// Atomically increments the hit counter on the entry for `hash`. + /// Silently no-ops when the entry was evicted or never inserted — + /// keeps the hot path lock-free. + pub fn record_hit(&self, hash: u64) { + if let Some(entry) = self.cache.get(&hash) { + entry.hit_count.fetch_add(1, Ordering::Relaxed); + } + } + + /// Same as `record_hit`, but for misses. + pub fn record_miss(&self, hash: u64) { + if let Some(entry) = self.cache.get(&hash) { + entry.miss_count.fetch_add(1, Ordering::Relaxed); + } + } + /// Marks the hash as most recently used if it exists pub fn promote(&self, hash: &u64) { if let Some(mut entry) = self.cache.get_mut(hash) { @@ -531,6 +649,8 @@ impl PreparedStatementCache { // Remove the oldest entry if let Some(key) = oldest_key { if let Some((_, entry)) = self.cache.remove(&key) { + self.total_memory_bytes + .fetch_sub(entry_bytes(&entry.parse), Ordering::Relaxed); let query = entry.parse.query().replace(['\n', '\r'], " "); let truncated: String = query.chars().take(80).collect(); let ellipsis = if query.chars().count() > 80 { @@ -658,6 +778,78 @@ mod tests { assert_eq!(entries[0].3, CacheEntryKind::Mixed); } + #[test] + fn lookup_by_hash_returns_none_for_unknown() { + let cache = PreparedStatementCache::new(8, 1); + assert!(cache.lookup_by_hash(0xdead_beef).is_none()); + } + + #[test] + fn lookup_by_hash_returns_parse_and_kind() { + let cache = PreparedStatementCache::new(8, 1); + let parse = make_parse("stmt", "SELECT 1"); + cache.get_or_insert(&parse, 0xCAFE, Some("stmt")); + let (got, kind) = cache.lookup_by_hash(0xCAFE).expect("entry must be present"); + assert_eq!(kind, CacheEntryKind::Named); + assert_eq!(got.query(), "SELECT 1"); + } + + #[test] + fn memory_usage_zero_when_empty() { + let cache = PreparedStatementCache::new(8, 1); + assert_eq!(cache.memory_usage(), 0); + } + + #[test] + fn memory_usage_tracks_inserts_and_eviction() { + let cache = PreparedStatementCache::new(2, 1); + let p1 = make_parse("a", "SELECT 1"); + let p2 = make_parse("b", "SELECT 22"); + let p3 = make_parse("c", "SELECT 333"); + + cache.get_or_insert(&p1, 1, Some("a")); + let after_one = cache.memory_usage(); + assert!(after_one > 0, "single insert must register bytes"); + + cache.get_or_insert(&p2, 2, Some("b")); + let after_two = cache.memory_usage(); + assert!( + after_two > after_one, + "second insert must add bytes ({after_one} -> {after_two})" + ); + + // Third insert pushes the cache past max_size, forcing one eviction. + // The post-eviction total must equal the bytes for the two surviving + // entries — the counter must have been decremented on remove. + cache.get_or_insert(&p3, 3, Some("c")); + let after_three = cache.memory_usage(); + assert_eq!( + cache.len(), + 2, + "max_size=2 must hold after the third insert + evict" + ); + let walk: usize = cache + .cache + .iter() + .map(|e| entry_bytes(&e.parse) as usize) + .sum(); + assert_eq!( + after_three, walk, + "incremental counter must match the per-entry walk after eviction" + ); + } + + #[test] + fn memory_usage_unchanged_on_repeat_hit() { + let cache = PreparedStatementCache::new(8, 1); + let parse = make_parse("stmt", "SELECT 1"); + cache.get_or_insert(&parse, 1, Some("stmt")); + let after_one = cache.memory_usage(); + // Second call hits the fast path — must not double-count. + cache.get_or_insert(&parse, 1, Some("stmt")); + assert_eq!(cache.memory_usage(), after_one); + } + /// A repeated hit with the same kind must not mutate the bitmask /// beyond the bit set at construction. The cache-line-friendly /// test-and-set guard relies on this invariant; verify the visible @@ -820,4 +1012,57 @@ mod tests { } assert!(anon_entry_for_test(0x105).is_some()); } + + #[test] + #[serial(query_interner)] + fn record_query_count_increments_named_entry() { + reset_interners_for_test(); + let _ = intern_query("select 100", 0xC0FFEE, false); + super::record_query_count(0xC0FFEE, false); + super::record_query_count(0xC0FFEE, false); + let snap = super::named_snapshot(); + let (_, e) = snap.iter().find(|(h, _)| *h == 0xC0FFEE).unwrap(); + assert!(e.count() >= 2); + } + + #[test] + fn record_query_count_no_op_on_unknown_hash() { + // Intentionally use a hash that is not interned — must not panic. + super::record_query_count(0xDEADC0DE, false); + super::record_query_count(0xDEADC0DE, true); + } + + #[test] + fn record_hit_no_op_when_hash_absent() { + let cache = PreparedStatementCache::new(8, 1); + cache.record_hit(0xDEADBEEF); + cache.record_miss(0xDEADBEEF); + // No panic = pass; counters unobservable on absent hash. + } + + #[test] + fn record_hit_increments_existing_entry() { + let cache = PreparedStatementCache::new(8, 1); + let parse = make_parse("stmt", "SELECT 1"); + cache.get_or_insert(&parse, 0x1111, Some("stmt")); + cache.record_hit(0x1111); + cache.record_hit(0x1111); + cache.record_miss(0x1111); + let entries = cache.get_entries(); + let row = entries.iter().find(|e| e.0 == 0x1111).unwrap(); + assert_eq!(row.4, 2, "hits"); + assert_eq!(row.5, 1, "misses"); + } + + #[test] + #[serial(query_interner)] + fn record_query_duration_us_accumulates() { + reset_interners_for_test(); + let _ = intern_query("select 200", 0xD00D00, false); + super::record_query_duration_us(0xD00D00, false, 100); + super::record_query_duration_us(0xD00D00, false, 250); + let snap = super::named_snapshot(); + let (_, e) = snap.iter().find(|(h, _)| *h == 0xD00D00).unwrap(); + assert_eq!(e.total_duration_us(), 350); + } } diff --git a/src/server/protocol_io.rs b/src/server/protocol_io.rs index b9bd0d1cf..55e957ae5 100644 --- a/src/server/protocol_io.rs +++ b/src/server/protocol_io.rs @@ -293,6 +293,7 @@ fn handle_error_response(server: &mut Server, message: &mut BytesMut) { details.push_str(&format!(", detail=\"{}\"", sanitize_for_log(detail))); } error!("{details}"); + server.address.stats.error_with_sqlstate(&msg.code); } else { error!( "[{}@{}] server error pid={}: could not parse error details", @@ -300,6 +301,7 @@ fn handle_error_response(server: &mut Server, message: &mut BytesMut) { server.address.pool_name, server.get_process_id(), ); + server.address.stats.error(); } // Exit COPY mode on error diff --git a/src/server/stream.rs b/src/server/stream.rs index 3cee3af3f..1a399460c 100644 --- a/src/server/stream.rs +++ b/src/server/stream.rs @@ -223,7 +223,7 @@ pub(crate) async fn create_tcp_stream_inner( server_tls.mode, elapsed.as_secs_f64() * 1000.0 ); - crate::prometheus::SHOW_SERVER_TLS_HANDSHAKE_DURATION + crate::web::metrics::SHOW_SERVER_TLS_HANDSHAKE_DURATION .with_label_values(&[pool_name]) .observe(elapsed.as_secs_f64()); Ok(StreamInner::TCPTls { stream: tls_stream }) @@ -238,7 +238,7 @@ pub(crate) async fn create_tcp_stream_inner( server_tls.mode, elapsed.as_secs_f64() * 1000.0 ); - crate::prometheus::SHOW_SERVER_TLS_HANDSHAKE_ERRORS + crate::web::metrics::SHOW_SERVER_TLS_HANDSHAKE_ERRORS .with_label_values(&[pool_name]) .inc(); Err(Error::SocketError(format!( @@ -253,7 +253,7 @@ pub(crate) async fn create_tcp_stream_inner( "tls required but server does not support tls, host={host} port={port} server_tls_mode={}", server_tls.mode ); - crate::prometheus::SHOW_SERVER_TLS_HANDSHAKE_ERRORS + crate::web::metrics::SHOW_SERVER_TLS_HANDSHAKE_ERRORS .with_label_values(&[pool_name]) .inc(); Err(Error::SocketError(format!( diff --git a/src/stats/address.rs b/src/stats/address.rs index 1d324a167..91fd8d224 100644 --- a/src/stats/address.rs +++ b/src/stats/address.rs @@ -1,5 +1,7 @@ +use dashmap::DashMap; use hdrhistogram::Histogram; use parking_lot::Mutex; +use std::collections::HashMap; use std::sync::atomic::*; /// Fields for tracking various statistics related to PostgreSQL connections by address. @@ -36,6 +38,15 @@ pub struct AddressStatFields { /// Maximum trackable time in microseconds for HDR histogram (10 minutes) const HISTOGRAM_MAX_VALUE_US: u64 = 10 * 60 * 1_000_000; +/// Canonical PostgreSQL SQLSTATE: exactly 5 uppercase ASCII letters or digits. +/// Bounding the breakdown map's key shape stops adversarial backends from +/// inflating it with arbitrary `ErrorResponse.code` payloads. +fn is_valid_sqlstate(s: &str) -> bool { + s.len() == 5 + && s.bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) +} + /// Number of significant digits for HDR histogram precision (3 = 0.1% error) const HISTOGRAM_SIGFIG: u8 = 2; @@ -86,6 +97,12 @@ pub struct AddressStats { /// to prefer slow pools as connection donors — one atomic load per candidate /// instead of locking the histogram mutex on every eviction call. pub p95_xact_time_us: AtomicU64, + + /// Cumulative error counter keyed by PostgreSQL SQLSTATE code (5-char). + /// Updated alongside `total.errors`. Sharded; the hot path takes a single + /// shard's read lock for the atomic increment, the slow path inserts a + /// new shard entry under a brief write lock. + pub errors_by_sqlstate: DashMap, } impl Default for AddressStats { @@ -99,6 +116,7 @@ impl Default for AddressStats { query_histogram: Mutex::new(new_histogram()), wait_histogram: Mutex::new(new_histogram()), p95_xact_time_us: AtomicU64::new(0), + errors_by_sqlstate: DashMap::new(), } } } @@ -332,6 +350,37 @@ impl AddressStats { self.current.errors.fetch_add(1, Ordering::Relaxed); } + /// Increment the total/current error counters and the per-SQLSTATE + /// breakdown bucket. After the first observation of a given code the hot + /// path takes one DashMap shard read lock; the first observation also + /// takes a brief shard write lock to allocate the bucket. + /// + /// Codes that fail validation (`is_valid_sqlstate`) are counted in the + /// aggregate `errors` counter but skipped from the breakdown so a + /// malformed or adversarial `ErrorResponse.code` field cannot grow the + /// map without bound. Canonical PostgreSQL SQLSTATEs are exactly 5 + /// uppercase-ASCII characters. + #[inline(always)] + pub fn error_with_sqlstate(&self, sqlstate: &str) { + self.error(); + if !is_valid_sqlstate(sqlstate) { + return; + } + self.errors_by_sqlstate + .entry(sqlstate.to_string()) + .or_insert_with(|| AtomicU64::new(0)) + .fetch_add(1, Ordering::Relaxed); + } + + /// Snapshot of the SQLSTATE breakdown as a plain map. Reading is O(N) + /// over the live entries with one atomic load each. + pub fn errors_by_sqlstate_snapshot(&self) -> HashMap { + self.errors_by_sqlstate + .iter() + .map(|kv| (kv.key().clone(), kv.value().load(Ordering::Relaxed))) + .collect() + } + /// Returns transaction time percentiles (p50, p90, p95, p99) in microseconds. /// /// Uses HDR histogram for O(1) percentile calculation. @@ -618,6 +667,71 @@ mod tests { assert_eq!(stats.current.errors.load(Ordering::Relaxed), 1); } + #[test] + fn test_error_with_sqlstate_breakdown() { + let stats = AddressStats::default(); + + stats.error_with_sqlstate("23503"); + stats.error_with_sqlstate("23503"); + stats.error_with_sqlstate("57P01"); + stats.error_with_sqlstate("53300"); + + // The aggregate counter increments alongside the per-code bucket. + assert_eq!(stats.total.errors.load(Ordering::Relaxed), 4); + assert_eq!(stats.current.errors.load(Ordering::Relaxed), 4); + + let snap = stats.errors_by_sqlstate_snapshot(); + assert_eq!(snap.get("23503"), Some(&2)); + assert_eq!(snap.get("57P01"), Some(&1)); + assert_eq!(snap.get("53300"), Some(&1)); + assert_eq!(snap.len(), 3); + } + + #[test] + fn test_error_with_sqlstate_snapshot_empty_by_default() { + let stats = AddressStats::default(); + // No PG-side errors yet — breakdown is empty even after a plain + // `error()` call. Only `error_with_sqlstate` populates the bucket. + stats.error(); + assert_eq!(stats.total.errors.load(Ordering::Relaxed), 1); + assert!(stats.errors_by_sqlstate_snapshot().is_empty()); + } + + #[test] + fn test_error_with_sqlstate_rejects_malformed_codes() { + let stats = AddressStats::default(); + + // Aggregate counter advances for every call, but only canonical + // codes land in the breakdown. + stats.error_with_sqlstate("23503"); // valid + stats.error_with_sqlstate(""); + stats.error_with_sqlstate("23503EXTRA"); + stats.error_with_sqlstate("aBc12"); + stats.error_with_sqlstate("23 03"); + stats.error_with_sqlstate("23503\u{0}"); + + assert_eq!(stats.total.errors.load(Ordering::Relaxed), 6); + let snap = stats.errors_by_sqlstate_snapshot(); + assert_eq!(snap.get("23503"), Some(&1)); + assert_eq!(snap.len(), 1); + } + + #[test] + fn test_is_valid_sqlstate() { + // Canonical codes accepted across the documented PG classes. + assert!(is_valid_sqlstate("00000")); + assert!(is_valid_sqlstate("23503")); + assert!(is_valid_sqlstate("57P01")); + assert!(is_valid_sqlstate("ZZZZZ")); + + // Anything else rejected. + assert!(!is_valid_sqlstate("")); + assert!(!is_valid_sqlstate("2350")); + assert!(!is_valid_sqlstate("235033")); + assert!(!is_valid_sqlstate("aBcDe")); + assert!(!is_valid_sqlstate("01-23")); + } + #[test] fn test_time_recording_methods() { let stats = AddressStats::default(); diff --git a/src/stats/client.rs b/src/stats/client.rs index 02936d56c..eec0a0366 100644 --- a/src/stats/client.rs +++ b/src/stats/client.rs @@ -117,6 +117,12 @@ pub struct ClientStats { /// Number of errors encountered by this client pub error_count: AtomicU64, + /// Nanoseconds elapsed since `connect_time` at the moment of the latest + /// state transition (set by `set_state`/`set_wait`/`set_state_wait`). + /// Used by `current_query_age_ms()` and `wait_ms()` accessors to expose + /// the duration the client has spent in its current state. + pub state_since_nanos: AtomicU64, + /// Prepared statement cache metrics /// ------------------------------------------------------------------------------------------ /// Number of entries in client's prepared statement cache @@ -158,6 +164,7 @@ impl Default for ClientStats { transaction_count: AtomicU64::new(0), query_count: AtomicU64::new(0), error_count: AtomicU64::new(0), + state_since_nanos: AtomicU64::new(0), prepared_cache_count: AtomicU64::new(0), prepared_cache_bytes: AtomicU64::new(0), prepared_named_count: AtomicU64::new(0), @@ -186,15 +193,47 @@ impl ClientStats { self.state_wait.load(Ordering::Relaxed) & 0x0F } + #[inline] + fn nanos_from_connect(&self) -> u64 { + clock::now() + .checked_duration_since(self.connect_time) + .unwrap_or_default() + .as_nanos() as u64 + } + + /// Maps a raw state byte to a logical group. + /// + /// Groups: 1 = active, 2 = idle, 3 = waiting, 0 = other. + /// The timestamp is written only on cross-group transitions so that + /// intra-group flips (e.g. ACTIVE_READ ↔ ACTIVE_WRITE) don't pay the + /// clock cost per query. + #[inline(always)] + fn state_group(state: u8) -> u8 { + match state { + CLIENT_STATE_ACTIVE => 1, + CLIENT_STATE_IDLE => 2, + CLIENT_STATE_WAITING => 3, + _ => 0, + } + } + #[inline(always)] pub fn set_state(&self, state: u8) { let cur = self.state_wait.load(Ordering::Relaxed); + let prev_state = cur >> 4; + if Self::state_group(prev_state) != Self::state_group(state) { + // .max(1) keeps 0 reserved as the "never set" sentinel. + let now = self.nanos_from_connect().max(1); + self.state_since_nanos.store(now, Ordering::Relaxed); + } let new = Self::pack(state, cur & 0x0F); self.state_wait.store(new, Ordering::Relaxed); } #[inline(always)] pub fn set_wait(&self, wait: u8) { + // set_wait only changes the wait nibble; the logical state group is + // unchanged, so no timestamp update is needed. let cur = self.state_wait.load(Ordering::Relaxed); let new = Self::pack(cur >> 4, wait); self.state_wait.store(new, Ordering::Relaxed); @@ -202,6 +241,13 @@ impl ClientStats { #[inline(always)] pub fn set_state_wait(&self, state: u8, wait: u8) { + let cur = self.state_wait.load(Ordering::Relaxed); + let prev_state = cur >> 4; + if Self::state_group(prev_state) != Self::state_group(state) { + // .max(1) keeps 0 reserved as the "never set" sentinel. + let now = self.nanos_from_connect().max(1); + self.state_since_nanos.store(now, Ordering::Relaxed); + } self.state_wait .store(Self::pack(state, wait), Ordering::Relaxed); } @@ -332,31 +378,31 @@ impl ClientStats { // State conversion utilities // ------------------------------------------------------------------------------------------ - /// Converts the client state to a human-readable string. + /// Returns the client state as a static string slice. /// /// # Returns /// - /// A string representation of the client state: "waiting", "idle", "active", or "unknown" - pub fn state_to_string(&self) -> String { + /// One of "waiting", "idle", "active", or "unknown". + pub fn state_str(&self) -> &'static str { match self.state() { - CLIENT_STATE_WAITING => "waiting".to_string(), - CLIENT_STATE_IDLE => "idle".to_string(), - CLIENT_STATE_ACTIVE => "active".to_string(), - _ => "unknown".to_string(), + CLIENT_STATE_WAITING => "waiting", + CLIENT_STATE_IDLE => "idle", + CLIENT_STATE_ACTIVE => "active", + _ => "unknown", } } - /// Converts the client wait status to a human-readable string. + /// Returns the client wait status as a static string slice. /// /// # Returns /// - /// A string representation of the wait status: "idle", "write", "read", or "unknown" - pub fn wait_to_string(&self) -> String { + /// One of "idle", "write", "read", or "unknown". + pub fn wait_str(&self) -> &'static str { match self.wait() { - CLIENT_WAIT_IDLE => "idle".to_string(), - CLIENT_WAIT_WRITE => "write".to_string(), - CLIENT_WAIT_READ => "read".to_string(), - _ => "unknown".to_string(), + CLIENT_WAIT_IDLE => "idle", + CLIENT_WAIT_WRITE => "write", + CLIENT_WAIT_READ => "read", + _ => "unknown", } } @@ -401,8 +447,8 @@ impl ClientStats { /// Returns the name of the application that established the connection. #[inline(always)] - pub fn application_name(&self) -> String { - self.application_name.clone() + pub fn application_name(&self) -> &str { + &self.application_name } /// Returns whether the client is using TLS/SSL encryption. @@ -413,20 +459,20 @@ impl ClientStats { /// Returns the PostgreSQL username used for the connection. #[inline(always)] - pub fn username(&self) -> String { - self.username.clone() + pub fn username(&self) -> &str { + &self.username } /// Returns the name of the connection pool this client is using. #[inline(always)] - pub fn pool_name(&self) -> String { - self.pool_name.clone() + pub fn pool_name(&self) -> &str { + &self.pool_name } /// Returns the IP address of the client. #[inline(always)] - pub fn ipaddr(&self) -> String { - self.ipaddr.clone() + pub fn ipaddr(&self) -> &str { + &self.ipaddr } // @@ -502,6 +548,35 @@ impl ClientStats { pub fn is_async_client(&self) -> bool { self.is_async_client.load(Ordering::Relaxed) } + + /// Returns the milliseconds elapsed since this client entered the ACTIVE + /// state. Returns `None` when the client is not currently active or the + /// timestamp has never been set. + #[inline] + pub fn current_query_age_ms(&self) -> Option { + if self.state() != CLIENT_STATE_ACTIVE { + return None; + } + let since = self.state_since_nanos.load(Ordering::Relaxed); + if since == 0 { + return None; + } + Some(self.nanos_from_connect().saturating_sub(since) / 1_000_000) + } + + /// Returns the milliseconds elapsed since this client entered the WAITING + /// state. Returns `None` when the client is not currently waiting. + #[inline] + pub fn wait_ms(&self) -> Option { + if self.state() != CLIENT_STATE_WAITING { + return None; + } + let since = self.state_since_nanos.load(Ordering::Relaxed); + if since == 0 { + return None; + } + Some(self.nanos_from_connect().saturating_sub(since) / 1_000_000) + } } #[cfg(test)] @@ -650,31 +725,31 @@ mod tests { fn test_state_conversion_methods() { let stats = ClientStats::default(); - // Test state_to_string + // Test state_str stats.set_state(CLIENT_STATE_IDLE); - assert_eq!(stats.state_to_string(), "idle"); + assert_eq!(stats.state_str(), "idle"); stats.set_state(CLIENT_STATE_ACTIVE); - assert_eq!(stats.state_to_string(), "active"); + assert_eq!(stats.state_str(), "active"); stats.set_state(CLIENT_STATE_WAITING); - assert_eq!(stats.state_to_string(), "waiting"); + assert_eq!(stats.state_str(), "waiting"); stats.set_state(0); // Invalid state - assert_eq!(stats.state_to_string(), "unknown"); + assert_eq!(stats.state_str(), "unknown"); - // Test wait_to_string + // Test wait_str stats.set_wait(CLIENT_WAIT_IDLE); - assert_eq!(stats.wait_to_string(), "idle"); + assert_eq!(stats.wait_str(), "idle"); stats.set_wait(CLIENT_WAIT_READ); - assert_eq!(stats.wait_to_string(), "read"); + assert_eq!(stats.wait_str(), "read"); stats.set_wait(CLIENT_WAIT_WRITE); - assert_eq!(stats.wait_to_string(), "write"); + assert_eq!(stats.wait_str(), "write"); stats.set_wait(0); // Invalid wait state - assert_eq!(stats.wait_to_string(), "unknown"); + assert_eq!(stats.wait_str(), "unknown"); } #[test] @@ -750,4 +825,62 @@ mod tests { }; stats.set_prepared_cache_stats(bogus); } + + #[test] + fn state_since_nanos_does_not_change_inside_active_group() { + // Enter the active group from idle so the timestamp is recorded. + let stats = ClientStats::default(); + stats.active_read(); + let t1 = stats.state_since_nanos.load(Ordering::Relaxed); + assert!(t1 > 0, "timestamp must be set on entry to active group"); + + // Intra-group flip: ACTIVE_READ → ACTIVE_WRITE should not update the timestamp. + stats.active_write(); + let t2 = stats.state_since_nanos.load(Ordering::Relaxed); + assert_eq!(t1, t2, "intra-active flip must not reset the timestamp"); + + // Another intra-group flip: ACTIVE_WRITE → ACTIVE_IDLE. + stats.active_idle(); + let t3 = stats.state_since_nanos.load(Ordering::Relaxed); + assert_eq!(t1, t3, "intra-active flip must not reset the timestamp"); + + // Cross-group transition to idle must update the timestamp. + stats.idle_read(); + let t4 = stats.state_since_nanos.load(Ordering::Relaxed); + // t4 may equal t1 if the test runs fast enough for the clock not to + // advance, but it was re-written (store was called). We verify by + // re-entering active and confirming the timestamp advances relative to + // the idle entry. + let _ = t4; // read but not directly assertable due to clock resolution + + // Cross-group back to active must update again. + stats.active_read(); + let t5 = stats.state_since_nanos.load(Ordering::Relaxed); + assert!(t5 > 0, "timestamp must be set after re-entering active"); + } + + #[test] + fn current_query_age_and_wait_ms_none_when_not_in_state() { + // A fresh client is IDLE: both accessors must return None. + let stats = ClientStats::default(); + assert_eq!(stats.current_query_age_ms(), None); + assert_eq!(stats.wait_ms(), None); + + // After transitioning to ACTIVE, current_query_age_ms returns Some + // and wait_ms returns None. + stats.set_state(CLIENT_STATE_ACTIVE); + assert!(stats.current_query_age_ms().is_some()); + assert_eq!(stats.wait_ms(), None); + + // After transitioning to WAITING, wait_ms returns Some and + // current_query_age_ms returns None. + stats.set_state(CLIENT_STATE_WAITING); + assert_eq!(stats.current_query_age_ms(), None); + assert!(stats.wait_ms().is_some()); + + // Back to IDLE: both return None again. + stats.set_state(CLIENT_STATE_IDLE); + assert_eq!(stats.current_query_age_ms(), None); + assert_eq!(stats.wait_ms(), None); + } } diff --git a/src/stats/pool.rs b/src/stats/pool.rs index 14ce598f7..c58122579 100644 --- a/src/stats/pool.rs +++ b/src/stats/pool.rs @@ -128,10 +128,10 @@ pub struct PoolStats { pub total_query_count: u64, /// Total bytes received from clients - total_received: u64, + pub total_received: u64, /// Total bytes sent to clients - total_sent: u64, + pub total_sent: u64, /// Total transaction processing time (microseconds) pub total_xact_time_microseconds: u64, @@ -169,16 +169,16 @@ pub struct PoolStats { pub async_clients_count: u64, /// Average bytes received per second - avg_recv: u64, + pub avg_recv: u64, /// Average bytes sent per second - avg_sent: u64, + pub avg_sent: u64, /// Average transaction processing time (microseconds) - avg_xact_time_microsecons: u64, + pub avg_xact_time_microsecons: u64, /// Average query processing time (microseconds) - avg_query_time_microseconds: u64, + pub avg_query_time_microseconds: u64, /// Whether the pool is paused (PAUSE command) pub paused: bool, @@ -304,10 +304,29 @@ impl PoolStats { let client_map = super::get_client_stats(); let server_map = super::get_server_stats(); - // Update client and server state counters Self::update_client_server_states(&mut virtual_map, &client_map, &server_map); + Self::aggregate_pool_stats(virtual_map) + } - // Get pool statistics (no aggregation needed since virtual pools were removed) + /// Build the pool lookup from caller-supplied client/server snapshots. + /// Used by routes that already cloned those maps for their own + /// rendering (e.g. `/api/overview`) — without this entry point the + /// route paid for two round-trips through the read lock + two HashMap + /// clones per request. + /// + /// Snapshot ordering inversion vs [`construct_pool_lookup`]: callers + /// of this function clone client/server maps **before** we read POOLS, + /// so a pool GC'd between those snapshots leaves an orphan + /// `ServerStats` in `server_map`. `update_client_server_states` skips + /// such entries with a debug log; the orphan is harmless because the + /// disconnect path already updated address.stats. + pub fn construct_pool_lookup_from( + client_map: &HashMap>, + server_map: &HashMap>, + ) -> HashMap { + let mut virtual_map: HashMap = HashMap::new(); + Self::initialize_pool_stats(&mut virtual_map); + Self::update_client_server_states(&mut virtual_map, client_map, server_map); Self::aggregate_pool_stats(virtual_map) } @@ -604,8 +623,8 @@ impl PoolStats { for client in client_map.values() { // Try to find the pool for this client match pool_map.get_mut(&PoolIdentifier { - db: client.pool_name(), - user: client.username(), + db: client.pool_name().to_string(), + user: client.username().to_string(), }) { Some(pool_stats) => { // Update client state counter based on client state @@ -647,8 +666,8 @@ impl PoolStats { for server in server_map.values() { // Try to find the pool for this server match pool_map.get_mut(&PoolIdentifier { - db: server.pool_name(), - user: server.username(), + db: server.pool_name().to_string(), + user: server.username().to_string(), }) { Some(pool_stats) => { // Update server state counter based on server state @@ -718,3 +737,46 @@ impl IntoIterator for PoolStats { .into_iter() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// Sanity check on the new entry point: empty client/server maps + /// must not panic and must return either an empty lookup (no pools + /// registered in this test process) or a lookup whose entries all + /// have zero state counters (parallel test populated POOLS but no + /// clients/servers exist in our maps). + #[test] + fn construct_pool_lookup_from_empty_maps_returns_zero_counters() { + let client_map: HashMap> = HashMap::new(); + let server_map: HashMap> = HashMap::new(); + let result = PoolStats::construct_pool_lookup_from(&client_map, &server_map); + for (id, stats) in result.iter() { + assert_eq!(stats.cl_active, 0, "{id} cl_active should be 0"); + assert_eq!(stats.cl_idle, 0, "{id} cl_idle should be 0"); + assert_eq!(stats.cl_waiting, 0, "{id} cl_waiting should be 0"); + assert_eq!(stats.sv_active, 0, "{id} sv_active should be 0"); + assert_eq!(stats.sv_idle, 0, "{id} sv_idle should be 0"); + } + } + + /// Both entry points must agree on shape when fed the same global + /// POOLS state and equivalent client/server maps. Validates that + /// `construct_pool_lookup_from` is a structural extract of + /// `construct_pool_lookup` rather than a divergent path. + #[test] + fn convenience_wrapper_matches_caller_supplied_path() { + let client_map = super::super::get_client_stats(); + let server_map = super::super::get_server_stats(); + let from_wrapper = PoolStats::construct_pool_lookup(); + let from_explicit = PoolStats::construct_pool_lookup_from(&client_map, &server_map); + let wrapper_keys: HashSet<&PoolIdentifier> = from_wrapper.keys().collect(); + let explicit_keys: HashSet<&PoolIdentifier> = from_explicit.keys().collect(); + assert_eq!( + wrapper_keys, explicit_keys, + "the two entry points should report the same pool set" + ); + } +} diff --git a/src/stats/server.rs b/src/stats/server.rs index 1547022c0..ce506cfbc 100644 --- a/src/stats/server.rs +++ b/src/stats/server.rs @@ -357,31 +357,31 @@ impl ServerStats { // State conversion utilities // ------------------------------------------------------------------------------------------ - /// Converts the server state to a human-readable string. + /// Returns the server state as a static string slice. /// /// # Returns /// - /// A string representation of the server state: "active", "idle", "login", or "unknown" - pub fn state_to_string(&self) -> String { + /// One of "active", "idle", "login", or "unknown". + pub fn state_str(&self) -> &'static str { match self.state() { - SERVER_STATE_ACTIVE => "active".to_string(), - SERVER_STATE_IDLE => "idle".to_string(), - SERVER_STATE_LOGIN => "login".to_string(), - _ => "unknown".to_string(), + SERVER_STATE_ACTIVE => "active", + SERVER_STATE_IDLE => "idle", + SERVER_STATE_LOGIN => "login", + _ => "unknown", } } - /// Converts the server wait status to a human-readable string. + /// Returns the server wait status as a static string slice. /// /// # Returns /// - /// A string representation of the wait status: "idle", "read", "write", or "unknown" - pub fn wait_to_string(&self) -> String { + /// One of "idle", "read", "write", or "unknown". + pub fn wait_str(&self) -> &'static str { match self.wait() { - SERVER_WAIT_IDLE => "idle".to_string(), - SERVER_WAIT_READ => "read".to_string(), - SERVER_WAIT_WRITE => "write".to_string(), - _ => "unknown".to_string(), + SERVER_WAIT_IDLE => "idle", + SERVER_WAIT_READ => "read", + SERVER_WAIT_WRITE => "write", + _ => "unknown", } } @@ -555,13 +555,13 @@ impl ServerStats { // ------------------------------------------------------------------------------------------ /// Returns the name of the connection pool this server is using. - pub fn pool_name(&self) -> String { - self.address.pool_name.clone() + pub fn pool_name(&self) -> &str { + &self.address.pool_name } /// Returns the PostgreSQL username used for the connection. - pub fn username(&self) -> String { - self.address.username.clone() + pub fn username(&self) -> &str { + &self.address.username } /// Returns the address name (host:port) for this server connection. @@ -569,6 +569,14 @@ impl ServerStats { self.address.name() } + /// Returns the current application name for this server connection. + /// + /// Returns an owned `String` because the field sits behind a Mutex; the + /// lock cannot escape to bind a `&str` to `&self`. + pub fn application_name(&self) -> String { + self.application_name.lock().clone() + } + /// Returns the server connection timestamp. pub fn connect_time(&self) -> quanta::Instant { self.connect_time @@ -804,31 +812,31 @@ mod tests { fn test_state_conversion_methods() { let stats = create_test_server_stats(); - // Test state_to_string + // Test state_str stats.set_state(SERVER_STATE_LOGIN); - assert_eq!(stats.state_to_string(), "login"); + assert_eq!(stats.state_str(), "login"); stats.set_state(SERVER_STATE_ACTIVE); - assert_eq!(stats.state_to_string(), "active"); + assert_eq!(stats.state_str(), "active"); stats.set_state(SERVER_STATE_IDLE); - assert_eq!(stats.state_to_string(), "idle"); + assert_eq!(stats.state_str(), "idle"); stats.set_state(0); // Invalid state - assert_eq!(stats.state_to_string(), "unknown"); + assert_eq!(stats.state_str(), "unknown"); - // Test wait_to_string + // Test wait_str stats.set_wait(SERVER_WAIT_IDLE); - assert_eq!(stats.wait_to_string(), "idle"); + assert_eq!(stats.wait_str(), "idle"); stats.set_wait(SERVER_WAIT_READ); - assert_eq!(stats.wait_to_string(), "read"); + assert_eq!(stats.wait_str(), "read"); stats.set_wait(SERVER_WAIT_WRITE); - assert_eq!(stats.wait_to_string(), "write"); + assert_eq!(stats.wait_str(), "write"); stats.set_wait(0); // Invalid wait state - assert_eq!(stats.wait_to_string(), "unknown"); + assert_eq!(stats.wait_str(), "unknown"); } #[test] diff --git a/src/stats/socket.rs b/src/stats/socket.rs index d6328f94d..8f5d2950d 100644 --- a/src/stats/socket.rs +++ b/src/stats/socket.rs @@ -31,45 +31,45 @@ enum SocketAddr { } #[derive(Default)] -struct TcpStateCount { +pub struct TcpStateCount { // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/net/tcp_states.h - established: u16, - syn_sent: u16, - syn_recv: u16, - fin_wait1: u16, - fin_wait2: u16, - time_wait: u16, - close: u16, - close_wait: u16, - last_ack: u16, - listen: u16, - closing: u16, - new_syn_recv: u16, - bound_inactive: u16, - - total_count: u32, + pub established: u16, + pub syn_sent: u16, + pub syn_recv: u16, + pub fin_wait1: u16, + pub fin_wait2: u16, + pub time_wait: u16, + pub close: u16, + pub close_wait: u16, + pub last_ack: u16, + pub listen: u16, + pub closing: u16, + pub new_syn_recv: u16, + pub bound_inactive: u16, + + pub total_count: u32, } #[derive(Default)] -struct UnixStreamStateCount { +pub struct UnixStreamStateCount { // https://github.com/ecki/net-tools/blob/master/netstat.c#L121 - free: u16, /* not allocated */ - unconnected: u16, /* unconnected to any socket */ - connecting: u16, /* in process of connecting */ - connected: u16, /* connected to socket */ - disconnecting: u16, /* in process of disconnecting */ + pub free: u16, /* not allocated */ + pub unconnected: u16, /* unconnected to any socket */ + pub connecting: u16, /* in process of connecting */ + pub connected: u16, /* connected to socket */ + pub disconnecting: u16, /* in process of disconnecting */ - total_count: u32, + pub total_count: u32, } #[derive(Default)] pub struct SocketStateCount { - tcp: TcpStateCount, - tcp6: TcpStateCount, - unix_stream: UnixStreamStateCount, - unix_dgram: u16, - unix_seq_packet: u16, - unknown: u16, + pub tcp: TcpStateCount, + pub tcp6: TcpStateCount, + pub unix_stream: UnixStreamStateCount, + pub unix_dgram: u16, + pub unix_seq_packet: u16, + pub unknown: u16, } impl SocketStateCount { diff --git a/src/web/auth.rs b/src/web/auth.rs new file mode 100644 index 000000000..abab46f88 --- /dev/null +++ b/src/web/auth.rs @@ -0,0 +1,144 @@ +//! Basic-auth parser for the web mux. +//! +//! HTTP/1.1 `Authorization: Basic ` header parsing +//! plus constant-time credential comparison. + +use base64::Engine; +use subtle::ConstantTimeEq; + +/// Authentication outcome for an inbound request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthOutcome { + /// Request carries no Authorization header. + Anonymous, + /// Authorization header present and matched the configured admin credentials. + Admin, + /// Authorization header present but malformed or did not match. + Rejected, +} + +/// Inspect the value of an HTTP `Authorization` header (or `None` if absent), +/// compare against `admin_username`/`admin_password` in constant time, and +/// classify the outcome. +/// +/// The comparison runs in constant time relative to the configured credentials +/// to deny timing oracles. We do **not** offer a way to learn whether the +/// username matched but the password didn't — both legs are checked together +/// without short-circuit. +pub fn classify( + authorization_header: Option<&str>, + admin_username: &str, + admin_password: &str, +) -> AuthOutcome { + let Some(header) = authorization_header else { + return AuthOutcome::Anonymous; + }; + let Some(b64) = header.strip_prefix("Basic ") else { + return AuthOutcome::Rejected; + }; + let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64.trim()) else { + return AuthOutcome::Rejected; + }; + let Ok(decoded_str) = std::str::from_utf8(&decoded) else { + return AuthOutcome::Rejected; + }; + let Some((user, pass)) = decoded_str.split_once(':') else { + return AuthOutcome::Rejected; + }; + // `&` instead of `&&`: avoids short-circuit, both legs always evaluated + // so timing depends only on configured credential lengths. + let matches = bool::from(user.as_bytes().ct_eq(admin_username.as_bytes())) + & bool::from(pass.as_bytes().ct_eq(admin_password.as_bytes())); + if matches { + AuthOutcome::Admin + } else { + AuthOutcome::Rejected + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b64(s: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(s) + } + + #[test] + fn anonymous_when_header_missing() { + assert_eq!(classify(None, "admin", "secret"), AuthOutcome::Anonymous); + } + + #[test] + fn admin_when_credentials_match() { + let header = format!("Basic {}", b64("admin:secret")); + assert_eq!( + classify(Some(&header), "admin", "secret"), + AuthOutcome::Admin + ); + } + + #[test] + fn rejected_when_password_wrong() { + let header = format!("Basic {}", b64("admin:wrong")); + assert_eq!( + classify(Some(&header), "admin", "secret"), + AuthOutcome::Rejected + ); + } + + #[test] + fn rejected_when_username_wrong() { + let header = format!("Basic {}", b64("evil:secret")); + assert_eq!( + classify(Some(&header), "admin", "secret"), + AuthOutcome::Rejected + ); + } + + #[test] + fn rejected_when_scheme_not_basic() { + let header = format!("Bearer {}", b64("admin:secret")); + assert_eq!( + classify(Some(&header), "admin", "secret"), + AuthOutcome::Rejected + ); + } + + #[test] + fn rejected_when_base64_invalid() { + assert_eq!( + classify(Some("Basic !!!not-base64!!!"), "admin", "secret"), + AuthOutcome::Rejected + ); + } + + #[test] + fn rejected_when_decoded_has_no_colon() { + let header = format!("Basic {}", b64("adminsecret")); + assert_eq!( + classify(Some(&header), "admin", "secret"), + AuthOutcome::Rejected + ); + } + + #[test] + fn rejected_when_decoded_is_invalid_utf8() { + let raw = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe, 0xfd]); + let header = format!("Basic {}", raw); + assert_eq!( + classify(Some(&header), "admin", "secret"), + AuthOutcome::Rejected + ); + } + + #[test] + fn admin_when_password_contains_colon() { + // Per RFC 7617 only the FIRST colon is the separator. + let header = format!("Basic {}", b64("admin:p:a:s:s")); + assert_eq!( + classify(Some(&header), "admin", "p:a:s:s"), + AuthOutcome::Admin + ); + } +} diff --git a/src/web/log_tap.rs b/src/web/log_tap.rs new file mode 100644 index 000000000..0d0453bf0 --- /dev/null +++ b/src/web/log_tap.rs @@ -0,0 +1,414 @@ +//! Lock-free LogTap: in-memory subset of log records served via /api/logs. +//! +//! Design (spec section 7.3, 9): +//! - Producer (`log::Log::log` hook) reads `tap_active` AtomicBool gate. +//! When off, exits in ~1 ns. When on, formats record into a bounded +//! buffer (4 KB cap, UTF-8 safe truncation) and `try_send` into a +//! bounded MPSC. Drop-new on channel-full keeps the SQL hot path +//! free of allocation-spikes from megabyte debug deparse output. +//! - Consumer is a single tokio task, the sole owner of the VecDeque. +//! Assigns monotonic `seq`, processes `Drain` commands by cloning a +//! filtered subset; never blocks producers. +//! - Reaper task disables the tap after 2 min without GETs. + +use std::collections::VecDeque; +use std::fmt::{self, Write as _}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use arc_swap::ArcSwap; +use log::{Level, Record}; +use once_cell::sync::Lazy; +use tokio::sync::{mpsc, oneshot}; + +// 4 KB keeps a megabyte-sized debug deparse line from filling the ring with +// one entry; longer messages are truncated at a UTF-8 boundary with a marker. +const PER_ENTRY_BYTE_CAP: usize = 4 * 1024; + +// Disable the tap after this much time without GETs — long enough that a +// stepped-away operator coming back from coffee still has the buffer alive, +// short enough to release memory once nobody is reading. Two minutes is the +// operator-feedback default; the tap re-arms instantly on the next request. +const IDLE_DISABLE_MS: u64 = 120_000; + +#[derive(Debug, Clone)] +pub struct LogEntry { + pub seq: u64, + pub ts_ms: u64, + pub level: Level, + pub target: String, + pub message: String, +} + +/// What the producer hands to the consumer through the MPSC. seq is set +/// by the consumer (sole writer); ts_ms/level/target/message by the producer. +struct RawEntry { + ts_ms: u64, + level: Level, + target: String, + message: String, +} + +pub enum TapCommand { + Drain { + since: u64, + max: usize, + level: Option, + target: Option, + reply: oneshot::Sender, + }, + Shutdown, +} + +pub struct DrainResult { + pub entries: Vec, + pub next_seq: u64, + pub dropped_before: u64, + pub used_entries: usize, +} + +pub struct LogTap { + tx: mpsc::Sender, + /// Combined drop counter — bumped on producer-side `try_send` failures + /// when the channel is full *and* on consumer-side ring-buffer + /// evictions when a viewer is too slow. Operators read this as a + /// single "messages I never saw" number. + pub(crate) dropped_total: Arc, + cmd_tx: mpsc::Sender, + pub(crate) last_request_at: Arc, +} + +impl LogTap { + /// Sends a Drain command to the consumer task and waits for the reply. + /// Returns `Err` only when the consumer is gone — handlers turn that + /// into a 200-with-empty-envelope so the operator's poll loop survives + /// a momentary lapse. + pub(crate) async fn drain( + &self, + since: u64, + max: usize, + level: Option, + target: Option, + ) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.cmd_tx + .send(TapCommand::Drain { + since, + max, + level, + target, + reply: reply_tx, + }) + .await + .map_err(|_| ())?; + reply_rx.await.map_err(|_| ()) + } +} + +/// Module-level statics — single global LogTap. The gate is private to +/// this module so callers cannot toggle it without going through +/// `enable_log_tap` / `disable_log_tap` and desyncing it from `LOG_TAP`. +static TAP_ACTIVE: AtomicBool = AtomicBool::new(false); +static LOG_TAP: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None)); +static LIFECYCLE: Lazy> = Lazy::new(|| Mutex::new(())); + +/// Monotonic millisecond clock anchored at the first call. +pub(crate) fn now_monotonic_ms() -> u64 { + static START: Lazy = Lazy::new(Instant::now); + START.elapsed().as_millis() as u64 +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Returns the current LogTap if active, otherwise None. Uses +/// `ArcSwap::load` rather than `load_full`: the outer `Arc>` +/// stays inside the hazard-pointer guard for the lifetime of the call, +/// so we pay one inner-Arc clone per producer push instead of two +/// when the tap is active. +pub fn log_tap() -> Option> { + LOG_TAP.load().as_ref().clone() +} + +struct BoundedWriter { + buf: String, + cap: usize, + overflow: bool, +} + +impl BoundedWriter { + fn new(cap: usize) -> Self { + // Pre-allocate up to 512 bytes — covers the median pg_doorman log + // line without a regrow, and stays well under PER_ENTRY_BYTE_CAP + // even if cap is large. Without this hint each push() reallocs + // a couple of times as the format! writer grows the String. + Self { + buf: String::with_capacity(cap.min(512)), + cap, + overflow: false, + } + } + fn finish(mut self) -> String { + if self.overflow { + self.buf.push_str("…"); + } + self.buf + } +} + +impl fmt::Write for BoundedWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + let remaining = self.cap.saturating_sub(self.buf.len()); + if remaining == 0 { + self.overflow = true; + return Ok(()); + } + if s.len() <= remaining { + self.buf.push_str(s); + } else { + let mut end = remaining; + while !s.is_char_boundary(end) { + end -= 1; + } + self.buf.push_str(&s[..end]); + self.overflow = true; + } + Ok(()) + } +} + +/// Producer-side hook called from `LogLevelController::log`. Returns +/// immediately when `TAP_ACTIVE` is false (one Acquire load, ~1 ns on x86). +/// Otherwise formats into a bounded buffer and try_sends through the +/// MPSC. On send failure (channel full or closed) the drop is counted +/// in `dropped_total`. +pub fn push(record: &Record) { + if !TAP_ACTIVE.load(Ordering::Acquire) { + return; + } + let Some(tap) = log_tap() else { + return; + }; + + let mut bw = BoundedWriter::new(PER_ENTRY_BYTE_CAP); + let _ = write!(&mut bw, "{}", record.args()); + let message = bw.finish(); + + let raw = RawEntry { + ts_ms: now_unix_ms(), + level: record.level(), + target: record.target().to_string(), + message, + }; + + if tap.tx.try_send(raw).is_err() { + tap.dropped_total.fetch_add(1, Ordering::Relaxed); + } +} + +struct ConsumerState { + entries: VecDeque, + next_seq: u64, + max_entries: usize, +} + +async fn run_consumer( + mut rx: mpsc::Receiver, + mut cmd_rx: mpsc::Receiver, + dropped: Arc, + max_entries: usize, +) { + let mut s = ConsumerState { + entries: VecDeque::with_capacity(max_entries), + next_seq: 0, + max_entries, + }; + + loop { + tokio::select! { + biased; + cmd = cmd_rx.recv() => { + match cmd { + Some(TapCommand::Drain { since, max, level, target, reply }) => { + let mut entries: Vec = Vec::new(); + for e in s.entries.iter() { + if e.seq < since { + continue; + } + if let Some(min_lvl) = level { + if e.level > min_lvl { + continue; + } + } + if let Some(t) = &target { + if !e.target.contains(t.as_str()) { + continue; + } + } + entries.push(e.clone()); + if entries.len() >= max { + break; + } + } + let front_seq = s.entries.front().map(|e| e.seq).unwrap_or(s.next_seq); + let dropped_before = front_seq.saturating_sub(since); + let _ = reply.send(DrainResult { + entries, + next_seq: s.next_seq, + dropped_before, + used_entries: s.entries.len(), + }); + } + Some(TapCommand::Shutdown) | None => break, + } + } + raw = rx.recv() => { + let Some(raw) = raw else { break; }; + let entry = LogEntry { + seq: s.next_seq, + ts_ms: raw.ts_ms, + level: raw.level, + target: raw.target, + message: raw.message, + }; + s.next_seq += 1; + s.entries.push_back(entry); + while s.entries.len() > s.max_entries { + s.entries.pop_front(); + dropped.fetch_add(1, Ordering::Relaxed); + } + } + } + } +} + +/// Activates the tap. Idempotent — if already active, returns the existing Arc. +/// Spawns the consumer task on first activation. Lifecycle mutex serialises +/// activation/reaping; this path is admin-triggered and never on the hot path. +pub fn enable_log_tap(max_entries: usize) -> Arc { + let _g = LIFECYCLE.lock().unwrap_or_else(|e| e.into_inner()); + + if TAP_ACTIVE.load(Ordering::Relaxed) { + if let Some(arc) = log_tap() { + return arc; + } + } + + let dropped_total = Arc::new(AtomicU64::new(0)); + let last_request_at = Arc::new(AtomicU64::new(now_monotonic_ms())); + // Floor of 64 keeps the channel from collapsing if config sets + // log_tap_max_entries near zero. + let max = max_entries.max(64); + let (tx, rx) = mpsc::channel(max); + // 8 cmd slots: requests are serialised by LIFECYCLE; slack for rapid + // enable/disable cycles. + let (cmd_tx, cmd_rx) = mpsc::channel(8); + + let dropped_for_consumer = dropped_total.clone(); + tokio::spawn(async move { + run_consumer(rx, cmd_rx, dropped_for_consumer, max).await; + }); + + let arc = Arc::new(LogTap { + tx, + dropped_total, + cmd_tx, + last_request_at, + }); + + LOG_TAP.store(Arc::new(Some(arc.clone()))); + TAP_ACTIVE.store(true, Ordering::Release); + + arc +} + +/// Deactivates the tap. Drops the producer-side senders so the consumer +/// task exits cleanly. New events while disabled are dropped at the gate. +pub fn disable_log_tap() { + let _g = LIFECYCLE.lock().unwrap_or_else(|e| e.into_inner()); + TAP_ACTIVE.store(false, Ordering::Release); + LOG_TAP.store(Arc::new(None)); +} + +/// Reaper task: disables the tap after 2 min without /api/logs traffic. +/// Spawned once during `start_web_server` startup when ui_active is true +/// and log_tap_max_entries > 0. +pub async fn run_reaper() { + // 5 s tick: catches the IDLE_DISABLE_MS deadline within one extra cycle. + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + interval.tick().await; // skip the first tick which fires immediately + loop { + interval.tick().await; + if let Some(tap) = log_tap() { + let last = tap.last_request_at.load(Ordering::Relaxed); + if now_monotonic_ms().saturating_sub(last) > IDLE_DISABLE_MS { + disable_log_tap(); + log::debug!("LogTap disabled (no consumers for 2 minutes)"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn bounded_writer_truncates_at_cap() { + let mut bw = BoundedWriter::new(10); + let _ = write!(&mut bw, "0123456789ABCDEF"); + let s = bw.finish(); + assert!(s.starts_with("0123456789")); + assert!(s.contains("…")); + } + + #[test] + fn bounded_writer_short_input_no_marker() { + let mut bw = BoundedWriter::new(100); + let _ = write!(&mut bw, "short"); + let s = bw.finish(); + assert_eq!(s, "short"); + } + + #[test] + fn bounded_writer_utf8_safe_truncation() { + let mut bw = BoundedWriter::new(4); + // emoji takes 4 bytes — first push fits exactly; second pushes overflow. + let _ = write!(&mut bw, "🎉🎉"); + let s = bw.finish(); + // First emoji intact, second dropped, marker added. + assert!(s.starts_with("🎉")); + assert!(s.contains("…")); + assert!(!s.contains("\u{FFFD}")); + } + + #[test] + #[serial] + fn push_does_not_panic_when_inactive() { + // Without env_logger::init() the global log macros are a no-op, + // so this test is a smoke guard against the gate panicking, not + // an end-to-end check of the gate. The gate logic itself is + // covered indirectly by enable_disable_roundtrip and the consumer + // unit tests in later tasks. + TAP_ACTIVE.store(false, Ordering::Relaxed); + log::info!("safety check — should be ignored"); + } + + #[tokio::test] + #[serial] + async fn enable_disable_roundtrip() { + // Idempotent: a second enable_log_tap while active returns the existing Arc. + disable_log_tap(); + let a = enable_log_tap(64); + let b = enable_log_tap(64); + assert!(Arc::ptr_eq(&a, &b)); + disable_log_tap(); + assert!(log_tap().is_none()); + } +} diff --git a/src/web/metrics/handler.rs b/src/web/metrics/handler.rs new file mode 100644 index 000000000..aa1d76f5b --- /dev/null +++ b/src/web/metrics/handler.rs @@ -0,0 +1,71 @@ +//! Handler that builds the /metrics body and writes it onto a TcpStream. +//! The accept loop and HTTP routing live in `crate::web::server`. + +use flate2::write::GzEncoder; +use flate2::Compression; +use log::error; +use prometheus::{Encoder, TextEncoder}; +use std::io::Write; +use tokio::io::{AsyncWriteExt, BufWriter}; +use tokio::net::tcp::OwnedWriteHalf; + +use super::metrics::update_metrics; +use super::REGISTRY; + +/// Builds the Prometheus metrics body and writes a complete HTTP/1.1 response +/// onto the supplied writer. The mux must have already parsed the request +/// (this function performs no reads on the socket). +pub(crate) async fn write_metrics_response( + writer: &mut BufWriter, + accepts_gzip: bool, +) { + update_metrics(); + + let encoder = TextEncoder::new(); + let metric_families = REGISTRY.gather(); + let mut buffer = Vec::new(); + + if let Err(e) = encoder.encode(&metric_families, &mut buffer) { + error!("Failed to encode metrics: {e}"); + return; + } + + let content_type = encoder.format_type(); + + let (response_body, content_encoding) = if accepts_gzip { + let mut compressed = Vec::new(); + { + let mut gz = GzEncoder::new(&mut compressed, Compression::default()); + if let Err(e) = gz.write_all(&buffer) { + error!("Failed to compress metrics data: {e}"); + return; + } + if let Err(e) = gz.finish() { + error!("Failed to finish gzip compression: {e}"); + return; + } + } + (compressed, "Content-Encoding: gzip\r\n") + } else { + (buffer, "") + }; + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {}\r\n{}Content-Length: {}\r\n\r\n", + content_type, + content_encoding, + response_body.len() + ); + + if let Err(e) = writer.write_all(response.as_bytes()).await { + error!("Failed to write HTTP response header: {e}"); + return; + } + if let Err(e) = writer.write_all(&response_body).await { + error!("Failed to write metrics data: {e}"); + return; + } + if let Err(e) = writer.flush().await { + error!("Failed to flush connection: {e}"); + } +} diff --git a/src/prometheus/metrics.rs b/src/web/metrics/metrics.rs similarity index 94% rename from src/prometheus/metrics.rs rename to src/web/metrics/metrics.rs index 5d3860f5c..76983645d 100644 --- a/src/prometheus/metrics.rs +++ b/src/web/metrics/metrics.rs @@ -11,7 +11,7 @@ use crate::pool::{PoolIdentifier, AUTH_QUERY_STATE, COORDINATORS, DYNAMIC_POOLS} use crate::stats::get_socket_states_count; use crate::stats::pool::PoolStats; use crate::stats::{ - get_server_stats, CANCEL_CONNECTION_COUNTER, PLAIN_CONNECTION_COUNTER, TLS_CONNECTION_COUNTER, + CANCEL_CONNECTION_COUNTER, PLAIN_CONNECTION_COUNTER, TLS_CONNECTION_COUNTER, TOTAL_CONNECTION_COUNTER, }; @@ -90,10 +90,16 @@ fn update_socket_metrics() { } fn update_pool_metrics() { - let lookup = PoolStats::construct_pool_lookup(); + // Reuse the shared 250 ms snapshot — codex Arch P2#6 / Perf P2#6. + // /metrics scrapes typically arrive every 15-30 s, but during an + // incident the SPA can be polling /api/* on the same listener at + // 1.5 s. Without the cache both paths each cloned CLIENT_STATS and + // SERVER_STATS under their own read lock; with the cache they + // share one snapshot whenever they fall inside the TTL. + let snap = crate::web::routes::collect::snapshot(); reset_pool_metrics(); - for (identifier, stats) in lookup.iter() { + for (identifier, stats) in snap.pool_lookup.iter() { update_pool_avg_metrics(identifier, stats); update_pool_server_metrics(identifier, stats); update_client_state_metrics(identifier, stats); @@ -158,9 +164,14 @@ fn update_server_metrics() { SHOW_SERVERS_PREPARED_HITS.reset(); SHOW_SERVERS_PREPARED_MISSES.reset(); SHOW_SERVER_TLS_CONNECTIONS.reset(); - let stats = get_server_stats(); - for server in stats.values() { - // Create owned strings to avoid borrowing issues + // Same snapshot the rest of the scrape used; falls back to a + // direct global read if the cache is somehow empty (e.g. the + // first scrape racing with TTL expiry). + let snap = crate::web::routes::collect::snapshot(); + for server in snap.server_states.values() { + // Borrow username/pool_name from the Arc; the snapshot + // owns the Arc for the duration of this loop body, so the slices + // remain valid for the metric label calls below. let username = server.username(); let pool_name = server.pool_name(); let process_id = server.process_id().to_string(); @@ -178,14 +189,14 @@ fn update_server_metrics() { for (metric, value) in &server_metrics { metric - .with_label_values(&[&username, &pool_name, &process_id]) + .with_label_values(&[username, pool_name, &process_id]) .set(*value); } // Count TLS-encrypted backend connections per pool. if server.tls() { SHOW_SERVER_TLS_CONNECTIONS - .with_label_values(&[&pool_name]) + .with_label_values(&[pool_name]) .inc(); } } diff --git a/src/prometheus/mod.rs b/src/web/metrics/mod.rs similarity index 99% rename from src/prometheus/mod.rs rename to src/web/metrics/mod.rs index cbac9862d..2c39b43b9 100644 --- a/src/prometheus/mod.rs +++ b/src/web/metrics/mod.rs @@ -10,16 +10,17 @@ use prometheus::{ }; // Sub-modules +mod handler; +#[allow(clippy::module_inception)] mod metrics; -mod server; -mod system; +pub(crate) mod system; #[cfg(test)] mod tests; // Re-exports +pub(crate) use handler::write_metrics_response; pub use metrics::{observe_anonymous_eviction, record_interner_gc, record_synthetic_miss}; -pub use server::start_prometheus_server; // Define the metrics we want to expose pub(crate) static REGISTRY: Lazy = Lazy::new(Registry::new); diff --git a/src/prometheus/system.rs b/src/web/metrics/system.rs similarity index 68% rename from src/prometheus/system.rs rename to src/web/metrics/system.rs index 18c5b7f24..b7a356f57 100644 --- a/src/prometheus/system.rs +++ b/src/web/metrics/system.rs @@ -1,16 +1,22 @@ //! System metrics utilities for Prometheus exporter. -/// Gets the current memory usage of the process in bytes +/// Gets the current resident memory (RSS) of the process in bytes. +/// +/// `/proc/self/statm` columns are documented in `man 5 proc`: +/// `size resident shared text lib data dt`. We want **resident** — +/// the number of pages backed by RAM right now (VmRSS). The first +/// field is `size` (VmSize, total virtual address space) and would +/// over-count by the heap arenas, mmaps, and library text pages +/// that the process has reserved but does not currently touch. pub fn get_process_memory_usage() -> u64 { #[cfg(target_os = "linux")] { - // On Linux, read from /proc/self/statm match std::fs::read_to_string("/proc/self/statm") { Ok(statm) => { let values: Vec<&str> = statm.split_whitespace().collect(); - if !values.is_empty() { - if let Ok(pages) = values[0].parse::() { - // Convert pages to bytes (page size is typically 4KB) + if values.len() >= 2 { + if let Ok(pages) = values[1].parse::() { + // Convert pages to bytes (page size is typically 4KB). return pages * 4096; } } diff --git a/src/prometheus/tests.rs b/src/web/metrics/tests.rs similarity index 88% rename from src/prometheus/tests.rs rename to src/web/metrics/tests.rs index 265af5115..8e332038e 100644 --- a/src/prometheus/tests.rs +++ b/src/web/metrics/tests.rs @@ -1,10 +1,11 @@ //! Tests for Prometheus metrics exporter. -use super::start_prometheus_server; use crate::stats::{ CANCEL_CONNECTION_COUNTER, PLAIN_CONNECTION_COUNTER, TLS_CONNECTION_COUNTER, TOTAL_CONNECTION_COUNTER, }; +use crate::web::{start_web_server, WebServerOptions}; +use serial_test::serial; use std::sync::atomic::Ordering; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -12,7 +13,11 @@ use tokio::net::TcpStream; // Test for the HTTP server functionality // This test is focused on the public interface of the prometheus module +// +// `#[serial]` because `start_web_server` writes the process-wide +// `WebServerOptions` slot used by every other web::tests test. #[tokio::test] +#[serial] async fn test_prometheus_server_basic() { // Set up some test metrics PLAIN_CONNECTION_COUNTER.store(10, Ordering::SeqCst); @@ -25,7 +30,16 @@ async fn test_prometheus_server_basic() { let server_addr = "127.0.0.1:16432"; let server_handle = tokio::spawn(async move { // This will run indefinitely, so we'll abort it after the test - start_prometheus_server(server_addr).await; + start_web_server( + server_addr, + WebServerOptions { + ui_active: false, + ui_anonymous: true, + admin_username: "admin".into(), + admin_password: "secret".into(), + }, + ) + .await; }); // Give the server a moment to start @@ -122,7 +136,16 @@ async fn test_prometheus_server_integration() { // Use a random high port to avoid conflicts let server_addr = "127.0.0.1:16432"; let server_handle = tokio::spawn(async move { - start_prometheus_server(server_addr).await; + start_web_server( + server_addr, + WebServerOptions { + ui_active: false, + ui_anonymous: true, + admin_username: "admin".into(), + admin_password: "secret".into(), + }, + ) + .await; }); // Give the server a moment to start diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 000000000..a5f8500e5 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,19 @@ +//! Web subsystem: Prometheus metrics endpoint, future REST API for the UI, +//! authentication, log tap, and SPA static assets. +//! +//! Phase 1 wires only the metrics submodule (the former `crate::web::metrics`). +//! Auth, routes, log_tap, and static_assets are added in subsequent phases. + +pub mod auth; +pub mod log_tap; +pub mod metrics; +pub mod routes; +pub mod server; +pub mod static_assets; + +#[cfg(test)] +pub(crate) use server::start_web_server; +pub use server::{bind_web_listener, refresh_options_from_config, serve_on, WebServerOptions}; + +#[cfg(test)] +mod tests; diff --git a/src/web/routes/admin.rs b/src/web/routes/admin.rs new file mode 100644 index 000000000..537bb1883 --- /dev/null +++ b/src/web/routes/admin.rs @@ -0,0 +1,226 @@ +//! `POST /api/admin/{action}` — write surface that mirrors the admin +//! protocol's RELOAD / PAUSE / RESUME / RECONNECT commands. Authorisation +//! is gated by the listener mux (admin basic-auth, see +//! `is_admin_only` in server.rs); this module just dispatches to the +//! async wrappers in `crate::admin::operations` and renders the reply. +//! +//! The optional `?db=` query parameter scopes pause/resume/reconnect +//! to a single database segment of the pool identifier (the second half of +//! `user@db`). RELOAD ignores it. +//! +//! The handler returns the same JSON envelope shape as the read endpoints: +//! `{"ts": ..., "action": "...", "affected_pools": N}`. Each successful +//! action also pushes one or more entries onto the `/api/events` ring so +//! the frontend's chart-annotation overlay paints a vertical line at the +//! moment of the action regardless of which transport triggered it. + +use serde_json::json; + +use crate::admin::operations::{ + pause_now, reconnect_now, reload_now, resume_now, AdminEffect, AdminScope, +}; +use crate::web::routes::collect::now_unix_ms; +use crate::web::routes::query::{first, parse_query}; +use crate::web::server::Response; + +pub(crate) async fn handle_admin_action(raw_path: &str) -> Response { + let (path, query_str) = match raw_path.split_once('?') { + Some((p, q)) => (p, q), + None => (raw_path, ""), + }; + let query = parse_query(query_str); + + // The path always carries the `/api/admin/` prefix at this point — the + // listener already gated on that. + let action = path.trim_start_matches("/api/admin/"); + + let scope = match parse_scope(&query) { + Ok(s) => s, + Err(msg) => { + // serde_json escapes the action / msg strings — `format!` here + // would inject any unescaped `"` from the URL into the body + // and break the SPA error handler. + return Response::ok_json(&json!({ + "action": action, + "error": "bad_scope", + "message": msg, + })) + .with_status(400, "Bad Request"); + } + }; + + match action { + "reload" => match reload_now().await { + Ok(changed) => json_reload(changed), + Err(err) => json_err("reload", &err.to_string()), + }, + "pause" => render_effect("pause", pause_now(scope)), + "resume" => render_effect("resume", resume_now(scope)), + "reconnect" => render_effect("reconnect", reconnect_now(scope)), + _ => Response::ok_json(&json!({ + "error": "unknown_action", + "message": format!("unknown admin action: {action}"), + })) + .with_status(404, "Not Found"), + } +} + +/// `?pool=user@db` selects one pool, `?db=name` selects every user@db +/// pool of one database, neither selects every pool. The two are +/// mutually exclusive — if both are present the request is rejected +/// rather than silently picking one. +fn parse_scope( + query: &std::collections::BTreeMap>, +) -> Result { + let pool = first(query, "pool"); + let db = first(query, "db"); + match (pool, db) { + (Some(_), Some(_)) => Err("?pool and ?db are mutually exclusive; pass only one"), + (Some(spec), None) => match spec.split_once('@') { + Some((user, database)) if !user.is_empty() && !database.is_empty() => { + Ok(AdminScope::Pool { + user: user.to_string(), + db: database.to_string(), + }) + } + _ => Err("?pool must be in user@database form"), + }, + (None, Some(name)) => Ok(AdminScope::Database(name)), + (None, None) => Ok(AdminScope::AllPools), + } +} + +/// Same outcome envelope shape across the two transports: 404 with a +/// typed JSON error when the scope filter excluded every pool, 200 with +/// the list of touched pool ids otherwise. Uses serde_json::json! so +/// every value goes through the SerDe escaper — codex Arch P2#4 +/// flagged the previous hand-formatted JSON because `db.replace('"', '\\"')` +/// missed control characters that PostgreSQL identifier rules technically +/// allow. +fn render_effect(action: &str, effect: AdminEffect) -> Response { + match effect { + AdminEffect::NoMatchingDb { db } => Response::ok_json(&json!({ + "ts": now_unix_ms(), + "action": action, + "error": "no_matching_db", + "db": db, + })) + .with_status(404, "Not Found"), + AdminEffect::NoMatchingPool { user, db } => Response::ok_json(&json!({ + "ts": now_unix_ms(), + "action": action, + "error": "no_matching_pool", + "user": user, + "db": db, + })) + .with_status(404, "Not Found"), + AdminEffect::Applied { affected } => json_ok(action, &affected), + } +} + +fn json_ok(action: &str, affected: &[crate::pool::PoolIdentifier]) -> Response { + // `affected_pools` keeps the bare count for backward compat; the + // typed `affected` array is what codex DBA P2#3 wanted so a DBA + // can see exactly which user@db rows the action ran against. + let ids: Vec = affected + .iter() + .map(|id| format!("{}@{}", id.user, id.db)) + .collect(); + Response::ok_json(&json!({ + "ts": now_unix_ms(), + "action": action, + "affected_pools": affected.len(), + "affected": ids, + })) +} + +/// `reload` is global, so `affected_pools` is meaningless (codex DBA +/// P2#5: the route used to hardcode `affected_pools: 1`). Surface +/// `changed: true|false` instead — DBAs need that signal to tell a +/// "config actually rotated" reload from a no-op SIGHUP. +fn json_reload(changed: bool) -> Response { + Response::ok_json(&json!({ + "ts": now_unix_ms(), + "action": "reload", + "changed": changed, + })) +} + +fn json_err(action: &str, message: &str) -> Response { + Response::ok_json(&json!({ + "ts": now_unix_ms(), + "action": action, + "error": message, + })) + .with_status(500, "Internal Server Error") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unknown_action_returns_404() { + let r = handle_admin_action("/api/admin/foo").await; + assert_eq!(r.status, 404); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("unknown_action")); + } + + #[tokio::test] + async fn pause_without_pools_reports_zero_affected() { + // No pools registered in unit-test global → pause_now returns 0. + let r = handle_admin_action("/api/admin/pause").await; + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains(r#""action":"pause""#), "{body}"); + assert!(body.contains(r#""affected_pools":0"#), "{body}"); + } + + #[tokio::test] + async fn pause_with_missing_db_filter_returns_404_with_typed_error() { + // Mirrors the PG admin protocol: `db` filter that matches no pool + // is a typo signal, not a silent zero. REST returns 404 + JSON body + // identifying the unknown db so the SPA can surface it. + let r = handle_admin_action("/api/admin/pause?db=nonexistent_db").await; + assert_eq!(r.status, 404); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains(r#""error":"no_matching_db""#), "{body}"); + assert!(body.contains(r#""db":"nonexistent_db""#), "{body}"); + } + + #[tokio::test] + async fn reconnect_with_missing_db_filter_also_404() { + let r = handle_admin_action("/api/admin/reconnect?db=ghost").await; + assert_eq!(r.status, 404); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains(r#""error":"no_matching_db""#), "{body}"); + assert!(body.contains(r#""db":"ghost""#), "{body}"); + } + + #[tokio::test] + async fn pause_with_missing_pool_filter_returns_404_no_matching_pool() { + let r = handle_admin_action("/api/admin/pause?pool=ghost@nope").await; + assert_eq!(r.status, 404); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains(r#""error":"no_matching_pool""#), "{body}"); + assert!(body.contains(r#""user":"ghost""#), "{body}"); + assert!(body.contains(r#""db":"nope""#), "{body}"); + } + + #[tokio::test] + async fn pause_with_pool_and_db_simultaneously_returns_400() { + let r = handle_admin_action("/api/admin/pause?pool=u@d&db=x").await; + assert_eq!(r.status, 400); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("bad_scope"), "{body}"); + } + + #[tokio::test] + async fn pause_with_malformed_pool_returns_400() { + let r = handle_admin_action("/api/admin/pause?pool=just_a_user").await; + assert_eq!(r.status, 400); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("user@database"), "{body}"); + } +} diff --git a/src/web/routes/apps.rs b/src/web/routes/apps.rs new file mode 100644 index 000000000..10e56ff07 --- /dev/null +++ b/src/web/routes/apps.rs @@ -0,0 +1,38 @@ +//! GET /api/apps?sort=&order= handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_apps; +use crate::web::routes::dto::{AppFilters, AppSort, SortOrder}; +use crate::web::routes::query::first; +use crate::web::server::Response; + +pub(crate) fn handle_apps(query: &BTreeMap>) -> Response { + let filters = AppFilters { + sort: match first(query, "sort").as_deref() { + Some("queries") => AppSort::Queries, + Some("transactions") => AppSort::Transactions, + Some("errors") => AppSort::Errors, + _ => AppSort::Clients, + }, + order: match first(query, "order").as_deref() { + Some("asc") => SortOrder::Asc, + _ => SortOrder::Desc, + }, + }; + Response::ok_json(&collect_apps(&filters)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apps_returns_200_with_envelope() { + let q = BTreeMap::new(); + let r = handle_apps(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"apps\"")); + } +} diff --git a/src/web/routes/auth_query.rs b/src/web/routes/auth_query.rs new file mode 100644 index 000000000..fab977d87 --- /dev/null +++ b/src/web/routes/auth_query.rs @@ -0,0 +1,22 @@ +//! GET /api/auth_query handler. + +use crate::web::routes::collect::collect_auth_query; +use crate::web::server::Response; + +pub(crate) fn handle_auth_query() -> Response { + Response::ok_json(&collect_auth_query()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_query_response_is_200_with_envelope() { + let r = handle_auth_query(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"pools\"")); + } +} diff --git a/src/web/routes/clients.rs b/src/web/routes/clients.rs new file mode 100644 index 000000000..e4a6c9c5e --- /dev/null +++ b/src/web/routes/clients.rs @@ -0,0 +1,74 @@ +//! GET /api/clients handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_clients; +use crate::web::routes::dto::{ClientFilters, ClientSort, SortOrder}; +use crate::web::routes::query::{first, parse_u64}; +use crate::web::server::Response; + +pub(crate) fn handle_clients(query: &BTreeMap>) -> Response { + let filters = parse_filters(query); + Response::ok_json(&collect_clients(&filters)) +} + +fn parse_filters(query: &BTreeMap>) -> ClientFilters { + ClientFilters { + limit: parse_u64(query, "limit", 100), + offset: parse_u64(query, "offset", 0), + sort: match first(query, "sort").as_deref() { + Some("errors_total") => ClientSort::ErrorsTotal, + Some("age_seconds") => ClientSort::AgeSeconds, + Some("current_query_age_ms") => ClientSort::CurrentQueryAgeMs, + _ => ClientSort::QueriesTotal, + }, + order: match first(query, "order").as_deref() { + Some("asc") => SortOrder::Asc, + _ => SortOrder::Desc, + }, + pool: first(query, "pool"), + database: first(query, "database"), + user: first(query, "user"), + addr: first(query, "addr"), + application_name: query.get("application_name").cloned().unwrap_or_default(), + state: query.get("state").cloned().unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clients_response_is_200_json_with_envelope() { + let q = BTreeMap::new(); + let r = handle_clients(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + for field in [ + "\"ts\"", + "\"total\"", + "\"limit\"", + "\"offset\"", + "\"clients\"", + ] { + assert!(body.contains(field), "missing {field} in {body}"); + } + } + + #[test] + fn parse_filters_picks_up_query_params() { + let mut q = BTreeMap::new(); + q.insert("limit".into(), vec!["50".into()]); + q.insert("sort".into(), vec!["errors_total".into()]); + q.insert("order".into(), vec!["asc".into()]); + q.insert("pool".into(), vec!["main@db1".into()]); + q.insert("addr".into(), vec!["10.0.5.".into()]); + let f = parse_filters(&q); + assert_eq!(f.limit, 50); + assert!(matches!(f.sort, ClientSort::ErrorsTotal)); + assert!(matches!(f.order, SortOrder::Asc)); + assert_eq!(f.pool.as_deref(), Some("main@db1")); + assert_eq!(f.addr.as_deref(), Some("10.0.5.")); + } +} diff --git a/src/web/routes/collect/apps.rs b/src/web/routes/collect/apps.rs new file mode 100644 index 000000000..1a66ec54a --- /dev/null +++ b/src/web/routes/collect/apps.rs @@ -0,0 +1,131 @@ +use std::sync::atomic::Ordering; + +use crate::web::routes::dto::{AppFilters, AppRowDto, AppSort, AppsDto, SortOrder}; + +use super::{now_unix_ms, snapshot}; + +pub(crate) fn collect_apps(filters: &AppFilters) -> AppsDto { + let snap = snapshot(); + let clients: Vec<_> = snap.client_states.values().cloned().collect(); + apps_from(clients, filters) +} + +fn apps_from( + snapshot: Vec>, + filters: &AppFilters, +) -> AppsDto { + use std::collections::HashMap; + + let mut acc: HashMap = HashMap::new(); + for s in &snapshot { + let app = s.application_name(); + let entry = acc.entry(app.to_string()).or_insert_with(|| AppRowDto { + application_name: app.to_string(), + clients: 0, + queries_total: 0, + transactions_total: 0, + errors_total: 0, + }); + entry.clients += 1; + entry.queries_total += s.query_count.load(Ordering::Relaxed); + entry.transactions_total += s.transaction_count.load(Ordering::Relaxed); + entry.errors_total += s.error_count.load(Ordering::Relaxed); + } + + let mut apps: Vec = acc.into_values().collect(); + apps.sort_by(|a, b| { + let ord = match filters.sort { + AppSort::Clients => a.clients.cmp(&b.clients), + AppSort::Queries => a.queries_total.cmp(&b.queries_total), + AppSort::Transactions => a.transactions_total.cmp(&b.transactions_total), + AppSort::Errors => a.errors_total.cmp(&b.errors_total), + }; + match filters.order { + SortOrder::Asc => ord, + SortOrder::Desc => ord.reverse(), + } + }); + + AppsDto { + ts: now_unix_ms(), + apps, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::client::ClientStats; + use crate::utils::clock; + use std::sync::Arc; + + fn make_client( + connection_id: u64, + db: &str, + user: &str, + app: &str, + queries: u64, + errors: u64, + ) -> Arc { + let stats = Arc::new(ClientStats::new( + connection_id, + app, + user, + db, + "127.0.0.1", + clock::now(), + false, + )); + stats.query_count.store(queries, Ordering::Relaxed); + stats.error_count.store(errors, Ordering::Relaxed); + stats + } + + #[test] + fn apps_aggregate_counts_clients_per_application_name() { + let clients = vec![ + make_client(1, "db", "u", "appA", 10, 0), + make_client(2, "db", "u", "appA", 20, 0), + make_client(3, "db", "u", "appB", 5, 0), + ]; + let f = AppFilters { + sort: AppSort::Clients, + order: SortOrder::Desc, + }; + let result = apps_from(clients, &f); + let app_a = result + .apps + .iter() + .find(|a| a.application_name == "appA") + .unwrap(); + let app_b = result + .apps + .iter() + .find(|a| a.application_name == "appB") + .unwrap(); + assert_eq!(app_a.clients, 2); + assert_eq!(app_a.queries_total, 30); + assert_eq!(app_b.clients, 1); + assert_eq!(app_b.queries_total, 5); + } + + #[test] + fn apps_sort_by_queries_desc() { + let clients = vec![ + make_client(1, "db", "u", "appA", 10, 0), + make_client(2, "db", "u", "appB", 100, 0), + make_client(3, "db", "u", "appC", 50, 0), + ]; + let f = AppFilters { + sort: AppSort::Queries, + order: SortOrder::Desc, + }; + let result = apps_from(clients, &f); + let names: Vec<_> = result + .apps + .iter() + .map(|a| a.application_name.clone()) + .collect(); + assert_eq!(names, vec!["appB", "appC", "appA"]); + } +} diff --git a/src/web/routes/collect/auth_query.rs b/src/web/routes/collect/auth_query.rs new file mode 100644 index 000000000..2f045e988 --- /dev/null +++ b/src/web/routes/collect/auth_query.rs @@ -0,0 +1,40 @@ +use crate::pool::{AUTH_QUERY_STATE, DYNAMIC_POOLS}; +use crate::web::routes::dto::{AuthQueryDto, AuthQueryRowDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_auth_query() -> AuthQueryDto { + let states = AUTH_QUERY_STATE.load(); + let dynamic = DYNAMIC_POOLS.load(); + + let mut pools: Vec = states + .iter() + .map(|(pool_name, state)| { + let cache_entries = state.cache_len() as u64; + let dyn_current = dynamic.iter().filter(|id| id.db == *pool_name).count() as u64; + let s = state.stats.snapshot(); + AuthQueryRowDto { + database: pool_name.clone(), + cache_entries, + cache_hits: s.cache_hits, + cache_misses: s.cache_misses, + cache_refetches: s.cache_refetches, + cache_rate_limited: s.cache_rate_limited, + auth_success: s.auth_success, + auth_failure: s.auth_failure, + executor_queries: s.executor_queries, + executor_errors: s.executor_errors, + dynamic_pools_current: dyn_current, + dynamic_pools_created: s.dynamic_pools_created, + dynamic_pools_destroyed: s.dynamic_pools_destroyed, + } + }) + .collect(); + + pools.sort_by(|a, b| a.database.cmp(&b.database)); + + AuthQueryDto { + ts: now_unix_ms(), + pools, + } +} diff --git a/src/web/routes/collect/clients.rs b/src/web/routes/collect/clients.rs new file mode 100644 index 000000000..36b972255 --- /dev/null +++ b/src/web/routes/collect/clients.rs @@ -0,0 +1,377 @@ +use std::sync::atomic::Ordering; + +use crate::web::routes::dto::{ClientDto, ClientFilters, ClientSort, ClientsDto, SortOrder}; + +use super::{now_unix_ms, snapshot, MAX_LIMIT}; + +pub(crate) fn collect_clients(filters: &ClientFilters) -> ClientsDto { + let snap = snapshot(); + let clients: Vec<_> = snap.client_states.values().cloned().collect(); + collect_clients_from(clients, filters) +} + +/// Pure inner logic for `collect_clients` — operates on a pre-built snapshot +/// so it can be called from unit tests without touching global state. +fn collect_clients_from( + snapshot: Vec>, + filters: &ClientFilters, +) -> ClientsDto { + let mut rows: Vec = snapshot + .iter() + .filter(|s| client_matches(s, filters)) + .map(client_to_dto) + .collect(); + + let total = rows.len() as u64; + + rows.sort_by(|a, b| { + let ord = match filters.sort { + ClientSort::QueriesTotal => a.queries_total.cmp(&b.queries_total), + ClientSort::ErrorsTotal => a.errors_total.cmp(&b.errors_total), + ClientSort::AgeSeconds => a.age_seconds.cmp(&b.age_seconds), + ClientSort::CurrentQueryAgeMs => a.current_query_age_ms.cmp(&b.current_query_age_ms), + }; + match filters.order { + SortOrder::Asc => ord, + SortOrder::Desc => ord.reverse(), + } + }); + + let limit = filters.limit.clamp(1, MAX_LIMIT); + let offset = filters.offset; + let page: Vec<_> = rows + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + + ClientsDto { + ts: now_unix_ms(), + total, + limit, + offset, + clients: page, + } +} + +fn client_matches(s: &crate::stats::ClientStats, f: &ClientFilters) -> bool { + let pool_name = s.pool_name(); + let user = s.username(); + let app = s.application_name(); + let state = s.state_str(); + + if let Some(p) = &f.pool { + let id = format!("{}@{}", user, pool_name); + if id != *p { + return false; + } + } + if let Some(db) = &f.database { + if pool_name != db { + return false; + } + } + if let Some(u) = &f.user { + if user != u { + return false; + } + } + if let Some(a) = &f.addr { + // Substring match — covers both "1.2.3.4" and "1.2.3.4:5432" forms, + // and supports operator typing partial subnets like "10.0.5.". + if !s.ipaddr().contains(a.as_str()) { + return false; + } + } + if !f.application_name.is_empty() && !f.application_name.iter().any(|x| x == app) { + return false; + } + if !f.state.is_empty() && !f.state.iter().any(|x| x == state) { + return false; + } + true +} + +fn client_to_dto(s: &std::sync::Arc) -> ClientDto { + let age_seconds = s.connect_time().elapsed().as_secs(); + ClientDto { + client_id: format!("#c{}", s.connection_id()), + database: s.pool_name().to_string(), + user: s.username().to_string(), + application_name: s.application_name().to_string(), + addr: s.ipaddr().to_string(), + tls: s.tls(), + state: s.state_str().to_string(), + wait: s.wait_str().to_string(), + wait_ms: s.wait_ms().unwrap_or(0), + transactions_total: s.transaction_count.load(Ordering::Relaxed), + queries_total: s.query_count.load(Ordering::Relaxed), + errors_total: s.error_count.load(Ordering::Relaxed), + age_seconds, + current_query_age_ms: s.current_query_age_ms().unwrap_or(0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::client::ClientStats; + use crate::utils::clock; + use std::sync::Arc; + + fn make_client( + connection_id: u64, + db: &str, + user: &str, + app: &str, + queries: u64, + errors: u64, + ) -> Arc { + let stats = Arc::new(ClientStats::new( + connection_id, + app, + user, + db, + "127.0.0.1", + clock::now(), + false, + )); + stats.query_count.store(queries, Ordering::Relaxed); + stats.error_count.store(errors, Ordering::Relaxed); + stats + } + + fn default_client_filters() -> ClientFilters { + ClientFilters { + limit: 100, + offset: 0, + sort: ClientSort::QueriesTotal, + order: SortOrder::Asc, + pool: None, + database: None, + user: None, + addr: None, + application_name: vec![], + state: vec![], + } + } + + // --------------------------------------------------------------------------- + // Client filter tests + // --------------------------------------------------------------------------- + + #[test] + fn client_filter_by_pool_exact_match() { + // pool filter uses the "user@db" composite id. + let clients = vec![ + make_client(1, "db1", "alice", "app", 0, 0), + make_client(2, "db2", "bob", "app", 0, 0), + ]; + let mut f = default_client_filters(); + f.pool = Some("alice@db1".to_string()); + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 1); + assert_eq!(result.clients[0].user, "alice"); + } + + #[test] + fn client_filter_by_database_only() { + let clients = vec![ + make_client(1, "prod", "alice", "app", 0, 0), + make_client(2, "staging", "alice", "app", 0, 0), + ]; + let mut f = default_client_filters(); + f.database = Some("prod".to_string()); + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 1); + assert_eq!(result.clients[0].database, "prod"); + } + + #[test] + fn client_filter_by_user_only() { + let clients = vec![ + make_client(1, "db", "alice", "app", 0, 0), + make_client(2, "db", "bob", "app", 0, 0), + make_client(3, "db", "alice", "app2", 0, 0), + ]; + let mut f = default_client_filters(); + f.user = Some("alice".to_string()); + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 2); + assert!(result.clients.iter().all(|c| c.user == "alice")); + } + + #[test] + fn client_filter_application_name_or_semantics() { + // A row matches if its app_name is in the filter list (OR). + let clients = vec![ + make_client(1, "db", "alice", "pgadmin", 0, 0), + make_client(2, "db", "bob", "psql", 0, 0), + make_client(3, "db", "carol", "other", 0, 0), + ]; + let mut f = default_client_filters(); + f.application_name = vec!["pgadmin".to_string(), "psql".to_string()]; + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 2); + let apps: Vec<_> = result + .clients + .iter() + .map(|c| c.application_name.as_str()) + .collect(); + assert!(apps.contains(&"pgadmin")); + assert!(apps.contains(&"psql")); + } + + #[test] + fn client_filter_state_or_semantics() { + // Default state for a fresh ClientStats is "idle". + let clients = vec![ + make_client(1, "db", "alice", "app", 0, 0), + make_client(2, "db", "bob", "app", 0, 0), + ]; + let mut f = default_client_filters(); + f.state = vec!["idle".to_string()]; + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 2); + + // Filter for a state that no client is in returns nothing. + f.state = vec!["active".to_string()]; + let clients2 = vec![make_client(10, "db", "alice", "app", 0, 0)]; + let result2 = collect_clients_from(clients2, &f); + assert_eq!(result2.total, 0); + } + + // --------------------------------------------------------------------------- + // Client sort tests + // --------------------------------------------------------------------------- + + #[test] + fn client_sort_queries_total_asc() { + let clients = vec![ + make_client(1, "db", "u", "a", 30, 0), + make_client(2, "db", "u", "a", 10, 0), + make_client(3, "db", "u", "a", 20, 0), + ]; + let mut f = default_client_filters(); + f.sort = ClientSort::QueriesTotal; + f.order = SortOrder::Asc; + let result = collect_clients_from(clients, &f); + let counts: Vec = result.clients.iter().map(|c| c.queries_total).collect(); + assert_eq!(counts, vec![10, 20, 30]); + } + + #[test] + fn client_sort_queries_total_desc() { + let clients = vec![ + make_client(1, "db", "u", "a", 30, 0), + make_client(2, "db", "u", "a", 10, 0), + make_client(3, "db", "u", "a", 20, 0), + ]; + let mut f = default_client_filters(); + f.sort = ClientSort::QueriesTotal; + f.order = SortOrder::Desc; + let result = collect_clients_from(clients, &f); + let counts: Vec = result.clients.iter().map(|c| c.queries_total).collect(); + assert_eq!(counts, vec![30, 20, 10]); + } + + #[test] + fn client_sort_errors_total_asc() { + let clients = vec![ + make_client(1, "db", "u", "a", 0, 5), + make_client(2, "db", "u", "a", 0, 1), + make_client(3, "db", "u", "a", 0, 3), + ]; + let mut f = default_client_filters(); + f.sort = ClientSort::ErrorsTotal; + f.order = SortOrder::Asc; + let result = collect_clients_from(clients, &f); + let errs: Vec = result.clients.iter().map(|c| c.errors_total).collect(); + assert_eq!(errs, vec![1, 3, 5]); + } + + #[test] + fn client_sort_errors_total_desc() { + let clients = vec![ + make_client(1, "db", "u", "a", 0, 5), + make_client(2, "db", "u", "a", 0, 1), + make_client(3, "db", "u", "a", 0, 3), + ]; + let mut f = default_client_filters(); + f.sort = ClientSort::ErrorsTotal; + f.order = SortOrder::Desc; + let result = collect_clients_from(clients, &f); + let errs: Vec = result.clients.iter().map(|c| c.errors_total).collect(); + assert_eq!(errs, vec![5, 3, 1]); + } + + #[test] + fn client_sort_age_seconds_asc() { + // All fixtures share the same clock::now() so ages will all be 0. + // The sort should be stable and return all rows without panicking. + let clients = vec![ + make_client(1, "db", "u", "a", 0, 0), + make_client(2, "db", "u", "a", 0, 0), + ]; + let mut f = default_client_filters(); + f.sort = ClientSort::AgeSeconds; + f.order = SortOrder::Asc; + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 2); + } + + #[test] + fn client_sort_current_query_age_ms_desc() { + // Clients not in ACTIVE state return current_query_age_ms == 0. + let clients = vec![ + make_client(1, "db", "u", "a", 0, 0), + make_client(2, "db", "u", "a", 0, 0), + ]; + let mut f = default_client_filters(); + f.sort = ClientSort::CurrentQueryAgeMs; + f.order = SortOrder::Desc; + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 2); + } + + // --------------------------------------------------------------------------- + // Client pagination tests + // --------------------------------------------------------------------------- + + #[test] + fn client_pagination_offset_beyond_total_returns_empty() { + let clients = vec![ + make_client(1, "db", "u", "a", 0, 0), + make_client(2, "db", "u", "a", 0, 0), + ]; + let mut f = default_client_filters(); + f.offset = 10; // beyond total of 2 + f.limit = 100; + let result = collect_clients_from(clients, &f); + assert_eq!(result.total, 2); + assert!(result.clients.is_empty()); + } + + #[test] + fn client_pagination_limit_clamped_to_max_limit() { + let clients: Vec<_> = (0..5) + .map(|i| make_client(i, "db", "u", "a", 0, 0)) + .collect(); + let mut f = default_client_filters(); + f.limit = MAX_LIMIT + 9999; // above cap + let result = collect_clients_from(clients, &f); + assert_eq!(result.limit, MAX_LIMIT); + } + + #[test] + fn client_pagination_limit_one() { + let clients: Vec<_> = (0..5) + .map(|i| make_client(i, "db", "u", "a", 0, 0)) + .collect(); + let mut f = default_client_filters(); + f.limit = 1; + let result = collect_clients_from(clients, &f); + assert_eq!(result.clients.len(), 1); + assert_eq!(result.total, 5); + } +} diff --git a/src/web/routes/collect/config.rs b/src/web/routes/collect/config.rs new file mode 100644 index 000000000..234181a5f --- /dev/null +++ b/src/web/routes/collect/config.rs @@ -0,0 +1,252 @@ +use std::collections::HashMap; + +use crate::config::get_config; +use crate::web::routes::dto::{ConfigDto, ConfigEntry}; + +use super::now_unix_ms; + +/// Returns `true` for configuration keys whose value should be masked in +/// `/api/config`. A key is secret if its trailing path segment (after the +/// last `.`) is exactly `password` or `secret`, or has any of the suffixes +/// `_password`, `_secret`, `_token`, `_key`. +/// +/// The trailing-segment matching is so that `pools.foo.users.bar.password` +/// is recognised as secret, not just top-level `password`. +fn is_secret_key(key: &str) -> bool { + let last_segment = key.rsplit('.').next().unwrap_or(key); + matches!(last_segment, "password" | "secret") + || last_segment.ends_with("_password") + || last_segment.ends_with("_secret") + || last_segment.ends_with("_token") + || last_segment.ends_with("_key") +} + +/// Bind-address fields require a restart; everything else takes effect on +/// the next backend or `RELOAD`. Listed precisely so the UI can render +/// the right "restart_required" pill instead of marking everything +/// reloadable. +const IMMUTABLES: &[&str] = &["host", "port", "connect_timeout"]; + +/// Flatten a serde JSON value into dotted keys → string values. Operators +/// have asked for a coverage-complete `/api/config` so they can verify +/// TLS / auth_query / pool sizing / prepared cache / web settings during +/// an incident — the previous hand-written `From<&Config>` only exposed +/// host/port/connect_timeout/idle_timeout/shutdown_timeout plus pool +/// users/mode, which DBA P3#7 (codex review) flagged as too thin. +fn flatten_json(prefix: &str, value: &serde_json::Value, out: &mut HashMap) { + match value { + serde_json::Value::Object(map) => { + for (k, v) in map { + let key = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + flatten_json(&key, v, out); + } + } + serde_json::Value::Array(arr) => { + // Render arrays as comma-joined when every element is a leaf, + // otherwise as `prefix.` rows. Operators reading + // `pools.app_db.users` want one row, not five. + if arr.iter().all(|v| { + !matches!( + v, + serde_json::Value::Object(_) | serde_json::Value::Array(_) + ) + }) { + let joined: Vec = arr.iter().map(json_leaf_to_string).collect(); + out.insert(prefix.to_string(), joined.join(", ")); + } else { + for (i, v) in arr.iter().enumerate() { + let key = format!("{prefix}.{i}"); + flatten_json(&key, v, out); + } + } + } + _ => { + out.insert(prefix.to_string(), json_leaf_to_string(value)); + } + } +} + +fn json_leaf_to_string(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + } +} + +pub(crate) fn collect_config() -> ConfigDto { + let config = get_config(); + + let mut flat: HashMap = HashMap::new(); + if let Ok(value) = serde_json::to_value(&config) { + flatten_json("", &value, &mut flat); + } + flat.retain(|k, _| !is_internal_key(k)); + + // Diff against `Config::default()` so the UI can show what is at + // its built-in default vs. what an operator changed via the config + // file. The defaults map is computed once per request — cheap, and + // a stable comparison surface for codex DBA P3#7. + let mut defaults: HashMap = HashMap::new(); + if let Ok(value) = serde_json::to_value(crate::config::Config::default()) { + flatten_json("", &value, &mut defaults); + } + + let mut entries: Vec = flat + .into_iter() + .map(|(key, value)| { + let secret = is_secret_key(&key); + let value = if secret { "***".to_string() } else { value }; + let default = defaults + .get(&key) + .map(|d| if secret { "***".to_string() } else { d.clone() }) + .unwrap_or_else(|| "-".to_string()); + let changeable = if IMMUTABLES.iter().any(|c| *c == key) { + "no" + } else { + "yes" + }; + let doc = lookup_doc(&key); + ConfigEntry { + key, + value, + default, + changeable, + doc, + } + }) + .collect(); + + entries.sort_by(|a, b| a.key.cmp(&b.key)); + + ConfigDto { + ts: now_unix_ms(), + config: entries, + } +} + +/// Drop fields that exist on the in-memory `Config` purely as state +/// (file path, parsed include list) and have no "what is the pooler +/// running with" meaning for an operator. +fn is_internal_key(key: &str) -> bool { + matches!(key, "path") || key.starts_with("include.") || key == "include" +} + +/// Map a flattened config key to (section, field-name) suitable for +/// `FieldsData::try_field`. Returns `None` for nested keys we have no +/// doc for (e.g. the integers inside a pools..users array). +fn key_to_section_field(key: &str) -> Option<(&'static str, &str)> { + if let Some(rest) = key.strip_prefix("general.") { + return Some(("general", rest)); + } + if let Some(rest) = key.strip_prefix("web.") { + return Some(("web", rest)); + } + if let Some(rest) = key.strip_prefix("pools.") { + // pools..users.. → ("user", ) + // pools..auth_query. → ("auth_query", ) + // pools.. → ("pool", ) + let (_name, tail) = rest.split_once('.')?; + if let Some(user_field) = tail.strip_prefix("users.") { + // skip past the index + let (_idx, f) = user_field.split_once('.')?; + return Some(("user", f)); + } + if let Some(aq_field) = tail.strip_prefix("auth_query.") { + return Some(("auth_query", aq_field)); + } + return Some(("pool", tail)); + } + None +} + +/// EN documentation string for a config key, or empty if not in fields.yaml. +fn lookup_doc(key: &str) -> String { + let Some((section, field)) = key_to_section_field(key) else { + return String::new(); + }; + crate::app::generate::annotated::FIELDS + .try_field(section, field) + .and_then(|f| f.config.as_ref()) + .map(|i18n| i18n.get(false).trim().to_string()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + #[test] + fn is_secret_key_top_level_password() { + assert!(super::is_secret_key("password")); + assert!(super::is_secret_key("admin_password")); + assert!(super::is_secret_key("server_password")); + } + + #[test] + fn is_secret_key_top_level_secret() { + assert!(super::is_secret_key("secret")); + assert!(super::is_secret_key("talos_jwt_secret")); + } + + #[test] + fn is_secret_key_token_and_key_suffixes() { + assert!(super::is_secret_key("api_token")); + assert!(super::is_secret_key("private_key")); + } + + #[test] + fn is_secret_key_nested_password_path() { + assert!(super::is_secret_key("pools.main.users.alice.password")); + assert!(super::is_secret_key("users.app.api_token")); + } + + #[test] + fn is_secret_key_does_not_match_unrelated_keys() { + assert!(!super::is_secret_key("host")); + assert!(!super::is_secret_key("port")); + assert!(!super::is_secret_key("connect_timeout")); + assert!(!super::is_secret_key("pool_mode")); + assert!(!super::is_secret_key("max_connections")); + } + + #[test] + fn is_secret_key_does_not_match_partial_substring() { + // Substring "password" elsewhere in the key should not trigger masking. + // Only exact equals or exact suffix counts. + assert!(!super::is_secret_key("password_check_attempts")); + assert!(!super::is_secret_key("not_a_secret_check")); + } + + /// Coverage check: the previous implementation only exposed + /// host/port/connect_timeout/idle_timeout/shutdown_timeout plus + /// pool users/mode. The flattened serde view now surfaces every + /// field in `Config` — verify a representative sample so a future + /// refactor that quietly trims keys gets caught. + #[test] + fn collect_config_exposes_operationally_relevant_fields() { + let dto = super::collect_config(); + let keys: std::collections::HashSet<&str> = + dto.config.iter().map(|e| e.key.as_str()).collect(); + // Spot-check four orthogonal areas DBA P3#7 called out: + // TLS server-side, prepared cache size, web listener, shutdown + // timeout. + assert!(keys.contains("general.host"), "{keys:?}"); + assert!(keys.contains("general.shutdown_timeout"), "{keys:?}"); + assert!(keys.contains("general.server_tls_mode"), "{keys:?}"); + assert!(keys.contains("web.enabled"), "{keys:?}"); + } + + #[test] + fn collect_config_drops_internal_keys() { + let dto = super::collect_config(); + for entry in &dto.config { + assert!( + !super::is_internal_key(&entry.key), + "internal key leaked: {entry:?}" + ); + } + } +} diff --git a/src/web/routes/collect/connections.rs b/src/web/routes/collect/connections.rs new file mode 100644 index 000000000..aff53c181 --- /dev/null +++ b/src/web/routes/collect/connections.rs @@ -0,0 +1,63 @@ +use crate::stats::{ + CANCEL_CONNECTION_COUNTER, PLAIN_CONNECTION_COUNTER, TLS_CONNECTION_COUNTER, + TOTAL_CONNECTION_COUNTER, +}; +use crate::web::routes::dto::ConnectionsDto; + +use super::{cnt, now_unix_ms}; + +pub(crate) fn collect_connections() -> ConnectionsDto { + connections_from_raw( + cnt(&TOTAL_CONNECTION_COUNTER), + cnt(&TLS_CONNECTION_COUNTER), + cnt(&PLAIN_CONNECTION_COUNTER), + cnt(&CANCEL_CONNECTION_COUNTER), + ) +} + +/// Builds a `ConnectionsDto` from raw counter values. Pure function — exists +/// so the `errors = total - tls - plain - cancel` derivation is exercised by +/// unit tests without touching the global atomics. +fn connections_from_raw(total: u64, tls: u64, plain: u64, cancel: u64) -> ConnectionsDto { + ConnectionsDto { + ts: now_unix_ms(), + total, + tls, + plain, + cancel, + // `errors` mirrors `SHOW CONNECTIONS`: it is whatever is left after + // subtracting the categorised counters from the total. May be zero or + // positive in normal operation. + errors: total + .saturating_sub(tls) + .saturating_sub(plain) + .saturating_sub(cancel), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn connections_errors_derive_from_total_minus_categorised() { + let dto = super::connections_from_raw(100, 60, 30, 5); + assert_eq!(dto.total, 100); + assert_eq!(dto.tls, 60); + assert_eq!(dto.plain, 30); + assert_eq!(dto.cancel, 5); + assert_eq!(dto.errors, 5); + } + + #[test] + fn connections_errors_zero_when_categories_cover_total() { + let dto = super::connections_from_raw(50, 30, 15, 5); + assert_eq!(dto.errors, 0); + } + + #[test] + fn connections_errors_saturate_when_categories_exceed_total() { + // Race: categorised counters momentarily ahead of total. + // Without saturating_sub this would underflow into u64::MAX. + let dto = super::connections_from_raw(10, 8, 5, 0); + assert_eq!(dto.errors, 0); + } +} diff --git a/src/web/routes/collect/databases.rs b/src/web/routes/collect/databases.rs new file mode 100644 index 000000000..dadbc40c9 --- /dev/null +++ b/src/web/routes/collect/databases.rs @@ -0,0 +1,37 @@ +use crate::pool::get_all_pools; +use crate::web::routes::dto::{DatabaseDto, DatabasesDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_databases() -> DatabasesDto { + let pools_map = get_all_pools(); + let mut databases: Vec = pools_map + .iter() + .map(|(_identifier, pool)| { + let address = pool.address(); + let settings = &pool.settings; + DatabaseDto { + name: address.name(), + host: address.host.clone(), + port: address.port, + database: address.database.clone(), + force_user: settings.user.username.clone(), + pool_size: settings.user.pool_size, + min_pool_size: settings.user.min_pool_size.unwrap_or(0), + // See DatabaseDto::reserve_pool — mirrors SHOW DATABASES quirk. + reserve_pool: 0, + pool_mode: settings.pool_mode.to_string(), + max_connections: settings.user.pool_size, + current_connections: pool.pool_state().size as u32, + } + }) + .collect(); + + // Deterministic order using the pool name composite key. + databases.sort_by(|a, b| a.name.cmp(&b.name)); + + DatabasesDto { + ts: now_unix_ms(), + databases, + } +} diff --git a/src/web/routes/collect/events.rs b/src/web/routes/collect/events.rs new file mode 100644 index 000000000..33f3930fb --- /dev/null +++ b/src/web/routes/collect/events.rs @@ -0,0 +1,27 @@ +use crate::admin::events::get_events_since; +use crate::web::routes::dto::{EventEntryDto, EventsDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_events(since: u64, max: u64) -> EventsDto { + // Cap max at 1000 — protects against accidental ?max=10000 over the wire. + const HARD_CAP: usize = 1000; + let max_n = (max.min(HARD_CAP as u64) as usize).max(1); + let (entries, next_seq) = get_events_since(since, max_n); + + let events: Vec = entries + .into_iter() + .map(|e| EventEntryDto { + seq: e.seq, + ts_ms: e.ts_ms, + target: e.target.to_string(), + message: e.message, + }) + .collect(); + + EventsDto { + ts: now_unix_ms(), + next_seq, + events, + } +} diff --git a/src/web/routes/collect/interner.rs b/src/web/routes/collect/interner.rs new file mode 100644 index 000000000..d24749b50 --- /dev/null +++ b/src/web/routes/collect/interner.rs @@ -0,0 +1,70 @@ +use crate::server::{anon_snapshot, named_snapshot, now_monotonic_ms}; +use crate::web::routes::dto::{InternerDto, InternerKindDto, InternerTopDto, InternerTopRowDto}; + +use super::{clamp_top_n, now_unix_ms}; + +pub(crate) fn collect_interner() -> InternerDto { + let named = named_snapshot(); + let anon = anon_snapshot(); + let named_bytes: u64 = named.iter().map(|(_, e)| e.text().len() as u64).sum(); + let anon_bytes: u64 = anon.iter().map(|(_, e)| e.text().len() as u64).sum(); + + InternerDto { + ts: now_unix_ms(), + named: InternerKindDto { + entries: named.len() as u64, + bytes: named_bytes, + }, + anonymous: InternerKindDto { + entries: anon.len() as u64, + bytes: anon_bytes, + }, + } +} + +pub(crate) fn collect_interner_top(n: u64) -> InternerTopDto { + let n = clamp_top_n(n); + let now = now_monotonic_ms(); + + enum Handle { + Named(std::sync::Arc), + Anon(std::sync::Arc), + } + + let mut combined: Vec<(u64, &'static str, usize, i64, Handle)> = Vec::new(); + for (hash, entry) in named_snapshot() { + let bytes = entry.text().len(); + combined.push((hash, "named", bytes, -1, Handle::Named(entry))); + } + for (hash, entry) in anon_snapshot() { + let idle = entry.idle_ms(now) as i64; + let bytes = entry.text().len(); + combined.push((hash, "anonymous", bytes, idle, Handle::Anon(entry))); + } + combined.sort_by_key(|r| std::cmp::Reverse(r.2)); + + let entries = combined + .into_iter() + .take(n as usize) + .map(|(hash, kind, bytes, idle_ms, handle)| { + let text = match handle { + Handle::Named(e) => e.text().clone(), + Handle::Anon(e) => e.text().clone(), + }; + let preview: String = text.chars().take(120).collect(); + InternerTopRowDto { + hash: format!("{:#x}", hash), + kind: kind.to_string(), + bytes: bytes as u64, + idle_ms, + preview, + } + }) + .collect(); + + InternerTopDto { + ts: now_unix_ms(), + n, + entries, + } +} diff --git a/src/web/routes/collect/log_level.rs b/src/web/routes/collect/log_level.rs new file mode 100644 index 000000000..0bdc7c941 --- /dev/null +++ b/src/web/routes/collect/log_level.rs @@ -0,0 +1,11 @@ +use crate::app::log_level; +use crate::web::routes::dto::LogLevelDto; + +use super::now_unix_ms; + +pub(crate) fn collect_log_level() -> LogLevelDto { + LogLevelDto { + ts: now_unix_ms(), + log_level: log_level::get_log_level(), + } +} diff --git a/src/web/routes/collect/mod.rs b/src/web/routes/collect/mod.rs new file mode 100644 index 000000000..7e357462a --- /dev/null +++ b/src/web/routes/collect/mod.rs @@ -0,0 +1,142 @@ +//! Pure collection functions for the REST API. +//! +//! Each function reads from project-wide global state (POOLS, +//! get_client_stats(), get_server_stats(), connection counters) and assembles +//! a serializable DTO. Locking is limited to brief Mutex acquisitions for +//! fields that lack a lock-free getter (server application_name). + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +mod apps; +mod auth_query; +mod clients; +mod config; +mod connections; +mod databases; +mod events; +mod interner; +mod log_level; +mod overview; +mod pool_coordinator; +mod pool_scaling; +mod pools; +mod prepared; +mod process; +mod servers; +mod snapshot; +#[cfg(target_os = "linux")] +mod sockets; +mod stats; +mod top; +mod users; +mod version; + +pub(crate) use self::apps::collect_apps; +pub(crate) use self::auth_query::collect_auth_query; +pub(crate) use self::clients::collect_clients; +pub(crate) use self::config::collect_config; +pub(crate) use self::connections::collect_connections; +pub(crate) use self::databases::collect_databases; +pub(crate) use self::events::collect_events; +pub(crate) use self::interner::{collect_interner, collect_interner_top}; +pub(crate) use self::log_level::collect_log_level; +pub(crate) use self::overview::collect_overview; +pub(crate) use self::pool_coordinator::collect_pool_coordinator; +pub(crate) use self::pool_scaling::collect_pool_scaling; +pub(crate) use self::pools::collect_pools; +pub(crate) use self::prepared::{collect_prepared, collect_prepared_text}; +pub(crate) use self::process::{collect_memory_breakdown, collect_process}; +pub(crate) use self::servers::collect_servers; +pub(crate) use self::snapshot::snapshot; +#[cfg(target_os = "linux")] +pub(crate) use self::sockets::collect_sockets; +pub(crate) use self::stats::collect_stats; +pub(crate) use self::top::{collect_top_clients, collect_top_prepared, collect_top_queries}; +pub(crate) use self::users::collect_users; +pub(crate) use self::version::collect_version; + +pub(super) fn cnt(counter: &AtomicUsize) -> u64 { + counter.load(Ordering::Relaxed) as u64 +} + +pub fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +// MAX_LIMIT capped at 1000 rows because at typical pooler scale (few thousand +// clients) this is enough for first-page UX; increase if operator feedback +// demands it. +pub(super) const MAX_LIMIT: u64 = 1000; + +/// Clamps the user-supplied `?n=` parameter to a sensible range. +/// +/// `0` and missing → default 20 (matches SHOW INTERNER TOP convention). +/// Values above 200 are capped — the page would be unusable beyond that +/// and a 100k-entry interner shouldn't materialise an unbounded preview list. +pub(crate) fn clamp_top_n(requested: u64) -> u64 { + const DEFAULT: u64 = 20; + const MAX: u64 = 200; + match requested { + 0 => DEFAULT, + n if n > MAX => MAX, + n => n, + } +} + +/// Clamps `?n=` for the Top-N client/apps endpoints. Same shape as +/// `clamp_top_n` for interner top, kept as a separate function so changing +/// the interner cap doesn't affect these page-sized lists. +pub(crate) fn clamp_top_clients_n(requested: u64) -> u64 { + const DEFAULT: u64 = 20; + const MAX: u64 = 200; + match requested { + 0 => DEFAULT, + n if n > MAX => MAX, + n => n, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clamp_top_n_zero_returns_default() { + assert_eq!(clamp_top_n(0), 20); + } + + #[test] + fn clamp_top_n_keeps_in_range() { + assert_eq!(clamp_top_n(1), 1); + assert_eq!(clamp_top_n(50), 50); + assert_eq!(clamp_top_n(200), 200); + } + + #[test] + fn clamp_top_n_caps_above_max() { + assert_eq!(clamp_top_n(201), 200); + assert_eq!(clamp_top_n(u64::MAX), 200); + } + + #[test] + fn clamp_top_clients_n_zero_returns_default() { + assert_eq!(clamp_top_clients_n(0), 20); + } + + #[test] + fn clamp_top_clients_n_keeps_in_range() { + assert_eq!(clamp_top_clients_n(1), 1); + assert_eq!(clamp_top_clients_n(50), 50); + assert_eq!(clamp_top_clients_n(200), 200); + } + + #[test] + fn clamp_top_clients_n_caps_above_max() { + assert_eq!(clamp_top_clients_n(201), 200); + assert_eq!(clamp_top_clients_n(u64::MAX), 200); + } +} diff --git a/src/web/routes/collect/overview.rs b/src/web/routes/collect/overview.rs new file mode 100644 index 000000000..56d71059c --- /dev/null +++ b/src/web/routes/collect/overview.rs @@ -0,0 +1,100 @@ +use std::sync::atomic::Ordering; + +use crate::app::server::{ + CLIENTS_IN_TRANSACTIONS, CURRENT_CLIENT_COUNT, MIGRATION_IN_PROGRESS, SHUTDOWN_IN_PROGRESS, + STARTED_AT, +}; +use crate::stats::{ + CANCEL_CONNECTION_COUNTER, PLAIN_CONNECTION_COUNTER, TLS_CONNECTION_COUNTER, + TOTAL_CONNECTION_COUNTER, +}; +use crate::web::metrics::system::get_process_memory_usage; +use crate::web::routes::dto::OverviewDto; + +use super::{cnt, now_unix_ms, snapshot}; + +pub(crate) fn collect_overview() -> OverviewDto { + let snap = snapshot(); + let client_states = &snap.client_states; + let server_states = &snap.server_states; + let pool_lookup = &snap.pool_lookup; + + let mut active_clients = 0u64; + let mut idle_clients = 0u64; + let mut waiting_clients = 0u64; + for stats in client_states.values() { + match stats.state_str() { + "active" => active_clients += 1, + "idle" => idle_clients += 1, + "waiting" => waiting_clients += 1, + _ => {} + } + } + + let mut active_servers = 0u64; + let mut idle_servers = 0u64; + for stats in server_states.values() { + match stats.state_str() { + "active" => active_servers += 1, + "idle" => idle_servers += 1, + _ => {} + } + } + + let connections_total = cnt(&TOTAL_CONNECTION_COUNTER); + let connections_tls_total = cnt(&TLS_CONNECTION_COUNTER); + let connections_plain_total = cnt(&PLAIN_CONNECTION_COUNTER); + let connections_cancel_total = cnt(&CANCEL_CONNECTION_COUNTER); + + let mut query_count_total = 0u64; + let mut transaction_count_total = 0u64; + let mut prepared_hits_total = 0u64; + let mut prepared_misses_total = 0u64; + let mut pools_paused = 0u64; + for stats in pool_lookup.values() { + query_count_total += stats.total_query_count; + transaction_count_total += stats.total_xact_count; + if stats.paused { + pools_paused += 1; + } + } + for stats in server_states.values() { + prepared_hits_total += stats.prepared_hit_count.load(Ordering::Relaxed); + prepared_misses_total += stats.prepared_miss_count.load(Ordering::Relaxed); + } + + OverviewDto { + ts: now_unix_ms(), + + active_clients, + idle_clients, + waiting_clients, + + active_servers, + idle_servers, + + connections_total, + connections_tls_total, + connections_plain_total, + connections_cancel_total, + + query_count_total, + transaction_count_total, + // Sum of per-pool error counters. Already populated by PoolStats.errors. + errors_count_total: pool_lookup.values().map(|s| s.errors).sum(), + + prepared_hits_total, + prepared_misses_total, + + pools_total: pool_lookup.len() as u64, + pools_paused, + + rss_bytes: get_process_memory_usage(), + uptime_seconds: STARTED_AT.elapsed().map(|d| d.as_secs()).unwrap_or(0), + pid: std::process::id(), + current_clients: CURRENT_CLIENT_COUNT.load(Ordering::Relaxed), + clients_in_transactions: CLIENTS_IN_TRANSACTIONS.load(Ordering::Relaxed), + shutdown_in_progress: SHUTDOWN_IN_PROGRESS.load(Ordering::Relaxed), + migration_in_progress: MIGRATION_IN_PROGRESS.load(Ordering::Relaxed), + } +} diff --git a/src/web/routes/collect/pool_coordinator.rs b/src/web/routes/collect/pool_coordinator.rs new file mode 100644 index 000000000..e8413ca15 --- /dev/null +++ b/src/web/routes/collect/pool_coordinator.rs @@ -0,0 +1,32 @@ +use crate::pool::COORDINATORS; +use crate::web::routes::dto::{PoolCoordinatorDto, PoolCoordinatorRowDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_pool_coordinator() -> PoolCoordinatorDto { + let coordinators = COORDINATORS.load(); + let mut databases: Vec = coordinators + .iter() + .map(|(db, coordinator)| { + let stats = coordinator.stats(); + let config = coordinator.config(); + PoolCoordinatorRowDto { + database: db.clone(), + max_db_conn: config.max_db_connections as u64, + current: stats.total_connections as u64, + reserve_size: config.reserve_pool_size as u64, + reserve_used: stats.reserve_in_use as u64, + evictions: stats.evictions_total, + reserve_acq: stats.reserve_acquisitions_total, + exhaustions: stats.exhaustions_total, + } + }) + .collect(); + + databases.sort_by(|a, b| a.database.cmp(&b.database)); + + PoolCoordinatorDto { + ts: now_unix_ms(), + databases, + } +} diff --git a/src/web/routes/collect/pool_scaling.rs b/src/web/routes/collect/pool_scaling.rs new file mode 100644 index 000000000..8c55a2ea9 --- /dev/null +++ b/src/web/routes/collect/pool_scaling.rs @@ -0,0 +1,33 @@ +use crate::pool::get_all_pools; +use crate::web::routes::dto::{PoolScalingDto, PoolScalingRowDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_pool_scaling() -> PoolScalingDto { + let mut entries: Vec<_> = get_all_pools() + .iter() + .map(|(id, pool)| (id.clone(), pool.database.scaling_stats())) + .collect(); + entries.sort_by(|a, b| (&a.0.db, &a.0.user).cmp(&(&b.0.db, &b.0.user))); + + let pools = entries + .into_iter() + .map(|(id, snapshot)| PoolScalingRowDto { + user: id.user.clone(), + database: id.db.clone(), + inflight: snapshot.inflight_creates as u64, + creates: snapshot.creates_started, + gate_waits: snapshot.burst_gate_waits, + gate_budget_ex: snapshot.burst_gate_budget_exhausted, + antic_notify: snapshot.anticipation_wakes_notify, + antic_timeout: snapshot.anticipation_wakes_timeout, + create_fallback: snapshot.create_fallback, + replenish_def: snapshot.replenish_deferred, + }) + .collect(); + + PoolScalingDto { + ts: now_unix_ms(), + pools, + } +} diff --git a/src/web/routes/collect/pools.rs b/src/web/routes/collect/pools.rs new file mode 100644 index 000000000..2c8d81009 --- /dev/null +++ b/src/web/routes/collect/pools.rs @@ -0,0 +1,84 @@ +use crate::pool::get_all_pools; +use crate::web::metrics::{ + FALLBACK_ACTIVE, SHOW_SERVER_TLS_CONNECTIONS, SHOW_SERVER_TLS_HANDSHAKE_ERRORS, +}; +use crate::web::routes::dto::{PoolDto, PoolsDto}; + +use super::{now_unix_ms, snapshot}; + +pub(crate) fn collect_pools() -> PoolsDto { + let snap = snapshot(); + let pool_lookup = &snap.pool_lookup; + let pools_map = get_all_pools(); + + let mut pools = Vec::with_capacity(pool_lookup.len()); + for (identifier, stats) in pool_lookup.iter() { + let Some(pool) = pools_map.get(identifier) else { + continue; + }; + let address = pool.address(); + let errors_by_sqlstate = address.stats.errors_by_sqlstate_snapshot(); + // The TLS / fallback metrics are written per database (the + // Address::pool_name field, which mirrors the database segment of + // `user@db`), so we read them with the same label. Every user@db + // pool of the same database therefore reports the same value — + // accurate for fallback (Patroni state is per database) and for + // backend TLS counters that share one connection set per backend. + let db_label = identifier.db.as_str(); + let fallback_active = FALLBACK_ACTIVE.with_label_values(&[db_label]).get() > 0.5; + let tls_handshake_errors_total = SHOW_SERVER_TLS_HANDSHAKE_ERRORS + .with_label_values(&[db_label]) + .get(); + let tls_backend_connections = SHOW_SERVER_TLS_CONNECTIONS + .with_label_values(&[db_label]) + .get() as u64; + let dto = PoolDto { + id: format!("{}@{}", identifier.user, identifier.db), + user: identifier.user.clone(), + database: identifier.db.clone(), + host: address.host.clone(), + port: address.port, + pool_mode: stats.mode.to_string(), + max_connections: stats.pool_size, + // min_pool_size lives on the per-user config; PoolSettings wraps it via settings.user. + min_connections: pool.settings.user.min_pool_size.unwrap_or(0), + connections: stats.sv_active + stats.sv_idle + stats.sv_used + stats.sv_login, + idle: stats.sv_idle, + active: stats.sv_active, + waiting: stats.cl_waiting, + max_active_age_ms: stats.oldest_active_age_ms, + // The HDR histograms underneath store microseconds; every DTO + // field on `_ms` is divided by 1_000.0 in floating-point so + // sub-millisecond percentiles do not collapse to zero — a + // pool whose true p95 is 420 µs reports `0.42` rather than + // `0` and matches the log line ("query_ms p95 = 0.42"). + query_p95_ms: stats.query_percentile.p95 as f64 / 1_000.0, + query_p99_ms: stats.query_percentile.p99 as f64 / 1_000.0, + transactions_p95_ms: stats.xact_percentile.p95 as f64 / 1_000.0, + transactions_p99_ms: stats.xact_percentile.p99 as f64 / 1_000.0, + wait_avg_ms: stats.avg_wait_time as f64 / 1_000.0, + wait_p95_ms: stats.wait_percentile.p95 as f64 / 1_000.0, + queries_total: stats.total_query_count, + transactions_total: stats.total_xact_count, + errors_total: stats.errors, + errors_by_sqlstate, + paused: stats.paused, + // RECONNECT bumps the per-pool epoch; surfacing it lets a DBA + // verify that a `RECONNECT db=...` rotated cached connections + // (e.g. after `ALTER ROLE`, grant change, or TLS rotation). + epoch: pool.database.reconnect_epoch() as u64, + fallback_active, + tls_handshake_errors_total, + tls_backend_connections, + }; + pools.push(dto); + } + + // Stable order for snapshot tests. + pools.sort_by(|a, b| a.id.cmp(&b.id)); + + PoolsDto { + ts: now_unix_ms(), + pools, + } +} diff --git a/src/web/routes/collect/prepared.rs b/src/web/routes/collect/prepared.rs new file mode 100644 index 000000000..cfc9f40cf --- /dev/null +++ b/src/web/routes/collect/prepared.rs @@ -0,0 +1,53 @@ +use crate::pool::get_all_pools; +use crate::web::routes::dto::{PreparedDto, PreparedRowDto, PreparedTextDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_prepared() -> PreparedDto { + let mut prepared: Vec = Vec::new(); + for (identifier, pool) in get_all_pools().iter() { + let Some(cache) = pool.prepared_statement_cache.as_ref() else { + continue; + }; + for (hash, parse, count_used, kind, hits, misses) in cache.get_entries() { + prepared.push(PreparedRowDto { + pool: identifier.to_string(), + hash: hash.to_string(), + name: parse.name.clone(), + count_used, + hits, + misses, + kind: kind.as_str().to_string(), + }); + } + } + + // Stable order: pool first, then hash, for deterministic UI display. + prepared.sort_by(|a, b| { + (a.pool.as_str(), a.hash.as_str()).cmp(&(b.pool.as_str(), b.hash.as_str())) + }); + + PreparedDto { + ts: now_unix_ms(), + prepared, + } +} + +pub(crate) fn collect_prepared_text(hash: u64) -> Option { + for (identifier, pool) in get_all_pools().iter() { + let Some(cache) = pool.prepared_statement_cache.as_ref() else { + continue; + }; + if let Some((parse, kind)) = cache.lookup_by_hash(hash) { + return Some(PreparedTextDto { + ts: now_unix_ms(), + hash: format!("{:#x}", hash), + pool: identifier.to_string(), + name: parse.name.clone(), + query: parse.query().to_string(), + kind: kind.as_str().to_string(), + }); + } + } + None +} diff --git a/src/web/routes/collect/process.rs b/src/web/routes/collect/process.rs new file mode 100644 index 000000000..f2d5ac94d --- /dev/null +++ b/src/web/routes/collect/process.rs @@ -0,0 +1,716 @@ +//! `/api/process` collector. Linux-first: reads `/proc/self/{stat,status,fd, +//! limits,task}` to fill in CPU, memory, and FD counters. macOS / others +//! fall back to the existing `get_process_memory_usage` and zero where no +//! cheap source is available. +//! +//! All values come from one-shot reads (no background sampling task), so +//! CPU is reported as monotonic microsecond counters; the frontend +//! computes the percentage from successive snapshots. +//! +//! `/proc/self/stat` field positions used here follow `proc(5)`. The kernel +//! defines `clock ticks per second` via `_SC_CLK_TCK` (always 100 in +//! practice on Linux); we use that constant rather than calling +//! `sysconf(3)` to keep the file dependency-free. + +use std::sync::atomic::Ordering; +use std::sync::LazyLock; + +use crate::app::server::STARTED_AT; +use crate::web::metrics::system::get_process_memory_usage; +use crate::web::routes::dto::{ + CgroupMemoryDto, JemallocStatsDto, MemoryBreakdownDto, MemoryCategoryDto, ProcessDto, + ProcessThreadDto, +}; + +use super::now_unix_ms; + +#[cfg(target_os = "linux")] +const CLK_TCK_HZ: u64 = 100; + +/// Process-lifetime constants — read them once on first request. The +/// alternative (read on every poll) costs an extra `/proc/self/limits` +/// read + libc::sysconf call + hostname syscall per /api/process tick; +/// at 1 Hz UI polling that is ~3 file reads/sec for values that cannot +/// change without re-exec. +static HOSTNAME: LazyLock = LazyLock::new(read_hostname_uncached); +static CPU_CORES: LazyLock = LazyLock::new(|| num_cpus::get() as u32); +static PID: LazyLock = LazyLock::new(std::process::id); +#[cfg(target_os = "linux")] +static FD_LIMIT: LazyLock = LazyLock::new(read_linux_fd_limit_uncached); +static STARTED_AT_MS: LazyLock = LazyLock::new(|| { + STARTED_AT + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +}); + +pub(crate) fn collect_process() -> ProcessDto { + let pid = *PID; + let hostname = HOSTNAME.clone(); + let uptime_seconds = STARTED_AT.elapsed().map(|d| d.as_secs()).unwrap_or(0); + let started_at_ms = *STARTED_AT_MS; + + let cpu_cores = *CPU_CORES; + + #[cfg(target_os = "linux")] + let (cpu_user_us, cpu_system_us, threads, threads_breakdown) = read_linux_cpu(); + #[cfg(target_os = "linux")] + let (vm_size_bytes, fd_open, fd_limit) = + (read_linux_vm_size_bytes(), read_linux_fd_open(), *FD_LIMIT); + + #[cfg(not(target_os = "linux"))] + let (cpu_user_us, cpu_system_us, threads, threads_breakdown): ( + u64, + u64, + u64, + Vec, + ) = (0, 0, 0, Vec::new()); + #[cfg(not(target_os = "linux"))] + let (vm_size_bytes, fd_open, fd_limit): (u64, u64, u64) = (0, 0, 0); + + ProcessDto { + ts: now_unix_ms(), + pid, + hostname, + uptime_seconds, + started_at_ms, + rss_bytes: get_process_memory_usage(), + vm_size_bytes, + threads, + fd_open, + fd_limit, + cpu_user_us, + cpu_system_us, + cpu_cores, + threads_breakdown, + } + .also(|d| { + // Avoid an unused-field warning when the entire `_us` path is zero + // on non-linux: the compiler sees the assignment. + let _ = d; + }) +} + +#[allow(dead_code)] +fn _atomic_dummy() { + // Keep the `Ordering` import alive even when only one cfg branch reads + // it (avoids a target-conditional unused-import warning). + let _ = Ordering::Relaxed; +} + +trait Also: Sized { + fn also(self, f: impl FnOnce(&Self)) -> Self; +} +impl Also for T { + fn also(self, f: impl FnOnce(&Self)) -> Self { + f(&self); + self + } +} + +/// Build `/api/process/memory`. Reads `/proc/self/status`, the cgroup files, +/// jemalloc stats, and the in-process interner totals; assembles a +/// breakdown the operator can read top-down to find a leak. PSS-based +/// figures (`/proc/self/smaps_rollup`) are deliberately not collected +/// here — the kernel walks every VMA, ~100 µs+ per request, and the +/// 5 s panel cadence does not need that resolution. +pub(crate) fn collect_memory_breakdown() -> MemoryBreakdownDto { + let rss_bytes = get_process_memory_usage(); + + #[cfg(target_os = "linux")] + let status = read_linux_status_block(); + #[cfg(not(target_os = "linux"))] + let status = StatusBlock::default(); + + #[cfg(target_os = "linux")] + let cgroup = read_cgroup_memory(); + #[cfg(not(target_os = "linux"))] + let cgroup = None; + + let jemalloc = read_jemalloc_stats(); + + // Interner totals come from the global cache. The collector at + // `crate::stats::interner` provides a snapshot of named/anonymous + // bytes; we duplicate the path the /api/interner endpoint uses so + // the two views always agree. + let (interner_named_bytes, interner_anonymous_bytes) = { + let int = crate::web::routes::collect::collect_interner(); + (int.named.bytes, int.anonymous.bytes) + }; + + let categories = build_categories( + rss_bytes, + status.rss_anon_bytes, + status.rss_file_bytes, + status.vm_stack_bytes, + status.vm_pte_bytes, + status.vm_swap_bytes, + interner_named_bytes, + interner_anonymous_bytes, + jemalloc.as_ref(), + ); + + MemoryBreakdownDto { + ts: now_unix_ms(), + rss_bytes, + vm_peak_bytes: status.vm_peak_bytes, + vm_hwm_bytes: status.vm_hwm_bytes, + vm_data_bytes: status.vm_data_bytes, + vm_stack_bytes: status.vm_stack_bytes, + vm_exe_bytes: status.vm_exe_bytes, + vm_lib_bytes: status.vm_lib_bytes, + vm_pte_bytes: status.vm_pte_bytes, + vm_swap_bytes: status.vm_swap_bytes, + rss_anon_bytes: status.rss_anon_bytes, + rss_file_bytes: status.rss_file_bytes, + rss_shmem_bytes: status.rss_shmem_bytes, + jemalloc, + cgroup, + interner_named_bytes, + interner_anonymous_bytes, + categories, + } +} + +#[derive(Default)] +struct StatusBlock { + vm_peak_bytes: Option, + vm_hwm_bytes: Option, + vm_data_bytes: Option, + vm_stack_bytes: Option, + vm_exe_bytes: Option, + vm_lib_bytes: Option, + vm_pte_bytes: Option, + vm_swap_bytes: Option, + rss_anon_bytes: Option, + rss_file_bytes: Option, + rss_shmem_bytes: Option, +} + +#[cfg(target_os = "linux")] +fn read_linux_status_block() -> StatusBlock { + let mut sb = StatusBlock::default(); + let Ok(raw) = std::fs::read_to_string("/proc/self/status") else { + return sb; + }; + let kib_after = |prefix: &str, line: &str| -> Option { + line.strip_prefix(prefix) + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|n| n.parse::().ok()) + .map(|kb| kb * 1024) + }; + for line in raw.lines() { + if let Some(b) = kib_after("VmPeak:", line) { + sb.vm_peak_bytes = Some(b); + } else if let Some(b) = kib_after("VmHWM:", line) { + sb.vm_hwm_bytes = Some(b); + } else if let Some(b) = kib_after("VmData:", line) { + sb.vm_data_bytes = Some(b); + } else if let Some(b) = kib_after("VmStk:", line) { + sb.vm_stack_bytes = Some(b); + } else if let Some(b) = kib_after("VmExe:", line) { + sb.vm_exe_bytes = Some(b); + } else if let Some(b) = kib_after("VmLib:", line) { + sb.vm_lib_bytes = Some(b); + } else if let Some(b) = kib_after("VmPTE:", line) { + sb.vm_pte_bytes = Some(b); + } else if let Some(b) = kib_after("VmSwap:", line) { + sb.vm_swap_bytes = Some(b); + } else if let Some(b) = kib_after("RssAnon:", line) { + sb.rss_anon_bytes = Some(b); + } else if let Some(b) = kib_after("RssFile:", line) { + sb.rss_file_bytes = Some(b); + } else if let Some(b) = kib_after("RssShmem:", line) { + sb.rss_shmem_bytes = Some(b); + } + } + sb +} + +/// Detect cgroup v2 first, fall back to v1. Container deployments mount +/// the namespace at `/sys/fs/cgroup/...` directly so the path lookup is +/// usually trivial; the host case (operator running pg_doorman bare) +/// reads `/proc/self/cgroup` to find the right subdirectory. +#[cfg(target_os = "linux")] +fn read_cgroup_memory() -> Option { + let proc_cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?; + let first = proc_cgroup.lines().next()?; + if first.starts_with("0::") { + // cgroup v2 unified. + let suffix = first.trim_start_matches("0::").trim_start_matches('/'); + let base = if suffix.is_empty() { + "/sys/fs/cgroup".to_string() + } else { + format!("/sys/fs/cgroup/{suffix}") + }; + let current = read_first_u64(&format!("{base}/memory.current"))?; + let max = read_first_u64_or_max(&format!("{base}/memory.max")); + let high = read_first_u64_or_max(&format!("{base}/memory.high")); + let peak = read_first_u64(&format!("{base}/memory.peak")); + return Some(CgroupMemoryDto { + version: 2, + current_bytes: current, + peak_bytes: peak, + max_bytes: max, + high_bytes: high, + }); + } + // cgroup v1 — find the `memory` controller line. + for line in proc_cgroup.lines() { + let parts: Vec<&str> = line.splitn(3, ':').collect(); + if parts.len() != 3 { + continue; + } + let controllers = parts[1]; + if !controllers.split(',').any(|c| c == "memory") { + continue; + } + let suffix = parts[2].trim_start_matches('/'); + let base = if suffix.is_empty() { + "/sys/fs/cgroup/memory".to_string() + } else { + format!("/sys/fs/cgroup/memory/{suffix}") + }; + let current = read_first_u64(&format!("{base}/memory.usage_in_bytes"))?; + let max = + read_first_u64(&format!("{base}/memory.limit_in_bytes")).filter(|&n| n < u64::MAX / 2); + return Some(CgroupMemoryDto { + version: 1, + current_bytes: current, + peak_bytes: read_first_u64(&format!("{base}/memory.max_usage_in_bytes")), + max_bytes: max, + high_bytes: None, + }); + } + None +} + +#[cfg(target_os = "linux")] +fn read_first_u64(path: &str) -> Option { + std::fs::read_to_string(path) + .ok()? + .trim() + .parse::() + .ok() +} + +#[cfg(target_os = "linux")] +fn read_first_u64_or_max(path: &str) -> Option { + let s = std::fs::read_to_string(path).ok()?; + let s = s.trim(); + if s == "max" { + return None; + } + s.parse::().ok() +} + +/// jemalloc accounting. The crate is linked unconditionally (see Cargo.toml), +/// so on every supported build target this returns `Some`. The `Option` +/// guards against `epoch::advance` failing on a future jemalloc release that +/// changes the mib layout — preserves a graceful fallback to "no jemalloc +/// data" without panicking. +fn read_jemalloc_stats() -> Option { + use tikv_jemalloc_ctl::{epoch, stats}; + // Advance the epoch so per-arena counters merge into the read paths + // below. Without this `stats.allocated` lags by up to 10 seconds + // under low traffic. + epoch::advance().ok()?; + let allocated = stats::allocated::read().ok()? as u64; + let active = stats::active::read().ok()? as u64; + let resident = stats::resident::read().ok()? as u64; + let mapped = stats::mapped::read().ok()? as u64; + let retained = stats::retained::read().ok()? as u64; + let metadata = stats::metadata::read().ok()? as u64; + Some(JemallocStatsDto { + allocated_bytes: allocated, + active_bytes: active, + resident_bytes: resident, + mapped_bytes: mapped, + retained_bytes: retained, + metadata_bytes: metadata, + fragmentation_bytes: resident.saturating_sub(allocated), + }) +} + +#[allow(clippy::too_many_arguments)] +fn build_categories( + rss_bytes: u64, + rss_anon_bytes: Option, + rss_file_bytes: Option, + vm_stack_bytes: Option, + vm_pte_bytes: Option, + vm_swap_bytes: Option, + interner_named_bytes: u64, + interner_anonymous_bytes: u64, + jemalloc: Option<&JemallocStatsDto>, +) -> Vec { + let app_caches = interner_named_bytes + interner_anonymous_bytes; + let mut cats: Vec = Vec::new(); + + cats.push(MemoryCategoryDto { + key: "app_caches", + label: "Internal caches", + bytes: app_caches, + explain: "SQL interner (named + anonymous) — pg_doorman-side state we own.", + }); + + if let Some(j) = jemalloc { + // Live = jemalloc.allocated minus the chunk we already attribute + // to internal caches. Floor at zero — under high churn `allocated` + // can briefly read below the cache estimate. + let live = j.allocated_bytes.saturating_sub(app_caches); + cats.push(MemoryCategoryDto { + key: "jemalloc_live", + label: "Live allocations", + bytes: live, + explain: "jemalloc.allocated minus tracked caches — Rust heap not in our maps yet.", + }); + cats.push(MemoryCategoryDto { + key: "jemalloc_fragmentation", + label: "Allocator fragmentation", + bytes: j.fragmentation_bytes, + explain: + "Pages jemalloc holds but is not currently using; reclaimable via arena.purge.", + }); + } + + if let Some(rf) = rss_file_bytes { + cats.push(MemoryCategoryDto { + key: "code_and_libs", + label: "Code + shared libs", + bytes: rf, + explain: "Resident pages backing the binary and shared objects. Static; growth = dlopen leak.", + }); + } + + let stack_pte = vm_stack_bytes.unwrap_or(0) + vm_pte_bytes.unwrap_or(0); + if stack_pte > 0 { + cats.push(MemoryCategoryDto { + key: "stacks_and_pagetables", + label: "Stacks + page tables", + bytes: stack_pte, + explain: "Per-thread stacks plus kernel page-table overhead. Grows with thread count.", + }); + } + + if let Some(sw) = vm_swap_bytes { + if sw > 0 { + cats.push(MemoryCategoryDto { + key: "swap", + label: "Swapped out", + bytes: sw, + explain: "Pages swapped to disk. Non-zero on a pooler is a red flag.", + }); + } + } + + // Anything in RSS that we did not attribute. Defends against operators + // expecting the bar to add up to RSS exactly. + let attributed: u64 = cats.iter().map(|c| c.bytes).sum(); + let remainder = rss_bytes.saturating_sub(attributed.min(rss_bytes)); + if remainder > 0 && rss_anon_bytes.is_some() { + cats.push(MemoryCategoryDto { + key: "other", + label: "Other (anonymous)", + bytes: remainder, + explain: "Anonymous pages not yet attributed to a known bucket.", + }); + } + + cats +} + +fn read_hostname_uncached() -> String { + // `gethostname(3)` lives in libc; reading `/proc/sys/kernel/hostname` + // works on Linux without a libc binding. Bounded read keeps the call + // cheap on hosts with overlong names. + #[cfg(target_os = "linux")] + { + if let Ok(s) = std::fs::read_to_string("/proc/sys/kernel/hostname") { + return s.trim().to_string(); + } + } + std::env::var("HOSTNAME").unwrap_or_default() +} + +#[cfg(target_os = "linux")] +fn read_linux_cpu() -> (u64, u64, u64, Vec) { + let (mut user, mut sys, mut threads) = (0u64, 0u64, 0u64); + if let Ok(stat) = std::fs::read_to_string("/proc/self/stat") { + if let Some(parts) = parse_proc_stat(&stat) { + user = ticks_to_us(parts.utime); + sys = ticks_to_us(parts.stime); + threads = parts.num_threads.max(0) as u64; + } + } + + let mut breakdown = Vec::new(); + if let Ok(entries) = std::fs::read_dir("/proc/self/task") { + for entry in entries.flatten() { + let tid_str = entry.file_name(); + let tid: u64 = match tid_str.to_string_lossy().parse() { + Ok(v) => v, + Err(_) => continue, + }; + let stat_path = entry.path().join("stat"); + let Ok(s) = std::fs::read_to_string(&stat_path) else { + continue; + }; + let Some(parts) = parse_proc_stat(&s) else { + continue; + }; + breakdown.push(ProcessThreadDto { + tid, + name: parts.comm, + cpu_user_us: ticks_to_us(parts.utime), + cpu_system_us: ticks_to_us(parts.stime), + }); + } + } + breakdown.sort_by(|a, b| { + let a_total = a.cpu_user_us + a.cpu_system_us; + let b_total = b.cpu_user_us + b.cpu_system_us; + b_total.cmp(&a_total) + }); + + (user, sys, threads, breakdown) +} + +#[cfg(target_os = "linux")] +fn read_linux_vm_size_bytes() -> u64 { + let Ok(status) = std::fs::read_to_string("/proc/self/status") else { + return 0; + }; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("VmSize:") { + // Format: "VmSize: 12345 kB" + let kb: u64 = rest + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + return kb * 1024; + } + } + 0 +} + +#[cfg(target_os = "linux")] +fn read_linux_fd_open() -> u64 { + std::fs::read_dir("/proc/self/fd") + .map(|it| it.filter_map(|e| e.ok()).count() as u64) + .unwrap_or(0) +} + +#[cfg(target_os = "linux")] +fn read_linux_fd_limit_uncached() -> u64 { + let Ok(limits) = std::fs::read_to_string("/proc/self/limits") else { + return 0; + }; + for line in limits.lines() { + if line.starts_with("Max open files") { + // "Max open files 65536 65536 files" + let mut tokens = line.split_whitespace().rev(); + // skip "files" + tokens.next(); + // hard limit + let _ = tokens.next(); + // soft limit + if let Some(soft) = tokens.next() { + if let Ok(n) = soft.parse() { + return n; + } + } + } + } + 0 +} + +/// Selected `/proc//stat` fields. Field index reference: `man proc(5)`. +#[cfg(target_os = "linux")] +struct ProcStat { + comm: String, + utime: u64, + stime: u64, + num_threads: i64, +} + +#[cfg(target_os = "linux")] +fn parse_proc_stat(raw: &str) -> Option { + // The `comm` field can contain whitespace and parentheses, so the standard + // trick is to find the *last* `)` and treat everything after it as the + // remaining whitespace-separated fields. + let lparen = raw.find('(')?; + let rparen = raw.rfind(')')?; + if rparen <= lparen + 1 { + return None; + } + let comm = raw[lparen + 1..rparen].to_string(); + let tail = &raw[rparen + 1..]; + let fields: Vec<&str> = tail.split_whitespace().collect(); + // After the literal `)` and a single space, field index 3 (`state`) is + // tokens[0]; `utime` is field 14 (tokens[11]), `stime` field 15 + // (tokens[12]), `num_threads` field 20 (tokens[17]). + let utime: u64 = fields.get(11).and_then(|s| s.parse().ok()).unwrap_or(0); + let stime: u64 = fields.get(12).and_then(|s| s.parse().ok()).unwrap_or(0); + let num_threads: i64 = fields.get(17).and_then(|s| s.parse().ok()).unwrap_or(0); + Some(ProcStat { + comm, + utime, + stime, + num_threads, + }) +} + +#[cfg(target_os = "linux")] +fn ticks_to_us(ticks: u64) -> u64 { + ticks.saturating_mul(1_000_000) / CLK_TCK_HZ +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn parse_proc_stat_handles_paren_in_comm() { + let raw = "1 (pg_doorman main) S 0 1 1 0 -1 4194304 1 0 0 0 100 50 0 0 20 0 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; + let parsed = parse_proc_stat(raw).expect("parse"); + assert_eq!(parsed.comm, "pg_doorman main"); + assert_eq!(parsed.utime, 100); + assert_eq!(parsed.stime, 50); + assert_eq!(parsed.num_threads, 8); + } + + #[cfg(target_os = "linux")] + #[test] + fn ticks_to_us_at_100hz_yields_10ms_steps() { + assert_eq!(ticks_to_us(0), 0); + assert_eq!(ticks_to_us(1), 10_000); + assert_eq!(ticks_to_us(100), 1_000_000); + } + + #[test] + fn collect_returns_envelope_on_any_platform() { + let dto = collect_process(); + assert!(dto.ts > 0); + // pid is always known. + assert!(dto.pid > 0); + // cpu_cores comes from num_cpus and is at least 1 on every supported + // build target. + assert!(dto.cpu_cores >= 1); + } + + fn jemalloc_with(allocated: u64, fragmentation: u64) -> JemallocStatsDto { + JemallocStatsDto { + allocated_bytes: allocated, + active_bytes: 0, + resident_bytes: allocated + fragmentation, + mapped_bytes: 0, + retained_bytes: 0, + metadata_bytes: 0, + fragmentation_bytes: fragmentation, + } + } + + fn cat<'a>(cats: &'a [MemoryCategoryDto], key: &str) -> Option<&'a MemoryCategoryDto> { + cats.iter().find(|c| c.key == key) + } + + #[test] + fn build_categories_empty_inputs_yields_only_caches_zero() { + let cats = build_categories(0, None, None, None, None, None, 0, 0, None); + // app_caches is unconditional, even at zero — it anchors the bar. + assert_eq!(cats.len(), 1); + assert_eq!(cats[0].key, "app_caches"); + assert_eq!(cats[0].bytes, 0); + } + + #[test] + fn build_categories_jemalloc_live_floors_at_zero_when_caches_exceed_allocated() { + // Cache estimate larger than jemalloc.allocated can briefly happen + // under churn (counters update at different epochs). Live must not + // underflow. + let j = jemalloc_with(100, 50); + let cats = build_categories(0, None, None, None, None, None, 1_000, 0, Some(&j)); + assert_eq!(cat(&cats, "jemalloc_live").unwrap().bytes, 0); + // Fragmentation is still surfaced as-is. + assert_eq!(cat(&cats, "jemalloc_fragmentation").unwrap().bytes, 50); + } + + #[test] + fn build_categories_attributed_over_rss_does_not_underflow_or_emit_other() { + // RSS smaller than the sum of attributed buckets — `other` must be + // suppressed and no panic. + let j = jemalloc_with(10_000, 2_000); + let cats = build_categories( + 1_000, // rss < attributed + Some(500), // rss_anon set, but remainder will be 0 + Some(5_000), // code_and_libs + Some(1_024), // stacks + Some(1_024), // pte + None, // no swap + 500, // named cache + 500, // anon cache + Some(&j), + ); + assert!(cat(&cats, "other").is_none()); + } + + #[test] + fn build_categories_swap_zero_is_hidden() { + let cats = build_categories(0, None, None, None, None, Some(0), 0, 0, None); + assert!(cat(&cats, "swap").is_none()); + } + + #[test] + fn build_categories_swap_none_is_hidden() { + let cats = build_categories(0, None, None, None, None, None, 0, 0, None); + assert!(cat(&cats, "swap").is_none()); + } + + #[test] + fn build_categories_stacks_pte_both_none_omits_row() { + let cats = build_categories(0, None, None, None, None, None, 0, 0, None); + assert!(cat(&cats, "stacks_and_pagetables").is_none()); + } + + #[test] + fn build_categories_other_only_appears_when_rss_anon_is_known() { + // Remainder is positive (RSS=1 GiB, nothing attributed) but rss_anon + // is None → cannot honestly attribute the slack → suppress `other`. + let cats_no_anon = build_categories( + 1_073_741_824, // 1 GiB + None, + None, + None, + None, + None, + 0, + 0, + None, + ); + assert!(cat(&cats_no_anon, "other").is_none()); + + // Same RSS, rss_anon known → `other` shows the slack. + let cats_with_anon = build_categories( + 1_073_741_824, + Some(500_000_000), + None, + None, + None, + None, + 0, + 0, + None, + ); + let other = cat(&cats_with_anon, "other").expect("other expected"); + assert_eq!(other.bytes, 1_073_741_824); + } + + #[test] + fn build_categories_swap_positive_is_surfaced() { + let cats = build_categories(0, None, None, None, None, Some(1_024 * 1_024), 0, 0, None); + assert_eq!(cat(&cats, "swap").unwrap().bytes, 1_024 * 1_024); + } +} diff --git a/src/web/routes/collect/servers.rs b/src/web/routes/collect/servers.rs new file mode 100644 index 000000000..5770695a7 --- /dev/null +++ b/src/web/routes/collect/servers.rs @@ -0,0 +1,266 @@ +use std::sync::atomic::Ordering; + +use crate::web::routes::dto::{ServerDto, ServerFilters, ServerSort, ServersDto, SortOrder}; + +use super::{now_unix_ms, snapshot, MAX_LIMIT}; + +pub(crate) fn collect_servers(filters: &ServerFilters) -> ServersDto { + let snap = snapshot(); + let servers: Vec<_> = snap.server_states.values().cloned().collect(); + collect_servers_from(servers, filters) +} + +/// Pure inner logic for `collect_servers` — operates on a pre-built snapshot +/// so it can be called from unit tests without touching global state. +fn collect_servers_from( + snapshot: Vec>, + filters: &ServerFilters, +) -> ServersDto { + let mut rows: Vec = snapshot + .iter() + .filter(|s| server_matches(s, filters)) + .map(server_to_dto) + .collect(); + + let total = rows.len() as u64; + + rows.sort_by(|a, b| { + let ord = match filters.sort { + ServerSort::AgeSeconds => a.age_seconds.cmp(&b.age_seconds), + ServerSort::QueriesTotal => a.queries_total.cmp(&b.queries_total), + ServerSort::ErrorsTotal => a.errors_total.cmp(&b.errors_total), + ServerSort::ActiveAgeMs => a.active_age_ms.cmp(&b.active_age_ms), + }; + match filters.order { + SortOrder::Asc => ord, + SortOrder::Desc => ord.reverse(), + } + }); + + let limit = filters.limit.clamp(1, MAX_LIMIT); + let offset = filters.offset; + let page: Vec<_> = rows + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + + ServersDto { + ts: now_unix_ms(), + total, + limit, + offset, + servers: page, + } +} + +fn server_matches(s: &crate::stats::ServerStats, f: &ServerFilters) -> bool { + let pool_name = s.pool_name(); + let user = s.username(); + + if let Some(p) = &f.pool { + let id = format!("{}@{}", user, pool_name); + if id != *p { + return false; + } + } + if let Some(db) = &f.database { + if pool_name != db { + return false; + } + } + if let Some(u) = &f.user { + if user != u { + return false; + } + } + true +} + +fn server_to_dto(s: &std::sync::Arc) -> ServerDto { + let age_seconds = s.connect_time().elapsed().as_secs(); + let application_name = s.application_name(); + ServerDto { + server_id: s.server_id(), + process_id: s.process_id(), + database: s.pool_name().to_string(), + user: s.username().to_string(), + application_name, + tls: s.tls(), + state: s.state_str().to_string(), + wait: s.wait_str().to_string(), + age_seconds, + active_age_ms: s.active_age_ms().unwrap_or(0), + transactions_total: s.transaction_count.load(Ordering::Relaxed), + queries_total: s.query_count.load(Ordering::Relaxed), + errors_total: s.error_count.load(Ordering::Relaxed), + bytes_sent: s.bytes_sent.load(Ordering::Relaxed), + bytes_received: s.bytes_received.load(Ordering::Relaxed), + prepared_hits_total: s.prepared_hit_count.load(Ordering::Relaxed), + prepared_misses_total: s.prepared_miss_count.load(Ordering::Relaxed), + prepared_cache_size: s.prepared_cache_size.load(Ordering::Relaxed), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::server::ServerStats; + use crate::utils::clock; + use std::sync::Arc; + + fn make_server(db: &str, user: &str) -> Arc { + let address = crate::config::Address { + pool_name: db.to_string(), + username: user.to_string(), + ..crate::config::Address::default() + }; + Arc::new(ServerStats::new(address, clock::now())) + } + + fn default_server_filters() -> ServerFilters { + ServerFilters { + limit: 100, + offset: 0, + sort: ServerSort::AgeSeconds, + order: SortOrder::Asc, + pool: None, + database: None, + user: None, + } + } + + // --------------------------------------------------------------------------- + // Server filter tests + // --------------------------------------------------------------------------- + + #[test] + fn server_filter_by_pool() { + let servers = vec![make_server("db1", "alice"), make_server("db2", "bob")]; + let mut f = default_server_filters(); + f.pool = Some("alice@db1".to_string()); + let result = collect_servers_from(servers, &f); + assert_eq!(result.total, 1); + assert_eq!(result.servers[0].database, "db1"); + } + + #[test] + fn server_filter_by_database() { + let servers = vec![ + make_server("prod", "alice"), + make_server("staging", "alice"), + ]; + let mut f = default_server_filters(); + f.database = Some("prod".to_string()); + let result = collect_servers_from(servers, &f); + assert_eq!(result.total, 1); + assert_eq!(result.servers[0].database, "prod"); + } + + #[test] + fn server_filter_by_user() { + let servers = vec![make_server("db", "alice"), make_server("db", "bob")]; + let mut f = default_server_filters(); + f.user = Some("alice".to_string()); + let result = collect_servers_from(servers, &f); + assert_eq!(result.total, 1); + assert_eq!(result.servers[0].user, "alice"); + } + + // --------------------------------------------------------------------------- + // Server sort tests + // --------------------------------------------------------------------------- + + #[test] + fn server_sort_queries_total_asc() { + let servers = vec![ + make_server("db", "u"), + make_server("db", "u"), + make_server("db", "u"), + ]; + servers[0].query_count.store(30, Ordering::Relaxed); + servers[1].query_count.store(10, Ordering::Relaxed); + servers[2].query_count.store(20, Ordering::Relaxed); + let mut f = default_server_filters(); + f.sort = ServerSort::QueriesTotal; + f.order = SortOrder::Asc; + let result = collect_servers_from(servers, &f); + let counts: Vec = result.servers.iter().map(|s| s.queries_total).collect(); + assert_eq!(counts, vec![10, 20, 30]); + } + + #[test] + fn server_sort_queries_total_desc() { + let servers = vec![ + make_server("db", "u"), + make_server("db", "u"), + make_server("db", "u"), + ]; + servers[0].query_count.store(30, Ordering::Relaxed); + servers[1].query_count.store(10, Ordering::Relaxed); + servers[2].query_count.store(20, Ordering::Relaxed); + let mut f = default_server_filters(); + f.sort = ServerSort::QueriesTotal; + f.order = SortOrder::Desc; + let result = collect_servers_from(servers, &f); + let counts: Vec = result.servers.iter().map(|s| s.queries_total).collect(); + assert_eq!(counts, vec![30, 20, 10]); + } + + #[test] + fn server_sort_errors_total_asc() { + let servers = vec![make_server("db", "u"), make_server("db", "u")]; + servers[0].error_count.store(5, Ordering::Relaxed); + servers[1].error_count.store(1, Ordering::Relaxed); + let mut f = default_server_filters(); + f.sort = ServerSort::ErrorsTotal; + f.order = SortOrder::Asc; + let result = collect_servers_from(servers, &f); + let errs: Vec = result.servers.iter().map(|s| s.errors_total).collect(); + assert_eq!(errs, vec![1, 5]); + } + + #[test] + fn server_sort_active_age_ms_desc() { + // Servers not in ACTIVE state return active_age_ms == 0. + let servers = vec![make_server("db", "u"), make_server("db", "u")]; + let mut f = default_server_filters(); + f.sort = ServerSort::ActiveAgeMs; + f.order = SortOrder::Desc; + let result = collect_servers_from(servers, &f); + assert_eq!(result.total, 2); + } + + #[test] + fn server_sort_age_seconds_desc() { + let servers = vec![make_server("db", "u"), make_server("db", "u")]; + let mut f = default_server_filters(); + f.sort = ServerSort::AgeSeconds; + f.order = SortOrder::Desc; + let result = collect_servers_from(servers, &f); + assert_eq!(result.total, 2); + } + + // --------------------------------------------------------------------------- + // Server pagination tests + // --------------------------------------------------------------------------- + + #[test] + fn server_pagination_offset_beyond_total_returns_empty() { + let servers = vec![make_server("db", "u"), make_server("db", "u")]; + let mut f = default_server_filters(); + f.offset = 10; + let result = collect_servers_from(servers, &f); + assert_eq!(result.total, 2); + assert!(result.servers.is_empty()); + } + + #[test] + fn server_pagination_limit_clamped_to_max_limit() { + let servers: Vec<_> = (0..5).map(|_| make_server("db", "u")).collect(); + let mut f = default_server_filters(); + f.limit = MAX_LIMIT + 9999; + let result = collect_servers_from(servers, &f); + assert_eq!(result.limit, MAX_LIMIT); + } +} diff --git a/src/web/routes/collect/snapshot.rs b/src/web/routes/collect/snapshot.rs new file mode 100644 index 000000000..438f182fe --- /dev/null +++ b/src/web/routes/collect/snapshot.rs @@ -0,0 +1,96 @@ +//! Short-TTL request snapshot cache. Codex Arch P2#6 / Perf P1#3 flagged +//! that adjacent endpoints (`/api/overview`, `/api/pools`, `/api/clients`, +//! `/api/servers`, `/api/apps`, `/api/stats`) each cloned the global +//! `CLIENT_STATS` / `SERVER_STATS` maps under their own read lock — the +//! same data was walked four to five times per UI poll cycle, and the +//! UI tabs disagreed on which "moment" they were reading. +//! +//! This module exposes a single [`snapshot()`] that returns an +//! `Arc`. Within a 250 ms TTL window every caller reuses the +//! same `Arc`, so a poll burst from the SPA pays for one snapshot and +//! shares the result. Outside the TTL the next caller rebuilds. +//! +//! Concurrency: the cache is an `ArcSwap`; one thread occasionally +//! rebuilds while others read the previous snapshot, never blocking. +//! After the rebuild the swap is atomic. Older readers keep their +//! `Arc` and finish without observing the change. +//! +//! Memory: at peak we hold two snapshots (the swapped-in current one +//! and any in-flight `Arc`s on stack) — same shape as `arc_swap` does +//! everywhere else in this codebase. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use once_cell::sync::Lazy; + +use crate::pool::PoolIdentifier; +use crate::stats::pool::PoolStats; +use crate::stats::{get_client_stats, get_server_stats, ClientStats, ServerStats}; + +/// One coherent view of pg_doorman's runtime state, suitable for +/// building any `/api/*` envelope without going back to globals. +pub struct Snapshot { + pub client_states: HashMap>, + pub server_states: HashMap>, + pub pool_lookup: HashMap, + pub built_at: Instant, +} + +/// 250 ms is roughly twice the SPA's fastest poll interval (1.5 s in +/// the operator console). Big enough that one poll cycle reuses the +/// snapshot across endpoints, small enough that the rendered numbers +/// still feel live to a human watching during an incident. +const TTL: Duration = Duration::from_millis(250); + +static CACHE: Lazy>>> = Lazy::new(|| ArcSwap::from_pointee(None)); + +/// Singleflight gate. A `/api/*` poll burst from one SPA tab brings six +/// adjacent endpoints into [`snapshot()`] within a few microseconds. The +/// first one to find an expired cache enters the critical section and +/// rebuilds; the rest queue on this mutex, then re-check the cache and +/// see fresh data without rebuilding. The mutex is held only across the +/// build itself; readers that find a fresh cache on the fast path never +/// touch it. +static REBUILD_LOCK: Mutex<()> = Mutex::new(()); + +/// Return the current snapshot, rebuilding it if older than [`TTL`]. +pub fn snapshot() -> Arc { + if let Some(existing) = CACHE.load().as_ref() { + if existing.built_at.elapsed() < TTL { + return existing.clone(); + } + } + // Slow path: cache is stale. Serialize rebuilds across concurrent + // callers — a poll burst should pay for one build, not N. After + // taking the lock, re-check: another caller may have rebuilt while + // we were waiting. + let _guard = REBUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(existing) = CACHE.load().as_ref() { + if existing.built_at.elapsed() < TTL { + return existing.clone(); + } + } + let fresh = Arc::new(build()); + CACHE.store(Arc::new(Some(fresh.clone()))); + fresh +} + +fn build() -> Snapshot { + // Snapshot ordering matches `PoolStats::construct_pool_lookup` — + // POOLS first, then CLIENT_STATS / SERVER_STATS — so the same race + // closure (a server orphaned by dynamic-pool GC) applies and the + // existing benign-orphan logging in `update_client_server_states` + // covers it. + let client_states = get_client_stats(); + let server_states = get_server_stats(); + let pool_lookup = PoolStats::construct_pool_lookup_from(&client_states, &server_states); + Snapshot { + client_states, + server_states, + pool_lookup, + built_at: Instant::now(), + } +} diff --git a/src/web/routes/collect/sockets.rs b/src/web/routes/collect/sockets.rs new file mode 100644 index 000000000..8e91c7df9 --- /dev/null +++ b/src/web/routes/collect/sockets.rs @@ -0,0 +1,48 @@ +use crate::web::routes::dto::{SocketsDto, TcpCounts, UnixStreamCounts}; + +use super::now_unix_ms; + +pub(crate) fn collect_sockets() -> Result { + use crate::stats::socket::{get_socket_states_count, TcpStateCount, UnixStreamStateCount}; + + let info = get_socket_states_count(std::process::id()) + .map_err(|_| "failed to read socket states from /proc")?; + + fn tcp(c: &TcpStateCount) -> TcpCounts { + TcpCounts { + established: c.established as u64, + syn_sent: c.syn_sent as u64, + syn_recv: c.syn_recv as u64, + fin_wait1: c.fin_wait1 as u64, + fin_wait2: c.fin_wait2 as u64, + time_wait: c.time_wait as u64, + close: c.close as u64, + close_wait: c.close_wait as u64, + last_ack: c.last_ack as u64, + listen: c.listen as u64, + closing: c.closing as u64, + new_syn_recv: c.new_syn_recv as u64, + bound_inactive: c.bound_inactive as u64, + } + } + + fn unix_stream(c: &UnixStreamStateCount) -> UnixStreamCounts { + UnixStreamCounts { + free: c.free as u64, + unconnected: c.unconnected as u64, + connecting: c.connecting as u64, + connected: c.connected as u64, + disconnecting: c.disconnecting as u64, + } + } + + Ok(SocketsDto { + ts: now_unix_ms(), + tcp: tcp(&info.tcp), + tcp6: tcp(&info.tcp6), + unix_stream: unix_stream(&info.unix_stream), + unix_dgram: info.unix_dgram as u64, + unix_seq_packet: info.unix_seq_packet as u64, + unknown: info.unknown as u64, + }) +} diff --git a/src/web/routes/collect/stats.rs b/src/web/routes/collect/stats.rs new file mode 100644 index 000000000..d39850428 --- /dev/null +++ b/src/web/routes/collect/stats.rs @@ -0,0 +1,42 @@ +use crate::web::routes::dto::{StatsDto, StatsRowDto}; + +use super::{now_unix_ms, snapshot}; + +pub(crate) fn collect_stats() -> StatsDto { + let snap = snapshot(); + let mut stats: Vec = snap + .pool_lookup + .iter() + .map(|(identifier, s)| StatsRowDto { + id: format!("{}@{}", identifier.user, identifier.db), + database: identifier.db.clone(), + user: identifier.user.clone(), + total_xact_count: s.total_xact_count, + total_query_count: s.total_query_count, + total_received: s.total_received, + total_sent: s.total_sent, + total_xact_time: s.total_xact_time_microseconds, + total_query_time: s.total_query_time_microseconds, + total_wait_time: s.wait_time, + total_errors: s.errors, + avg_xact_count: s.avg_xact_count, + avg_query_count: s.avg_query_count, + avg_recv: s.avg_recv, + avg_sent: s.avg_sent, + // `avg_errors` mirrors `generate_show_stats_row`: uses `errors` (no + // per-window rate stored in PoolStats). + avg_errors: s.errors, + avg_xact_time: s.avg_xact_time_microsecons, + avg_query_time: s.avg_query_time_microseconds, + avg_wait_time: s.avg_wait_time, + }) + .collect(); + + // Stable order: same `id` ordering as `/api/pools` for deterministic UI. + stats.sort_by(|a, b| a.id.cmp(&b.id)); + + StatsDto { + ts: now_unix_ms(), + stats, + } +} diff --git a/src/web/routes/collect/top.rs b/src/web/routes/collect/top.rs new file mode 100644 index 000000000..5571396d8 --- /dev/null +++ b/src/web/routes/collect/top.rs @@ -0,0 +1,264 @@ +use std::sync::atomic::Ordering; + +use crate::pool::get_all_pools; +use crate::server::{anon_snapshot, named_snapshot}; +use crate::stats::get_client_stats; +use crate::web::routes::dto::{ + TopClientBy, TopClientFilters, TopClientRowDto, TopClientsDto, TopPreparedBy, TopPreparedDto, + TopPreparedFilters, TopPreparedRowDto, TopQueriesDto, TopQueryBy, TopQueryFilters, + TopQueryRowDto, +}; + +use super::{clamp_top_clients_n, now_unix_ms}; + +pub(crate) fn collect_top_prepared(filters: &TopPreparedFilters) -> TopPreparedDto { + let n = clamp_top_clients_n(filters.n); + + let mut rows: Vec = Vec::new(); + for (identifier, pool) in get_all_pools().iter() { + let Some(cache) = pool.prepared_statement_cache.as_ref() else { + continue; + }; + for (hash, parse, count_used, kind, hits, misses) in cache.get_entries() { + rows.push(TopPreparedRowDto { + pool: identifier.to_string(), + hash: hash.to_string(), + name: parse.name.clone(), + count_used, + hits, + misses, + kind: kind.as_str().to_string(), + }); + } + } + + truncate_top_n(&mut rows, n as usize, |a, b| match filters.by { + TopPreparedBy::Hits => b.hits.cmp(&a.hits), + TopPreparedBy::Misses => b.misses.cmp(&a.misses), + }); + + TopPreparedDto { + ts: now_unix_ms(), + by: filters.by.as_str().to_string(), + n, + prepared: rows, + } +} + +/// Partition `rows` so the first `n` items are the top-N according to +/// `cmp`, then sort just those for stable display order. Avoids the +/// O(n log n) cost of fully sorting a 10k-entry interner snapshot when +/// the operator only needs the leading 20. +fn truncate_top_n(rows: &mut Vec, n: usize, mut cmp: F) +where + F: FnMut(&T, &T) -> std::cmp::Ordering, +{ + if rows.len() <= n { + rows.sort_by(&mut cmp); + return; + } + // select_nth_unstable_by partitions in O(n); the truncate that + // follows is the actual size cap, and the final sort runs against + // the n winners only. + rows.select_nth_unstable_by(n, &mut cmp); + rows.truncate(n); + rows.sort_by(&mut cmp); +} + +pub(crate) fn collect_top_clients(filters: &TopClientFilters) -> TopClientsDto { + let snapshot: Vec<_> = get_client_stats().values().cloned().collect(); + top_clients_from(snapshot, filters) +} + +fn top_clients_from( + snapshot: Vec>, + filters: &TopClientFilters, +) -> TopClientsDto { + let n = clamp_top_clients_n(filters.n); + + let mut rows: Vec = snapshot + .iter() + .filter(|s| { + if let Some(p) = &filters.pool { + let id = format!("{}@{}", s.username(), s.pool_name()); + if id != *p { + return false; + } + } + true + }) + .map(|s| { + let age_seconds = s.connect_time().elapsed().as_secs(); + let queries_total = s.query_count.load(Ordering::Relaxed); + let errors_total = s.error_count.load(Ordering::Relaxed); + let qps = queries_total as f64 / age_seconds.max(1) as f64; + TopClientRowDto { + client_id: format!("#c{}", s.connection_id()), + application_name: s.application_name().to_string(), + user: s.username().to_string(), + database: s.pool_name().to_string(), + addr: s.ipaddr().to_string(), + age_seconds, + queries_total, + errors_total, + qps, + } + }) + .collect(); + + truncate_top_n(&mut rows, n as usize, |a, b| { + // All Top-N sorts are descending — operators want busiest first. + match filters.by { + TopClientBy::Qps => b + .qps + .partial_cmp(&a.qps) + .unwrap_or(std::cmp::Ordering::Equal), + TopClientBy::Errors => b.errors_total.cmp(&a.errors_total), + TopClientBy::Age => b.age_seconds.cmp(&a.age_seconds), + } + }); + + TopClientsDto { + ts: now_unix_ms(), + by: filters.by.as_str().to_string(), + n, + clients: rows, + } +} + +pub(crate) fn collect_top_queries(filters: &TopQueryFilters) -> TopQueriesDto { + let n = clamp_top_clients_n(filters.n); + + let mut rows: Vec = Vec::new(); + + for (hash, entry) in named_snapshot() { + let count = entry.count(); + let total_duration_us = entry.total_duration_us(); + let avg_duration_ms = if count == 0 { + 0.0 + } else { + total_duration_us as f64 / count as f64 / 1_000.0 + }; + let preview: String = entry.text().chars().take(120).collect(); + rows.push(TopQueryRowDto { + hash: format!("{:#x}", hash), + kind: "named".to_string(), + query: preview, + count, + total_duration_us, + avg_duration_ms, + }); + } + for (hash, entry) in anon_snapshot() { + let count = entry.count(); + let total_duration_us = entry.total_duration_us(); + let avg_duration_ms = if count == 0 { + 0.0 + } else { + total_duration_us as f64 / count as f64 / 1_000.0 + }; + let preview: String = entry.text().chars().take(120).collect(); + rows.push(TopQueryRowDto { + hash: format!("{:#x}", hash), + kind: "anonymous".to_string(), + query: preview, + count, + total_duration_us, + avg_duration_ms, + }); + } + + truncate_top_n(&mut rows, n as usize, |a, b| match filters.by { + TopQueryBy::Count => b.count.cmp(&a.count), + TopQueryBy::Duration => b + .avg_duration_ms + .partial_cmp(&a.avg_duration_ms) + .unwrap_or(std::cmp::Ordering::Equal), + }); + + TopQueriesDto { + ts: now_unix_ms(), + by: filters.by.as_str().to_string(), + n, + queries: rows, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stats::client::ClientStats; + use crate::utils::clock; + use crate::web::routes::dto::TopClientBy; + use std::sync::Arc; + + fn make_client( + connection_id: u64, + db: &str, + user: &str, + app: &str, + queries: u64, + errors: u64, + ) -> Arc { + let stats = Arc::new(ClientStats::new( + connection_id, + app, + user, + db, + "127.0.0.1", + clock::now(), + false, + )); + stats.query_count.store(queries, Ordering::Relaxed); + stats.error_count.store(errors, Ordering::Relaxed); + stats + } + + #[test] + fn top_clients_sort_by_errors_desc() { + let clients = vec![ + make_client(1, "db", "u", "a", 0, 5), + make_client(2, "db", "u", "a", 0, 1), + make_client(3, "db", "u", "a", 0, 3), + ]; + let f = TopClientFilters { + by: TopClientBy::Errors, + n: 10, + pool: None, + }; + let result = top_clients_from(clients, &f); + let errs: Vec = result.clients.iter().map(|c| c.errors_total).collect(); + assert_eq!(errs, vec![5, 3, 1]); + assert_eq!(result.by, "errors"); + } + + #[test] + fn top_clients_n_default_when_zero() { + let clients: Vec<_> = (0..5) + .map(|i| make_client(i, "db", "u", "a", 0, 0)) + .collect(); + let f = TopClientFilters { + by: TopClientBy::Qps, + n: 0, + pool: None, + }; + let result = top_clients_from(clients, &f); + assert_eq!(result.n, 20); + } + + #[test] + fn top_clients_pool_filter_excludes_others() { + let clients = vec![ + make_client(1, "db1", "alice", "a", 0, 0), + make_client(2, "db2", "bob", "a", 0, 0), + ]; + let f = TopClientFilters { + by: TopClientBy::Qps, + n: 10, + pool: Some("alice@db1".to_string()), + }; + let result = top_clients_from(clients, &f); + assert_eq!(result.clients.len(), 1); + assert_eq!(result.clients[0].user, "alice"); + } +} diff --git a/src/web/routes/collect/users.rs b/src/web/routes/collect/users.rs new file mode 100644 index 000000000..7a078eaec --- /dev/null +++ b/src/web/routes/collect/users.rs @@ -0,0 +1,26 @@ +use crate::pool::get_all_pools; +use crate::web::routes::dto::{UserDto, UsersDto}; + +use super::now_unix_ms; + +pub(crate) fn collect_users() -> UsersDto { + let pools_map = get_all_pools(); + let mut users: Vec = pools_map + .iter() + .map(|(identifier, pool)| UserDto { + name: identifier.user.clone(), + pool_mode: pool.settings.pool_mode.to_string(), + }) + .collect(); + + users.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.pool_mode.cmp(&b.pool_mode)) + }); + + UsersDto { + ts: now_unix_ms(), + users, + } +} diff --git a/src/web/routes/collect/version.rs b/src/web/routes/collect/version.rs new file mode 100644 index 000000000..5cb7d4dc8 --- /dev/null +++ b/src/web/routes/collect/version.rs @@ -0,0 +1,12 @@ +use crate::web::routes::dto::VersionDto; + +use super::now_unix_ms; + +pub(crate) fn collect_version() -> VersionDto { + VersionDto { + version: env!("CARGO_PKG_VERSION"), + git_commit: option_env!("PG_DOORMAN_GIT_COMMIT").unwrap_or("unknown"), + build_date: option_env!("PG_DOORMAN_BUILD_DATE").unwrap_or("unknown"), + ts: now_unix_ms(), + } +} diff --git a/src/web/routes/config.rs b/src/web/routes/config.rs new file mode 100644 index 000000000..682df3625 --- /dev/null +++ b/src/web/routes/config.rs @@ -0,0 +1,22 @@ +//! GET /api/config handler. + +use crate::web::routes::collect::collect_config; +use crate::web::server::Response; + +pub(crate) fn handle_config() -> Response { + Response::ok_json(&collect_config()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_response_is_200_with_envelope() { + let r = handle_config(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"config\"")); + } +} diff --git a/src/web/routes/connections.rs b/src/web/routes/connections.rs new file mode 100644 index 000000000..f548fa611 --- /dev/null +++ b/src/web/routes/connections.rs @@ -0,0 +1,30 @@ +//! GET /api/connections handler. + +use crate::web::routes::collect::collect_connections; +use crate::web::server::Response; + +pub(crate) fn handle_connections() -> Response { + Response::ok_json(&collect_connections()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connections_response_is_200_with_envelope() { + let r = handle_connections(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + for field in [ + "\"ts\"", + "\"total\"", + "\"tls\"", + "\"plain\"", + "\"cancel\"", + "\"errors\"", + ] { + assert!(body.contains(field), "missing {field} in {body}"); + } + } +} diff --git a/src/web/routes/databases.rs b/src/web/routes/databases.rs new file mode 100644 index 000000000..f58061811 --- /dev/null +++ b/src/web/routes/databases.rs @@ -0,0 +1,22 @@ +//! GET /api/databases handler. + +use crate::web::routes::collect::collect_databases; +use crate::web::server::Response; + +pub(crate) fn handle_databases() -> Response { + Response::ok_json(&collect_databases()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn databases_response_is_200_with_envelope() { + let r = handle_databases(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"databases\"")); + } +} diff --git a/src/web/routes/dto.rs b/src/web/routes/dto.rs new file mode 100644 index 000000000..da27e75ca --- /dev/null +++ b/src/web/routes/dto.rs @@ -0,0 +1,918 @@ +//! JSON DTO types for the Web UI REST API. +//! +//! These structs define the wire format that the frontend consumes; they are +//! the source of truth for response shapes documented in spec sections 8.3+. +//! Field naming follows the spec exactly. Per-handler unit tests assert that +//! every required JSON key is present in the serialized output; full snapshot +//! tests are a candidate follow-up. + +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug, Serialize)] +pub(crate) struct VersionDto { + pub version: &'static str, + pub git_commit: &'static str, + pub build_date: &'static str, + pub ts: u64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct OverviewDto { + pub ts: u64, + + pub active_clients: u64, + pub idle_clients: u64, + pub waiting_clients: u64, + + pub active_servers: u64, + pub idle_servers: u64, + + pub connections_total: u64, + pub connections_tls_total: u64, + pub connections_plain_total: u64, + pub connections_cancel_total: u64, + + pub query_count_total: u64, + pub transaction_count_total: u64, + pub errors_count_total: u64, + + pub prepared_hits_total: u64, + pub prepared_misses_total: u64, + + pub pools_total: u64, + pub pools_paused: u64, + + /// Process resident-set size in bytes, sampled at request time. Linux + /// reads `/proc/self/statm`; macOS shells out to `ps`. Provides the + /// "is the pooler leaking memory" tile without requiring Prometheus. + pub rss_bytes: u64, + /// Seconds since the binary started (`STARTED_AT` lazy in app/server.rs). + pub uptime_seconds: u64, + /// OS process id. Useful when correlating with external tools (`htop`, + /// `lsof`, `gdb`) on the same host. + pub pid: u32, + /// Number of clients currently connected to the pooler. Mirrors + /// `CURRENT_CLIENT_COUNT` in app/server.rs; not derivable from the per- + /// pool counts because clients do not always belong to a pool yet (e.g. + /// during startup negotiation). + pub current_clients: i64, + /// Number of clients currently inside an open PG transaction holding + /// a backend connection. Mirrors `CLIENTS_IN_TRANSACTIONS`. + pub clients_in_transactions: i64, + /// Set during `SIGTERM`/admin SHUTDOWN. Operator-visible "the pooler is + /// draining, do not deploy now" indicator. + pub shutdown_in_progress: bool, + /// Set during binary upgrade — clients are migrating to the new process. + pub migration_in_progress: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct PoolsDto { + pub ts: u64, + pub pools: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct PoolDto { + /// Stable identifier `@`. + pub id: String, + pub user: String, + pub database: String, + pub host: String, + pub port: u16, + pub pool_mode: String, + + pub max_connections: u32, + pub min_connections: u32, + pub connections: u64, + pub idle: u64, + pub active: u64, + pub waiting: u64, + + pub max_active_age_ms: u64, + + /// Latency percentiles in milliseconds. Stored as `f64` rather than + /// `u64` so sub-millisecond values survive: a workload whose true + /// p95 is 420 µs would otherwise integer-divide to `0 ms` in the + /// DTO and render as a bogus zero on the dashboard while the log + /// line correctly showed `query_ms p95 = 0.42`. + pub query_p95_ms: f64, + pub query_p99_ms: f64, + pub transactions_p95_ms: f64, + pub transactions_p99_ms: f64, + + pub wait_avg_ms: f64, + pub wait_p95_ms: f64, + + pub queries_total: u64, + pub transactions_total: u64, + pub errors_total: u64, + /// Cumulative error breakdown keyed by PostgreSQL SQLSTATE. Includes + /// both PG-side ErrorResponse codes and pg_doorman-side codes such as + /// `53300` raised on checkout failure. Omitted from the JSON when no + /// errors have been classified yet. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub errors_by_sqlstate: HashMap, + + pub paused: bool, + pub epoch: u64, + + /// Patroni-assisted fallback flag. Mirrors the `pg_doorman_fallback_active` + /// gauge — `true` when the local backend is in cooldown and the pool is + /// routing through a fallback host discovered via Patroni `/cluster`. + pub fallback_active: bool, + /// Cumulative count of TLS handshake errors against the backend for this + /// pool. Mirrors `pg_doorman_server_tls_handshake_errors_total`. + pub tls_handshake_errors_total: u64, + /// Live TLS-encrypted backend connections held by the pool. Mirrors + /// `pg_doorman_server_tls_connections`. + pub tls_backend_connections: u64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ClientsDto { + pub ts: u64, + pub total: u64, + pub limit: u64, + pub offset: u64, + pub clients: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ClientDto { + pub client_id: String, + pub database: String, + pub user: String, + pub application_name: String, + pub addr: String, + pub tls: bool, + pub state: String, + pub wait: String, + pub wait_ms: u64, + pub transactions_total: u64, + pub queries_total: u64, + pub errors_total: u64, + pub age_seconds: u64, + pub current_query_age_ms: u64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ServersDto { + pub ts: u64, + pub total: u64, + pub limit: u64, + pub offset: u64, + pub servers: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ServerDto { + pub server_id: i32, + pub process_id: i32, + pub database: String, + pub user: String, + pub application_name: String, + pub tls: bool, + pub state: String, + pub wait: String, + pub age_seconds: u64, + pub active_age_ms: u64, + pub transactions_total: u64, + pub queries_total: u64, + pub errors_total: u64, + pub bytes_sent: u64, + pub bytes_received: u64, + pub prepared_hits_total: u64, + pub prepared_misses_total: u64, + pub prepared_cache_size: u64, +} + +// Filter structs are NOT serialized; they're internal request DTOs. + +#[derive(Debug, Default, Clone)] +pub(crate) struct ClientFilters { + pub limit: u64, + pub offset: u64, + pub sort: ClientSort, + pub order: SortOrder, + pub pool: Option, + pub database: Option, + pub user: Option, + /// Substring match against `ClientStats.addr` (e.g. "10.0.5." for a subnet + /// or "1.2.3.4:5432" for an exact peer). + pub addr: Option, + pub application_name: Vec, + pub state: Vec, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum ClientSort { + #[default] + QueriesTotal, + ErrorsTotal, + AgeSeconds, + CurrentQueryAgeMs, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum SortOrder { + Asc, + #[default] + Desc, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct ServerFilters { + pub limit: u64, + pub offset: u64, + pub sort: ServerSort, + pub order: SortOrder, + pub pool: Option, + pub database: Option, + pub user: Option, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum ServerSort { + #[default] + AgeSeconds, + QueriesTotal, + ErrorsTotal, + ActiveAgeMs, +} + +/// `GET /api/process` — process resource snapshot. Linux reads `/proc/self/*` +/// directly; non-Linux platforms return zeros where information is not +/// available without extra dependencies. CPU usage is provided as +/// monotonic microsecond counters (user + system, total + per thread); the +/// frontend computes `%` by sampling deltas across two consecutive polls. +#[derive(Debug, Serialize)] +pub(crate) struct ProcessDto { + pub ts: u64, + pub pid: u32, + pub hostname: String, + pub uptime_seconds: u64, + pub started_at_ms: u64, + pub rss_bytes: u64, + pub vm_size_bytes: u64, + pub threads: u64, + pub fd_open: u64, + pub fd_limit: u64, + /// Cumulative user-mode CPU time across the whole process, microseconds. + pub cpu_user_us: u64, + /// Cumulative kernel-mode CPU time across the whole process, microseconds. + pub cpu_system_us: u64, + /// Number of online CPU cores (`num_cpus::get`). Frontend uses this to + /// turn the cumulative deltas into a percentage of one core or of all + /// cores depending on the operator's preference. + pub cpu_cores: u32, + /// Per-thread CPU breakdown. Sorted by `cpu_user_us + cpu_system_us` + /// descending so the hottest tokio worker is at the top. Linux only; + /// other platforms return an empty list. + pub threads_breakdown: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ProcessThreadDto { + pub tid: u64, + /// Comm field from `/proc/self/task//stat` — limited to 15 chars by + /// the kernel. Names like `tokio-runtime-w`, `pg_doorman` for the main + /// thread, etc. + pub name: String, + pub cpu_user_us: u64, + pub cpu_system_us: u64, +} + +/// `GET /api/process/memory` — memory breakdown for the RSS panel. +/// Linux fills every field; macOS / others return what they can and +/// leave Linux-only fields `None`. +#[derive(Debug, Serialize)] +pub(crate) struct MemoryBreakdownDto { + pub ts: u64, + pub rss_bytes: u64, + /// Fields harvested from `/proc/self/status` in one pass. `None` on + /// non-Linux. + pub vm_peak_bytes: Option, + pub vm_hwm_bytes: Option, + pub vm_data_bytes: Option, + pub vm_stack_bytes: Option, + pub vm_exe_bytes: Option, + pub vm_lib_bytes: Option, + pub vm_pte_bytes: Option, + pub vm_swap_bytes: Option, + pub rss_anon_bytes: Option, + pub rss_file_bytes: Option, + pub rss_shmem_bytes: Option, + /// jemalloc accounting (the global allocator pg_doorman links). + /// `None` only if the ctl call failed (should never happen at runtime). + pub jemalloc: Option, + /// Container memory limits and current usage. `None` on non-Linux or + /// when the cgroup files are not readable (chroot, custom mounts). + pub cgroup: Option, + /// pg_doorman-internal accountable bytes — the SQL interner cache and + /// the prepared-statement cache. Operators look here first when RSS + /// climbs. + pub interner_named_bytes: u64, + pub interner_anonymous_bytes: u64, + /// Operator-facing rollup categories. Each maps to a `MemoryCategoryDto` + /// with a stable `key` so the frontend can paint a stacked bar without + /// hard-coding category names. + pub categories: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct JemallocStatsDto { + pub allocated_bytes: u64, + pub active_bytes: u64, + pub resident_bytes: u64, + pub mapped_bytes: u64, + pub retained_bytes: u64, + pub metadata_bytes: u64, + /// `resident − allocated`. Pages jemalloc holds but is not currently + /// using; reclaimable on demand. + pub fragmentation_bytes: u64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct CgroupMemoryDto { + /// 1 for cgroup v1, 2 for cgroup v2 unified hierarchy. + pub version: u8, + pub current_bytes: u64, + /// On cgroup v2: `memory.peak` (kernels ≥ 5.19); `None` otherwise. + /// On cgroup v1: historical maximum from `memory.max_usage_in_bytes`. + pub peak_bytes: Option, + /// `None` when the limit is "max" (uncapped). + pub max_bytes: Option, + /// `None` on cgroup v1. + pub high_bytes: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct MemoryCategoryDto { + pub key: &'static str, + pub label: &'static str, + pub bytes: u64, + pub explain: &'static str, +} + +/// `GET /api/connections` — cumulative connection counters. +/// +/// `errors` is derived as `total - tls - plain - cancel` to mirror the +/// existing `SHOW CONNECTIONS` admin output exactly. Operators reading the +/// REST API see the same values they saw via the admin protocol. +#[derive(Debug, Serialize)] +pub(crate) struct ConnectionsDto { + pub ts: u64, + pub total: u64, + pub tls: u64, + pub plain: u64, + pub cancel: u64, + pub errors: u64, +} + +/// `GET /api/stats` — per-pool aggregated counters. +/// +/// Field names mirror `SHOW STATS` columns. Time fields (`*_xact_time`, +/// `*_query_time`, `*_wait_time`) are microseconds, matching the units stored +/// in `PoolStats`. Frontend converts to milliseconds for display. +#[derive(Debug, Serialize)] +pub(crate) struct StatsDto { + pub ts: u64, + pub stats: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct StatsRowDto { + /// Stable identifier `@`, matches `PoolDto.id`. + pub id: String, + pub database: String, + pub user: String, + pub total_xact_count: u64, + pub total_query_count: u64, + pub total_received: u64, + pub total_sent: u64, + pub total_xact_time: u64, + pub total_query_time: u64, + pub total_wait_time: u64, + pub total_errors: u64, + pub avg_xact_count: u64, + pub avg_query_count: u64, + pub avg_recv: u64, + pub avg_sent: u64, + pub avg_errors: u64, + pub avg_xact_time: u64, + pub avg_query_time: u64, + pub avg_wait_time: u64, +} + +/// `GET /api/databases` — configured database/pool entries. +/// Field names mirror `SHOW DATABASES` columns. +#[derive(Debug, Serialize)] +pub(crate) struct DatabasesDto { + pub ts: u64, + pub databases: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct DatabaseDto { + pub name: String, + pub host: String, + pub port: u16, + pub database: String, + pub force_user: String, + pub pool_size: u32, + pub min_pool_size: u32, + /// Always 0. `SHOW DATABASES` hardcodes 0 for this column even though + /// pg_doorman does honour `reserve_pool_size` in the connection pool + /// itself; the REST API mirrors the admin protocol's shape. For the + /// configured value use `SHOW POOLS` or the + /// `pg_doorman_pool_size{type="reserve_pool_size"}` Prometheus gauge. + pub reserve_pool: u32, + pub pool_mode: String, + pub max_connections: u32, + pub current_connections: u32, +} + +/// `GET /api/users` — list of configured users. +/// +/// One row per `(user, database)` pair from the pool registry. Mirrors +/// `SHOW USERS`: same user appearing in multiple databases yields multiple +/// rows (the admin command did not deduplicate). +#[derive(Debug, Serialize)] +pub(crate) struct UsersDto { + pub ts: u64, + pub users: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct UserDto { + pub name: String, + pub pool_mode: String, +} + +/// `GET /api/config` — flattened key/value view of the active configuration. +/// +/// Mirrors the columns of `SHOW CONFIG`. Values for secret keys are replaced +/// with `"***"`; the predicate is documented on `is_secret_key` in collect.rs. +/// The flat representation today omits per-user passwords, admin_password, +/// talos_jwt_secret and similar (existing limitation of +/// `From<&Config> for HashMap`); when that conversion is +/// later extended the masker will pick up the new keys automatically. +#[derive(Debug, Serialize)] +pub(crate) struct ConfigDto { + pub ts: u64, + pub config: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ConfigEntry { + pub key: String, + pub value: String, + /// Built-in default (computed by serializing `Config::default()`). + /// `"-"` when the field has no representation in the default config + /// (e.g. user-defined pools). + pub default: String, + /// `"yes"` for keys that take effect on `RELOAD`, `"no"` for keys that + /// require a restart. Mirrors the `immutables` list inside `show_config`. + pub changeable: &'static str, + /// EN-language description sourced from `fields.yaml`. Empty for + /// fields without a documented surface (operator-defined sections, + /// internal bookkeeping). Operators see this as the per-row tooltip. + pub doc: String, +} + +/// `GET /api/log_level` — the active log filter (RUST_LOG-style). +#[derive(Debug, Serialize)] +pub(crate) struct LogLevelDto { + pub ts: u64, + pub log_level: String, +} + +/// `GET /api/auth_query` — per-pool auth_query cache and authentication +/// metrics. Field names mirror `SHOW AUTH_QUERY` columns. +#[derive(Debug, Serialize)] +pub(crate) struct AuthQueryDto { + pub ts: u64, + pub pools: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct AuthQueryRowDto { + pub database: String, + pub cache_entries: u64, + pub cache_hits: u64, + pub cache_misses: u64, + pub cache_refetches: u64, + pub cache_rate_limited: u64, + pub auth_success: u64, + pub auth_failure: u64, + pub executor_queries: u64, + pub executor_errors: u64, + pub dynamic_pools_current: u64, + pub dynamic_pools_created: u64, + pub dynamic_pools_destroyed: u64, +} + +/// `GET /api/pool_scaling` — per-pool counters for the anticipation and +/// bounded-burst create paths. Field names mirror `SHOW POOL_SCALING`. +#[derive(Debug, Serialize)] +pub(crate) struct PoolScalingDto { + pub ts: u64, + pub pools: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct PoolScalingRowDto { + pub user: String, + pub database: String, + pub inflight: u64, + pub creates: u64, + pub gate_waits: u64, + pub gate_budget_ex: u64, + pub antic_notify: u64, + pub antic_timeout: u64, + pub create_fallback: u64, + pub replenish_def: u64, +} + +/// `GET /api/pool_coordinator` — per-database limits and reserve-pool counters. +/// Field names mirror `SHOW POOL_COORDINATOR`. +#[derive(Debug, Serialize)] +pub(crate) struct PoolCoordinatorDto { + pub ts: u64, + pub databases: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct PoolCoordinatorRowDto { + pub database: String, + pub max_db_conn: u64, + pub current: u64, + pub reserve_size: u64, + pub reserve_used: u64, + pub evictions: u64, + pub reserve_acq: u64, + pub exhaustions: u64, +} + +/// `GET /api/sockets` — TCP / TCP6 / Unix socket state counts. Linux-only. +/// Field names mirror the backend `SocketStateCount` and (transitively) +/// the columns of `SHOW SOCKETS`. +#[derive(Debug, Serialize)] +pub(crate) struct SocketsDto { + pub ts: u64, + pub tcp: TcpCounts, + pub tcp6: TcpCounts, + pub unix_stream: UnixStreamCounts, + pub unix_dgram: u64, + pub unix_seq_packet: u64, + pub unknown: u64, +} + +#[derive(Debug, Serialize, Default)] +pub(crate) struct TcpCounts { + pub established: u64, + pub syn_sent: u64, + pub syn_recv: u64, + pub fin_wait1: u64, + pub fin_wait2: u64, + pub time_wait: u64, + pub close: u64, + pub close_wait: u64, + pub last_ack: u64, + pub listen: u64, + pub closing: u64, + pub new_syn_recv: u64, + pub bound_inactive: u64, +} + +#[derive(Debug, Serialize, Default)] +pub(crate) struct UnixStreamCounts { + pub free: u64, + pub unconnected: u64, + pub connecting: u64, + pub connected: u64, + pub disconnecting: u64, +} + +/// `GET /api/prepared` — aggregate of pool-level prepared-statement caches. +/// +/// Public endpoint. The `query` text is intentionally NOT included here to +/// avoid leaking SQL bodies to anonymous Web UI viewers; the admin-only +/// `/api/prepared/text/{hash}` endpoint returns the text on demand. +#[derive(Debug, Serialize)] +pub(crate) struct PreparedDto { + pub ts: u64, + pub prepared: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct PreparedRowDto { + /// Pool identifier in the form rendered by `PoolIdentifier::Display`. + pub pool: String, + /// 64-bit FxHash, formatted as decimal to mirror SHOW PREPARED STATEMENTS. + pub hash: String, + pub name: String, + pub count_used: u64, + /// Cumulative Parse-time hits — server already had this prepared statement + /// when the client asked. Per-pool, per-CacheEntry. Lost on LRU eviction. + pub hits: u64, + /// Cumulative Parse-time misses — server lacked this prepared statement, + /// requiring a fresh Parse to PostgreSQL. Per-pool, per-CacheEntry. + pub misses: u64, + /// One of "named", "anonymous", "mixed" — `CacheEntryKind::as_str`. + pub kind: String, +} + +/// `GET /api/interner` — global query interner aggregate. +/// Public; no SQL preview. +#[derive(Debug, Serialize)] +pub(crate) struct InternerDto { + pub ts: u64, + pub named: InternerKindDto, + pub anonymous: InternerKindDto, +} + +#[derive(Debug, Serialize)] +pub(crate) struct InternerKindDto { + pub entries: u64, + pub bytes: u64, +} + +/// `GET /api/interner/top?n=N` — admin-only Top-N interner entries by +/// interned-text byte length, with a 120-character SQL preview. +#[derive(Debug, Serialize)] +pub(crate) struct InternerTopDto { + pub ts: u64, + /// The clamped value of `n` actually used (1..=MAX). + pub n: u64, + pub entries: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct InternerTopRowDto { + /// `0x` form of the FxHash, matching SHOW INTERNER TOP. + pub hash: String, + /// `"named"` or `"anonymous"`. + pub kind: String, + pub bytes: u64, + /// Idle milliseconds for anonymous entries; `-1` for named (named tracks + /// GC state instead of last-used). + pub idle_ms: i64, + /// First 120 characters of the interned text (truncated by chars, not + /// bytes — keeps multi-byte UTF-8 sequences whole). + pub preview: String, +} + +/// `GET /api/top/clients` — Top-N clients by qps / errors / age. +#[derive(Debug, Serialize)] +pub(crate) struct TopClientsDto { + pub ts: u64, + /// The sort dimension actually used: `"qps"`, `"errors"`, `"age"`. + pub by: String, + /// The clamped value of `n` actually used (1..=200; default 20). + pub n: u64, + pub clients: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TopClientRowDto { + /// `"#cN"` form — matches `ClientDto.client_id`. + pub client_id: String, + pub application_name: String, + pub user: String, + pub database: String, + pub addr: String, + pub age_seconds: u64, + pub queries_total: u64, + pub errors_total: u64, + /// Server-side computed `queries_total / age_seconds.max(1)`, exposed + /// for parity with the `by=qps` sort dimension and so the frontend + /// does not have to recompute when rendering the table column. + pub qps: f64, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum TopClientBy { + #[default] + Qps, + Errors, + Age, +} + +impl TopClientBy { + pub fn as_str(self) -> &'static str { + match self { + TopClientBy::Qps => "qps", + TopClientBy::Errors => "errors", + TopClientBy::Age => "age", + } + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct TopClientFilters { + pub by: TopClientBy, + pub n: u64, + pub pool: Option, +} + +/// `GET /api/apps` — per-application_name aggregate of client counters. +#[derive(Debug, Serialize)] +pub(crate) struct AppsDto { + pub ts: u64, + pub apps: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct AppRowDto { + pub application_name: String, + /// Number of currently-connected clients reporting this application_name. + pub clients: u64, + /// Cumulative counters; frontend computes rates from successive snapshots. + pub queries_total: u64, + pub transactions_total: u64, + pub errors_total: u64, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum AppSort { + #[default] + Clients, + Queries, + Transactions, + Errors, +} + +impl AppSort { + #[allow(dead_code)] + pub(crate) fn as_str(self) -> &'static str { + match self { + AppSort::Clients => "clients", + AppSort::Queries => "queries", + AppSort::Transactions => "transactions", + AppSort::Errors => "errors", + } + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct AppFilters { + pub sort: AppSort, + pub order: SortOrder, +} + +/// `GET /api/top/queries` — Top-N interner-tracked queries by count or +/// average duration. See plan for accuracy notes (Bind-counted, batch- +/// level duration attribution). +#[derive(Debug, Serialize)] +pub(crate) struct TopQueriesDto { + pub ts: u64, + pub by: String, + pub n: u64, + pub queries: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TopQueryRowDto { + /// `0x` form of the FxHash, matching `/api/interner/top`. + pub hash: String, + /// `"named"` or `"anonymous"`. + pub kind: String, + /// First 120 characters of the interned text (UTF-8 safe). + pub query: String, + pub count: u64, + pub total_duration_us: u64, + /// Average duration in milliseconds: `total_duration_us / count / 1000`. + /// Returns `0.0` when count is 0 (entry interned but never Bound). + pub avg_duration_ms: f64, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum TopQueryBy { + #[default] + Count, + Duration, +} + +impl TopQueryBy { + pub fn as_str(self) -> &'static str { + match self { + TopQueryBy::Count => "count", + TopQueryBy::Duration => "duration", + } + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct TopQueryFilters { + pub by: TopQueryBy, + pub n: u64, +} + +/// `GET /api/events?since=&max=` — admin command timeline used +/// for vertical-line annotations on the Overview graphs. Bounded ring +/// buffer; oldest events drop silently when full. +#[derive(Debug, Serialize)] +pub(crate) struct EventsDto { + pub ts: u64, + /// Sequence number to poll with on the next request to receive only + /// events newer than this batch. Equal to `since` when nothing new. + pub next_seq: u64, + pub events: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct EventEntryDto { + pub seq: u64, + pub ts_ms: u64, + /// One of `"RELOAD"`, `"PAUSE"`, `"RESUME"`, `"RECONNECT"`. + pub target: String, + pub message: String, +} + +/// `GET /api/prepared/text/{hash}` — admin-only body of a single prepared +/// statement. Returns 404 when the hash is not present in any pool's cache. +#[derive(Debug, Serialize)] +pub(crate) struct PreparedTextDto { + pub ts: u64, + pub hash: String, + pub pool: String, + pub name: String, + pub query: String, + pub kind: String, +} + +/// `GET /api/top/prepared?by=hits|misses&n=20` — Top-N prepared statements +/// across all pools, sorted by cumulative hit or miss count. Public; no SQL +/// preview — for the body use admin-only `/api/prepared/text/{hash}`. +#[derive(Debug, Serialize)] +pub(crate) struct TopPreparedDto { + pub ts: u64, + pub by: String, + pub n: u64, + pub prepared: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TopPreparedRowDto { + pub pool: String, + pub hash: String, + pub name: String, + pub count_used: u64, + pub hits: u64, + pub misses: u64, + pub kind: String, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) enum TopPreparedBy { + #[default] + Hits, + Misses, +} + +impl TopPreparedBy { + pub fn as_str(self) -> &'static str { + match self { + TopPreparedBy::Hits => "hits", + TopPreparedBy::Misses => "misses", + } + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct TopPreparedFilters { + pub by: TopPreparedBy, + pub n: u64, +} + +/// `GET /api/logs?since=&max=&level=&target=` — admin-only live tail +/// over the in-memory LogTap ring (spec section 8.6 + 9). +#[derive(Debug, Serialize)] +pub(crate) struct LogsDto { + pub ts: u64, + pub tap_active: bool, + pub tap_capacity_entries: u64, + pub tap_used_entries: u64, + /// Sequence number to poll with on the next request. + pub next_seq: u64, + /// Records lost from the ring before `since` (consumer evicted older + /// entries because the buffer is full). Operator falling behind sees + /// this grow. + pub dropped_before: u64, + /// Cumulative drops since the tap was activated. Includes evict-drops + /// (consumer ring overflow) and burst-drops (producer try_send full). + pub dropped_total: u64, + pub entries: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct LogEntryDto { + pub seq: u64, + pub ts_ms: u64, + pub level: String, + pub target: String, + pub message: String, +} diff --git a/src/web/routes/events.rs b/src/web/routes/events.rs new file mode 100644 index 000000000..77a4591b5 --- /dev/null +++ b/src/web/routes/events.rs @@ -0,0 +1,29 @@ +//! GET /api/events?since=&max= handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_events; +use crate::web::routes::query::parse_u64; +use crate::web::server::Response; + +pub(crate) fn handle_events(query: &BTreeMap>) -> Response { + let since = parse_u64(query, "since", 0); + let max = parse_u64(query, "max", 200); + Response::ok_json(&collect_events(since, max)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn events_returns_200_with_envelope() { + let q = BTreeMap::new(); + let r = handle_events(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"next_seq\"")); + assert!(body.contains("\"events\"")); + } +} diff --git a/src/web/routes/interner.rs b/src/web/routes/interner.rs new file mode 100644 index 000000000..4ea70e98a --- /dev/null +++ b/src/web/routes/interner.rs @@ -0,0 +1,22 @@ +//! GET /api/interner handler. Public — aggregate without SQL preview. + +use crate::web::routes::collect::collect_interner; +use crate::web::server::Response; + +pub(crate) fn handle_interner() -> Response { + Response::ok_json(&collect_interner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interner_response_is_200_with_envelope() { + let r = handle_interner(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"named\"")); + assert!(body.contains("\"anonymous\"")); + } +} diff --git a/src/web/routes/interner_top.rs b/src/web/routes/interner_top.rs new file mode 100644 index 000000000..152a264ce --- /dev/null +++ b/src/web/routes/interner_top.rs @@ -0,0 +1,37 @@ +//! GET /api/interner/top?n=N handler. Admin-only (mux gates the prefix). + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_interner_top; +use crate::web::routes::query::parse_u64; +use crate::web::server::Response; + +pub(crate) fn handle_interner_top(query: &BTreeMap>) -> Response { + let n = parse_u64(query, "n", 0); // 0 → default in clamp_top_n + Response::ok_json(&collect_interner_top(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interner_top_response_is_200() { + let q = BTreeMap::new(); + let r = handle_interner_top(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"entries\"")); + assert!(body.contains("\"n\":20")); + } + + #[test] + fn interner_top_honours_n_query_param() { + let mut q = BTreeMap::new(); + q.insert("n".into(), vec!["50".into()]); + let r = handle_interner_top(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"n\":50")); + } +} diff --git a/src/web/routes/log_level.rs b/src/web/routes/log_level.rs new file mode 100644 index 000000000..0b27a5da9 --- /dev/null +++ b/src/web/routes/log_level.rs @@ -0,0 +1,22 @@ +//! GET /api/log_level handler. + +use crate::web::routes::collect::collect_log_level; +use crate::web::server::Response; + +pub(crate) fn handle_log_level() -> Response { + Response::ok_json(&collect_log_level()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_level_response_is_200_with_envelope() { + let r = handle_log_level(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"log_level\"")); + } +} diff --git a/src/web/routes/logs.rs b/src/web/routes/logs.rs new file mode 100644 index 000000000..a082c69b8 --- /dev/null +++ b/src/web/routes/logs.rs @@ -0,0 +1,140 @@ +//! GET /api/logs?since=&max=&level=&target= — admin-only live tail +//! over the in-memory LogTap ring. +//! +//! Bypass-routed in `web::server::handle_connection` because the handler +//! must `await` the consumer task via `tokio::sync::mpsc` and `oneshot`; +//! the rest of the API stays sync. See server.rs for the bypass site. + +use std::collections::BTreeMap; +use std::sync::atomic::Ordering; + +use log::Level; + +use crate::config::get_config; +use crate::web::log_tap::{enable_log_tap, log_tap, now_monotonic_ms}; +use crate::web::routes::collect::now_unix_ms; +use crate::web::routes::dto::{LogEntryDto, LogsDto}; +use crate::web::routes::query::{first, parse_u64}; +use crate::web::server::Response; + +pub(crate) async fn handle_logs(query: &BTreeMap>) -> Response { + let cap = get_config().web.log_tap_max_entries; + if cap == 0 { + return Response::json( + 503, + "Service Unavailable", + r#"{"error":"log_tap_disabled","message":"log_tap_max_entries is 0 in config"}"#, + ); + } + + let since = parse_u64(query, "since", 0); + // 1..=1000 keeps a single drain bounded; 200 matches /api/events default. + let max_n = parse_u64(query, "max", 200).clamp(1, 1000) as usize; + + let level = + first(query, "level") + .as_deref() + .and_then(|s| match s.to_ascii_uppercase().as_str() { + "ERROR" => Some(Level::Error), + "WARN" | "WARNING" => Some(Level::Warn), + "INFO" => Some(Level::Info), + "DEBUG" => Some(Level::Debug), + "TRACE" => Some(Level::Trace), + _ => None, + }); + let target = first(query, "target"); + + // Activate on first call; subsequent calls reuse the existing Arc. + let tap = match log_tap() { + Some(t) => t, + None => enable_log_tap(cap as usize), + }; + // Bumps the reaper deadline so the tap stays alive while operators poll. + tap.last_request_at + .store(now_monotonic_ms(), Ordering::Relaxed); + + let drain = match tap.drain(since, max_n, level, target).await { + Ok(d) => d, + Err(_) => { + log::warn!("LogTap drain failed: consumer task gone"); + return empty_response(cap); + } + }; + + let entries: Vec = drain + .entries + .into_iter() + .map(|e| LogEntryDto { + seq: e.seq, + ts_ms: e.ts_ms, + level: e.level.as_str().to_string(), + target: e.target, + message: e.message, + }) + .collect(); + + Response::ok_json(&LogsDto { + ts: now_unix_ms(), + tap_active: true, + tap_capacity_entries: cap as u64, + tap_used_entries: drain.used_entries as u64, + next_seq: drain.next_seq, + dropped_before: drain.dropped_before, + dropped_total: tap.dropped_total.load(Ordering::Relaxed), + entries, + }) +} + +/// Fallback when the consumer task is gone (e.g. shutdown raced an in-flight +/// request). Returns the same envelope shape with an empty entry list and +/// `tap_active = false` so the frontend can recover without surfacing 5xx. +fn empty_response(cap: u32) -> Response { + Response::ok_json(&LogsDto { + ts: now_unix_ms(), + tap_active: false, + tap_capacity_entries: cap as u64, + tap_used_entries: 0, + next_seq: 0, + dropped_before: 0, + dropped_total: 0, + entries: Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cap_zero_503_response_shape() { + // Shape-parity guard for the `cap == 0` branch in handle_logs: + // we can't override config in a unit test, so this constructs the + // equivalent Response and pins the status + body keyword. Behavior + // of the branch itself is exercised end-to-end by the integration + // tests in src/web/tests.rs. + let r = Response::json( + 503, + "Service Unavailable", + r#"{"error":"log_tap_disabled","message":"log_tap_max_entries is 0 in config"}"#, + ); + assert_eq!(r.status, 503); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("log_tap_disabled")); + } + + #[test] + fn empty_response_shape_matches_logs_dto() { + let r = empty_response(8192); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + for field in [ + "\"ts\"", + "\"tap_active\":false", + "\"tap_capacity_entries\":8192", + "\"next_seq\":0", + "\"entries\":[]", + ] { + assert!(body.contains(field), "missing {field} in {body}"); + } + } +} diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs new file mode 100644 index 000000000..0e063c264 --- /dev/null +++ b/src/web/routes/mod.rs @@ -0,0 +1,36 @@ +//! REST API routes mounted under `/api/`. +//! +//! Phase 3a wires only `/api/version`, `/api/overview`, `/api/pools`. +//! Subsequent phases add `/api/clients`, `/api/servers`, top-N, etc. + +pub mod collect; +pub mod dto; + +pub(crate) mod admin; +pub(crate) mod apps; +pub(crate) mod auth_query; +pub(crate) mod clients; +pub(crate) mod config; +pub(crate) mod connections; +pub(crate) mod databases; +pub(crate) mod events; +pub(crate) mod interner; +pub(crate) mod interner_top; +pub(crate) mod log_level; +pub(crate) mod logs; +pub(crate) mod overview; +pub(crate) mod pool_coordinator; +pub(crate) mod pool_scaling; +pub(crate) mod pools; +pub(crate) mod prepared; +pub(crate) mod prepared_text; +pub(crate) mod process; +pub(crate) mod query; +pub(crate) mod servers; +pub(crate) mod sockets; +pub(crate) mod stats; +pub(crate) mod top_clients; +pub(crate) mod top_prepared; +pub(crate) mod top_queries; +pub(crate) mod users; +pub(crate) mod version; diff --git a/src/web/routes/overview.rs b/src/web/routes/overview.rs new file mode 100644 index 000000000..814071d85 --- /dev/null +++ b/src/web/routes/overview.rs @@ -0,0 +1,41 @@ +//! GET /api/overview handler. + +use crate::web::routes::collect::collect_overview; +use crate::web::server::Response; + +pub(crate) fn handle_overview() -> Response { + Response::ok_json(&collect_overview()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overview_response_is_200_json() { + let r = handle_overview(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + for field in [ + "\"ts\"", + "\"active_clients\"", + "\"idle_clients\"", + "\"waiting_clients\"", + "\"active_servers\"", + "\"idle_servers\"", + "\"connections_total\"", + "\"connections_tls_total\"", + "\"connections_plain_total\"", + "\"connections_cancel_total\"", + "\"query_count_total\"", + "\"transaction_count_total\"", + "\"errors_count_total\"", + "\"prepared_hits_total\"", + "\"prepared_misses_total\"", + "\"pools_total\"", + "\"pools_paused\"", + ] { + assert!(body.contains(field), "missing {field} in body={body}"); + } + } +} diff --git a/src/web/routes/pool_coordinator.rs b/src/web/routes/pool_coordinator.rs new file mode 100644 index 000000000..32a915519 --- /dev/null +++ b/src/web/routes/pool_coordinator.rs @@ -0,0 +1,22 @@ +//! GET /api/pool_coordinator handler. + +use crate::web::routes::collect::collect_pool_coordinator; +use crate::web::server::Response; + +pub(crate) fn handle_pool_coordinator() -> Response { + Response::ok_json(&collect_pool_coordinator()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_coordinator_response_is_200_with_envelope() { + let r = handle_pool_coordinator(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"databases\"")); + } +} diff --git a/src/web/routes/pool_scaling.rs b/src/web/routes/pool_scaling.rs new file mode 100644 index 000000000..97af41964 --- /dev/null +++ b/src/web/routes/pool_scaling.rs @@ -0,0 +1,22 @@ +//! GET /api/pool_scaling handler. + +use crate::web::routes::collect::collect_pool_scaling; +use crate::web::server::Response; + +pub(crate) fn handle_pool_scaling() -> Response { + Response::ok_json(&collect_pool_scaling()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_scaling_response_is_200_with_envelope() { + let r = handle_pool_scaling(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"pools\"")); + } +} diff --git a/src/web/routes/pools.rs b/src/web/routes/pools.rs new file mode 100644 index 000000000..94fcb3f8b --- /dev/null +++ b/src/web/routes/pools.rs @@ -0,0 +1,22 @@ +//! GET /api/pools handler. + +use crate::web::routes::collect::collect_pools; +use crate::web::server::Response; + +pub(crate) fn handle_pools() -> Response { + Response::ok_json(&collect_pools()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pools_response_is_200_json_with_array() { + let r = handle_pools(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\""), "body={body}"); + assert!(body.contains("\"pools\""), "body={body}"); + } +} diff --git a/src/web/routes/prepared.rs b/src/web/routes/prepared.rs new file mode 100644 index 000000000..24d0e784b --- /dev/null +++ b/src/web/routes/prepared.rs @@ -0,0 +1,22 @@ +//! GET /api/prepared handler. Public — aggregate without SQL text. + +use crate::web::routes::collect::collect_prepared; +use crate::web::server::Response; + +pub(crate) fn handle_prepared() -> Response { + Response::ok_json(&collect_prepared()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepared_response_is_200_with_envelope() { + let r = handle_prepared(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"prepared\"")); + } +} diff --git a/src/web/routes/prepared_text.rs b/src/web/routes/prepared_text.rs new file mode 100644 index 000000000..c3350e417 --- /dev/null +++ b/src/web/routes/prepared_text.rs @@ -0,0 +1,68 @@ +//! GET /api/prepared/text/{hash} handler. Admin-only (mux gates the prefix). + +use crate::web::routes::collect::collect_prepared_text; +use crate::web::server::Response; + +pub(crate) fn handle_prepared_text(hash_str: &str) -> Response { + let Some(hash) = parse_hash(hash_str) else { + return Response::json( + 400, + "Bad Request", + r#"{"error":"bad_hash","message":"hash must be decimal or 0x-prefixed hex u64"}"#, + ); + }; + match collect_prepared_text(hash) { + Some(dto) => Response::ok_json(&dto), + None => Response::json( + 404, + "Not Found", + r#"{"error":"not_found","message":"prepared statement not found for hash"}"#, + ), + } +} + +fn parse_hash(s: &str) -> Option { + if let Some(stripped) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + return u64::from_str_radix(stripped, 16).ok(); + } + s.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepared_text_returns_404_on_unknown_hash() { + let r = handle_prepared_text("0xdeadbeef"); + assert_eq!(r.status, 404); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("not_found")); + } + + #[test] + fn prepared_text_returns_400_on_malformed_hash() { + let r = handle_prepared_text("not-a-hash"); + assert_eq!(r.status, 400); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("bad_hash")); + } + + #[test] + fn parse_hash_decimal() { + assert_eq!(parse_hash("12345"), Some(12345)); + } + + #[test] + fn parse_hash_hex_prefix() { + assert_eq!(parse_hash("0xff"), Some(255)); + assert_eq!(parse_hash("0XFF"), Some(255)); + } + + #[test] + fn parse_hash_invalid() { + assert_eq!(parse_hash(""), None); + assert_eq!(parse_hash("xyz"), None); + assert_eq!(parse_hash("0xZZ"), None); + } +} diff --git a/src/web/routes/process.rs b/src/web/routes/process.rs new file mode 100644 index 000000000..af4081732 --- /dev/null +++ b/src/web/routes/process.rs @@ -0,0 +1,44 @@ +//! `GET /api/process` — process resource snapshot. Linux reads +//! `/proc/self/*`. macOS / others fill what they can from the existing +//! `get_process_memory_usage()` and otherwise zero out fields the operator +//! tile must still draw a card for. The route is `pub(crate)` and dispatched +//! from `web::server::route_api`. + +use crate::web::server::Response; + +use super::collect::{collect_memory_breakdown, collect_process}; + +pub(crate) fn handle_process() -> Response { + Response::ok_json(&collect_process()) +} + +/// `GET /api/process/memory` — drill-down for the RSS panel. Heavier than +/// `/api/process` (jemalloc epoch advance, full /proc/self/status parse, +/// cgroup files) so it lives behind a separate route. +pub(crate) fn handle_process_memory() -> Response { + Response::ok_json(&collect_memory_breakdown()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_response_envelope_shape() { + let r = handle_process(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + for field in [ + "\"ts\"", + "\"pid\"", + "\"hostname\"", + "\"uptime_seconds\"", + "\"rss_bytes\"", + "\"cpu_user_us\"", + "\"cpu_system_us\"", + "\"threads_breakdown\"", + ] { + assert!(body.contains(field), "missing {field} in {body}"); + } + } +} diff --git a/src/web/routes/query.rs b/src/web/routes/query.rs new file mode 100644 index 000000000..dac6639c1 --- /dev/null +++ b/src/web/routes/query.rs @@ -0,0 +1,101 @@ +//! Hand-rolled query string parser. +//! +//! Returns `BTreeMap>` so multi-value keys (e.g. +//! `?application_name=a&application_name=b`) are preserved in order. +//! Keeps the dependency surface small (no `serde_urlencoded`). + +use std::collections::BTreeMap; + +pub fn parse_query(q: &str) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + if q.is_empty() { + return out; + } + for part in q.split('&') { + if part.is_empty() { + continue; + } + let (k, v) = match part.split_once('=') { + Some((k, v)) => (decode(k), decode(v)), + None => (decode(part), String::new()), + }; + out.entry(k).or_default().push(v); + } + out +} + +fn decode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '+' => out.push(' '), + '%' => { + let hi = chars.next(); + let lo = chars.next(); + if let (Some(hi), Some(lo)) = (hi, lo) { + if let (Some(hi), Some(lo)) = (hi.to_digit(16), lo.to_digit(16)) { + out.push(((hi << 4 | lo) as u8) as char); + continue; + } + } + } + other => out.push(other), + } + } + out +} + +pub fn first(map: &BTreeMap>, key: &str) -> Option { + map.get(key).and_then(|v| v.first()).cloned() +} + +pub fn parse_u64(map: &BTreeMap>, key: &str, default: u64) -> u64 { + first(map, key) + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_query_returns_empty_map() { + assert!(parse_query("").is_empty()); + } + + #[test] + fn single_value() { + let m = parse_query("limit=50"); + assert_eq!(m.get("limit"), Some(&vec!["50".to_string()])); + } + + #[test] + fn multiple_values_for_same_key() { + let m = parse_query("application_name=a&application_name=b"); + assert_eq!( + m.get("application_name"), + Some(&vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn percent_decoding() { + let m = parse_query("user=alice%40example"); + assert_eq!(m.get("user"), Some(&vec!["alice@example".to_string()])); + } + + #[test] + fn plus_to_space() { + let m = parse_query("application_name=my+app"); + assert_eq!(m.get("application_name"), Some(&vec!["my app".to_string()])); + } + + #[test] + fn parse_u64_with_default() { + let m = parse_query("limit=42"); + assert_eq!(parse_u64(&m, "limit", 100), 42); + assert_eq!(parse_u64(&m, "missing", 100), 100); + } +} diff --git a/src/web/routes/servers.rs b/src/web/routes/servers.rs new file mode 100644 index 000000000..c9b71a254 --- /dev/null +++ b/src/web/routes/servers.rs @@ -0,0 +1,55 @@ +//! GET /api/servers handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_servers; +use crate::web::routes::dto::{ServerFilters, ServerSort, SortOrder}; +use crate::web::routes::query::{first, parse_u64}; +use crate::web::server::Response; + +pub(crate) fn handle_servers(query: &BTreeMap>) -> Response { + let filters = parse_filters(query); + Response::ok_json(&collect_servers(&filters)) +} + +fn parse_filters(query: &BTreeMap>) -> ServerFilters { + ServerFilters { + limit: parse_u64(query, "limit", 100), + offset: parse_u64(query, "offset", 0), + sort: match first(query, "sort").as_deref() { + Some("queries_total") => ServerSort::QueriesTotal, + Some("errors_total") => ServerSort::ErrorsTotal, + Some("active_age_ms") => ServerSort::ActiveAgeMs, + _ => ServerSort::AgeSeconds, + }, + order: match first(query, "order").as_deref() { + Some("asc") => SortOrder::Asc, + _ => SortOrder::Desc, + }, + pool: first(query, "pool"), + database: first(query, "database"), + user: first(query, "user"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn servers_response_is_200_json_with_envelope() { + let q = BTreeMap::new(); + let r = handle_servers(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + for field in [ + "\"ts\"", + "\"total\"", + "\"limit\"", + "\"offset\"", + "\"servers\"", + ] { + assert!(body.contains(field), "missing {field} in {body}"); + } + } +} diff --git a/src/web/routes/sockets.rs b/src/web/routes/sockets.rs new file mode 100644 index 000000000..dda8a2ba4 --- /dev/null +++ b/src/web/routes/sockets.rs @@ -0,0 +1,51 @@ +//! GET /api/sockets handler. Linux-only — non-linux returns 503 not_supported. + +use crate::web::server::Response; + +pub(crate) fn handle_sockets() -> Response { + #[cfg(target_os = "linux")] + { + match crate::web::routes::collect::collect_sockets() { + Ok(dto) => Response::ok_json(&dto), + Err(msg) => { + log::error!("collect_sockets failed: {msg}"); + Response::json( + 500, + "Internal Server Error", + r#"{"error":"sockets_unavailable","message":"failed to read socket states"}"#, + ) + } + } + } + #[cfg(not(target_os = "linux"))] + { + Response::json( + 503, + "Service Unavailable", + r#"{"error":"not_supported","message":"sockets endpoint requires Linux"}"#, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn sockets_response_is_200_on_linux() { + let r = handle_sockets(); + // Note: returns 500 if /proc/net/tcp* unreadable in CI sandbox; accept + // both as long as the handler did not panic. + assert!(r.status == 200 || r.status == 500, "got {}", r.status); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn sockets_response_is_503_on_non_linux() { + let r = handle_sockets(); + assert_eq!(r.status, 503); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("not_supported")); + } +} diff --git a/src/web/routes/stats.rs b/src/web/routes/stats.rs new file mode 100644 index 000000000..286836372 --- /dev/null +++ b/src/web/routes/stats.rs @@ -0,0 +1,22 @@ +//! GET /api/stats handler. + +use crate::web::routes::collect::collect_stats; +use crate::web::server::Response; + +pub(crate) fn handle_stats() -> Response { + Response::ok_json(&collect_stats()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stats_response_is_200_with_envelope() { + let r = handle_stats(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"stats\"")); + } +} diff --git a/src/web/routes/top_clients.rs b/src/web/routes/top_clients.rs new file mode 100644 index 000000000..932f5ffc9 --- /dev/null +++ b/src/web/routes/top_clients.rs @@ -0,0 +1,47 @@ +//! GET /api/top/clients?by=qps|errors|age&n=20&pool= handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_top_clients; +use crate::web::routes::dto::{TopClientBy, TopClientFilters}; +use crate::web::routes::query::{first, parse_u64}; +use crate::web::server::Response; + +pub(crate) fn handle_top_clients(query: &BTreeMap>) -> Response { + let filters = TopClientFilters { + by: match first(query, "by").as_deref() { + Some("errors") => TopClientBy::Errors, + Some("age") => TopClientBy::Age, + _ => TopClientBy::Qps, + }, + n: parse_u64(query, "n", 0), + pool: first(query, "pool"), + }; + Response::ok_json(&collect_top_clients(&filters)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn top_clients_returns_200_with_envelope() { + let q = BTreeMap::new(); + let r = handle_top_clients(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"by\":\"qps\"")); + assert!(body.contains("\"n\":20")); + assert!(body.contains("\"clients\"")); + } + + #[test] + fn top_clients_by_errors_param() { + let mut q = BTreeMap::new(); + q.insert("by".into(), vec!["errors".into()]); + let r = handle_top_clients(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"by\":\"errors\"")); + } +} diff --git a/src/web/routes/top_prepared.rs b/src/web/routes/top_prepared.rs new file mode 100644 index 000000000..0d1eea57b --- /dev/null +++ b/src/web/routes/top_prepared.rs @@ -0,0 +1,44 @@ +//! GET /api/top/prepared?by=hits|misses&n=20 handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_top_prepared; +use crate::web::routes::dto::{TopPreparedBy, TopPreparedFilters}; +use crate::web::routes::query::{first, parse_u64}; +use crate::web::server::Response; + +pub(crate) fn handle_top_prepared(query: &BTreeMap>) -> Response { + let filters = TopPreparedFilters { + by: match first(query, "by").as_deref() { + Some("misses") => TopPreparedBy::Misses, + _ => TopPreparedBy::Hits, + }, + n: parse_u64(query, "n", 0), + }; + Response::ok_json(&collect_top_prepared(&filters)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn top_prepared_returns_200_with_envelope() { + let q = BTreeMap::new(); + let r = handle_top_prepared(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"by\":\"hits\"")); + assert!(body.contains("\"n\":20")); + assert!(body.contains("\"prepared\"")); + } + + #[test] + fn top_prepared_by_misses_param() { + let mut q = BTreeMap::new(); + q.insert("by".into(), vec!["misses".into()]); + let r = handle_top_prepared(&q); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"by\":\"misses\"")); + } +} diff --git a/src/web/routes/top_queries.rs b/src/web/routes/top_queries.rs new file mode 100644 index 000000000..672b58fcb --- /dev/null +++ b/src/web/routes/top_queries.rs @@ -0,0 +1,44 @@ +//! GET /api/top/queries?by=count|duration&n=20 handler. + +use std::collections::BTreeMap; + +use crate::web::routes::collect::collect_top_queries; +use crate::web::routes::dto::{TopQueryBy, TopQueryFilters}; +use crate::web::routes::query::{first, parse_u64}; +use crate::web::server::Response; + +pub(crate) fn handle_top_queries(query: &BTreeMap>) -> Response { + let filters = TopQueryFilters { + by: match first(query, "by").as_deref() { + Some("duration") => TopQueryBy::Duration, + _ => TopQueryBy::Count, + }, + n: parse_u64(query, "n", 0), + }; + Response::ok_json(&collect_top_queries(&filters)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn top_queries_returns_200_with_envelope() { + let q = BTreeMap::new(); + let r = handle_top_queries(&q); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"by\":\"count\"")); + assert!(body.contains("\"n\":20")); + assert!(body.contains("\"queries\"")); + } + + #[test] + fn top_queries_by_duration_param() { + let mut q = BTreeMap::new(); + q.insert("by".into(), vec!["duration".into()]); + let r = handle_top_queries(&q); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"by\":\"duration\"")); + } +} diff --git a/src/web/routes/users.rs b/src/web/routes/users.rs new file mode 100644 index 000000000..5ae085a7c --- /dev/null +++ b/src/web/routes/users.rs @@ -0,0 +1,22 @@ +//! GET /api/users handler. + +use crate::web::routes::collect::collect_users; +use crate::web::server::Response; + +pub(crate) fn handle_users() -> Response { + Response::ok_json(&collect_users()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn users_response_is_200_with_envelope() { + let r = handle_users(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"ts\"")); + assert!(body.contains("\"users\"")); + } +} diff --git a/src/web/routes/version.rs b/src/web/routes/version.rs new file mode 100644 index 000000000..76e2a24a4 --- /dev/null +++ b/src/web/routes/version.rs @@ -0,0 +1,24 @@ +//! GET /api/version handler. + +use crate::web::routes::collect::collect_version; +use crate::web::server::Response; + +pub(crate) fn handle_version() -> Response { + Response::ok_json(&collect_version()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_response_is_200_json_with_required_fields() { + let r = handle_version(); + assert_eq!(r.status, 200); + let body = std::str::from_utf8(&r.body).unwrap(); + assert!(body.contains("\"version\""), "body={body}"); + assert!(body.contains("\"git_commit\""), "body={body}"); + assert!(body.contains("\"build_date\""), "body={body}"); + assert!(body.contains("\"ts\""), "body={body}"); + } +} diff --git a/src/web/server/http.rs b/src/web/server/http.rs new file mode 100644 index 000000000..dcecbe2f1 --- /dev/null +++ b/src/web/server/http.rs @@ -0,0 +1,174 @@ +//! HTTP/1.1 keep-alive connection driver. Each accepted socket runs through +//! [`handle_connection`], which loops reading request heads off a per-connection +//! buffer, dispatches to the router (or to async log/admin handlers when the +//! response cannot be produced synchronously), and stops when the client +//! signals close or the per-connection request cap is hit. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, BufReader, BufWriter}; +use tokio::net::tcp::OwnedReadHalf; +use tokio::net::TcpStream; + +use crate::web::auth::{classify, AuthOutcome}; +use crate::web::metrics::write_metrics_response; + +use super::router::{dispatch, unauthorized_for}; +use super::state::WebServerOptions; +use super::wire::{find_double_crlf, write_simple, ParsedRequest, ReadError}; + +/// Soft cap on requests per keep-alive connection. After this many +/// requests we close so a misbehaving client cannot pin a worker +/// forever; HTTP/1.1 clients that need more will reconnect. +const KEEPALIVE_MAX_REQUESTS: u32 = 1000; + +/// Idle timeout between requests on a keep-alive connection. Browsers +/// hold these open for minutes by default; pg_doorman terminates faster +/// because each idle connection still costs an FD and a tokio task. +const KEEPALIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +pub(super) async fn handle_connection(stream: TcpStream, opts: Arc) { + let (read_half, write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + let mut writer = BufWriter::new(write_half); + + let mut req_buf: Vec = Vec::with_capacity(4096); + let mut handled = 0u32; + while handled < KEEPALIVE_MAX_REQUESTS { + // `req_buf` carries over any bytes from the previous read that + // belonged to the *next* request (clients can pipeline two GETs + // into one TCP write). `read_request_head` extends it until the + // header terminator is in view, then we slice off only the + // first request and keep the tail for the next iteration. + let head_end = match read_request_head(&mut reader, &mut req_buf).await { + Ok(0) => return, // peer closed cleanly between requests + Ok(end) => end, + Err(ReadError::Io(_)) | Err(ReadError::Idle) => return, + Err(ReadError::TooLarge) => { + let _ = write_simple(&mut writer, 431, "Request Header Fields Too Large").await; + return; + } + }; + let close_after = { + let head_bytes = &req_buf[..head_end]; + let raw = match std::str::from_utf8(head_bytes) { + Ok(s) => s, + Err(_) => { + let _ = write_simple(&mut writer, 400, "Bad Request").await; + return; + } + }; + let Some(parsed) = ParsedRequest::parse(raw) else { + let _ = write_simple(&mut writer, 400, "Bad Request").await; + return; + }; + let close_after = parsed.connection_close; + + // /metrics is always served, regardless of ui_active or auth. + if parsed.method == "GET" && parsed.path == "/metrics" { + write_metrics_response(&mut writer, parsed.accepts_gzip).await; + } else { + let auth = classify( + parsed.authorization, + &opts.admin_username, + &opts.admin_password, + ); + + // /api/logs needs an async handler because it talks to the LogTap consumer + // task via mpsc + oneshot; the rest of the API stays sync. Pre-screen + // ui_active and admin auth here so dispatch() never sees the path on the + // success branch — on auth failure or inactive UI we fall through to + // dispatch() which already returns the right 401/404. + if opts.ui_active + && parsed.method == "GET" + && (parsed.path == "/api/logs" || parsed.path.starts_with("/api/logs?")) + { + if auth != AuthOutcome::Admin { + let _ = unauthorized_for(&parsed).write(&mut writer).await; + } else { + let query_str = parsed.path.split_once('?').map(|(_, q)| q).unwrap_or(""); + let query = crate::web::routes::query::parse_query(query_str); + let response = crate::web::routes::logs::handle_logs(&query).await; + let _ = response.write(&mut writer).await; + } + } else if opts.ui_active + && parsed.method == "POST" + && parsed.path.starts_with("/api/admin/") + { + if auth != AuthOutcome::Admin { + let _ = unauthorized_for(&parsed).write(&mut writer).await; + } else { + let response = + crate::web::routes::admin::handle_admin_action(parsed.path).await; + let _ = response.write(&mut writer).await; + } + } else { + let response = dispatch(&parsed, &opts, auth); + let _ = response.write(&mut writer).await; + } + } + close_after + }; + + // Discard the request we just answered; pipelined bytes (a + // second request that came in the same TCP read) stay at the + // head of `req_buf` for the next iteration to consume. + req_buf.drain(..head_end); + handled += 1; + if close_after { + return; + } + } + // Hit the per-connection request cap. Close so the client knows to + // reconnect rather than queue more behind us. +} + +/// Extend `buf` with bytes from the wire until the request-header +/// terminator `\r\n\r\n` is in view. Returns the offset *just past* the +/// terminator (so the caller knows where the headers end and any +/// pipelined body / next request begin), or `Ok(0)` if the peer closed +/// cleanly between requests. Caps the buffer at 32 KiB so a malicious +/// client cannot push us into OOM. +async fn read_request_head( + reader: &mut BufReader, + buf: &mut Vec, +) -> Result { + const MAX_HEADER_BYTES: usize = 32 * 1024; + if buf.is_empty() { + // Wait up to KEEPALIVE_IDLE_TIMEOUT for the first byte; once + // bytes arrive, the read loop below drives without an outer + // timeout because the headers are bounded by MAX_HEADER_BYTES. + let mut chunk = [0u8; 1024]; + let read_fut = reader.read(&mut chunk); + let n = match tokio::time::timeout(KEEPALIVE_IDLE_TIMEOUT, read_fut).await { + Ok(r) => r?, + Err(_elapsed) => return Err(ReadError::Idle), + }; + if n == 0 { + return Ok(0); + } + buf.extend_from_slice(&chunk[..n]); + } + if let Some(end) = find_double_crlf(buf) { + return Ok(end); + } + let mut chunk = [0u8; 1024]; + loop { + let n = reader.read(&mut chunk).await?; + if n == 0 { + // Peer closed mid-request — treat as malformed. + return Err(ReadError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "EOF mid request headers", + ))); + } + buf.extend_from_slice(&chunk[..n]); + if let Some(end) = find_double_crlf(buf) { + return Ok(end); + } + if buf.len() >= MAX_HEADER_BYTES { + return Err(ReadError::TooLarge); + } + } +} diff --git a/src/web/server/listener.rs b/src/web/server/listener.rs new file mode 100644 index 000000000..a91c4e90c --- /dev/null +++ b/src/web/server/listener.rs @@ -0,0 +1,74 @@ +//! TCP bind and accept loop. The listener owns the lifetime of the +//! [`WebServerOptions`] slot — it seeds the slot on bind so every spawned +//! connection task reads the same reload-aware view via +//! [`current_options`]. + +use std::net::SocketAddr; +use std::sync::Arc; + +use log::{error, info}; +use tokio::net::{TcpListener, TcpSocket}; + +use super::http::handle_connection; +use super::state::{current_options, install_options, WebServerOptions}; + +/// Bind the listener synchronously and return it. Used by callers that +/// want to fail fast when the configured port is taken: the daemon's +/// readiness signal must wait until the web subsystem is verifiably +/// listening, otherwise systemd / a binary-upgrade parent treats the +/// pooler as healthy while `/metrics` and the UI are silently down. +pub fn bind_web_listener(host: &str) -> std::io::Result { + info!("binding web listener on {host}"); + let addr: SocketAddr = host.parse().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Failed to parse socket address '{host}': {e}"), + ) + })?; + + let listen_socket = if addr.is_ipv4() { + TcpSocket::new_v4() + } else { + TcpSocket::new_v6() + }?; + + listen_socket.set_reuseaddr(true)?; + listen_socket.set_reuseport(true)?; + listen_socket.bind(addr)?; + let listener = listen_socket.listen(1024)?; + info!("web listener bound on {addr}"); + Ok(listener) +} + +/// Test-only convenience that binds and serves in one call. Production +/// code uses [`bind_web_listener`] + [`serve_on`] so a port collision +/// fails the whole startup instead of leaving the listener task +/// panicked behind a successful readiness signal. Gated on `cfg(test)` +/// so external embedders cannot trip the panic by accident. +#[cfg(test)] +pub(crate) async fn start_web_server(host: &str, opts: WebServerOptions) { + let listener = bind_web_listener(host) + .unwrap_or_else(|e| panic!("Failed to bind web listener on {host}: {e}")); + serve_on(listener, opts).await; +} + +/// Drive the accept loop on a pre-bound listener. Used by both +/// [`start_web_server`] and the production startup path that binds +/// synchronously before spawning. +pub async fn serve_on(listener: TcpListener, opts: WebServerOptions) { + install_options(Arc::new(opts)); + + loop { + match listener.accept().await { + Ok((stream, _)) => { + let opts = current_options(); + tokio::spawn(async move { + handle_connection(stream, opts).await; + }); + } + Err(e) => { + error!("Failed to accept connection: {e}"); + } + } + } +} diff --git a/src/web/server/mod.rs b/src/web/server/mod.rs new file mode 100644 index 000000000..bb651f66b --- /dev/null +++ b/src/web/server/mod.rs @@ -0,0 +1,34 @@ +//! HTTP listener + path mux for the web subsystem. +//! +//! Routes: +//! - `GET /metrics` → Prometheus exporter, no auth. +//! - `GET /api/version` → version info, public. +//! - `GET /api/overview` → cluster overview, public. +//! - `GET /api/pools` → pool list, public. +//! - `GET /api/*` → other endpoints return 501 until wired in later phases. +//! - `GET /` | `GET /assets/*` → SPA placeholder, returns 404 (filled in phase 7). +//! - everything else → 404. +//! +//! Submodule layout (codex Arch P2#5 split — the original single-file +//! `server.rs` mixed listener lifecycle, HTTP parsing, auth policy, routing, +//! and response serialization in ~1300 lines): +//! - [`state`] — reload-aware [`WebServerOptions`] backed by `ArcSwap`. +//! - [`wire`] — request parser, response builder, gzip cache, header helpers. +//! - [`http`] — keep-alive driven HTTP/1.1 connection handler. +//! - [`router`] — path dispatch and admin-only gating. +//! - [`listener`] — TCP bind and accept loop. + +mod http; +mod listener; +mod router; +mod state; +mod wire; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(crate) use listener::start_web_server; +pub use listener::{bind_web_listener, serve_on}; +pub use state::{refresh_options_from_config, WebServerOptions}; +pub(crate) use wire::Response; diff --git a/src/web/server/router.rs b/src/web/server/router.rs new file mode 100644 index 000000000..c528e9908 --- /dev/null +++ b/src/web/server/router.rs @@ -0,0 +1,132 @@ +//! Path dispatch and admin-only gating. The router has no knowledge of +//! the wire format beyond what [`ParsedRequest`] exposes — it picks a +//! handler and returns a [`Response`]. + +use crate::web::auth::AuthOutcome; +use crate::web::routes; +use crate::web::routes::query::parse_query; + +use super::state::WebServerOptions; +use super::wire::{ParsedRequest, Response}; + +/// Admin-only path prefixes (require `Admin` auth regardless of `ui_anonymous`). +/// Spec section 6.1. +const ADMIN_ONLY_PREFIXES: &[&str] = &[ + "/api/logs", + "/api/prepared/text/", + "/api/interner/top", + // /api/top/queries returns SQL previews — first 120 chars of cached + // statements. Tenant ids, literal values, schema names, and the + // occasional accidental secret embedded in SQL all leak through; + // keep it admin-only regardless of `ui_anonymous`. + "/api/top/queries", + "/api/admin/", +]; + +pub(super) fn is_admin_only(path: &str) -> bool { + ADMIN_ONLY_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +/// Picks the right 401 shape for the caller. Browsers and curl get the +/// `WWW-Authenticate: Basic` challenge so existing tooling keeps working; +/// `Accept: application/json` (the SPA) gets a plain 401 so the React +/// modal can take over without the browser caching credentials. +pub(super) fn unauthorized_for(req: &ParsedRequest<'_>) -> Response { + if req.accepts_json { + Response::unauthorized_silent() + } else { + Response::unauthorized() + } +} + +fn route_api(req: &ParsedRequest<'_>) -> Response { + let (path, query_str) = match req.path.split_once('?') { + Some((p, q)) => (p, q), + None => (req.path, ""), + }; + let query = parse_query(query_str); + + // Prefix-routed paths first (admin-only; mux already gated auth). + if let Some(hash) = path.strip_prefix("/api/prepared/text/") { + return routes::prepared_text::handle_prepared_text(hash); + } + + match path { + "/api/version" => routes::version::handle_version(), + "/api/overview" => routes::overview::handle_overview(), + "/api/pools" => routes::pools::handle_pools(), + "/api/clients" => routes::clients::handle_clients(&query), + "/api/connections" => routes::connections::handle_connections(), + "/api/databases" => routes::databases::handle_databases(), + "/api/servers" => routes::servers::handle_servers(&query), + "/api/stats" => routes::stats::handle_stats(), + "/api/users" => routes::users::handle_users(), + "/api/auth_query" => routes::auth_query::handle_auth_query(), + "/api/config" => routes::config::handle_config(), + "/api/log_level" => routes::log_level::handle_log_level(), + "/api/pool_coordinator" => routes::pool_coordinator::handle_pool_coordinator(), + "/api/pool_scaling" => routes::pool_scaling::handle_pool_scaling(), + "/api/process" => routes::process::handle_process(), + "/api/process/memory" => routes::process::handle_process_memory(), + "/api/sockets" => routes::sockets::handle_sockets(), + "/api/prepared" => routes::prepared::handle_prepared(), + "/api/interner" => routes::interner::handle_interner(), + "/api/interner/top" => routes::interner_top::handle_interner_top(&query), + "/api/top/clients" => routes::top_clients::handle_top_clients(&query), + "/api/top/prepared" => routes::top_prepared::handle_top_prepared(&query), + "/api/top/queries" => routes::top_queries::handle_top_queries(&query), + "/api/apps" => routes::apps::handle_apps(&query), + "/api/events" => routes::events::handle_events(&query), + _ => Response::json( + 501, + "Not Implemented", + r#"{"error":"not_implemented","message":"endpoint will be wired in a later phase"}"#, + ), + } +} + +pub(super) fn dispatch( + req: &ParsedRequest<'_>, + opts: &WebServerOptions, + auth: AuthOutcome, +) -> Response { + let is_admin_post = req.method == "POST" && req.path.starts_with("/api/admin/"); + if req.method != "GET" && req.method != "HEAD" && !is_admin_post { + return Response::status(405, "Method Not Allowed"); + } + + if !opts.ui_active { + // /metrics already handled before dispatch(). + return Response::status(404, "Not Found"); + } + + let is_api = req.path.starts_with("/api/"); + let admin_only = is_api && is_admin_only(req.path); + + // The SPA shell (HTML, CSS, JS, fonts, favicon) carries no operator data + // — the basic-auth challenge is reserved for `/api/*`. Letting the shell + // load anonymously avoids the double-prompt operators saw on a deep link: + // browser-native basic auth on the HTML, then the React `AuthGate` modal + // on the first JSON fetch. Now the React modal is the single password + // prompt the operator ever sees. + let needs_admin = admin_only || (is_api && !opts.ui_anonymous); + if needs_admin && auth != AuthOutcome::Admin { + return unauthorized_for(req); + } + + if is_api { + return route_api(req); + } + + // SPA: serve the embedded bundle. Anything that is not /api or /metrics + // resolves to a static asset or falls back to the SPA shell so client-side + // routes (`/pools`, `/clients/...`) work on a hard refresh. + let bundle_path = req.path.split_once('?').map(|(p, _)| p).unwrap_or(req.path); + if let Some(asset) = crate::web::static_assets::lookup(bundle_path) { + return Response::static_asset(&asset, req.accepts_gzip); + } + + Response::status(404, "Not Found") +} diff --git a/src/web/server/state.rs b/src/web/server/state.rs new file mode 100644 index 000000000..9cfd183ad --- /dev/null +++ b/src/web/server/state.rs @@ -0,0 +1,79 @@ +//! Reload-aware listener options. Every request reads the current value +//! through [`current_options`]; admin-protocol `RELOAD` and the REST +//! `/api/admin/reload` endpoint update the global config and then call +//! [`refresh_options_from_config`] to swap the slot atomically. + +use std::sync::{Arc, OnceLock}; + +use arc_swap::ArcSwap; + +use crate::config::Config; + +/// Runtime state needed by the mux on every request. +#[derive(Clone)] +pub struct WebServerOptions { + /// `true` when `[web].ui = true` AND admin_password is non-default. + /// When `false`, the listener serves only `/metrics`; everything else → 404. + pub ui_active: bool, + /// `[web].ui_anonymous` — gates the public `/api/*` endpoints when + /// `ui_active`. The SPA shell (HTML/CSS/JS/font/svg) is always served + /// anonymously so a hard refresh of a deep link does not trigger a + /// browser-native basic-auth prompt on top of the React `AuthGate`. + pub ui_anonymous: bool, + pub admin_username: String, + pub admin_password: String, +} + +impl WebServerOptions { + /// Build the request-time options from a config snapshot. `ui_active` + /// is gated on a non-default admin password — `web.ui = true` paired + /// with an empty/`"admin"` password is silently demoted to "metrics + /// only", matching the explicit warning the startup path logs in + /// `app::server::run_server`. + pub fn from_config(cfg: &Config) -> Self { + let admin_default = + cfg.general.admin_password.is_empty() || cfg.general.admin_password == "admin"; + WebServerOptions { + ui_active: cfg.web.ui && !admin_default, + ui_anonymous: cfg.web.ui_anonymous, + admin_username: cfg.general.admin_username.clone(), + admin_password: cfg.general.admin_password.clone(), + } + } +} + +/// Reload-aware options snapshot used by every request. Installed once on +/// `start_web_server`, swapped atomically when the admin protocol or the +/// REST `/api/admin/reload` endpoint replaces the global config. Without +/// this, `RELOAD` would update `/api/config` but the listener would keep +/// authenticating against the old password and ignoring `[web].ui_anonymous` +/// changes until the next process restart. +static WEB_OPTIONS: OnceLock> = OnceLock::new(); + +pub(super) fn install_options(opts: Arc) { + if let Some(swap) = WEB_OPTIONS.get() { + swap.store(opts); + } else { + let _ = WEB_OPTIONS.set(ArcSwap::from(opts)); + } +} + +pub(super) fn current_options() -> Arc { + WEB_OPTIONS + .get() + .map(|swap| swap.load_full()) + .unwrap_or_else(|| { + // Fallback for code paths that read options before the listener + // started. Recomputes from the live config so behavior is at + // least defined; `start_web_server` will replace it on bind. + Arc::new(WebServerOptions::from_config(&crate::config::get_config())) + }) +} + +/// Re-derive the listener's runtime options from the current global config. +/// Called by every code path that updates the global `Config` (admin +/// protocol `RELOAD`, REST `/api/admin/reload`). Idempotent. +pub fn refresh_options_from_config() { + let cfg = crate::config::get_config(); + install_options(Arc::new(WebServerOptions::from_config(&cfg))); +} diff --git a/src/web/server/tests.rs b/src/web/server/tests.rs new file mode 100644 index 000000000..15f04f9bc --- /dev/null +++ b/src/web/server/tests.rs @@ -0,0 +1,603 @@ +use crate::config::Config; +use crate::web::auth::AuthOutcome; + +use super::router::{dispatch, is_admin_only}; +use super::state::WebServerOptions; +use super::wire::ParsedRequest; + +fn opts(ui_active: bool, ui_anonymous: bool) -> WebServerOptions { + WebServerOptions { + ui_active, + ui_anonymous, + admin_username: "admin".into(), + admin_password: "secret".into(), + } +} + +fn req<'a>(method: &'a str, path: &'a str) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + authorization: None, + accepts_gzip: false, + accepts_json: false, + connection_close: false, + } +} + +fn req_json<'a>(method: &'a str, path: &'a str) -> ParsedRequest<'a> { + ParsedRequest { + method, + path, + authorization: None, + accepts_gzip: false, + accepts_json: true, + connection_close: false, + } +} + +fn config_with(ui: bool, ui_anonymous: bool, admin_password: &str) -> Config { + let mut cfg = Config::default(); + cfg.web.ui = ui; + cfg.web.ui_anonymous = ui_anonymous; + cfg.general.admin_username = "admin".into(); + cfg.general.admin_password = admin_password.into(); + cfg +} + +#[test] +fn from_config_demotes_ui_when_admin_password_empty() { + let opts = WebServerOptions::from_config(&config_with(true, false, "")); + assert!(!opts.ui_active, "empty password must disable UI"); +} + +#[test] +fn from_config_demotes_ui_when_admin_password_is_default_admin() { + let opts = WebServerOptions::from_config(&config_with(true, false, "admin")); + assert!(!opts.ui_active, "literal 'admin' must disable UI"); +} + +#[test] +fn from_config_keeps_ui_off_when_web_ui_false_even_with_strong_password() { + let opts = WebServerOptions::from_config(&config_with(false, false, "secret")); + assert!(!opts.ui_active); +} + +#[test] +fn from_config_enables_ui_when_password_strong_and_web_ui_true() { + let opts = WebServerOptions::from_config(&config_with(true, true, "secret")); + assert!(opts.ui_active); + assert!(opts.ui_anonymous); +} + +#[test] +fn from_config_copies_credentials_through() { + let mut cfg = config_with(false, false, "p4ssw0rd"); + cfg.general.admin_username = "ops".into(); + let opts = WebServerOptions::from_config(&cfg); + assert_eq!(opts.admin_username, "ops"); + assert_eq!(opts.admin_password, "p4ssw0rd"); +} + +#[test] +fn parse_minimal_get() { + let raw = "GET /api/foo HTTP/1.1\r\nHost: x\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert_eq!(p.method, "GET"); + assert_eq!(p.path, "/api/foo"); + assert_eq!(p.authorization, None); + assert!(!p.accepts_gzip); +} + +#[test] +fn parse_with_authorization_header() { + let raw = "GET /api/foo HTTP/1.1\r\nHost: x\r\nAuthorization: Basic abc\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert_eq!(p.authorization, Some("Basic abc")); + assert!(!p.accepts_gzip); +} + +#[test] +fn parse_with_lowercase_authorization() { + let raw = "GET /api/foo HTTP/1.1\r\nHost: x\r\nauthorization: Basic abc\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert_eq!(p.authorization, Some("Basic abc")); + assert!(!p.accepts_gzip); +} + +#[test] +fn parse_rejects_malformed_request_line() { + assert!(ParsedRequest::parse("garbage").is_none()); +} + +#[test] +fn parse_detects_accept_application_json() { + let raw = "GET /api/foo HTTP/1.1\r\nHost: x\r\nAccept: application/json\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert!(p.accepts_json); +} + +#[test] +fn parse_detects_lowercase_accept() { + let raw = "GET /api/foo HTTP/1.1\r\nHost: x\r\naccept: application/json, */*\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert!(p.accepts_json); +} + +#[test] +fn parse_does_not_detect_json_when_accept_is_html() { + let raw = "GET / HTTP/1.1\r\nHost: x\r\nAccept: text/html\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert!(!p.accepts_json); +} + +#[test] +fn parse_detects_gzip_in_accept_encoding() { + let raw = "GET /metrics HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip, deflate\r\n\r\n"; + let p = ParsedRequest::parse(raw).unwrap(); + assert!(p.accepts_gzip); +} + +#[test] +fn dispatch_rejects_post() { + let r = dispatch( + &req("POST", "/api/foo"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 405); +} + +#[test] +fn dispatch_404_when_ui_inactive() { + let r = dispatch( + &req("GET", "/api/foo"), + &opts(false, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 404); +} + +#[test] +fn dispatch_unknown_api_returns_501() { + let r = dispatch( + &req("GET", "/api/not-yet-wired"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 501); +} + +#[test] +fn dispatch_overview_returns_200() { + let r = dispatch( + &req("GET", "/api/overview"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_version_returns_200() { + let r = dispatch( + &req("GET", "/api/version"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_pools_returns_200() { + let r = dispatch( + &req("GET", "/api/pools"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_admin_anonymous_json_request_returns_401_without_challenge() { + // SPA / JSON callers must NOT receive WWW-Authenticate, otherwise the + // browser caches credentials we did not solicit and replays them + // forever, hiding the React sign-in modal. + let r = dispatch( + &req_json("GET", "/api/logs"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); + assert!( + !r.extra_headers + .iter() + .any(|(k, _)| *k == "WWW-Authenticate"), + "JSON 401 should not advertise Basic auth" + ); +} + +#[test] +fn dispatch_401_on_anonymous_admin_path() { + let r = dispatch( + &req("GET", "/api/logs"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); + assert!(r + .extra_headers + .iter() + .any(|(k, _)| *k == "WWW-Authenticate")); +} + +// Note: the admin-success path for /api/logs is handled by the bypass +// in `handle_connection` (async handler over LogTap mpsc), not by +// `dispatch`. Integration tests in src/web/tests.rs cover that route. + +#[test] +fn dispatch_401_on_anonymous_public_when_ui_anonymous_false() { + let r = dispatch( + &req("GET", "/api/overview"), + &opts(true, false), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); +} + +#[test] +fn dispatch_serves_spa_shell_at_root() { + let r = dispatch(&req("GET", "/"), &opts(true, true), AuthOutcome::Admin); + assert_eq!(r.status, 200); + assert!( + r.extra_headers + .iter() + .any(|(k, v)| *k == "Content-Type" && v.contains("text/html")), + "root should serve the SPA shell" + ); +} + +#[test] +fn dispatch_serves_spa_shell_for_unknown_route() { + // Client-side router hits this path on a hard refresh of a deep link. + let r = dispatch(&req("GET", "/pools"), &opts(true, true), AuthOutcome::Admin); + assert_eq!(r.status, 200); + assert!( + r.extra_headers + .iter() + .any(|(k, v)| *k == "Content-Type" && v.contains("text/html")), + "deep link should fall back to the SPA shell" + ); +} + +#[test] +fn dispatch_serves_spa_shell_anonymously_when_ui_anonymous_false() { + // Hard-refreshing a deep link must not trigger a browser-native + // basic-auth prompt on top of the React `AuthGate`. The SPA shell is + // anonymous regardless of `ui_anonymous`; only `/api/*` is gated. + for path in ["/", "/overview", "/pools/some-pool"] { + let r = dispatch( + &req("GET", path), + &opts(true, false), + AuthOutcome::Anonymous, + ); + assert_eq!( + r.status, 200, + "anonymous SPA shell should serve {path} with ui_anonymous=false" + ); + assert!( + r.extra_headers + .iter() + .any(|(k, v)| *k == "Content-Type" && v.contains("text/html")), + "{path} should serve the SPA shell as text/html" + ); + } +} + +#[test] +fn dispatch_still_gates_api_when_ui_anonymous_false_after_spa_relax() { + // Counterpart to `dispatch_serves_spa_shell_anonymously_when_ui_anonymous_false`: + // the loosened gate must not leak `/api/*` to anonymous callers. + let r = dispatch( + &req("GET", "/api/overview"), + &opts(true, false), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); +} + +#[test] +fn dispatch_returns_404_for_unknown_asset_when_index_missing() { + // The bundle is committed in this repo so this test only exercises the + // Cache-Control / mime path, not the missing-bundle branch — keep the + // assertion shape forward-compatible by allowing 200 (asset hit) or + // SPA fallback. + let r = dispatch( + &req("GET", "/assets/missing.js"), + &opts(true, true), + AuthOutcome::Admin, + ); + // SPA fallback always returns 200 with the index when the bundle is + // present. If we ever ship without dist, this would be 404. + assert!(r.status == 200 || r.status == 404, "got {}", r.status); +} + +#[test] +fn is_admin_only_recognises_logs() { + assert!(is_admin_only("/api/logs")); + assert!(is_admin_only("/api/logs?since=10")); + assert!(is_admin_only("/api/prepared/text/abc")); + assert!(is_admin_only("/api/interner/top")); +} + +#[test] +fn is_admin_only_does_not_match_public() { + assert!(!is_admin_only("/api/overview")); + assert!(!is_admin_only("/api/pools")); + assert!(!is_admin_only("/api/prepared")); +} + +#[test] +fn dispatch_clients_returns_200() { + let r = dispatch( + &req("GET", "/api/clients"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_clients_with_query_params_returns_200() { + let r = dispatch( + &req("GET", "/api/clients?limit=10&sort=errors_total"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_servers_returns_200() { + let r = dispatch( + &req("GET", "/api/servers"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_connections_returns_200() { + let r = dispatch( + &req("GET", "/api/connections"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_stats_returns_200() { + let r = dispatch( + &req("GET", "/api/stats"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_databases_returns_200() { + let r = dispatch( + &req("GET", "/api/databases"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_users_returns_200() { + let r = dispatch( + &req("GET", "/api/users"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_auth_query_returns_200() { + let r = dispatch( + &req("GET", "/api/auth_query"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_config_returns_200() { + let r = dispatch( + &req("GET", "/api/config"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_log_level_returns_200() { + let r = dispatch( + &req("GET", "/api/log_level"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_pool_coordinator_returns_200() { + let r = dispatch( + &req("GET", "/api/pool_coordinator"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_pool_scaling_returns_200() { + let r = dispatch( + &req("GET", "/api/pool_scaling"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[cfg(target_os = "linux")] +#[test] +fn dispatch_sockets_returns_200_on_linux() { + let r = dispatch( + &req("GET", "/api/sockets"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + // 500 acceptable in sandbox; handler did not panic = pass. + assert!(r.status == 200 || r.status == 500, "got {}", r.status); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn dispatch_sockets_returns_503_on_non_linux() { + let r = dispatch( + &req("GET", "/api/sockets"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 503); +} + +#[test] +fn dispatch_prepared_returns_200() { + let r = dispatch( + &req("GET", "/api/prepared"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_interner_returns_200() { + let r = dispatch( + &req("GET", "/api/interner"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_interner_top_anonymous_returns_401() { + let r = dispatch( + &req("GET", "/api/interner/top"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); +} + +#[test] +fn dispatch_interner_top_admin_returns_200() { + let r = dispatch( + &req("GET", "/api/interner/top?n=10"), + &opts(true, true), + AuthOutcome::Admin, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_prepared_text_anonymous_returns_401() { + let r = dispatch( + &req("GET", "/api/prepared/text/0x123"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); +} + +#[test] +fn dispatch_prepared_text_admin_unknown_hash_returns_404() { + let r = dispatch( + &req("GET", "/api/prepared/text/0xdeadbeef"), + &opts(true, true), + AuthOutcome::Admin, + ); + assert_eq!(r.status, 404); +} + +#[test] +fn dispatch_top_clients_returns_200() { + let r = dispatch( + &req("GET", "/api/top/clients"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_top_queries_anonymous_returns_401() { + // /api/top/queries returns SQL previews — admin-only regardless of + // ui_anonymous so tenant identifiers and embedded secrets do not leak. + let r = dispatch( + &req("GET", "/api/top/queries"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 401); +} + +#[test] +fn dispatch_top_queries_admin_returns_200() { + let r = dispatch( + &req("GET", "/api/top/queries"), + &opts(true, true), + AuthOutcome::Admin, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_top_prepared_returns_200() { + let r = dispatch( + &req("GET", "/api/top/prepared"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_apps_returns_200() { + let r = dispatch( + &req("GET", "/api/apps"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} + +#[test] +fn dispatch_events_returns_200() { + let r = dispatch( + &req("GET", "/api/events"), + &opts(true, true), + AuthOutcome::Anonymous, + ); + assert_eq!(r.status, 200); +} diff --git a/src/web/server/wire.rs b/src/web/server/wire.rs new file mode 100644 index 000000000..72b13d9e3 --- /dev/null +++ b/src/web/server/wire.rs @@ -0,0 +1,288 @@ +//! Request parsing and response serialization. The wire layer has no +//! knowledge of routing, auth, or static-asset semantics beyond cache +//! headers — it just turns bytes into [`ParsedRequest`] and a +//! [`Response`] back into bytes. + +use tokio::io::{AsyncWriteExt, BufWriter}; +use tokio::net::tcp::OwnedWriteHalf; + +#[derive(Debug)] +pub(super) enum ReadError { + /// Underlying socket error. We do not track the inner error because + /// the only action on Io is "close the connection" — same as Idle. + #[allow(dead_code)] + Io(std::io::Error), + Idle, + TooLarge, +} + +impl From for ReadError { + fn from(e: std::io::Error) -> Self { + ReadError::Io(e) + } +} + +/// Index of the byte immediately after the first `\r\n\r\n` sequence, +/// or `None` if the buffer does not yet contain the terminator. +pub(super) fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4) +} + +#[derive(Debug)] +pub(super) struct ParsedRequest<'a> { + pub(super) method: &'a str, + pub(super) path: &'a str, + pub(super) authorization: Option<&'a str>, + pub(super) accepts_gzip: bool, + /// True when the request advertises `Accept: application/json`. The SPA + /// `fetch()` wrapper sets this on every call; a browser hitting the URL + /// directly would not. The mux uses it to skip the `WWW-Authenticate` + /// header on 401 — otherwise the browser caches whatever the user typed + /// in its native basic-auth dialog and replays it forever, hiding our + /// React sign-in modal. + pub(super) accepts_json: bool, + /// True when the request explicitly opts out of HTTP/1.1 keep-alive + /// (`Connection: close`) or speaks an older HTTP version. The mux + /// uses it to decide whether to drop the connection after the + /// response or wait for another request on the same socket. + pub(super) connection_close: bool, +} + +impl<'a> ParsedRequest<'a> { + pub(super) fn parse(raw: &'a str) -> Option { + let mut lines = raw.split("\r\n"); + let request_line = lines.next()?; + let mut parts = request_line.splitn(3, ' '); + let method = parts.next()?; + let path = parts.next()?; + let http_version = parts.next()?; + + let mut authorization = None; + let mut accepts_gzip = false; + let mut accepts_json = false; + let mut connection_close = !http_version.eq_ignore_ascii_case("HTTP/1.1"); + for line in lines { + if line.is_empty() { + break; + } + // Headers are case-insensitive per RFC 7230. Match by case- + // insensitive prefix without allocating a lowercase copy of + // the header value — `to_lowercase()` per request line was + // codex perf P3#9. + if let Some(value) = strip_header_prefix(line, "Authorization") { + authorization = Some(value); + } else if let Some(value) = strip_header_prefix(line, "Accept-Encoding") { + if contains_ascii_ci(value, "gzip") { + accepts_gzip = true; + } + } else if let Some(value) = strip_header_prefix(line, "Accept") { + if contains_ascii_ci(value, "application/json") { + accepts_json = true; + } + } else if let Some(value) = strip_header_prefix(line, "Connection") { + if contains_ascii_ci(value, "close") { + connection_close = true; + } + } + } + Some(ParsedRequest { + method, + path, + authorization, + accepts_gzip, + accepts_json, + connection_close, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Response { + pub(crate) status: u16, + pub(crate) reason: &'static str, + pub(crate) extra_headers: Vec<(&'static str, String)>, + pub(crate) body: Vec, +} + +impl Response { + pub(crate) fn status(status: u16, reason: &'static str) -> Self { + Response { + status, + reason, + extra_headers: Vec::new(), + body: Vec::new(), + } + } + + pub(crate) fn json(status: u16, reason: &'static str, body: &str) -> Self { + Response { + status, + reason, + extra_headers: vec![("Content-Type", "application/json".into())], + body: body.as_bytes().to_vec(), + } + } + + /// 401 with `WWW-Authenticate`. Use only for non-JSON callers (curl, + /// direct browser navigation) — the SPA path uses `unauthorized_silent` + /// to keep the browser from caching credentials we did not solicit. + pub(crate) fn unauthorized() -> Self { + let mut r = Response::status(401, "Unauthorized"); + r.extra_headers.push(( + "WWW-Authenticate", + "Basic realm=\"pg_doorman admin\"".into(), + )); + r + } + + /// 401 without `WWW-Authenticate`. Use for SPA / JSON callers so the + /// browser does not cache rejected credentials and replay them under + /// our React modal. + pub(crate) fn unauthorized_silent() -> Self { + Response::status(401, "Unauthorized") + } + + /// Serves a static asset (SPA bundle file). Hashed assets get a long + /// immutable cache; the SPA shell (`index.html`) is no-cache so a redeploy + /// reaches operators on their next reload. When the caller advertises + /// `Accept-Encoding: gzip` and the asset compresses worthwhile (text-like + /// MIME, > 256 bytes), the body is gzipped on the fly — that turns the + /// ~280 KB JS bundle into ~95 KB on the wire. + pub(crate) fn static_asset( + asset: &crate::web::static_assets::Asset, + accepts_gzip: bool, + ) -> Self { + let cache = if asset.immutable { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + let mut headers = vec![ + ("Content-Type", asset.mime.into()), + ("Cache-Control", cache.into()), + ]; + // The bundle stores compressible assets pre-gzipped (post-build + // step) — that keeps the binary ~270 kB smaller than embedding raw + // text and lets the browser get the bytes verbatim. Clients that + // don't advertise gzip (rare: curl without `--compressed`, headless + // probes) get an on-the-fly flate2 decode. + let body = if asset.pre_gzipped { + if accepts_gzip { + headers.push(("Content-Encoding", "gzip".into())); + asset.bytes.to_vec() + } else { + decompress_gzip(asset.bytes).unwrap_or_else(|_| asset.bytes.to_vec()) + } + } else { + asset.bytes.to_vec() + }; + Response { + status: 200, + reason: "OK", + extra_headers: headers, + body, + } + } + + /// Override the status line on a Response built via [`Response::ok_json`]. + /// Useful when the body shape is the same JSON envelope but the + /// outcome should travel back as 4xx/5xx — codex Arch P2#4 admin + /// route refactor. + pub(crate) fn with_status(mut self, status: u16, reason: &'static str) -> Self { + self.status = status; + self.reason = reason; + self + } + + pub(crate) fn ok_json(value: &T) -> Self { + match serde_json::to_vec(value) { + Ok(body) => Response { + status: 200, + reason: "OK", + extra_headers: vec![("Content-Type", "application/json".into())], + body, + }, + Err(e) => { + log::error!("Failed to serialize JSON response: {e}"); + Response::status(500, "Internal Server Error") + } + } + } + + pub(super) async fn write(self, writer: &mut BufWriter) -> std::io::Result<()> { + let mut head = format!( + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n", + self.status, + self.reason, + self.body.len() + ); + for (k, v) in &self.extra_headers { + head.push_str(k); + head.push_str(": "); + head.push_str(v); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + writer.write_all(head.as_bytes()).await?; + if !self.body.is_empty() { + writer.write_all(&self.body).await?; + } + writer.flush().await + } +} + +pub(super) async fn write_simple( + writer: &mut BufWriter, + status: u16, + reason: &'static str, +) -> std::io::Result<()> { + Response::status(status, reason).write(writer).await +} + +/// Strip a case-insensitive `Header: ` prefix (header name + `: `) +/// without allocating. Returns the header value when the prefix matches, +/// `None` otherwise. ASCII-only by design — HTTP header names are +/// strictly ASCII per RFC 7230. +fn strip_header_prefix<'a>(line: &'a str, header: &str) -> Option<&'a str> { + let need = header.len() + 2; // ": " + let bytes = line.as_bytes(); + if bytes.len() < need { + return None; + } + if !line.as_bytes()[..header.len()].eq_ignore_ascii_case(header.as_bytes()) { + return None; + } + if &bytes[header.len()..need] != b": " { + return None; + } + Some(&line[need..]) +} + +/// Case-insensitive `contains` over ASCII bytes. Avoids the +/// `value.to_lowercase()` allocation that codex P3#9 flagged. +fn contains_ascii_ci(haystack: &str, needle: &str) -> bool { + let h = haystack.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() { + return true; + } + if h.len() < n.len() { + return false; + } + h.windows(n.len()).any(|w| w.eq_ignore_ascii_case(n)) +} + +/// Decompress a pre-gzipped asset for the rare client that does not +/// advertise gzip. Compressible assets are pre-gzipped at build time so +/// the binary ships only the compressed form; clients that omit +/// `Accept-Encoding: gzip` (curl without `--compressed`, plain probes) +/// pay this decode once per request, which is acceptable because the +/// console is a low-traffic operator surface. +fn decompress_gzip(bytes: &[u8]) -> std::io::Result> { + use flate2::read::GzDecoder; + use std::io::Read; + let mut decoder = GzDecoder::new(bytes); + let mut out = Vec::with_capacity(bytes.len() * 4); + decoder.read_to_end(&mut out)?; + Ok(out) +} diff --git a/src/web/static_assets.rs b/src/web/static_assets.rs new file mode 100644 index 000000000..fe2327519 --- /dev/null +++ b/src/web/static_assets.rs @@ -0,0 +1,152 @@ +//! SPA bundle embedded into the binary. +//! +//! `frontend/dist/` is checked into the repo (Web UI design decision #22) and +//! pulled in here at compile time via `include_dir!`. The lookup helper +//! returns the file contents and a Content-Type for the requested URL path, +//! falling back to `index.html` for any URL that does not match a real asset +//! — the SPA owns its own routing, so a deep-link like `/pools` should +//! resolve to the SPA shell, not 404. +//! +//! The empty-bundle case (someone built the workspace without ever running +//! `npm run build` in `frontend/`) is handled gracefully: callers receive +//! `None` and the mux returns 404, mirroring the pre-embedding behaviour. + +use include_dir::{include_dir, Dir}; + +static SPA: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/frontend/dist"); + +/// Asset payload returned to the mux. `bytes` is whatever the bundle holds +/// for the matched URL — gzipped when `pre_gzipped == true` (the build step +/// pre-compresses every text-like file), raw otherwise. `mime` and +/// `immutable` describe the *decompressed* identity, so a `.js.gz` blob +/// still reports `application/javascript` and lives under `assets/`. +pub(crate) struct Asset { + pub bytes: &'static [u8], + pub mime: &'static str, + pub immutable: bool, + /// `true` when `bytes` is the gzip-compressed form of the asset. The + /// caller either serves it directly with `Content-Encoding: gzip` or + /// decompresses on the fly for clients that do not advertise gzip. + pub pre_gzipped: bool, +} + +/// Looks up the request path inside the embedded bundle. +/// +/// Tries the exact path first, then `{path}.gz` for the pre-compressed form +/// the build pipeline writes. Falls back to `index.html(.gz)` so the SPA +/// owns deep-link routing — only `/api/*` and `/metrics` ever return a real +/// 404. Returns `None` when the bundle is empty (no `index.html` at all). +pub(crate) fn lookup(path: &str) -> Option { + if !has_index() { + return None; + } + + let stripped = path.trim_start_matches('/'); + if let Some(asset) = lookup_exact(stripped) { + return Some(asset); + } + // SPA fallback for client-side routes (`/pools`, `/clients/...`). + lookup_exact("index.html") +} + +/// Try the literal path first, then the `.gz` neighbour. Helpers below +/// keep the static lifetimes explicit so a refactor cannot accidentally +/// borrow from the request String. +fn lookup_exact(stripped: &str) -> Option { + if let Some(file) = SPA.get_file(stripped) { + return Some(asset_for(file, false)); + } + let gz_path = format!("{stripped}.gz"); + SPA.get_file(&gz_path).map(|f| asset_for(f, true)) +} + +fn asset_for(file: &'static include_dir::File<'static>, pre_gzipped: bool) -> Asset { + let target_path: &'static std::path::Path = file.path(); + let raw_path: &'static str = target_path.to_str().unwrap_or(""); + // For the gzipped variant the SPA path ends in ".gz"; the wire-level + // identity (mime, immutability marker) reflects the original extension, + // so strip the suffix when classifying. + let identity_path: &'static str = if pre_gzipped { + raw_path.strip_suffix(".gz").unwrap_or(raw_path) + } else { + raw_path + }; + Asset { + bytes: file.contents(), + mime: mime_for(identity_path), + immutable: identity_path.starts_with("assets/"), + pre_gzipped, + } +} + +fn has_index() -> bool { + SPA.get_file("index.html").is_some() || SPA.get_file("index.html.gz").is_some() +} + +fn mime_for(path: &str) -> &'static str { + let ext = path.rsplit('.').next().unwrap_or(""); + match ext { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "txt" | "map" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mime_for_known_extensions() { + assert_eq!(mime_for("a.html"), "text/html; charset=utf-8"); + assert_eq!(mime_for("a.js"), "application/javascript; charset=utf-8"); + assert_eq!(mime_for("a.css"), "text/css; charset=utf-8"); + assert_eq!(mime_for("a.svg"), "image/svg+xml"); + assert_eq!(mime_for("a.woff2"), "font/woff2"); + } + + #[test] + fn mime_for_unknown_falls_back_to_octet_stream() { + assert_eq!(mime_for("a.xyz"), "application/octet-stream"); + assert_eq!(mime_for("noext"), "application/octet-stream"); + } + + #[test] + fn lookup_returns_index_for_root() { + // The bundle is committed in this repo, so this should resolve. + let asset = lookup("/"); + assert!(asset.is_some(), "lookup('/') should hit the SPA shell"); + let a = asset.unwrap(); + assert!(!a.immutable); + assert_eq!(a.mime, "text/html; charset=utf-8"); + } + + #[test] + fn lookup_falls_back_to_index_for_unknown_path() { + let asset = lookup("/pools/foo/bar").expect("fallback to index"); + assert_eq!(asset.mime, "text/html; charset=utf-8"); + } + + #[test] + fn lookup_returns_assets_with_immutable_marker() { + let asset_dir = SPA.get_dir("assets").expect("dist must contain assets/"); + let any = asset_dir + .files() + .next() + .expect("dist/assets/ must contain at least one file"); + let req = format!("/{}", any.path().to_string_lossy()); + let a = lookup(&req).expect("lookup hashed asset"); + assert!(a.immutable, "asset under assets/ should be immutable"); + } +} diff --git a/src/web/tests.rs b/src/web/tests.rs new file mode 100644 index 000000000..088a3dedd --- /dev/null +++ b/src/web/tests.rs @@ -0,0 +1,571 @@ +//! End-to-end smoke tests for the web listener mux. +//! +//! Each test spawns a real listener on `127.0.0.1:` (chosen via +//! `portpicker`, already in dev-dependencies), opens a TcpStream and sends a +//! hand-rolled HTTP/1.1 request line + headers. We avoid pulling reqwest into +//! dev-deps for these few cases. +//! +//! Tests run with `#[serial]` because the listener stores its `WebServerOptions` +//! in a process-wide `ArcSwap` (see `start_web_server`) so that admin +//! `RELOAD` can update auth/gating without a restart. Two parallel listeners +//! in the same test binary would clobber each other's slot — a non-issue in +//! production where exactly one listener exists per process. + +use std::time::Duration; + +use base64::Engine; +use serial_test::serial; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::web::{serve_on, WebServerOptions}; + +fn opts(ui_active: bool, ui_anonymous: bool) -> WebServerOptions { + WebServerOptions { + ui_active, + ui_anonymous, + admin_username: "admin".into(), + admin_password: "secret".into(), + } +} + +/// Bind on `127.0.0.1:0`, ask the kernel for an actual port, hand the +/// pre-bound listener to the accept loop. Replaces the previous +/// `portpicker::pick_unused_port + sleep(150ms)` pattern that codex +/// flagged: pick_unused_port races between picking and binding (the +/// port can be claimed in that window), and the fixed sleep was a +/// readiness fudge instead of a synchronisation point. +async fn spawn_server(opts: WebServerOptions) -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind 127.0.0.1:0"); + let port = listener.local_addr().expect("local_addr").port(); + tokio::spawn(async move { + serve_on(listener, opts).await; + }); + // The listener already accepts connections — this short yield gives + // the spawned task a tick to enter the accept loop. Two orders of + // magnitude shorter than the old 150 ms sleep. + tokio::time::sleep(Duration::from_millis(5)).await; + port +} + +async fn send(port: u16, request: &str) -> String { + // Auto-inject `Connection: close` so the keep-alive accept loop + // releases the socket as soon as the response is written. Without + // this every test would block for `KEEPALIVE_IDLE_TIMEOUT` (30 s) + // before `read_to_end` saw EOF. + let mut request = request.to_string(); + if !request.to_lowercase().contains("connection:") { + request = request.replacen("\r\n\r\n", "\r\nConnection: close\r\n\r\n", 1); + } + let mut stream = tokio::time::timeout( + Duration::from_secs(2), + TcpStream::connect(("127.0.0.1", port)), + ) + .await + .expect("connect timeout") + .expect("connect"); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + let _ = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut response)).await; + String::from_utf8_lossy(&response).into_owned() +} + +#[tokio::test] +#[serial] +async fn metrics_endpoint_serves_prometheus_body_when_ui_inactive() { + let port = spawn_server(opts(false, true)).await; + let raw = send(port, "GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.contains("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("pg_doorman_"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_returns_404_when_ui_inactive() { + let port = spawn_server(opts(false, true)).await; + let raw = send( + port, + "GET /api/overview HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 404"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_unknown_route_returns_501_when_ui_active_anonymous() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/not-yet-wired HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 501"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_admin_route_returns_401_without_auth() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/logs HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 401"), "raw={raw}"); + assert!(raw.contains("WWW-Authenticate: Basic"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_logs_admin_returns_envelope_or_disabled() { + let port = spawn_server(opts(true, true)).await; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:secret"); + let req = format!( + "GET /api/logs HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic {creds}\r\n\r\n" + ); + let raw = send(port, &req).await; + // 200 when log_tap_max_entries > 0 (default 8192); 503 when an operator + // explicitly disabled the tap. Both are valid contract outcomes. + assert!( + raw.starts_with("HTTP/1.1 200 OK") || raw.starts_with("HTTP/1.1 503"), + "raw={raw}" + ); + if raw.starts_with("HTTP/1.1 200 OK") { + for field in [ + "\"ts\"", + "\"tap_active\"", + "\"tap_capacity_entries\"", + "\"next_seq\"", + "\"entries\"", + ] { + assert!(raw.contains(field), "missing {field} in {raw}"); + } + } else { + assert!(raw.contains("log_tap_disabled"), "raw={raw}"); + } +} + +#[tokio::test] +#[serial] +async fn api_public_route_returns_401_when_ui_anonymous_false() { + let port = spawn_server(opts(true, false)).await; + let raw = send( + port, + "GET /api/overview HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 401"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_version_returns_json() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/version HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("Content-Type: application/json"), "raw={raw}"); + assert!(raw.contains("\"version\""), "raw={raw}"); + assert!(raw.contains("\"git_commit\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_overview_returns_json_when_ui_active() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/overview HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"active_clients\""), "raw={raw}"); + assert!(raw.contains("\"pools_total\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_pools_returns_json_when_ui_active() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/pools HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"pools\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_overview_still_404_when_ui_inactive() { + let port = spawn_server(opts(false, true)).await; + let raw = send( + port, + "GET /api/overview HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 404"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_clients_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/clients HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"clients\""), "raw={raw}"); + assert!(raw.contains("\"total\""), "raw={raw}"); + assert!(raw.contains("\"limit\":100"), "raw={raw}"); + assert!(raw.contains("\"offset\":0"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_clients_with_query_params() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/clients?limit=50&offset=10&sort=age_seconds&order=asc HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"limit\":50"), "raw={raw}"); + assert!(raw.contains("\"offset\":10"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_servers_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/servers HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"servers\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_connections_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/connections HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + for field in [ + "\"ts\"", + "\"total\"", + "\"tls\"", + "\"plain\"", + "\"cancel\"", + "\"errors\"", + ] { + assert!(raw.contains(field), "missing {field} in {raw}"); + } +} + +#[tokio::test] +#[serial] +async fn api_stats_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"ts\""), "raw={raw}"); + assert!(raw.contains("\"stats\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_databases_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/databases HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"ts\""), "raw={raw}"); + assert!(raw.contains("\"databases\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_users_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/users HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"ts\""), "raw={raw}"); + assert!(raw.contains("\"users\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_config_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/config HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"ts\""), "raw={raw}"); + assert!(raw.contains("\"config\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_log_level_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/log_level HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"log_level\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_auth_query_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/auth_query HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"pools\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_pool_scaling_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/pool_scaling HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"pools\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_pool_coordinator_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/pool_coordinator HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"databases\""), "raw={raw}"); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +#[serial] +async fn api_sockets_returns_200_or_500_on_linux() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/sockets HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!( + raw.starts_with("HTTP/1.1 200 OK") || raw.starts_with("HTTP/1.1 500"), + "raw={raw}" + ); +} + +#[cfg(not(target_os = "linux"))] +#[tokio::test] +#[serial] +async fn api_sockets_returns_503_on_non_linux() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/sockets HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 503"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_prepared_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/prepared HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"prepared\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_interner_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/interner HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"named\""), "raw={raw}"); + assert!(raw.contains("\"anonymous\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_interner_top_anonymous_returns_401() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/interner/top HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 401"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_interner_top_admin_returns_200() { + let port = spawn_server(opts(true, true)).await; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:secret"); + let req = format!( + "GET /api/interner/top?n=5 HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic {creds}\r\n\r\n" + ); + let raw = send(port, &req).await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"n\":5"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_prepared_text_anonymous_returns_401() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/prepared/text/0x123 HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 401"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_top_clients_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/top/clients HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"by\":\"qps\""), "raw={raw}"); + assert!(raw.contains("\"n\":20"), "raw={raw}"); + assert!(raw.contains("\"clients\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_apps_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/apps HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"apps\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_top_queries_anonymous_returns_401() { + // /api/top/queries returns SQL previews — admin-only regardless of + // ui_anonymous so SQL literals and secrets do not leak. + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/top/queries HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 401"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_top_queries_admin_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:secret"); + let req = format!( + "GET /api/top/queries HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic {creds}\r\n\r\n" + ); + let raw = send(port, &req).await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"by\":\"count\""), "raw={raw}"); + assert!(raw.contains("\"queries\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_prepared_text_admin_unknown_hash_returns_404() { + let port = spawn_server(opts(true, true)).await; + let creds = base64::engine::general_purpose::STANDARD.encode("admin:secret"); + let req = format!( + "GET /api/prepared/text/0xdeadbeef HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic {creds}\r\n\r\n" + ); + let raw = send(port, &req).await; + assert!(raw.starts_with("HTTP/1.1 404"), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_top_prepared_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send( + port, + "GET /api/top/prepared HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"by\":\"hits\""), "raw={raw}"); + assert!(raw.contains("\"prepared\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn api_events_returns_envelope() { + let port = spawn_server(opts(true, true)).await; + let raw = send(port, "GET /api/events HTTP/1.1\r\nHost: localhost\r\n\r\n").await; + assert!(raw.starts_with("HTTP/1.1 200 OK"), "raw={raw}"); + assert!(raw.contains("\"events\""), "raw={raw}"); + assert!(raw.contains("\"next_seq\""), "raw={raw}"); +} + +#[tokio::test] +#[serial] +async fn http_keep_alive_serves_two_requests_on_one_connection() { + // Two sequential GETs without `Connection: close` should both come + // back over the same TCP connection. The server then closes when + // we drop our half (no more requests). Until codex perf P1#2 the + // listener closed after one request, forcing the SPA to reconnect + // multiple times per poll interval. + let port = spawn_server(opts(true, true)).await; + let mut stream = tokio::time::timeout( + Duration::from_secs(2), + TcpStream::connect(("127.0.0.1", port)), + ) + .await + .expect("connect timeout") + .expect("connect"); + stream + .write_all(b"GET /api/version HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + // Request #2 piggybacks immediately — keep-alive means the listener + // is still reading the same socket. + stream + .write_all(b"GET /api/overview HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut buf = Vec::new(); + let _ = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut buf)).await; + let raw = String::from_utf8_lossy(&buf); + // Both bodies should be in the buffer; count 200 status lines. + let oks = raw.matches("HTTP/1.1 200 OK").count(); + assert_eq!( + oks, 2, + "expected two 200 OK responses, got {oks} in:\n{raw}" + ); + assert!(raw.contains("\"version\""), "first response missing: {raw}"); + assert!( + raw.contains("\"active_clients\""), + "second response missing: {raw}" + ); +} diff --git a/tests/bdd/features/web-ui.feature b/tests/bdd/features/web-ui.feature new file mode 100644 index 000000000..10f548bd5 --- /dev/null +++ b/tests/bdd/features/web-ui.feature @@ -0,0 +1,110 @@ +@web-ui +Feature: Web UI listener + Sanity coverage for the embedded SPA, the public API endpoints, the + admin auth gate, and the JSON 401 path that lets the React modal take + over from the browser's native basic-auth dialog. + + Background: + Given PostgreSQL started with pg_hba.conf: + """ + local all all trust + host all all 127.0.0.1/32 trust + host all all ::1/128 trust + """ + And pg_doorman hba file contains: + """ + host all example_user_1 127.0.0.1/32 trust + """ + And pg_doorman started with config: + """ + [general] + host = "127.0.0.1" + port = ${DOORMAN_PORT} + pg_hba = {path = "${DOORMAN_HBA_FILE}"} + admin_username = "admin" + admin_password = "webui_bdd" + + [web] + enabled = true + host = "127.0.0.1" + port = 9127 + ui = true + ui_anonymous = true + log_tap_max_entries = 4096 + + [pools.example_db] + server_host = "127.0.0.1" + server_port = ${PG_PORT} + pool_mode = "transaction" + + [[pools.example_db.users]] + username = "example_user_1" + password = "" + pool_size = 5 + """ + + Scenario: SPA shell is served at / + When I run shell command: + """ + curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:9127/ + """ + Then the command should succeed + And output contains "200" + And output contains "text/html" + + Scenario: Deep link falls back to the SPA shell + When I run shell command: + """ + curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:9127/pools + """ + Then the command should succeed + And output contains "200" + And output contains "text/html" + + Scenario: /api/version is anonymous when ui_anonymous is true + When I run shell command: + """ + curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9127/api/version + """ + Then the command should succeed + And output contains "200" + + Scenario: /api/logs is admin-only without credentials + When I run shell command: + """ + curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9127/api/logs + """ + Then the command should succeed + And output contains "401" + + Scenario: JSON callers receive 401 without WWW-Authenticate + When I run shell command: + """ + curl -s -i -H "Accept: application/json" http://127.0.0.1:9127/api/logs | grep -ci 'WWW-Authenticate' || echo 0 + """ + Then the command should succeed + And output contains "0" + + Scenario: Curl-style callers do receive WWW-Authenticate on 401 + When I run shell command: + """ + curl -s -i http://127.0.0.1:9127/api/logs | grep -ci 'WWW-Authenticate' + """ + Then the command should succeed + And output contains "1" + + Scenario: /api/logs accepts admin basic auth + When I run shell command: + """ + curl -s --user 'admin:webui_bdd' -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9127/api/logs + """ + Then the command should succeed + And output contains "200" + + Scenario: /metrics still serves Prometheus when the UI is on + When I run shell command: + """ + curl -s http://127.0.0.1:9127/metrics | head -1 + """ + Then the command should succeed + And output contains "# HELP"