diff --git a/HISTORY.md b/HISTORY.md index f155b9b..ad777b8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1635,3 +1635,62 @@ snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41, існував і фактично не знаходився — користувач повідомив, що видалення користувачів «немає». +--- + +## «Просить логін після кожного оновлення сторінки» + +Дві різні поломки в одному симптомі. + +**Перша: користувач без пошти не міг поновити сесію взагалі.** Разом із +входом за логіном пошта стала необовʼязковою, але `RotateSession` читав +`email::text` без `COALESCE`. Scan падав, помилка перетворювалась на +«сесія недійсна» — і такі люди могли увійти, але після перезавантаження +сторінки летіли на форму входу знову й знову. Живий прогін: свіжий +користувач без адреси, перший же `/auth/refresh` → 401. + +**Друга: ротація токена не переживала подвійного обміну.** React у +режимі розробки виконує ефекти двічі, тож відновлення сесії йшло двома +запитами поспіль: перший обмінював токен і відкликав старий, другий +приносив уже відкликаний. Це не лише про режим розробки — дві вкладки, +відкриті одночасно, дають рівно ту саму гонку в проді. + +Тепер `core.sessions.replaced_by` тримає ланцюг, і щойно відкликаний +токен веде до свого наступника. Вікно — 30 секунд: вистачає на +подвійний обмін від того самого клієнта й замало для реального повтору +перехопленого токена, той приходить хвилинами пізніше. Перевірено: обмін +старим токеном одразу → 200, він же через дві хвилини → 401. + +Плюс засувка на клієнті: відновлення сесії рівно одне на завантаження +сторінки. + +--- + +## Однакові відступи + +Сторінки розповзлися: одні малювали тіло з `p-3 md:p-4`, інші клали +`p-4` на внутрішній блок, треті не мали відступу взагалі — і текст стояв +упритул до бічної панелі. + +`PageBody` тепер один на всіх: відступи, прокрутка й `min-h-0` в одному +місці. Заміряно в браузері: усі десять сторінок дають рівно 16 px від +краю. + +--- + +## Мапа: видалення й вигляд вузла + +**Видалення мапи** — кнопка була відсутня, хоча ендпоїнт існував із +Етапу 4. З підтвердженням, яке пояснює, що саме зникне: схема, а не +хости й дані. + +**Форма вузла.** Картка, пігулка або крапка. Крапка потрібна не для +краси: на схемі з сотні вузлів важливо бачити стан усіх, а не читати +сотню підписів. Плюс товщина рамки, колір заливки, приховування підпису +й цифр пінга. + +**Аварія й попередження світяться.** Рамка іншого кольору не помітна +периферійним зором, а ореол (`box-shadow` у два шари) видно навіть на +віддаленому масштабі, коли підписи вже не читаються. Червоний лишається +тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор +перестане на нього реагувати. + diff --git a/db/migrations/0019_session_rotation_grace.sql b/db/migrations/0019_session_rotation_grace.sql new file mode 100644 index 0000000..9189509 --- /dev/null +++ b/db/migrations/0019_session_rotation_grace.sql @@ -0,0 +1,21 @@ +-- ===================================================================== +-- NetPulse :: 0019_session_rotation_grace.sql +-- Ротація refresh-токена перестає ламати сесію при подвійному обміні. +-- +-- Симптом, з якого почалося: сторінка просила логін після кожного +-- перезавантаження. React у режимі розробки виконує ефекти двічі, тож +-- відновлення сесії йшло двома запитами поспіль. Перший обмінював +-- токен і відкликав старий, другий приносив уже відкликаний — і +-- отримував «сесії немає». +-- +-- Це не лише про режим розробки: дві вкладки, відкриті одночасно, +-- дають рівно ту саму гонку в проді. +-- ===================================================================== + +-- На що замінили токен. Дозволяє простежити ланцюг: пред'явлений +-- щойно відкликаний токен веде до свого наступника, а не в нікуди. +ALTER TABLE core.sessions + ADD COLUMN replaced_by uuid REFERENCES core.sessions(id) ON DELETE SET NULL; + +CREATE INDEX sessions_replaced_idx ON core.sessions (replaced_by) + WHERE replaced_by IS NOT NULL; diff --git a/server/internal/store/users.go b/server/internal/store/users.go index d0dd4d0..f836aba 100644 --- a/server/internal/store/users.go +++ b/server/internal/store/users.go @@ -236,6 +236,28 @@ func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, ip n // Ротація при кожному обміні, а не подовження строку: викрадений // refresh-токен спрацює щонайбільше один раз, і законний власник // одразу отримає відмову — це видимий сигнал компрометації. +// RotationGrace — скільки щойно відкликаний токен ще веде до наступника. +// +// Тридцяти секунд вистачає на подвійний обмін від того самого клієнта +// (дві вкладки, повторний запит після обриву, подвійний ефект React) і +// замало для реального повтору перехопленого токена — той приходить +// хвилинами й годинами пізніше. +const RotationGrace = 30 * time.Second + +// maxRotationHops — запобіжник на випадок зіпсованого ланцюга. +// +// Без нього пошкоджені дані (цикл у replaced_by) зациклили б запит. +const maxRotationHops = 5 + +// RotateSession обмінює refresh-токен на новий. +// +// Пред'явлений токен, який уже обміняли щойно, не вважається помилкою: +// запит іде за ланцюгом replaced_by до живої сесії й обмінює її. Інакше +// два одночасні відновлення сесії з одного браузера гарантовано вбивали +// б одне одного — і саме це виглядало як «просить логін після кожного +// оновлення сторінки». +// +// Токен, обміняний давно, лишається помилкою: це вже не гонка, а повтор. func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) { sum := sha256.Sum256([]byte(refreshToken)) @@ -243,11 +265,13 @@ func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.I oldID, userID, tenantID string expires time.Time revoked *time.Time + replacedBy *string ) err := s.pool.QueryRow(ctx, ` - SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), expires_at, revoked_at + SELECT id::text, user_id::text, COALESCE(tenant_id::text,''), + expires_at, revoked_at, replaced_by::text FROM core.sessions WHERE token_hash = $1 - `, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked) + `, sum[:]).Scan(&oldID, &userID, &tenantID, &expires, &revoked, &replacedBy) if errors.Is(err, pgx.ErrNoRows) { return nil, nil, ErrSessionInvalid @@ -255,13 +279,44 @@ func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.I if err != nil { return nil, nil, err } - if revoked != nil || time.Now().After(expires) { + if time.Now().After(expires) { return nil, nil, ErrSessionInvalid } + // Токен уже обміняли — йдемо за ланцюгом, поки не знайдемо живу + // сесію. Кожен крок перевіряється на вікно: старий обмін означає + // повтор, а не гонку. + for hop := 0; revoked != nil; hop++ { + if hop >= maxRotationHops || replacedBy == nil { + return nil, nil, ErrSessionInvalid + } + if time.Since(*revoked) > RotationGrace { + return nil, nil, ErrSessionInvalid + } + err := s.pool.QueryRow(ctx, ` + SELECT id::text, expires_at, revoked_at, replaced_by::text + FROM core.sessions WHERE id = $1 + `, *replacedBy).Scan(&oldID, &expires, &revoked, &replacedBy) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, ErrSessionInvalid + } + if err != nil { + return nil, nil, err + } + if time.Now().After(expires) { + return nil, nil, ErrSessionInvalid + } + } + + // COALESCE обов'язковий: пошта стала необов'язковою разом із входом + // за логіном, і без нього Scan падав на кожному користувачі без + // адреси. Виглядало це так, ніби сесія недійсна — тобто такі люди + // могли увійти, але після перезавантаження сторінки летіли на + // форму входу знову й знову. var u User if err := s.pool.QueryRow(ctx, ` - SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled + SELECT id::text, COALESCE(email::text,''), COALESCE(full_name,''), + locale, timezone, mfa_enabled FROM core.users WHERE id = $1 AND deleted_at IS NULL `, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil { return nil, nil, ErrSessionInvalid @@ -279,13 +334,16 @@ func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.I return nil, nil, ErrSessionInvalid } - if _, err := s.pool.Exec(ctx, - `UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil { + sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua) + if err != nil { return nil, nil, err } - sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua) - if err != nil { + // Відкликаємо після створення наступника, а не до: інакше збій + // створення лишив би людину взагалі без сесії. + if _, err := s.pool.Exec(ctx, + `UPDATE core.sessions SET revoked_at = now(), replaced_by = $2 WHERE id = $1`, + oldID, sess.ID); err != nil { return nil, nil, err } return sess, &u, nil diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5b1e578..2c0381b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -128,6 +128,9 @@ async function raw(path: string, init?: RequestInit): Promise { // тоді, коли все насправді гаразд. let refreshing: Promise | null = null +// Відновлення сесії на старті — одне на завантаження сторінки. +let restoring: Promise | null = null + async function refreshOnce(): Promise { if (!refreshing) { refreshing = raw('/api/v1/auth/refresh', { method: 'POST' }) @@ -167,8 +170,18 @@ export const api = { body: JSON.stringify({ login, password, tenant_id: tenantID }), }), - /** Тихе відновлення сесії після перезавантаження сторінки. */ - restore: () => refreshOnce(), + /** + * Тихе відновлення сесії після перезавантаження сторінки. + * + * Рівно одне на завантаження: React у режимі розробки виконує ефекти + * двічі, а обмін refresh-токена одноразовий — другий виклик приносив + * би вже відкликаний токен. Засувка на рівні модуля, а не ref у + * компоненті, бо переживає розмонтування. + */ + restore: () => { + if (!restoring) restoring = refreshOnce() + return restoring + }, logout: async () => { try { @@ -232,6 +245,8 @@ export const api = { getMap: (id: string) => request(`/api/v1/maps/${id}`), + deleteMap: (id: string) => request(`/api/v1/maps/${id}`, { method: 'DELETE' }), + createMap: (name: string, slug: string, kind = 'logical') => request('/api/v1/maps', { method: 'POST', diff --git a/web/src/components/DeviceNode.tsx b/web/src/components/DeviceNode.tsx index 00a3957..ab6616d 100644 --- a/web/src/components/DeviceNode.tsx +++ b/web/src/components/DeviceNode.tsx @@ -12,19 +12,63 @@ export type DeviceNodeData = { width?: number /** Масштаб підпису: 'sm' | 'md' | 'lg'. */ size?: string + /** Колір рамки. Перекриває статусний. */ color?: string + /** Форма вузла: card | pill | dot. */ + shape?: string + /** Товщина рамки в пікселях. */ + borderWidth?: number + /** Колір заливки. */ + fill?: string + /** Показувати підпис поруч із крапкою (для shape=dot). */ + hideLabel?: boolean + /** Показувати цифри пінга. */ + hideMetrics?: boolean } export type DeviceNodeType = Node // Кольори статусів. Червоний лишається лише за обривом — якщо ним // підсвічувати ще й «невідомо», оператор перестане на нього реагувати. -const STATUS: Record = { - up: { ring: 'border-emerald-500/70', dot: 'bg-emerald-400', text: 'text-emerald-300' }, - down: { ring: 'border-red-500', dot: 'bg-red-500', text: 'text-red-300' }, - warning: { ring: 'border-amber-500/80', dot: 'bg-amber-400', text: 'text-amber-300' }, - maintenance: { ring: 'border-sky-500/60', dot: 'bg-sky-400', text: 'text-sky-300' }, - unknown: { ring: 'border-slate-600', dot: 'bg-slate-500', text: 'text-slate-400' }, +const STATUS: Record< + string, + { ring: string; dot: string; text: string; halo: string; raw: string } +> = { + up: { + ring: 'border-emerald-500/70', + dot: 'bg-emerald-400', + text: 'text-emerald-300', + halo: '', + raw: '#34d399', + }, + down: { + ring: 'border-red-500', + dot: 'bg-red-500', + text: 'text-red-300', + halo: 'shadow-[0_0_0_4px_rgba(239,68,68,0.18),0_0_22px_4px_rgba(239,68,68,0.35)]', + raw: '#ef4444', + }, + warning: { + ring: 'border-amber-500/80', + dot: 'bg-amber-400', + text: 'text-amber-300', + halo: 'shadow-[0_0_0_4px_rgba(245,158,11,0.16),0_0_18px_3px_rgba(245,158,11,0.28)]', + raw: '#f59e0b', + }, + maintenance: { + ring: 'border-sky-500/60', + dot: 'bg-sky-400', + text: 'text-sky-300', + halo: '', + raw: '#38bdf8', + }, + unknown: { + ring: 'border-slate-600', + dot: 'bg-slate-500', + text: 'text-slate-400', + halo: '', + raw: '#64748b', + }, } const ICONS: Record = { @@ -45,25 +89,90 @@ const TEXT_SIZE: Record = { lg: 'text-base', } +const DOT_SIZE: Record = { sm: 12, md: 18, lg: 26 } + export function DeviceNode({ data, selected }: NodeProps) { const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other - const textSize = TEXT_SIZE[data.size ?? 'md'] ?? TEXT_SIZE.md + const size = data.size ?? 'md' + const textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md + const shape = data.shape ?? 'card' - return ( -
+ // Аварія й попередження світяться. Це не прикраса: на схемі з сотні + // вузлів рамка іншого кольору не помітна периферійним зором, а ореол + // видно навіть на віддаленому масштабі, коли підписи вже не читаються. + const halo = s.halo + const ring = selected ? 'ring-2 ring-sky-400 ring-offset-2 ring-offset-slate-950' : '' + + const handles = ( + <> + + ) + + // ---- крапка ------------------------------------------------------- + // + // Найщільніша форма: на великій схемі важливо бачити стан сотні + // вузлів, а не читати сотню підписів. + if (shape === 'dot') { + const d = DOT_SIZE[size] ?? DOT_SIZE.md + return ( +
+ {handles} +
+ {!data.hideLabel && ( + + {data.label} + + )} +
+ ) + } + + // ---- пігулка ------------------------------------------------------ + if (shape === 'pill') { + return ( +
+ {handles} + + {data.label} +
+ ) + } + + // ---- картка (типова) ---------------------------------------------- + return ( +
+ {handles}
@@ -73,11 +182,9 @@ export function DeviceNode({ data, selected }: NodeProps) { {/* Цифри показуємо лише коли вони є: порожній рядок «— мс» створює враження зламаного моніторингу. */} - {(data.rtt !== undefined || data.loss !== undefined) && ( + {!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
- {data.rtt !== undefined && ( - {data.rtt.toFixed(2)} мс - )} + {data.rtt !== undefined && {data.rtt.toFixed(2)} мс} {data.loss !== undefined && data.loss > 0 && ( втрати {data.loss.toFixed(0)}% )} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index 1526039..1945291 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -47,6 +47,11 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props) width: n.width, size: n.style?.size as string | undefined, color: n.style?.color as string | undefined, + shape: n.style?.shape as string | undefined, + borderWidth: n.style?.borderWidth as number | undefined, + fill: n.style?.fill as string | undefined, + hideLabel: n.style?.hideLabel as boolean | undefined, + hideMetrics: n.style?.hideMetrics as boolean | undefined, }, })), [state.nodes, readOnly], diff --git a/web/src/components/NodeInspector.tsx b/web/src/components/NodeInspector.tsx index f9a4cce..5091c20 100644 --- a/web/src/components/NodeInspector.tsx +++ b/web/src/components/NodeInspector.tsx @@ -21,6 +21,14 @@ const SIZE_CHOICES = [ { key: 'lg', label: 'великий' }, ] +// Форма вузла. Крапка потрібна не для краси: на схемі з сотні вузлів +// важливо бачити стан усіх, а не читати сотню підписів. +const SHAPE_CHOICES = [ + { key: 'card', label: 'картка' }, + { key: 'pill', label: 'пігулка' }, + { key: 'dot', label: 'крапка' }, +] + /** * Інспектор вузла: підпис, вигляд, розмір, положення. * @@ -47,6 +55,13 @@ export function NodeInspector({ const [icon, setIcon] = useState((node.style?.icon as string) ?? '') const [size, setSize] = useState((node.style?.size as string) ?? 'md') const [color, setColor] = useState((node.style?.color as string) ?? '') + const [shape, setShape] = useState((node.style?.shape as string) ?? 'card') + const [fill, setFill] = useState((node.style?.fill as string) ?? '') + const [border, setBorder] = useState( + node.style?.borderWidth ? String(node.style.borderWidth) : '', + ) + const [hideLabel, setHideLabel] = useState((node.style?.hideLabel as boolean) ?? false) + const [hideMetrics, setHideMetrics] = useState((node.style?.hideMetrics as boolean) ?? false) const [width, setWidth] = useState(node.width ? String(Math.round(node.width)) : '') const [locked, setLocked] = useState(node.locked) const [busy, setBusy] = useState(false) @@ -58,6 +73,11 @@ export function NodeInspector({ setIcon((node.style?.icon as string) ?? '') setSize((node.style?.size as string) ?? 'md') setColor((node.style?.color as string) ?? '') + setShape((node.style?.shape as string) ?? 'card') + setFill((node.style?.fill as string) ?? '') + setBorder(node.style?.borderWidth ? String(node.style.borderWidth) : '') + setHideLabel((node.style?.hideLabel as boolean) ?? false) + setHideMetrics((node.style?.hideMetrics as boolean) ?? false) setWidth(node.width ? String(Math.round(node.width)) : '') setLocked(node.locked) }, [node.id, node.label, node.style, node.width, node.locked]) @@ -67,6 +87,11 @@ export function NodeInspector({ icon !== ((node.style?.icon as string) ?? '') || size !== ((node.style?.size as string) ?? 'md') || color !== ((node.style?.color as string) ?? '') || + shape !== ((node.style?.shape as string) ?? 'card') || + fill !== ((node.style?.fill as string) ?? '') || + border !== (node.style?.borderWidth ? String(node.style.borderWidth) : '') || + hideLabel !== ((node.style?.hideLabel as boolean) ?? false) || + hideMetrics !== ((node.style?.hideMetrics as boolean) ?? false) || width !== (node.width ? String(Math.round(node.width)) : '') || locked !== node.locked @@ -82,6 +107,17 @@ export function NodeInspector({ else delete style.size if (color) style.color = color else delete style.color + if (shape && shape !== 'card') style.shape = shape + else delete style.shape + if (fill) style.fill = fill + else delete style.fill + const bw = Number(border) + if (border.trim() !== '' && Number.isFinite(bw) && bw > 0) style.borderWidth = bw + else delete style.borderWidth + if (hideLabel) style.hideLabel = true + else delete style.hideLabel + if (hideMetrics) style.hideMetrics = true + else delete style.hideMetrics const patch: NodeInput = { id: node.id, label, style, locked } const w = Number(width) @@ -154,6 +190,52 @@ export function NodeInspector({
+ + + + + setBorder(e.target.value)} + /> + +
+ +
+ +
+ setFill(e.target.value)} + /> + {fill && ( + + )} +
+
- +
+ {shape === 'dot' && ( + + )} + + +
+ )} @@ -138,6 +167,8 @@ export function MapPage() { /> )} + + setConfirm(null)} />
) } diff --git a/web/src/pages/MetricsPage.tsx b/web/src/pages/MetricsPage.tsx index 573af82..23982b6 100644 --- a/web/src/pages/MetricsPage.tsx +++ b/web/src/pages/MetricsPage.tsx @@ -4,6 +4,7 @@ import { Button, Card, ErrorNote, + PageBody, PageHeader, Spinner, ago, @@ -118,7 +119,7 @@ export function MetricsPage() { ) return ( -
+ <> - {err} + + {err} {devices.length === 0 ? (

Хостів ще немає

@@ -230,6 +232,7 @@ export function MetricsPage() {
)} -
+ + ) } diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx index 7300e01..cc16634 100644 --- a/web/src/pages/RulesPage.tsx +++ b/web/src/pages/RulesPage.tsx @@ -9,6 +9,7 @@ import { ErrorNote, Field, Modal, + PageBody, PageHeader, Spinner, Toggle, @@ -107,12 +108,8 @@ export function RulesPage() { } /> -
- {error && ( -
- {error} -
- )} + + {error} {loading ? ( @@ -201,7 +198,7 @@ export function RulesPage() { ]} /> )} -
+ {(creating || editing) && ( -
- {error && ( -
- {error} -
- )} + + {error} {loading ? ( @@ -185,7 +182,7 @@ export function TeamPage() { ))}
-
+ {editing && ( return ( -
+ <> - {err} + + {err}
{list.map((t) => ( @@ -189,7 +191,8 @@ export function TemplatesPage() { }} /> )} -
+
+ ) }