From e8d75473ecdf23a6138a1527cdb4adb1f2defd3b Mon Sep 17 00:00:00 2001 From: dmitrivasilyev Date: Wed, 6 May 2026 07:42:08 +0300 Subject: [PATCH 001/128] docs: brainstorming spec for web UI Captures the design agreed during brainstorm: - scope: observability + live-tail logs (no write-commands in MVP) - architecture: relocate src/prometheus to src/web, single listener serves both /metrics and /api/* - config: [web] section with serde-alias for [prometheus] (back-compat), ui/ui_anonymous/log_tap_kb flags with safe defaults - auth: reuse admin_username/admin_password, basic-auth on admin paths, refuse to enable UI when admin_password is the default ("admin"/empty) - LogTap: lazy ArcSwap-based ring with reaper task (30s grace) - frontend: React+TypeScript SPA embedded via include_dir!, uPlot for charts, sessionStorage for in-browser history Adds .superpowers/ to .gitignore (brainstorm session workdir). --- .../specs/2026-05-06-web-ui-design.md | 651 ++++++++++++++++++ 1 file changed, 651 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-web-ui-design.md diff --git a/docs/superpowers/specs/2026-05-06-web-ui-design.md b/docs/superpowers/specs/2026-05-06-web-ui-design.md new file mode 100644 index 000000000..50ddd94b7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-web-ui-design.md @@ -0,0 +1,651 @@ +# pg_doorman Web UI — Design + +Дата: 2026-05-06. +Ветка: `feat/client-cache-anonymous-lru` (на момент брейншторма; имплементация пойдёт отдельной веткой). +Целевой релиз: следующий минор (3.8.0 либо ближайший по календарю). + +## 1. Контекст и цель + +В pg_doorman есть `/metrics` (Prometheus exporter) и админ-консоль через `psql` к admin-порту. Этого достаточно для долговременного мониторинга (Grafana поверх метрик) и точечных операций, но плохо подходит для двух сценариев: + +1. Оперативный обзор «что прямо сейчас происходит в пулере» — список клиентов, серверов, состояний пулов, потоки ошибок. Метрики не показывают индивидуальные соединения; psql admin требует знать команды и форматирует таблицы под терминал. +2. Live-tail логов на инциденте — сейчас оператор должен иметь shell-доступ к машине и `tail -F` соответствующий файл. Для удалённых инсталляций это лишнее препятствие. + +Web UI закрывает оба сценария, не вводя новых сетевых сущностей: переиспользует тот же listener, что обслуживает `/metrics`. + +## 2. Scope MVP / non-goals + +**В MVP входит:** +- Observability-страницы: Overview (live counters + 7 sparkline-графиков), Pools, Clients, Servers, Prepared, Interner, Config. +- Live-tail логов в браузере (admin-only). +- Конфиг-флаги, anonymous-режим, basic-auth для admin-путей. +- React + TypeScript SPA, embedded в бинарь. + +**Явно не в MVP:** +- Никаких write-команд через UI: `PAUSE / RESUME / RECONNECT / RELOAD / SHUTDOWN / UPGRADE / SET log_level / RESET INTERNER` — только psql admin. +- Никакого WebSocket/SSE — push добавим, если polling окажется слабым местом. +- Никакого серверного хранилища истории — графики живут в JS-памяти браузера (sessionStorage). +- Никакой темизации, i18n, мобильной адаптации — оставлено на follow-up. +- Никакого per-pool breakdown на графиках в Overview — только агрегаты. + +## 3. Decision log + +| # | Вопрос | Выбор | +|---|---|---| +| 1 | MVP scope | Observability + live-tail логов | +| 2 | Доставка логов | Polling `/api/logs?since=…` | +| 3 | HTTP-роутинг и зависимости | Руками на tokio (расширяем существующий код) | +| 4 | Конфиг-флаги | Один флаг `ui` + `ui_anonymous` + реюз `admin_username/password` | +| 5 | LogTap lifecycle | Lazy refcount (включается при первом запросе, reaper выключает по таймауту) | +| 6 | Набор графиков | 7 базовых, без per-pool breakdown | +| 7 | UI-стек | Полная сборка (Vite + npm) | +| 8 | Фреймворк | React + TypeScript | +| 9 | Каркас навигации | Sidebar nav | +| 10 | Расположение модуля | Перенести `src/prometheus/` → `src/web/` | +| 11 | Имя config-секции | `[web]` с `serde(alias = "prometheus")` | +| 12 | Refactoring `admin/show.rs` | Extract pure `collect_*()` functions, две сериализации (pg / json) | +| 13 | Дефолтный пароль + UI | Не поднимать UI с warning'ом, listener для `/metrics` продолжает работать | + +## 4. Архитектура + +### 4.1 Топология listener'а на `:9127` + +``` +GET /metrics → web::metrics::handle (no auth, как сегодня) +GET /api/ → web::routes::* (auth по правилам ниже) +GET / | /assets/... → web::static_assets (отдача SPA) +``` + +Один listener, один порт. Если `web.ui = false` (дефолт) — mux отдаёт всё кроме `/metrics` как 404, поведение текущего prometheus-эндпоинта не меняется. + +### 4.2 Реорганизация модулей + +`src/prometheus/` упраздняется как самостоятельный crate-модуль; его содержимое переезжает в `src/web/`. + +``` +src/web/ +├── mod.rs # pub mod {server, auth, routes, log_tap, static_assets, metrics}; +├── server.rs # listener + mux (бывший prometheus/server.rs, расширенный) +├── auth.rs # парсер basic-auth, constant-time compare +├── log_tap.rs # ring buffer + lazy refcount + reaper +├── static_assets.rs # отдача SPA через include_dir! +├── metrics/ +│ ├── mod.rs # REGISTRY, update_metrics (бывший prometheus/metrics.rs) +│ ├── system.rs # бывший prometheus/system.rs +│ └── tests.rs # бывший prometheus/tests.rs +└── routes/ + ├── mod.rs # mux dispatch + ├── overview.rs # GET /api/overview + ├── pools.rs # GET /api/pools + ├── clients.rs # GET /api/clients + ├── servers.rs # GET /api/servers + ├── connections.rs # GET /api/connections + ├── stats.rs # GET /api/stats + ├── databases.rs # GET /api/databases + ├── users.rs # GET /api/users + ├── config.rs # GET /api/config (с маскингом секретов) + ├── auth_query.rs # GET /api/auth_query + ├── log_level.rs # GET /api/log_level + ├── pool_scaling.rs # GET /api/pool_scaling + ├── pool_coordinator.rs # GET /api/pool_coordinator + ├── sockets.rs # GET /api/sockets (linux-only) + ├── prepared.rs # GET /api/prepared (агрегаты, без текстов) + ├── prepared_text.rs # GET /api/prepared/text/{hash} (admin) + ├── interner.rs # GET /api/interner (агрегаты) + ├── interner_top.rs # GET /api/interner/top (admin) + ├── logs.rs # GET /api/logs (admin) + └── version.rs # GET /api/version +``` + +`use crate::prometheus::*` по проекту → `use crate::web::metrics::*`. Замена тривиальная. + +### 4.3 Frontend проект + +``` +frontend/ +├── package.json, package-lock.json, tsconfig.json +├── vite.config.ts # dev proxy /api/* → :9127 +├── index.html +├── src/ +│ ├── main.tsx, App.tsx +│ ├── api.ts, types.ts +│ ├── pages/ (Overview, Pools, Clients, Servers, Prepared, Interner, Logs, Config) +│ ├── components/ (Sidebar, Chart, Table, LogStream, AuthGate) +│ ├── hooks/ (usePoll, useHistory, useAdminAuth) +│ └── styles/tailwind.css +└── public/favicon.ico +``` + +Стек: React 18 + TS 5 + Vite 5 + react-router 6 + uPlot 1.6 + Tailwind v3. + +### 4.4 Embedding в бинарь + +`src/web/static_assets.rs` использует `include_dir!` macro: + +```rust +use include_dir::{include_dir, Dir}; +static SPA: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/frontend/dist"); +``` + +CI билдит фронт перед `cargo build`. В dev — `vite dev` отдельно с proxy на `:9127`. + +## 5. Конфиг + +### 5.1 TOML + +```toml +[web] +enabled = true # поднимать listener (бывший [prometheus] enabled) +host = "0.0.0.0" +port = 9127 +ui = false # NEW: дать UI и /api/* +ui_anonymous = true # NEW: public-пути без auth +log_tap_kb = 64 # NEW: capacity ring buffer'а логов +``` + +### 5.2 Rust-структура и backwards compat + +```rust +#[derive(Serialize, Deserialize, ...)] +pub struct Web { + #[serde(default = "Web::default_enabled")] + pub enabled: bool, + #[serde(default = "Web::default_host")] + pub host: String, + #[serde(default = "Web::default_port")] + pub port: u16, + #[serde(default = "Web::default_ui")] + pub ui: bool, + #[serde(default = "Web::default_ui_anonymous")] + pub ui_anonymous: bool, + #[serde(default = "Web::default_log_tap_kb")] + pub log_tap_kb: u32, +} + +// в Config: +#[serde(alias = "prometheus")] +pub web: Web, +``` + +`serde(alias)` позволяет старому конфигу с `[prometheus] enabled = true` работать без изменений. Документацию обновляем под новое имя; alias упоминаем сноской «`[prometheus]` остаётся валидным алиасом для обратной совместимости». + +### 5.3 Дефолты + +| Ключ | Дефолт | Обоснование | +|---|---|---| +| `enabled` | `false` | Совпадает с текущим `prometheus.enabled` | +| `host` | `"0.0.0.0"` | Совпадает с текущим | +| `port` | `9127` | Совпадает с текущим | +| `ui` | `false` | Opt-in, чтобы UI не появлялся у обновляющихся юзеров | +| `ui_anonymous` | `true` | Когда юзер opt-in'ил UI, default'но видно как `/metrics` | +| `log_tap_kb` | `64` | Достаточно для нескольких минут активного лога; маленький RAM-footprint | + +### 5.4 Startup safety: дефолтный пароль + ui + +```rust +let ui_active = if cfg.web.ui { + if cfg.general.admin_password == "admin" || cfg.general.admin_password.is_empty() { + log::warn!( + "web.ui = true ignored: admin_password is default/empty. \ + Set a real admin_password to enable the UI. /metrics continues to work." + ); + false + } else { + true + } +} else { + false +}; +``` + +`ui_active` определяет, поднимать ли SPA-handler, `/api/*`, reaper LogTap'а. `/metrics` от этого не зависит — он живёт всегда при `web.enabled=true`. + +## 6. Auth & access matrix + +### 6.1 Dispatch + +``` +GET /metrics → no auth, всегда 200 +GET /api/ → require basic-auth(admin), иначе 401 +GET /api/ | / | /assets/* + → if ui_anonymous=true: pass + else: require basic-auth(admin), иначе 401 +``` + +Admin-only пути (фиксированный set, проверяется по startsWith): +- `/api/logs` +- `/api/prepared/text/` +- `/api/interner/top` + +### 6.2 Парсер basic-auth + +```rust +fn check_basic_auth(header: Option<&str>, user: &str, pass: &str) -> bool { + let Some(value) = header.and_then(|h| h.strip_prefix("Basic ")) else { return false; }; + let Ok(decoded) = base64::decode(value.trim()) else { return false; }; + let Ok(s) = std::str::from_utf8(&decoded) else { return false; }; + let Some((u, p)) = s.split_once(':') else { return false; }; + use subtle::ConstantTimeEq; + bool::from(u.as_bytes().ct_eq(user.as_bytes())) + & bool::from(p.as_bytes().ct_eq(pass.as_bytes())) // & вместо &&: без short-circuit +} +``` + +Новые deps: `base64` (~5k LOC) и `subtle` (~500 LOC, constant-time primitives). Обе крошечные, без транзитивных зависимостей. + +При неуспешной auth: `401 Unauthorized` + `WWW-Authenticate: Basic realm="pg_doorman admin"` — браузер сам показывает login dialog. + +## 7. Backend компоненты + +### 7.1 `src/web/server.rs` + +Owns `TcpListener`, accept loop, парсит request line + минимальные headers (`Authorization`, `Accept-Encoding`), вызывает `auth::check`, диспатчит в `routes::*`. Spawn-per-connection (как сейчас в `prometheus::server.rs`). Запускает reaper-task для LogTap при старте. ~200 строк. + +### 7.2 `src/web/auth.rs` + +Парсер `Authorization: Basic `, `check_basic_auth(...)`, `Authenticated { admin: bool }`. + +### 7.3 `src/web/log_tap.rs` + +```rust +pub struct LogEntry { + pub seq: u64, + pub ts_ms: u64, + pub level: log::Level, + pub target: String, + pub message: String, +} + +pub struct LogTap { + entries: parking_lot::Mutex>, + current_bytes: AtomicUsize, + max_bytes: usize, + next_seq: AtomicU64, + dropped_total: AtomicU64, + last_request_at: AtomicU64, // monotonic ms +} +``` + +Per-entry лимит — **4 KB** на одну строку (длинные обрезаются с маркером `…`), чтобы один debug-лог с большим SQL не вытеснил хвост. + +### 7.4 Доработка `src/app/log_level.rs` + +Поле `tap: ArcSwap>>`. В `Log::log()`: + +```rust +fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + self.inner.log(record); + if let Some(t) = self.tap.load().as_ref() { + t.push(record.level(), record.target(), record.args()); + } + } +} +``` + +API: +- `enable_log_tap(cap_bytes) -> Arc` (idempotent, CAS на `ArcSwap`) +- `disable_log_tap()` +- `log_tap() -> Option>` + +Hot path при `tap=None`: один `ArcSwap::load` + `Option::is_none` ≈ ~5 ns. При `Some`: format + Mutex lock + `VecDeque::push_back` + eviction ≈ единицы µs. + +### 7.5 Refactoring `src/admin/show.rs` + +Существующие функции делают collection + serialization за один проход (под Postgres-протокол). Чтобы не дублировать логику для JSON, выносим collection в чистые функции: + +```rust +// src/admin/show.rs (рефакторинг) +pub fn collect_pools() -> Vec { ... } +pub fn collect_clients() -> Vec { ... } +// ... + +#[derive(Serialize, Clone)] +pub struct PoolRow { /* поля */ } +``` + +Старые `show_pools(stream)` теперь вызывают `collect_pools()` + `render_pg_rows(...)`. Новые `routes::pools::handle()` вызывают `collect_pools()` + `serde_json::to_vec(...)`. Pure functions тестируются изолированно. + +### 7.6 `src/web/static_assets.rs` + +```rust +pub fn resolve(path: &str) -> Option<(&'static str, &'static [u8])> { + let normalized = if path == "/" { "index.html" } else { path.trim_start_matches('/') }; + if let Some(file) = SPA.get_file(normalized) { + return Some((content_type_for(normalized), file.contents())); + } + // SPA fallback для client-side routing: путь без расширения → index.html + if !normalized.contains('.') { + return SPA.get_file("index.html").map(|f| ("text/html", f.contents())); + } + None +} +``` + +`include_dir` — compile-time, ~1 KSLoc. Bundle добавит ~150–250 KB к бинарнику (ожидается). + +## 8. API endpoints + +### 8.1 Общий принцип + +- Плоский JSON, без wrapper-конверта. +- У объектных response'ов поле `ts: ` для синхронизации графиков. +- Pagination через `?limit=&offset=` (clients, servers, prepared). +- Все timestamps — unix milliseconds. +- HTTP-коды: `200 / 401 / 404 / 500 / 503`. +- Error body: `{"error": "", "message": ""}`. + +### 8.2 Полный список + +| Путь | Доступ | Назначение | +|---|---|---| +| `/metrics` | always public | Prometheus exporter, не трогаем | +| `/api/version` | public | `{version, build_date, git_commit, ts}` | +| `/api/overview` | public | Композит для главной (см. 8.3) | +| `/api/pools` | public | Per-pool строки | +| `/api/clients` | public | Active client connections (с pagination) | +| `/api/servers` | public | Backend connections (с pagination) | +| `/api/connections` | public | Cumulative counters (total/tls/plain/cancel/errors) | +| `/api/stats` | public | Per-pool xact/query/wait counters | +| `/api/databases` | public | Конфиг database entries | +| `/api/users` | public | Список пользователей | +| `/api/config` | public | Ключ-значение, **секреты маскированы** | +| `/api/auth_query` | public | Auth-query cache stats per pool | +| `/api/log_level` | public | Текущий filter (RUST_LOG-формат) | +| `/api/pool_scaling` | public | Anticipation/burst-gate counters per pool | +| `/api/pool_coordinator` | public | Coordinator limits/usage per database | +| `/api/sockets` | public, linux-only | TCP socket states | +| `/api/prepared` | public | Агрегат без текстов | +| `/api/interner` | public | Агрегат без preview | +| `/api/prepared/text/{hash}` | **admin** | Тело конкретного prepared statement | +| `/api/interner/top?n=N` | **admin** | Top-N интернированных запросов с 120-char preview | +| `/api/logs?since=&max=` | **admin** | Live-tail (см. секция 9) | + +Маскирование секретов в `/api/config`: значение `"***"` для любого поля, чьё имя в TOML — точно `password`, `secret`, либо имеет суффикс `_password`/`_secret`/`_token`/`_key`. Покрывает `admin_password`, `talos_jwt_secret`, per-user `[user] password`, потенциальные `*_token` и `*_key`. Конкретный whitelist полей фиксируется в `routes::config` тестом, чтобы добавление нового секрета в config'е сразу провалило тест без апдейта маскера. + +### 8.3 Shape `/api/overview` + +```json +{ + "ts": 1714752000123, + "active_clients": 1247, "idle_clients": 312, + "active_servers": 78, "idle_servers": 22, + "connections_total": 18934, "connections_tls": 18012, "connections_plain": 922, + "connections_cancel": 41, "connections_errors": 14, + "query_count_total": 9871234, + "transaction_count_total": 4123456, + "prepared_hit_count": 88123, + "prepared_miss_count": 412, + "pool_size_sum": 100, "pool_current_sum": 78, + "wait_queue_depth_sum": 0, + "pools_total": 12, "pools_paused": 0 +} +``` + +Все cumulative-счётчики передаются как есть; клиент сам считает дельты для tps/qps/hit_rate/conn_rate за окно. + +### 8.4 Shape `/api/pools` + +```json +{ "ts": ..., "pools": [ + { "id":"main@db1", "user":"app", "database":"db1", "host":"pg1", "port":5432, + "pool_mode":"transaction", "pool_size":50, "min_pool_size":5, + "current":42, "idle":8, "active":34, "waiting":0, + "paused":false, "epoch":3 }, + ... +] } +``` + +### 8.5 Shape `/api/clients?limit=100&offset=0` + +```json +{ "ts": ..., "total": 1247, "limit": 100, "offset": 0, "clients": [ + { "client_id":"#c12345", "database":"db1", "user":"app", + "application_name":"myservice@v3", "addr":"10.1.2.3:54321", + "tls":true, "state":"active", "wait":"none", + "transaction_count":4123, "query_count":18421, "error_count":2, + "age_seconds":1842 }, + ... +] } +``` + +### 8.6 Shape `/api/logs?since=&max=<200>` + +```json +{ "ts": ..., + "tap_active": true, + "tap_capacity_bytes": 65536, + "tap_used_bytes": 18024, + "next_seq": 10421, + "dropped_before": 0, + "entries": [ + { "seq":10401, "ts_ms":1714752000098, "level":"INFO", + "target":"pg_doorman::pool", + "message":"server #s12 returned to pool main@db1 (idle)" }, + ... + ] +} +``` + +При `web.log_tap_kb = 0` — handler возвращает 503 + `{"error":"log_tap_disabled","message":"log_tap_kb is 0 in config"}`. + +## 9. LogTap lifecycle + +### 9.1 State machine + +``` +Off ──first GET /api/logs──▶ Active ──no requests for 30s──▶ Off + │ + └── push() из Log::log() пока Active +``` + +`Off` = `controller.tap = None` (нулевой оверхед в hot path). +`Active` = `controller.tap = Some(Arc)`. + +### 9.2 Activation handler + +```rust +async fn handle_logs(query: LogsQuery, auth: Authenticated) -> Response { + if !auth.admin { return Response::status(401); } + if config.web.log_tap_kb == 0 { + return Response::json_status(503, Error { code: "log_tap_disabled", ... }); + } + let tap = log_level::log_tap() + .unwrap_or_else(|| log_level::enable_log_tap(config.web.log_tap_kb as usize * 1024)); + let DrainResult { entries, next_seq, dropped_before } + = tap.drain_since(query.since, query.max.unwrap_or(200)); + Response::json(/* ... */) +} +``` + +### 9.3 Reaper + +```rust +async fn reaper() { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + loop { + interval.tick().await; + if let Some(tap) = log_level::log_tap() { + let last = tap.last_request_at.load(Relaxed); + if now_monotonic_ms().saturating_sub(last) > 30_000 { + log_level::disable_log_tap(); + log::debug!("LogTap disabled (no consumers for 30s)"); + } + } + } +} +``` + +Reaper-task запускается один раз в `web::server::start` при `ui_active = true && log_tap_kb > 0`. + +### 9.4 Гонки и инварианты + +1. **Activation race** (T1, T2 одновременно делают первый GET). `enable_log_tap(cap)` идемпотентна — внутри CAS на `ArcSwap` с условием `None`. Проигравший получает существующий Arc. +2. **Reap race** (reaper выключает в момент полла). Reaper делает store None, через ms T2 enable'ит новый. Дыра в истории ровно 0–5 секунд в худшем случае. +3. **Push-during-reap**. Push идёт в старый Arc; запись копится; когда последний `Arc::strong_count` дропается — весь ring освобождается через Drop. +4. **Seq monotonicity**. `fetch_add` гарантирует уникальность. Порядок в `VecDeque` может слегка отличаться от seq при контенции (T1 fetch_add'нул раньше, lock взял позже). UI sort'ит по seq при render'е. + +### 9.5 Format и truncation + +Каждая запись в ring — структура `LogEntry` (см. 7.3). Сериализация в JSON делается в handler'е, не при push'е. `message` обрезается до 4 KB при push с маркером `…` если длиннее. `target` — путь модуля Rust (например, `"pg_doorman::pool::server_pool"`), обрезке не подвергается. + +### 9.6 Filter (level/target) + +На клиенте, не на сервере. Сервер всегда отдаёт полный stream; UI фильтрует через React state. Если ring заполняется debug-сообщениями быстрее, чем оператор успевает их читать — добавим server-side filter `?level=&target=` без breaking change. + +## 10. Frontend + +### 10.1 Стек (фиксируем) + +| Компонент | Версия | Размер | +|---|---|---| +| React | 18.x | ~45 KB gzipped | +| TypeScript | 5.x | (compile only) | +| Vite | 5.x | (build only) | +| react-router | 6.x | ~10 KB | +| uPlot | 1.6.x | ~40 KB, ноль deps | +| Tailwind | 3.x | tree-shaken до ~10 KB | + +Никаких UI-китов (MUI/Chakra/shadcn) в MVP. Никакого state-менеджера (Redux/Zustand) — `useState` + custom hooks. + +### 10.2 Polling и история + +`usePoll(fetcher, intervalMs)` — обёртка `useEffect`, очищает interval при unmount. Дефолтный interval — 1500 мс. + +`useHistory(key, maxPoints)` — rolling window 120 точек (≈ 3 минуты при 1.5s polling), персистит в `sessionStorage` под `key`. Кнопка «Reset history» в Overview очищает sessionStorage. + +### 10.3 Auth flow в браузере + +При `ui_anonymous = true`: фронт делает запросы без `Authorization`. Если 401 на admin-only endpoint — `AuthGate` показывает modal с input для credentials, сохраняет в memory (React state), повторяет запрос с `Authorization: Basic `. + +При `ui_anonymous = false`: первый запрос (`GET /api/version`) возвращает 401 → `AuthGate` перехватывает, дальше всё под basic-auth. + +Credentials хранятся **только в memory** (React state). При F5 нужно перевводить. + +### 10.4 Build/CI + +В `release`-pipeline шаг перед `cargo build`: + +```yaml +- name: Build frontend + run: | + cd frontend + npm ci + npm run build +``` + +В dev: `vite dev` на :5173, proxy `/api/*` → :9127. Hot reload работает. + +В CI checks: `npm run typecheck` + `npm run lint`. + +## 11. Error handling + +### 11.1 HTTP-коды + +| Код | Когда | +|---|---| +| 200 | OK | +| 401 | Auth required (включая admin-only без creds, и любой путь при `ui_anonymous=false` без creds) | +| 404 | Путь не найден; `/api/*` при `ui_active=false` | +| 500 | Handler поймал `Result::Err` (логируется через `log::error!`) | +| 503 | `/api/logs` при `log_tap_kb=0` | + +### 11.2 Backend handlers + +Ловим `Result` и логируем через `log::error!` + возвращаем 500 с body `{"error":"internal","message":""}`. + +`tokio::spawn` per-connection изолирует panic — паника в одном handler'е роняет одно соединение, listener продолжает accept'ить. + +### 11.3 Frontend + +- 401 → `AuthGate` показывает modal, повтор запроса. +- 5xx → inline-баннер «Backend error: », polling продолжается. +- Network error / connection refused → «Backend unreachable» баннер. +- Дроп логов → `LogStream` показывает строку ` lines dropped` серой плашкой. + +## 12. Testing + +### 12.1 Rust unit tests + +- `src/web/auth.rs` — base64 decode, malformed headers, constant-time compare, default-password rejection. +- `src/web/log_tap.rs` — push с overflow и eviction, drain_since (старее ring → `dropped_before`, в ring → корректный slice, после tail → пусто), seq monotonicity при concurrent push, idempotent enable. +- `src/web/routes/*.rs` — каждый handler проверяется на JSON shape (snapshot-тесты на serde output). Чистые `collect_*()` функции тестируются отдельно от serializers. + +### 12.2 Rust integration tests + +Поднимаем настоящий HTTP listener в test, делаем `reqwest`: +- public path при `ui_anonymous=true` → 200 без auth. +- admin-only без creds → 401 + WWW-Authenticate. +- admin-only с правильными creds → 200. +- `ui=false` → `/metrics` 200, `/` 404. +- `ui=true && admin_password="admin"` → warn в логах, `/api/*` 404. + +### 12.3 BDD (`tests/bdd/`) + +- `web_anonymous.feature` — read-only сценарии без auth. +- `web_admin.feature` — auth flow, доступ к логам. +- `web_default_password.feature` — UI отключается при дефолтном пароле. +- `web_log_tap.feature` — lazy activation и reaper deactivation. + +### 12.4 Frontend tests + +- `vitest` для hooks (`usePoll`, `useHistory`). +- React Testing Library для `AuthGate` (рендер + 401 → modal). +- Smoke build: `npm run build` без warnings (CI gate). + +## 13. Migration + +### 13.1 Конфиг + +Backwards-compatible через `#[serde(alias = "prometheus")]`. Старые `pg_doorman.toml` с `[prometheus] enabled = true / host / port` продолжат работать без изменений. Дефолты новых ключей (`ui = false`, `ui_anonymous = true`, `log_tap_kb = 64`) безопасны: ничего не появляется само. + +Ограничение alias-подхода: нельзя одновременно держать в одном TOML и `[prometheus]`, и `[web]` — это будет ошибка парсинга. Документируем явно, в `pg_doorman.example.toml` показываем только новое имя. + +### 13.2 Код + +- `git mv src/prometheus src/web/metrics` +- `git mv src/web/metrics/server.rs src/web/server.rs` +- Глобальная замена `crate::prometheus::*` → `crate::web::metrics::*` (sanity: `cargo build && cargo clippy && cargo test`). +- Создаются: `src/web/{auth,log_tap,static_assets,routes/}.rs`. +- Обновляется: `src/app/log_level.rs` (поле `tap`, методы `enable/disable/log_tap`). + +### 13.3 Документация + +- `documentation/{en,ru}/src/configuration*.md` — обновить секцию `[web]`, добавить feature-флаги, упомянуть deprecated alias `[prometheus]`. +- `changelog.md` — entry под следующий минор. +- `README.md` — короткая ссылка на UI feature. + +### 13.4 Версионирование + +Следующий релиз — minor (3.8.0 либо ближайший по календарю), не major. Переименование секции через alias — не breaking change. + +### 13.5 Релиз-чеклист + +- `cargo fmt && cargo clippy -- --deny warnings && cargo test` — чисто +- BDD пройден +- Frontend `npm run build` без warnings +- Размер бинарника проверен (ожидание +200–400 KB) +- Hot path логов: бенчмарк на info-уровне до/после с `tap=None` (должен быть в шуме измерений) + +## 14. Implementation phases + +Будет уточнено в `writing-plans`. Высокоуровневое разбиение: + +1. **Reorg + config** — переезд `prometheus → web/metrics`, новая `Web` структура с alias, defaults. Без изменения поведения наружу. +2. **Listener + mux + auth** — расширение `web/server.rs` до mux'а, `web/auth.rs`, отказ при дефолтном пароле. `/metrics` продолжает работать. +3. **Backend routes** — `routes/*.rs` плюс рефакторинг `admin/show.rs` (`collect_*` функции), все public endpoint'ы. +4. **LogTap + admin endpoint'ы** — `log_tap.rs`, `enable_log_tap` в `LogLevelController`, `routes/logs.rs`, reaper-task. +5. **Frontend skeleton** — `frontend/` с Vite, sidebar layout, AuthGate, react-router. Все страницы — заглушки. +6. **Frontend pages** — Overview (с uPlot), Pools, Clients, Servers, Prepared, Interner, Logs, Config. +7. **Embedding + CI + docs** — `include_dir!`, build-pipeline шаг, документация, BDD-сценарии. + +## 15. Open follow-ups (не входит в MVP, явно отложено) + +- Per-pool breakdown на графиках Overview (легенда, цвета, persistance выбора). +- Server-side filter для `/api/logs?level=&target=` если ring заполняется быстрее, чем оператор читает. +- SSE / WebSocket для логов и графиков, если polling окажется слабым местом. +- Темизация (light/dark, system-preference). +- i18n. +- Мобильная адаптация sidebar nav. +- Admin-команды через UI: PAUSE / RESUME / RECONNECT / RELOAD / SET log_level — после security-обзора (CSRF, audit log, идемпотентность кнопок). +- Серверное хранение коротких rolling-окон (например, 24 часа в SQLite/parquet) для bookmarkable URL с конкретным временным окном. From c69fbd2fe009fbacd1401b82fcf1db884012dc25 Mon Sep 17 00:00:00 2001 From: dmitrivasilyev Date: Wed, 6 May 2026 09:13:30 +0300 Subject: [PATCH 002/128] =?UTF-8?q?docs:=20web=20UI=20design=20package=20?= =?UTF-8?q?=E2=80=94=20spec,=20design=20system,=20phase=201=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates 2026-05-06-web-ui-design.md with findings from four parallel reviews (perf, UX, DBA/DevOps, dashboard research): lock-free MPSC LogTap, six-page navigation with drawer-based drill-down, sort/filter/URL-state on tables, /api/top/* endpoints, threshold-driven health computed on the frontend, and a dedicated observability layout & thresholds section. Adds 2026-05-06-web-ui-design-system.md: industrial/utilitarian visual language with IBM Plex Sans+Mono, dark-primary palette, sidebar 220 px, dense 32 px tables, threshold paint mixin, four-sparkline Golden Signals strip, keyboard shortcuts, and three empty-state variants. Adds plans/2026-05-06-web-ui-phase-1.md: bite-sized TDD plan for the first refactor step — rename [prometheus] config section to [web] with a serde alias, move src/prometheus to src/web/metrics. No behaviour change, namespace preparation for upcoming phases. --- .../plans/2026-05-06-web-ui-phase-1.md | 720 +++++++++++++++++ .../specs/2026-05-06-web-ui-design-system.md | 605 +++++++++++++++ .../specs/2026-05-06-web-ui-design.md | 723 ++++++++++++++++-- 3 files changed, 1969 insertions(+), 79 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-06-web-ui-phase-1.md create mode 100644 docs/superpowers/specs/2026-05-06-web-ui-design-system.md diff --git a/docs/superpowers/plans/2026-05-06-web-ui-phase-1.md b/docs/superpowers/plans/2026-05-06-web-ui-phase-1.md new file mode 100644 index 000000000..a14a210c4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-web-ui-phase-1.md @@ -0,0 +1,720 @@ +# Web UI — Phase 1 Implementation Plan: Reorg + Web Config + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Переименовать `[prometheus]` секцию конфига в `[web]` (с serde alias для обратной совместимости), добавить новые поля (`ui`, `ui_anonymous`, `log_tap_max_entries`), переместить модуль `src/prometheus/` в `src/web/metrics/` без изменения внешнего поведения. После фазы 1 `/metrics` продолжает работать ровно как сейчас, старые конфиги с `[prometheus]` парсятся без изменений. + +**Architecture:** Тонкий, чисто рефакторинговый шаг. Ни одной строки логики — только переименования файлов, struct'ов, полей и use-путей. Все существующие тесты должны продолжать проходить без изменений (кроме переименования `Prometheus` → `Web`, которое затронет один-два теста явно). Фаза подготавливает namespace для последующих `web/auth.rs`, `web/log_tap.rs`, `web/routes/`, которые будут жить рядом с `web/metrics/`. + +**Tech Stack:** Rust + serde (`#[serde(alias = ...)]` для backwards compat) + cargo (build, test, clippy, fmt). Никакого frontend-кода в этой фазе. + +**Reference:** +- Spec: `docs/superpowers/specs/2026-05-06-web-ui-design.md` разделы 4.2 (реорганизация модулей), 5.1-5.2 (TOML, Rust struct), 5.3 (дефолты), 14.1-14.2 (migration). +- Decision log #11 (имя секции `[web]` с alias `prometheus`), #14, #19. + +**Не входит в фазу 1** (это будут отдельные планы для фаз 2-7): +- Listener mux, HTTP routing для `/api/*`, auth. +- Routes handlers, `collect_*()` рефакторинг `admin/show.rs`. +- LogTap. +- Frontend. +- BDD-сценарии. + +--- + +## File Structure + +**Новые файлы / директории:** +- `src/web/mod.rs` — root модуля. На фазе 1 содержит только `pub mod metrics;`. +- `src/web/metrics/mod.rs` — то, что было `src/prometheus/mod.rs`. +- `src/web/metrics/server.rs` — то, что было `src/prometheus/server.rs`. +- `src/web/metrics/metrics.rs` — то, что было `src/prometheus/metrics.rs`. +- `src/web/metrics/system.rs` — то, что было `src/prometheus/system.rs`. +- `src/web/metrics/tests.rs` — то, что было `src/prometheus/tests.rs`. +- `src/config/web.rs` — то, что было `src/config/prometheus.rs`, содержит `pub struct Web` с alias на `Prometheus`. + +**Удаляемые файлы:** +- `src/prometheus/` — вся директория после `git mv` в `src/web/metrics/`. +- `src/config/prometheus.rs` — после переименования в `src/config/web.rs`. + +**Модифицируемые файлы:** +- `src/lib.rs` (или `src/main.rs` — узнать в Task 0): убрать `pub mod prometheus;`, добавить `pub mod web;`. Также `mod config;` остаётся. +- `src/config/mod.rs:218`: поле `pub prometheus: Prometheus` → `pub web: Web` с `#[serde(alias = "prometheus")]`. +- `src/config/mod.rs` upper imports: `use crate::config::prometheus::Prometheus;` → `use crate::config::web::Web;`. Также `pub mod prometheus;` → `pub mod web;` (если объявление есть). +- `src/app/server.rs:1`: `use crate::prometheus::{record_interner_gc, start_prometheus_server};` → `use crate::web::metrics::{record_interner_gc, start_prometheus_server};`. +- `src/app/server.rs:361-368`: `config.prometheus.enabled / .host / .port` → `config.web.enabled / .host / .port`. +- `src/app/generate/annotated.rs:189`: `&config.prometheus` → `&config.web`. Любая function `write_prometheus_section` остаётся как есть (это про TOML output, не про struct field) — но имя секции в выводе меняется на `[web]`. +- `pg_doorman.toml` (пример): секция `[prometheus]` → `[web]`, плюс новые поля закомментированными. +- Все usage points `crate::prometheus::SHOW_CONNECTIONS` и подобные глобальные метрики → `crate::web::metrics::SHOW_CONNECTIONS` (Task 5 проходит по всем). + +--- + +## Task 0: Baseline проверка + +**Files:** none modified. + +- [ ] **Step 0.1: Зафиксировать чистое состояние ветки** + +```bash +cd /home/vadv/Projects/pg_doorman +git status +``` +Expected: working tree clean (или только uncommitted ожидаемые изменения, типа спеки которая уже была закоммичена). Если есть untracked / unstaged — обсудить с user перед началом плана. + +- [ ] **Step 0.2: Зафиксировать прохождение тестов до начала** + +```bash +cargo test --lib --quiet 2>&1 | tail -30 +``` +Expected: PASS, без failures, без warnings. Запомнить число тестов — после фазы 1 оно должно совпасть. + +- [ ] **Step 0.3: Зафиксировать состояние clippy** + +```bash +cargo clippy --all-targets -- --deny warnings 2>&1 | tail -10 +``` +Expected: тихий выход, никаких warnings. Если warnings есть — это блокер, фиксим до начала плана. + +- [ ] **Step 0.4: Зафиксировать, lib.rs или main.rs корневой модуль** + +```bash +ls /home/vadv/Projects/pg_doorman/src/lib.rs /home/vadv/Projects/pg_doorman/src/main.rs 2>/dev/null +``` +Зависит от структуры — pg_doorman это бинарь, поэтому возможно отсутствие lib.rs. В оставшихся task'ах используется обозначение `` — на этом шаге фиксируем, какой именно файл это. + +--- + +## Task 1: Переименовать `Prometheus` struct → `Web`, добавить новые поля + +**Files:** +- Modify: `src/config/prometheus.rs` (переименовываем struct, переносим в новый файл в Task 2). +- Modify: `src/config/mod.rs` (поле `prometheus` → `web` с alias). +- Test: `src/config/tests.rs` (новые тесты на парсинг `[web]` и `[prometheus]` alias). + +- [ ] **Step 1.1: Написать failing-тест на парсинг `[web]` секции** + +Открыть `src/config/tests.rs`. В конец файла добавить: + +```rust +#[tokio::test] +async fn test_config_web_section() { + use crate::config::Config; + let toml = r#" +[general] +host = "0.0.0.0" +port = 6432 +admin_username = "admin" +admin_password = "secret" + +[web] +enabled = true +host = "127.0.0.1" +port = 9128 +ui = true +ui_anonymous = false +log_tap_max_entries = 4096 + +[pools.test] +[pools.test.users] +0 = { username = "u", password = "p", pool_size = 5 } +"#; + let config: Config = toml::from_str(toml).expect("parse [web] section"); + assert!(config.web.enabled); + assert_eq!(config.web.host, "127.0.0.1"); + assert_eq!(config.web.port, 9128); + assert!(config.web.ui); + assert!(!config.web.ui_anonymous); + assert_eq!(config.web.log_tap_max_entries, 4096); +} +``` + +- [ ] **Step 1.2: Написать failing-тест на backwards-compat alias `[prometheus]`** + +В тот же файл добавить: + +```rust +#[tokio::test] +async fn test_config_prometheus_alias() { + use crate::config::Config; + let toml = r#" +[general] +host = "0.0.0.0" +port = 6432 +admin_username = "admin" +admin_password = "secret" + +[prometheus] +enabled = true +host = "127.0.0.1" +port = 9128 + +[pools.test] +[pools.test.users] +0 = { username = "u", password = "p", pool_size = 5 } +"#; + let config: Config = toml::from_str(toml).expect("parse [prometheus] alias"); + assert!(config.web.enabled); + assert_eq!(config.web.host, "127.0.0.1"); + assert_eq!(config.web.port, 9128); + // Дефолты новых полей сохраняются + assert!(!config.web.ui); + assert!(config.web.ui_anonymous); + assert_eq!(config.web.log_tap_max_entries, 8192); +} +``` + +- [ ] **Step 1.3: Запустить тесты, убедиться что они falling** + +```bash +cargo test --lib config::tests::test_config_web_section -- --nocapture 2>&1 | tail -20 +cargo test --lib config::tests::test_config_prometheus_alias -- --nocapture 2>&1 | tail -20 +``` +Expected: оба `error[E0...]` или panic про отсутствующее `config.web` поле — компилируется неудачно, как и должно. + +- [ ] **Step 1.4: Переименовать `Prometheus` → `Web` в `src/config/prometheus.rs`** + +В файле `src/config/prometheus.rs`: + +```rust +#[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(), + } + } + + pub fn default_host() -> String { "0.0.0.0".to_string() } + pub fn default_port() -> u16 { 9127 } + pub fn default_enabled() -> bool { false } + pub fn default_ui() -> bool { false } + pub fn default_ui_anonymous() -> bool { true } + pub fn default_log_tap_max_entries() -> u32 { 8192 } +} +``` + +(Если в `src/config/prometheus.rs` есть функция `default_enable` — переименовать в `default_enabled` для консистентности.) + +- [ ] **Step 1.5: Обновить `Config` struct в `src/config/mod.rs`** + +Найти строку `pub prometheus: Prometheus,` (около line 218) и заменить блок: + +```rust + // Web UI / metrics settings. + #[serde(default = "Web::empty", alias = "prometheus")] + pub web: Web, +``` + +И вверху файла обновить import: + +```rust +pub mod web; +``` + +(Старая строка `pub mod prometheus;` если есть — удалить; иначе создать новую `pub mod web;`.) + +В том же файле `pub use prometheus::Prometheus;` (если есть) удалить, добавить `pub use web::Web;`. + +- [ ] **Step 1.6: Запустить тесты — failed → переименование пройдёт компиляцию** + +```bash +cargo build --lib 2>&1 | tail -30 +``` +Expected: ошибки компиляции в use-сайтах: `app/server.rs`, `app/generate/annotated.rs`. Это нормально — починим в следующих task'ах. + +- [ ] **Step 1.7: Не коммитить пока — фаза 1 завершается коммитом в Task 7** + +Переходим к Task 2. + +--- + +## Task 2: Переместить `src/config/prometheus.rs` → `src/config/web.rs` + +**Files:** +- Move: `src/config/prometheus.rs` → `src/config/web.rs`. +- Modify: `src/config/mod.rs`. + +- [ ] **Step 2.1: git mv файла** + +```bash +git mv src/config/prometheus.rs src/config/web.rs +``` + +- [ ] **Step 2.2: Подтвердить, что внутри файла остался `pub struct Web`** + +Open `src/config/web.rs`. Проверить, что после Task 1 он содержит `pub struct Web` со всеми новыми полями. + +- [ ] **Step 2.3: Удалить `pub mod prometheus;` из `src/config/mod.rs`** + +Если он был добавлен в Task 1 и при этом ещё есть отдельная строка `pub mod prometheus;` — удалить, оставить только `pub mod web;`. + +- [ ] **Step 2.4: Запустить cargo build** + +```bash +cargo build --lib 2>&1 | tail -30 +``` +Expected: ошибки компиляции по-прежнему только в `app/server.rs` и `app/generate/annotated.rs`, не в config модуле. + +- [ ] **Step 2.5: Не коммитить пока** + +--- + +## Task 3: Обновить call sites вне модуля config + +**Files:** +- Modify: `src/app/server.rs:1` (use), `src/app/server.rs:361-368` (config field access). +- Modify: `src/app/generate/annotated.rs:189` (config field access; функция `write_prometheus_section` остаётся, но имя секции в её output меняется на `[web]`). + +- [ ] **Step 3.1: `src/app/server.rs` — обновить use-import** + +Найти на line 1 (или около): +```rust +use crate::prometheus::{record_interner_gc, start_prometheus_server}; +``` +Заменить на: +```rust +use crate::web::metrics::{record_interner_gc, start_prometheus_server}; +``` + +(Hmm: на этом шаге crate::web::metrics ещё не существует. Поэтому фактическое перемещение модуля — Task 4. Здесь сохраним временно `crate::prometheus`, обновим в Task 5. Корректировка плана: оставить use-import как есть на этом шаге, обновить только field access.) + +**Replan Step 3.1:** обновить только `config.prometheus.*` → `config.web.*`, use-импорт остаётся `use crate::prometheus::*` до Task 5. + +Найти на line 361-368: +```rust +if config.prometheus.enabled { + tokio::task::spawn(async move { + start_prometheus_server( + format!("{}:{}", config.prometheus.host, config.prometheus.port).as_str(), + ) + .await; + }); +} +``` +Заменить на: +```rust +if config.web.enabled { + tokio::task::spawn(async move { + start_prometheus_server( + format!("{}:{}", config.web.host, config.web.port).as_str(), + ) + .await; + }); +} +``` + +- [ ] **Step 3.2: `src/app/generate/annotated.rs:189` — обновить field access** + +Найти строку с `&config.prometheus` (около line 189) и заменить на `&config.web`. Имя самой функции (`write_prometheus_section` или похожее) на этом шаге не трогаем — оно касается output-секции в TOML, тоже надо переименовать, но в Task 6. + +- [ ] **Step 3.3: Проверить `cargo build`** + +```bash +cargo build --lib 2>&1 | tail -10 +``` +Expected: компиляция проходит. Если осталась ошибка `field 'prometheus' not found on Config` — дополнительные usage points, найти их через `grep -rn 'config\.prometheus' src/`: + +```bash +grep -rn 'config\.prometheus' src/ +``` +И заменить каждый на `config.web`. + +- [ ] **Step 3.4: Прогнать тесты, чтобы убедиться что нечего не сломалось** + +```bash +cargo test --lib 2>&1 | tail -20 +``` +Expected: PASS все тесты, кроме новых из Task 1.5–1.7 (но они уже должны проходить, потому что field renamed). + +- [ ] **Step 3.5: Не коммитить пока** + +--- + +## Task 4: Reorg `src/prometheus/` → `src/web/metrics/` + +**Files:** +- Create: `src/web/mod.rs`. +- Move: `src/prometheus/{mod.rs, server.rs, metrics.rs, system.rs, tests.rs}` → `src/web/metrics/`. +- Modify: `` (lib.rs или main.rs — найдено в Task 0): убрать `pub mod prometheus;`, добавить `pub mod web;`. + +- [ ] **Step 4.1: Создать `src/web/` директорию + `src/web/mod.rs`** + +```bash +mkdir -p /home/vadv/Projects/pg_doorman/src/web +``` + +Создать файл `src/web/mod.rs` со следующим содержимым: + +```rust +//! 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::prometheus`). +//! Auth, routes, log_tap, and static_assets are added in subsequent phases. + +pub mod metrics; +``` + +- [ ] **Step 4.2: Переместить файлы через git mv** + +```bash +cd /home/vadv/Projects/pg_doorman +git mv src/prometheus src/web/metrics +``` + +После этого `src/web/metrics/` должен содержать `mod.rs`, `server.rs`, `metrics.rs`, `system.rs`, `tests.rs`. Директория `src/prometheus/` исчезает. + +- [ ] **Step 4.3: Обновить root module declaration** + +В файле, найденном в Task 0.4 (например `src/lib.rs` или `src/main.rs`), найти строку `pub mod prometheus;` (или `mod prometheus;`) и заменить на `pub mod web;` (или `mod web;` соответственно — какой visibility был у prometheus, такой же оставить у web). + +Если оба варианта `pub mod prometheus;` и `mod prometheus;` появлялись в разных файлах — обновить обе. + +- [ ] **Step 4.4: Запустить cargo build** + +```bash +cargo build --lib 2>&1 | tail -30 +``` +Expected: ошибки в каждом файле, который имеет `use crate::prometheus::*;` или ссылается на `crate::prometheus::SHOW_CONNECTIONS` (и подобные глобальные метрики). Это нормально, чиним в Task 5. + +- [ ] **Step 4.5: Не коммитить пока** + +--- + +## Task 5: Обновить все use crate::prometheus → crate::web::metrics + +**Files:** +- Modify: каждый `.rs` файл с использованием `crate::prometheus`. + +- [ ] **Step 5.1: Найти все use-сайты** + +```bash +grep -rln 'crate::prometheus' src/ +``` + +Скорее всего попадут: `src/app/server.rs`, `src/pool/*.rs` (fallback.rs и пр.), `src/server/*.rs`, `src/web/metrics/metrics.rs` (внутри сам себя — `super::REGISTRY` и пр., их не трогаем), `src/admin/*.rs`. Плюс возможно `src/stats/`. + +- [ ] **Step 5.2: Применить замену** + +Для каждого файла из вывода Step 5.1, заменить `crate::prometheus` на `crate::web::metrics`. Это можно сделать одним sed (но с обязательной перепроверкой grep'ом после): + +```bash +grep -rln 'crate::prometheus' src/ | xargs sed -i 's|crate::prometheus|crate::web::metrics|g' +``` + +(Без `--no-backup` или подобного — sed -i работает inplace.) + +- [ ] **Step 5.3: Запустить cargo build, проверить что компиляция идёт** + +```bash +cargo build --lib 2>&1 | tail -30 +``` +Expected: компиляция должна пройти. Если остались ошибки про `crate::prometheus` — найти их, исправить вручную (типичная причина — multiline use или формат `use crate ::\n prometheus`). Проверить также: + +```bash +grep -rn 'crate::prometheus' src/ +``` +Expected: пусто. + +- [ ] **Step 5.4: Запустить полный набор тестов** + +```bash +cargo test --lib 2>&1 | tail -20 +``` +Expected: PASS, тестов столько же сколько в Task 0.2 + 2 новых (test_config_web_section, test_config_prometheus_alias). + +- [ ] **Step 5.5: Запустить интеграционные тесты prometheus** + +```bash +cargo test --lib web::metrics::tests::test_prometheus_server_basic 2>&1 | tail -20 +``` +Expected: PASS. Этот тест продолжает использовать имя `start_prometheus_server` функции — оно не переименовывается в фазе 1. + +- [ ] **Step 5.6: Не коммитить пока** + +--- + +## Task 6: Обновить пример `pg_doorman.toml` + +**Files:** +- Modify: `pg_doorman.toml` (или другой example в корне репо). +- Modify: `src/app/generate/annotated.rs` — функция, которая генерирует `[prometheus]`-секцию, должна теперь генерировать `[web]` с новыми полями. + +- [ ] **Step 6.1: Найти и обновить example TOML** + +В `pg_doorman.toml` (linе 410–423) заменить блок: + +```toml +# ############################################################################ +# PROMETHEUS METRICS +# ############################################################################ +[prometheus] +# Enable Prometheus metrics exporter. +# Default: false +enabled = false + +# Host for the metrics HTTP endpoint. +# Default: "0.0.0.0" +host = "0.0.0.0" + +# Port for the metrics HTTP endpoint. +# Default: 9127 +port = 9127 +``` + +на: + +```toml +# ############################################################################ +# WEB UI / METRICS +# ############################################################################ +# The legacy [prometheus] section name is also accepted as an alias for +# backwards compatibility. +[web] +# Enable HTTP listener (Prometheus metrics + future Web UI). +# Default: false +enabled = false + +# Host for the HTTP endpoint. +# Default: "0.0.0.0" +host = "0.0.0.0" + +# Port for the HTTP endpoint. +# Default: 9127 +port = 9127 + +# Serve the Web UI (and /api/* routes) on this listener. +# Requires admin_password to be set to a non-default value. +# Default: false +ui = false + +# Allow unauthenticated access to the Web UI public pages. +# When false, basic-auth (admin_username/admin_password) is required for /. +# Default: true +ui_anonymous = true + +# Capacity of the in-memory log tail buffer (entries, not bytes). +# Set to 0 to disable /api/logs entirely. +# Default: 8192 +log_tap_max_entries = 8192 +``` + +- [ ] **Step 6.2: Обновить `src/app/generate/annotated.rs`** + +Найти функцию, генерирующую секцию (вероятно `write_prometheus_section` или подобное). Переименовать заголовок секции в выводе с `[prometheus]` на `[web]`. Добавить новые поля (`ui`, `ui_anonymous`, `log_tap_max_entries`). Если у функции есть имя `write_prometheus_section` — переименовать в `write_web_section` (только в этом файле, проверить нет ли ссылок). + +```bash +grep -rn 'write_prometheus_section' src/ +``` +И заменить везде. + +- [ ] **Step 6.3: Запустить cargo build, тесты** + +```bash +cargo build --lib 2>&1 | tail -10 +cargo test --lib 2>&1 | tail -20 +``` +Expected: PASS, без warnings. + +- [ ] **Step 6.4: Не коммитить пока** + +--- + +## Task 7: Final-проверка + коммит + +**Files:** none modified at this stage. + +- [ ] **Step 7.1: cargo fmt** + +```bash +cargo fmt +git diff --stat +``` +Expected: либо diff пустой (если код уже отформатирован), либо мелкие forматные изменения. Никаких больших diffs — если есть, разобраться откуда. + +- [ ] **Step 7.2: cargo clippy --deny warnings** + +```bash +cargo clippy --all-targets -- --deny warnings 2>&1 | tail -20 +``` +Expected: тихий выход, никаких warnings. Если warnings есть — фиксим до коммита. + +- [ ] **Step 7.3: cargo test (полный набор)** + +```bash +cargo test --lib 2>&1 | tail -30 +``` +Expected: PASS. Число тестов = baseline (Task 0.2) + 2 (новые tests из Task 1). + +- [ ] **Step 7.4: Smoke check `/metrics` endpoint** + +Поднять doorman локально с дефолтным конфигом, в котором `enabled = true`: + +```bash +cargo build --release 2>&1 | tail -5 +``` + +Создать temp config: + +```bash +cat > /tmp/doorman-phase1.toml <<'EOF' +[general] +host = "127.0.0.1" +port = 16432 +admin_username = "admin" +admin_password = "phase1test" + +[web] +enabled = true +host = "127.0.0.1" +port = 19127 + +[pools.smoke] +[pools.smoke.users] +0 = { username = "u", password = "p", pool_size = 5 } +EOF + +./target/release/pg_doorman --config /tmp/doorman-phase1.toml & +DOORMAN_PID=$! +sleep 2 +curl -s http://127.0.0.1:19127/metrics | head -20 +kill $DOORMAN_PID +``` +Expected: `pg_doorman_*` метрики в выводе. Это smoke-тест: фаза 1 не должна сломать /metrics endpoint. + +- [ ] **Step 7.5: Smoke check backwards-compat alias** + +Тот же smoke, но с `[prometheus]` вместо `[web]`: + +```bash +cat > /tmp/doorman-phase1-alias.toml <<'EOF' +[general] +host = "127.0.0.1" +port = 16432 +admin_username = "admin" +admin_password = "phase1test" + +[prometheus] +enabled = true +host = "127.0.0.1" +port = 19127 + +[pools.smoke] +[pools.smoke.users] +0 = { username = "u", password = "p", pool_size = 5 } +EOF + +./target/release/pg_doorman --config /tmp/doorman-phase1-alias.toml & +DOORMAN_PID=$! +sleep 2 +curl -s http://127.0.0.1:19127/metrics | head -5 +kill $DOORMAN_PID +``` +Expected: точно такой же успех — alias работает. + +- [ ] **Step 7.6: Pre-commit code review** + +Согласно CLAUDE.md правилу — перед commit'ом запустить отдельного агента для code review с черновиком commit-сообщения. + +Черновик: +``` +refactor(web): rename [prometheus] config section to [web], move src/prometheus to src/web/metrics + +Что требовалось: подготовить namespace для будущего Web UI (auth, log tap, REST routes), переименовать [prometheus] секцию конфига в [web], добавить новые поля (ui, ui_anonymous, log_tap_max_entries) с защищающими дефолтами, не сломав существующие конфиги пользователей. + +Суть: модуль src/prometheus стал src/web/metrics, Prometheus struct стал Web с serde alias на старое имя; новые поля имеют conservative дефолты (ui=false, ui_anonymous=true, log_tap_max_entries=8192). /metrics endpoint работает как раньше; конфиги с [prometheus] продолжают парситься без изменений. Никакой новой логики — чисто рефакторинг под фазу 1 имплементации Web UI. +``` + +Дальше — диспатч Agent с `subagent_type: general-purpose`, `model: opus`, передаём весь промпт code-review агента из CLAUDE.md, с этим черновиком. + +- [ ] **Step 7.7: Если ревью «КОММИТ ЗАБЛОКИРОВАН» — починить блокеры, повторить ревью** + +Цикл до получения «Ревью пройдено». + +- [ ] **Step 7.8: Создать единый коммит фазы 1** + +```bash +git add -A +git status # глянуть что попадает +git commit -m "refactor(web): rename [prometheus] config section to [web], move src/prometheus to src/web/metrics + +Renamed config section [prometheus] to [web] with serde alias for backward +compatibility, added new fields ui, ui_anonymous and log_tap_max_entries with +conservative defaults. Moved src/prometheus to src/web/metrics. /metrics +endpoint remains unchanged; existing configs with [prometheus] continue to +parse. No behavior change — preparation namespace for upcoming Web UI phases." +``` + +(Commit message — на английском, по project convention из feedback memory.) + +- [ ] **Step 7.9: Проверить, что ветка в чистом состоянии после коммита** + +```bash +git log --oneline -1 +git status +``` +Expected: HEAD на новом коммите, working tree clean. + +- [ ] **Step 7.10: Mark task #3 в TaskList как completed** + +Это сигнал, что фаза 1 готова. Дальнейшие фазы 2-7 — отдельные планы. + +--- + +## Self-review + +**Spec coverage check:** +- ✅ Reorg `src/prometheus/` → `src/web/metrics/` — Task 4. +- ✅ Новый `Web` struct с alias — Task 1, 2. +- ✅ Новые поля `ui`, `ui_anonymous`, `log_tap_max_entries` с дефолтами — Task 1.4. +- ✅ Дефолты `ui=false`, `ui_anonymous=true`, `log_tap_max_entries=8192` (раздел 5.3) — Task 1.4. +- ✅ Backwards-compat для старого `[prometheus]` — Task 1.2 (тест), Task 1.5 (alias). +- ✅ `/metrics` продолжает работать — Task 7.4 (smoke). +- ✅ Pre-commit code review — Task 7.6. +- ✅ Commit message — Task 7.8. + +**Не покрыто этой фазой (намеренно):** +- mux на listener'е → фаза 2. +- Auth → фаза 2. +- Backend routes / collect_*() рефакторинг — фаза 3. +- LogTap — фаза 4. +- Frontend skeleton — фаза 5. +- Frontend pages — фаза 6. +- Embedding + CI — фаза 7. + +**Type-consistency check:** +- `Web` struct: одно имя по всему плану. +- `default_enabled` (не `default_enable`) — поправлено в Task 1.4. +- Field name `pub web: Web` — одинаково в Config (Task 1.5) и в use-сайтах (Task 3). + +**Placeholder check:** обыскал план на «TBD», «implement later», «similar to» — отсутствуют. Каждый шаг содержит либо точный код, либо точную команду. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-06-web-ui-phase-1.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — fresh subagent per task, review между task'ами, fast iteration. +2. **Inline Execution** — выполняем task'и в этой сессии последовательно с checkpoint'ами для review. + +Which approach? diff --git a/docs/superpowers/specs/2026-05-06-web-ui-design-system.md b/docs/superpowers/specs/2026-05-06-web-ui-design-system.md new file mode 100644 index 000000000..2bbf3f57c --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-web-ui-design-system.md @@ -0,0 +1,605 @@ +# pg_doorman Web UI — Design System + +Дата: 2026-05-06. +Дополнение к `2026-05-06-web-ui-design.md`. Фиксирует визуальный язык до начала кодинга, чтобы не выбирать каждый раз заново шрифт, цвет границы или плотность таблицы. + +## 1. Контекст и аудитория + +Целевой пользователь — DBA, SRE или dev-on-call в момент инцидента или регулярного оперативного обзора. Контекст использования: +- Десктоп, монитор большой, окно браузера часто не во весь экран — сетка должна работать от 960 px ширины. +- Сценарий — быстро понять, что происходит, и принять решение, идти ли в `psql` admin. UI **только показывает**, не правит. +- Часто открыт рядом с Grafana, терминалом, slack — должен визуально стоять рядом, не сливаться и не кричать. + +Из этого следуют два решения, остальное — производное от них: +1. **Density над air-space.** Плотная сетка, мало padding, числа в моно. Свободное пространство тратим только на разделение блоков, не на «дыхание» внутри. +2. **Dark theme primary.** Operational tools чаще включают ночью на инциденте; dark — нейтральнее на длинной сессии и не слепит. Light theme — follow-up после MVP. + +## 2. Aesthetic direction + +**Industrial / utilitarian с нотами brutally minimal.** + +Что это значит конкретно: +- Минимум декоративных элементов: никаких градиентов, теней, glassmorphism, скруглённых карточек с большими радиусами. +- Тонкие границы 1 px, тонкие разделители, большая часть «компонентов» — это таблицы и числа. +- Один акцентный цвет (cyan), используется только для интерактивности (focus, active, primary CTA-equivalent типа кнопки «Reset history»), не для декора. +- Семантические цвета (green/amber/red) применяются точечно — статусные badge, alerts, error-баннер. Не используем как декор. +- Никакого drop-shadow на ровном UI; box-shadow допустим только для overlay-элементов (modal, dropdown). +- Все числа — в табличных моно-цифрах. Это сигнатурный признак инструмента: «это DBA-tool, тут важна каждая цифра». + +**Memorable element:** на каждой странице — таблица или плотная грид-метрика, где числа выровнены справа в IBM Plex Mono с tabular figures. UI узнаётся именно по этому, как Grafana по графикам или Linear по shortcut-меню. + +**Чего избегаем (анти-слоп для нашего контекста):** +- Inter, Roboto, Arial, system-ui как primary шрифт. +- Фиолетовый/синий gradient на белом. +- «Облачные» иллюстрации, маскоты, эмодзи. +- Иконки с заливкой (filled). Только outline 1.5 stroke. +- Border-radius > 6 px на любом контейнере. +- Полупрозрачные blurred backgrounds. + +## 3. Typography + +### 3.1 Семейства + +| Роль | Шрифт | Источник | Fallback | +|---|---|---|---| +| UI / sans | IBM Plex Sans (400, 500, 600) | self-host woff2, ~30 KB на вес | system-ui, sans-serif | +| Mono / numbers / logs / SQL | IBM Plex Mono (400, 500) | self-host woff2, ~25 KB на вес | ui-monospace, monospace | + +Self-host обязателен: бинарь embed'ит SPA через `include_dir!`, никаких внешних CDN-загрузок. Веса загружаем только реально используемые (две на каждое семейство = 4 файла, ≈110 KB total после woff2-сжатия). + +IBM Plex выбран осознанно: +- Distinctive, не Inter — проходит anti-slop фильтр. +- Хорошо читается в плотной сетке, рассчитан на data-tables. +- Mono-вариант имеет правильные tabular figures и slashed zero — нужно для логов и счётчиков. +- Open-source (SIL OFL), без лицензионных проблем. + +### 3.2 Шкала размеров + +```css +--font-size-xs: 11px /* table secondary, axis labels, badge */ +--font-size-sm: 13px /* default table cell, secondary text */ +--font-size-base: 14px /* body, sidebar items */ +--font-size-md: 16px /* page section title */ +--font-size-lg: 20px /* page H1, hero metric label */ +--font-size-xl: 28px /* hero metric number в Overview */ +``` + +Мы намеренно **не** идём по 16 px base из mainstream-гайда — это operational density UI, аналог Bloomberg terminal'а или Datadog'а, где 13 px sans + 12 px mono норма. 14 px base — уважительный компромисс, читается без напряжения, но не съедает экран. + +Line-height: 1.4 для UI-текста, 1.5 для логов (читабельность многострочных сообщений), 1.0 для hero metric. + +Letter-spacing: `0.01em` для caps-меток («ACTIVE», «PAUSED», «TLS»). Везде остальном — default. + +### 3.3 Веса + +- 400 (regular) — body, table cell, log entry. +- 500 (medium) — table header, sidebar active item, badge, page H1. +- 600 (semibold) — hero metric number в Overview, alerts. + +Никаких 300/700/900 — три веса хватит, больше создаёт визуальный шум. + +### 3.4 Tabular figures + +Везде, где число читается в таблице или счётчике — `font-variant-numeric: tabular-nums slashed-zero`. Применяется через CSS-класс `tabular`, который ставится на `` числовых колонок и на hero-metric. + +## 4. Color + +### 4.1 Палитра (dark, primary) + +```css +:root { + /* Surface */ + --bg: #0a0d12; /* page background */ + --surface: #11151c; /* cards, table bg */ + --surface-2: #161b24; /* hover, active row */ + --surface-3: #1c2230; /* dropdown, modal */ + + /* Border */ + --border: #232a36; /* default border, table rows */ + --border-strong:#2d3543; /* card edges, focus outline */ + + /* Text */ + --text: #e6e9ee; /* primary */ + --text-muted: #8a93a4; /* secondary, table headers */ + --text-dim: #5a6275; /* tertiary, captions, disabled */ + + /* Accent */ + --accent: #22b8cf; /* cyan-500-ish, primary interactive */ + --accent-hover: #3ec8d9; + --accent-fg: #042024; /* text on accent fill */ + + /* Semantic */ + --success: #2dc26b; + --warning: #f5a524; + --danger: #e5484d; + --info: #5b8cff; + + /* Chart palette (uPlot lines) */ + --chart-1: #22b8cf; /* primary */ + --chart-2: #2dc26b; /* secondary positive */ + --chart-3: #f5a524; /* tertiary / warning curve */ + --chart-4: #b18cf5; /* quaternary, used sparingly */ +} +``` + +Проверка контраста (WCAG AA): +- text on bg: 13.8:1 ✓ +- text-muted on bg: 6.4:1 ✓ +- text-dim on bg: 4.0:1 ✓ (хватает для tertiary captions, не для body) +- accent on bg: 7.6:1 ✓ +- accent-fg on accent fill: 7.1:1 ✓ + +### 4.2 Использование акцента + +Один акцент — cyan. Намеренно не amber: amber мы оставили под warning, чтобы не было коллизии «акцент совпал с предупреждением». Cyan distinctive, не повторяет ни Linear-фиолетовый, ни Grafana-оранжевый, ни GitHub-зелёный. + +Где появляется accent: +- Focus ring всех интерактивных элементов (`outline: 2px solid var(--accent); outline-offset: 2px`). +- Active sidebar item (текст + 2 px левая полоса). +- Hover на ссылках в таблицах. +- Sparkline primary curve в Overview. +- Tab underline (2 px под активной вкладкой). + +Где accent **не** появляется: декоративные badge, заголовки, фон карточки, иконки в покое. + +### 4.3 Light theme (follow-up) + +Зарезервируем переменные с теми же именами для `:root.light`. Конкретные значения подбираем после MVP — сейчас не тратим время. + +## 5. Layout и плотность + +### 5.1 App shell + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Top bar (48 px): │ +│ pg_doorman v3.8.0 [● OK] 12 pools | 0 paused | 0.20 err/s │ +│ Updated 0.8s ago [admin] │ +├──────────┬───────────────────────────────────────────────────────────┤ +│ Sidebar │ Page header (56 px: title + actions + breadcrumbs) │ +│ 220 px ├───────────────────────────────────────────────────────────┤ +│ │ Content (padding 16 px) │ +│ │ │ +└──────────┴───────────────────────────────────────────────────────────┘ +``` + +- **Top bar 48 px** (увеличена с 40 px по UX-ревью: health pill + freshness не помещаются в 40 px без compromised читаемости). Содержит: + - Слева: `pg_doorman` brand + версия (Plex Sans 14 px medium). + - Центр-лево: HealthPill (см. 6.5) + 3-4 chips с ключевыми контаминированными счётчиками из `/api/overview`. + - Справа: FreshnessIndicator (см. 6.6) + индикатор admin-state (`[admin]` если auth прошёл). +- **Sidebar 220 px**, текст 14 px sans, иконка 16 px lucide, padding 6 px × 12 px. Текущая страница — левая полоса 2 px `--accent` + текст medium. +- **Content padding 16 px** со всех сторон. На широких экранах (>1280 px) — `max-width: 1440px`, центрируется. +- **Sidebar collapse до 56 px** (icon-only) — follow-up, не в MVP. + +Top bar — единственный «sticky» элемент (всегда виден при scroll). Page header не sticky — при длинных таблицах (clients, logs) контент важнее. + +### 5.2 Сетка + +8 px база (`--space-1 = 4px`, `--space-2 = 8px`, …, `--space-8 = 32px`). Пользуемся ею для всего: padding, gap, margin. Запрещены значения вне шкалы (никаких `padding: 7px`). + +### 5.3 Таблицы (главный паттерн) + +``` +┌─────────────────────────────────────────────────┐ +│ HEADER │ /* surface, 13 px medium muted, 32 px height */ +├─────────────────────────────────────────────────┤ +│ row 1 │ /* 32 px height, 13 px regular */ +│ row 2 │ +│ ... │ +└─────────────────────────────────────────────────┘ +``` + +- Row height 32 px. Hover — фон `--surface-2`, без анимации. Граница между строк 1 px `--border`. +- Cell padding `8px 12px`. +- Header sticky при scroll'е длинных таблиц (clients, servers, prepared, logs). +- Числовые колонки — `text-align: right`, класс `tabular`, IBM Plex Mono. +- Status-колонки — badge (см. ниже), не текст. +- Truncate длинных строк через `text-overflow: ellipsis` + `title=` для tooltip'а. + +Pagination footer (для clients/servers/prepared) — 32 px, моно счётчик «1–100 of 1247» слева, кнопки «Prev / Next» справа, без номеров страниц. + +### 5.4 Карточки и метрики + +Только в Overview. На остальных страницах — таблицы и тонкие счётчики в page header. + +Card: +- `background: var(--surface); border: 1px solid var(--border); border-radius: 4px;` +- Padding 16 px, gap между карточками 12 px. +- Заголовок карточки (label) — 11 px medium muted, caps, letter-spacing 0.05em. +- Hero metric — 28 px IBM Plex Mono semibold tabular, цвет `--text`. +- Sparkline под hero — высота 56 px, без axis labels, без grid (мини-формат). + +## 6. Компоненты + +Никакого UI-кита (shadcn/MUI/Chakra) — пишем 8–10 примитивов руками. Все — на CSS variables, без runtime CSS-in-JS. + +### 6.1 Минимальный набор + +| Компонент | Назначение | Размер | +|---|---|---| +| `Button` | Действие (Reset history, Refresh, Cancel modal) | 32 px height | +| `Badge` | Статус (active/idle/paused, error count) | 20 px height, caps 11 px | +| `HealthPill` | Глобальное OK / degraded / critical в top bar | 24 px height, caps 11 px | +| `FreshnessIndicator` | «Updated Xs ago» с цветовой индикацией возраста | 24 px height | +| `Tab` | Sub-nav на странице (Caches → Prepared/QueryCache, ConfigState разделы) | 32 px height, underline 2 px accent | +| `Input` | Поле в AuthGate modal, search box | 32 px height | +| `SearchBox` | Global search в page header (cmd-K) | 32 px height, icon prefix | +| `Modal` | AuthGate, confirm dialog | max-width 420 px, centered | +| `Drawer` | Pool detail из `/pools` row click | 480 px width, slide-in справа | +| `Banner` | «Backend unreachable», «log_tap_disabled» | 36 px height, full-width inside content | +| `Sparkline` | Mini-chart в карточках Overview / per-pool row | 56 px height (golden signals) / 24 px (per-pool inline) | +| `Heatmap` | Pool fill heatmap (Overview row 3b) | row 24 px × N pools | +| `Chart` | Полноразмерный uPlot (Overview 3a/3c/3d, ConfigState вкладки) | 200 px default | +| `LogStream` | Виртуализированный stream логов | flex-1 | +| `TimePicker` | «Jump to ts» в LogStream (follow-up MVP) | 32 px height inline | +| `Gauge` | Inline saturation gauge per-pool row | 100 px width × 16 px | +| `EmptyState` | 3 варианта: OK / Info / Warming | 120 px | +| `ThresholdPaint` (mixin) | Применяется к Sparkline / Chart / row для подсветки crit/warn состояния | — | + +### 6.2 Button — варианты + +```css +.btn { + height: 32px; + padding: 0 12px; + font: 500 13px/1 'IBM Plex Sans'; + border-radius: 4px; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + transition: background 100ms; +} +.btn:hover { background: var(--surface-2); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.btn-primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; } +.btn-danger { color: var(--danger); border-color: var(--danger); } +.btn-ghost { background: transparent; border-color: transparent; } +``` + +Никаких icon-buttons без `aria-label`. Никаких disabled с opacity 0.5 — лучше не показывать вовсе. + +### 6.3 Badge + +``` +ACTIVE IDLE PAUSED ERROR TLS PLAIN +``` + +11 px medium caps mono, padding 2 × 6 px, border-radius 2 px. Цвет: +- `ACTIVE` / `TLS` — `--success` фон 14% opacity, текст `--success`. +- `IDLE` / `PLAIN` — `--text-muted` без фона. +- `PAUSED` / `WARN` — `--warning` фон 14% opacity, текст `--warning`. +- `ERROR` — `--danger` фон 14% opacity, текст `--danger`. + +### 6.4 LogStream + +Это самый «характерный» компонент — фактически весь экран `/logs`. + +**Базовый рендер:** +- Шрифт IBM Plex Mono 12 px, line-height 1.5. +- Каждая запись: `[seq] [HH:mm:ss.SSS] [LEVEL] target — message`. +- Level раскрашен: ERROR `--danger`, WARN `--warning`, INFO `--text`, DEBUG `--text-muted`, TRACE `--text-dim`. +- Target — `--accent` (как ссылка, без подчёркивания), click → добавляет в filter. +- Multi-line message: `\n` внутри message рендерится как visual line break, остальные строки идут с indent 2ch и без header'а (только сам текст). Один `LogEntry` = один логический блок, независимо от высоты в пикселях. +- Длинные строки: soft-wrap по умолчанию (160ch ≈ 1 line). Toggle «Wrap / Truncate» в header. +- Дроп: серая sticky плашка ` lines dropped` (фон `--surface-2`, `--text-muted` italic), отображается до тех пор, пока оператор не подтвердил «dismiss». +- Search highlight: matched substring через `` с фоном `rgba(--accent, 0.25)` без изменения цвета текста. + +**Auto-scroll behaviour** (UX-ревью): +- При активном auto-scroll новая запись прокручивает к bottom. +- Когда оператор начинает scroll вверх — auto-scroll **выключается автоматически**. Sticky button «Resume tail» появляется в правом нижнем углу LogStream area. +- Click «Resume tail» → возврат к live. Также re-scroll до bottom включает обратно. + +**Filter и default mode:** +- Default open mode — `level=WARN` (errors+warnings only), one-click toggle на `level=DEBUG/TRACE`. Это дешёвая защита от 80% шума при первом открытии. +- Filter передаётся в backend через `?level=&target=` (server-side, см. раздел 9.6 основной спеки), substring search — клиентский поверх уже отфильтрованного. +- Filter UI: 3 control'а в header — level select, target combobox с autocomplete по уже наблюдённым target'ам, substring input. Reset — кнопка `×` рядом. + +**Time-jump (follow-up):** +- TimePicker в header «Jump to ts» (формат `HH:MM:SS.mmm`). Преобразуется в seq клиентски через бинарный поиск по уже загруженной истории. Если нужно прыгнуть за пределы загруженного — выкатывается banner «Outside loaded window, fetching…» и делается запрос с `?ts_ms=`. + +**Виртуализация:** через `react-window` (FixedSizeList либо VariableSizeList для multi-line entries). Альтернатива — ручной windowing — рассматривается при имплементации фазы 6, выбор зависит от объёма зависимости. + +### 6.5 HealthPill + +Глобальный индикатор в top bar, всегда виден. + +``` +[● OK] — фон transparent, dot --success, текст --text muted +[● degraded] — фон rgba(--warning, 0.14), dot --warning, текст --warning +[● critical] — фон rgba(--danger, 0.14), dot --danger, текст --danger +``` + +- Высота 24 px, padding `2 × 8 px`, border-radius 12 px (rounded pill). +- Текст: 11 px medium caps, letter-spacing 0.05em. +- Источник: `/api/overview.health.state`. При `state ≠ ok` справа от pill курсивом отображается `health.reason` (`--text-muted`, 11 px regular, max-width 320 px с ellipsis). +- Click → переход на `/pools?filter=critical` (или другую страницу, релевантную причине). +- Tooltip: full reason без truncate. + +### 6.6 FreshnessIndicator + +«Updated Xs ago» в top bar справа. Защищает оператора от ситуации «UI выглядит как live, но молча завис». + +| Возраст последнего успешного poll'а | Цвет | Формат | +|---|---|---| +| < 3 s | `--text-muted` | `Updated 0.8s ago` | +| 3–10 s | `--text-muted` (норма при перерывах) | `Updated 5s ago` | +| 10–30 s | `--warning` | `Updated 14s ago — retrying` | +| > 30 s | `--danger` | `Stale 45s — backend unreachable` | + +- Возраст вычисляется из `Date.now() - last_successful_poll_ts`, обновляется requestAnimationFrame'ом раз в 250 ms (без лишних re-render'ов всего дерева). +- Источник timestamp: успешный fetch любого `/api/*` endpoint'а (через `useFreshness` hook). +- Не блокирует UI — оператор продолжает видеть последнюю удачную копию данных. + +### 6.7 Drawer + +Slide-in справа панель для drill-down. Используется в Pools (server detail), Caches (prepared text view), Clients (client detail) — везде, где нужен «details on demand» без потери context'а. + +- Width 480 px (на narrow 1120 px остаётся 640 px для основной таблицы). +- Backdrop: `rgba(0,0,0,0.5)` поверх content, click closes drawer. +- Slide-in 150 ms ease-out из `transform: translateX(100%)` в `0`. +- Header: title + close button (`X` keyboard ESC). +- Body: scrollable, padding 16 px. +- ARIA: `role=dialog aria-modal=true`, focus trap внутри. +- URL: `?drawer=` — bookmarkable. F5 восстанавливает open state. + +### 6.8 Heatmap + +Pool fill heatmap для Overview row 3b. Каждая строка — pool, ячейки — 60 семплов saturation за последние 1.5 минуты. + +- Row height 24 px, label слева 140 px (truncate ellipsis при overflow), 60 cells × 6 px каждая. +- Цвет ячейки: + - 0–69 % saturation → `--success` opacity 0.15–0.6 (gradient по %). + - 70–89 % → `--warning` opacity 0.4–0.8. + - 90–100 % → `--danger` opacity 0.6–1.0. +- Hover на ячейке → tooltip с pool id, ts, saturation %, connections / max_connections. +- Click на ячейке → переход на `/pools?focus=&ts=` (drill-down во временной точке). +- Click на label → переход на `/pools?focus=` (без time anchor). +- При >30 пулах: первые 30 показаны, кнопка «Show all (12 hidden)» снизу разворачивает. + +### 6.9 ThresholdPaint mixin + +Применяется к Sparkline, Chart, table row, гауджу — единый набор visual cues для подсветки `warning` / `critical` состояния. + +**Sparkline cell:** +```css +.sparkline-warn { border-left: 2px solid var(--warning); + background: rgba(212, 160, 23, 0.03); } +.sparkline-crit { border-left: 2px solid var(--danger); + background: rgba(229, 72, 77, 0.04); } +``` + +**Numeric value:** dot prefix, не цвет текста. +```html +● 84 ms +● 720 ms +``` + +**Table row** (Pools, Clients): +```css +.row-warn { border-left: 2px solid var(--warning); } +.row-crit { border-left: 2px solid var(--danger); } +``` + +**Chart threshold lines:** dashed horizontal через uPlot `hooks.draw`: +- Warning line: `--warning` color, dashArray `[4, 4]`, opacity 0.4. +- Critical line: `--danger` color, dashArray `[4, 4]`, opacity 0.5. +- Drawn под data line (background layer, не передний план). + +**Запрещено:** flash, blink, sound, modal popup. Anomaly highlighting — пассивный visual cue, не алерт. + +## 7. Графики (uPlot) + +### 7.1 Общий стиль + +Все графики Overview и ConfigState — uPlot, единый стиль: + +- Толщина линии 1 px (не 2). Мы в industrial-стиле, не infographic. +- Цвета серий — `--chart-1..4`, в порядке появления. На stacked area — opacity 0.4 для fill, full color для top stroke. +- Ось X — Unix timestamp, формат `HH:mm:ss`. Ось Y — числа без префиксов «k/M», полная цифра в моно. +- Grid: 1 px `--border` каждые 25% оси Y, без вертикальных grid-линий. +- Axis labels: 10 px IBM Plex Mono, цвет `--text-dim`. +- Tooltip: `--surface-3` фон, 11 px mono, 1 px border `--border-strong`, padding 4 × 8 px. +- Title графика — над uPlot блоком, отдельный label, не внутри uPlot config. + +### 7.2 Cross-hair sync + +Все графики Overview rows 2-3 (Golden Signals strip + per-aspect detail) объединены через uPlot `sync.key = 'overview'`. Hover в любом чарте подсвечивает ту же временную точку во всех остальных. Tooltip cursor — vertical guideline 1 px `--accent`, label справа фиксированный. + +`sync.key` нужен также для Pools-страницы (per-pool inline sparklines синхронизируются между собой), но **не** между разными страницами. + +### 7.3 Threshold lines + +Подключаются через mixin `ThresholdPaint` (раздел 6.9). Реализация — uPlot hook `draw`, рисующий dashed horizontal lines поверх grid'а перед data line. Для каждого графика, который имеет threshold (см. таблицу 15.4 в основной спеке) — обязательно показать warn и crit линии. + +### 7.4 Sparkline (отдельный режим) + +Mini-chart — height 56 px (Overview Golden Signals strip) либо 24 px (per-pool inline). Без axis labels, без grid (только 1 horizontal threshold line при необходимости). Tooltip по hover — числовое значение последней точки. + +### 7.5 Состав графиков + +См. раздел 15 «Observability layout & thresholds» основной спеки — там описано, какой тип графика на каком ряду какой страницы и какие threshold ему рисовать. Этот раздел — только про визуальный язык, не про composition. + +## 8. Иконография + +- Библиотека — `lucide-react`. Tree-shakable, ~3–5 KB на 20–30 нужных иконок. +- Размеры: 16 px (sidebar item, badge), 20 px (page header action), 14 px (inline в тексте). +- `stroke-width: 1.5`. Outline only, никаких filled. +- Цвет наследуется от текста. На active state — `--accent`. + +Базовый набор для MVP: +`activity` (overview), `database` (pools), `users` (clients), `server` (servers), `layers` (prepared), `hash` (interner), `scroll-text` (logs), `settings` (config), `lock` (auth), `alert-triangle` (warning), `x-circle` (error), `check-circle` (ok), `pause` (paused pool), `refresh-cw` (reset), `chevron-left/right` (pagination). + +## 9. Анимации + +Минимум, только feedback на действия пользователя. + +| Что | Длительность | Easing | +|---|---|---| +| Hover background change | 100 ms | linear | +| Focus ring появление | 0 ms (instant) | — | +| Modal in/out | 150 ms | ease-out | +| Banner slide-in | 150 ms | ease-out | +| Tab underline shift | 150 ms | ease-out | +| Skeleton pulse | 1500 ms loop | ease-in-out | + +Анимировать **только** `transform` и `opacity`. Никаких `width`/`height`/`top`/`left`. + +`prefers-reduced-motion: reduce` — выключаем все анимации кроме skeleton pulse (и тот делаем opacity-only, без scale). + +## 10. Keyboard shortcuts + +Operational tool, не SaaS-обыватель. DBA быстрее работает руками с keyboard, чем мышью. Для MVP — следующий минимальный набор (UX/DBA-ревью): + +### 10.1 Global + +| Shortcut | Действие | +|---|---| +| `?` | Open shortcuts overlay | +| `g o` | Перейти на Overview | +| `g p` | Перейти на Pools | +| `g c` | Перейти на Clients | +| `g s` | Перейти на Caches (Storage/Statements) | +| `g l` | Перейти на Logs | +| `g k` | Перейти на ConfigState | +| `/` | Focus search в текущей странице | +| `Esc` | Close drawer / modal / cancel filter | +| `r` | Manual refresh (форсирует следующий poll) | +| `Shift+R` | Reset history (sessionStorage) | + +### 10.2 Tables + +| Shortcut | Действие | +|---|---| +| `j` / `↓` | Next row | +| `k` / `↑` | Previous row | +| `Enter` | Open row drawer (если применимо) | +| `g g` | Top of table | +| `Shift+G` | Bottom of table | +| `PageDown` / `PageUp` | Scroll page | + +### 10.3 LogStream + +| Shortcut | Действие | +|---|---| +| `Space` | Pause / resume auto-scroll | +| `g g` | Top (oldest in ring) | +| `Shift+G` | Bottom (latest, resume tail) | +| `j` / `k` | Step один entry | +| `f` | Cycle level filter (WARN → INFO → DEBUG → TRACE → WARN) | +| `t` | Focus target filter | +| `/` | Focus substring search | +| `n` / `N` | Next / previous match (post-search) | + +### 10.4 Реализация + +`useKeyboard` hook в `frontend/src/hooks/`. Использует event listener на `document`, исключает срабатывание внутри `` / `