Сесія переживає перезавантаження, однакові відступи, вигляд вузлів мапи

«Просить логін після кожного оновлення» — дві поломки в одному симптомі.

Перша: RotateSession читав email::text без COALESCE, а пошта стала
необов'язковою разом із входом за логіном. Користувач без адреси міг
увійти й не міг поновити сесію взагалі.

Друга: ротація не переживала подвійного обміну. React у режимі розробки
виконує ефекти двічі, дві вкладки в проді дають ту саму гонку: перший
запит відкликав токен, другий приносив уже відкликаний. Тепер
core.sessions.replaced_by тримає ланцюг, вікно 30 секунд — вистачає на
гонку від того самого клієнта й замало для реального повтору.

PageBody один на всіх сторінках: відступи й прокрутка більше не
залежать від того, хто писав сторінку. Заміряно — рівно 16 px скрізь.

Мапа: видалення (ендпоїнт існував із Етапу 4, кнопки не було) з
підтвердженням, і вигляд вузла — форма (картка/пігулка/крапка), товщина
рамки, заливка, приховування підпису й цифр. Аварія й попередження
світяться ореолом: рамка іншого кольору не помітна периферійним зором.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 18:02:18 +03:00
parent 05adce6e7b
commit a4a44b1d76
16 changed files with 494 additions and 79 deletions

View file

@ -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` у два шари) видно навіть на
віддаленому масштабі, коли підписи вже не читаються. Червоний лишається
тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор
перестане на нього реагувати.

View file

@ -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;

View file

@ -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

View file

@ -128,6 +128,9 @@ async function raw<T>(path: string, init?: RequestInit): Promise<T> {
// тоді, коли все насправді гаразд.
let refreshing: Promise<boolean> | null = null
// Відновлення сесії на старті — одне на завантаження сторінки.
let restoring: Promise<boolean> | null = null
async function refreshOnce(): Promise<boolean> {
if (!refreshing) {
refreshing = raw<LoginResponse>('/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<MapState>(`/api/v1/maps/${id}`),
deleteMap: (id: string) => request<void>(`/api/v1/maps/${id}`, { method: 'DELETE' }),
createMap: (name: string, slug: string, kind = 'logical') =>
request<MapSummary>('/api/v1/maps', {
method: 'POST',

View file

@ -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<DeviceNodeData, 'device'>
// Кольори статусів. Червоний лишається лише за обривом — якщо ним
// підсвічувати ще й «невідомо», оператор перестане на нього реагувати.
const STATUS: Record<string, { ring: string; dot: string; text: string }> = {
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<string, string> = {
@ -45,25 +89,90 @@ const TEXT_SIZE: Record<string, string> = {
lg: 'text-base',
}
const DOT_SIZE: Record<string, number> = { sm: 12, md: 18, lg: 26 }
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
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 (
<div
className={`rounded-lg border-2 bg-slate-900/95 px-3 py-2 shadow-lg backdrop-blur
${s.ring} ${selected ? 'ring-2 ring-sky-400' : ''}`}
// Ширину задає людина в інспекторі; колір рамки — теж, і тоді він
// перекриває статусний. Це свідомо: на схемі, де кольором
// позначені майданчики, статус читають за крапкою.
style={{
minWidth: data.width ?? 150,
...(data.color ? { borderColor: data.color } : {}),
}}
>
// Аварія й попередження світяться. Це не прикраса: на схемі з сотні
// вузлів рамка іншого кольору не помітна периферійним зором, а ореол
// видно навіть на віддаленому масштабі, коли підписи вже не читаються.
const halo = s.halo
const ring = selected ? 'ring-2 ring-sky-400 ring-offset-2 ring-offset-slate-950' : ''
const handles = (
<>
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" />
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-slate-500" />
</>
)
// ---- крапка -------------------------------------------------------
//
// Найщільніша форма: на великій схемі важливо бачити стан сотні
// вузлів, а не читати сотню підписів.
if (shape === 'dot') {
const d = DOT_SIZE[size] ?? DOT_SIZE.md
return (
<div className="flex items-center gap-1.5">
{handles}
<div
className={`shrink-0 rounded-full ${halo} ${ring}`}
style={{
width: d,
height: d,
background: data.fill ?? data.color ?? s.raw,
border: `${data.borderWidth ?? 2}px solid ${data.color ?? 'rgba(15,23,42,0.9)'}`,
}}
title={`${data.label}${data.status}`}
/>
{!data.hideLabel && (
<span className={`whitespace-nowrap font-medium text-slate-200 ${textSize}`}>
{data.label}
</span>
)}
</div>
)
}
// ---- пігулка ------------------------------------------------------
if (shape === 'pill') {
return (
<div
className={`flex items-center gap-2 rounded-full border-2 px-3 py-1 backdrop-blur
${s.ring} ${halo} ${ring}`}
style={{
background: data.fill ?? 'rgba(15,23,42,0.95)',
...(data.color ? { borderColor: data.color } : {}),
...(data.borderWidth ? { borderWidth: data.borderWidth } : {}),
}}
>
{handles}
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<span className={`truncate font-medium text-slate-100 ${textSize}`}>{data.label}</span>
</div>
)
}
// ---- картка (типова) ----------------------------------------------
return (
<div
className={`rounded-lg border-2 px-3 py-2 shadow-lg backdrop-blur ${s.ring} ${halo} ${ring}`}
// Ширину задає людина в інспекторі; колір рамки — теж, і тоді він
// перекриває статусний. Це свідомо: на схемі, де кольором
// позначені майданчики, статус читають за крапкою й ореолом.
style={{
minWidth: data.width ?? 150,
background: data.fill ?? 'rgba(15,23,42,0.95)',
...(data.color ? { borderColor: data.color } : {}),
...(data.borderWidth ? { borderWidth: data.borderWidth } : {}),
}}
>
{handles}
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
@ -73,11 +182,9 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
{/* Цифри показуємо лише коли вони є: порожній рядок « мс»
створює враження зламаного моніторингу. */}
{(data.rtt !== undefined || data.loss !== undefined) && (
{!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
<div className="mt-1 flex gap-3 text-[11px] tabular-nums">
{data.rtt !== undefined && (
<span className={s.text}>{data.rtt.toFixed(2)} мс</span>
)}
{data.rtt !== undefined && <span className={s.text}>{data.rtt.toFixed(2)} мс</span>}
{data.loss !== undefined && data.loss > 0 && (
<span className="text-red-300">втрати {data.loss.toFixed(0)}%</span>
)}

View file

@ -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],

View file

@ -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({
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Форма">
<select
className={`${inputClass} py-1.5`}
value={shape}
onChange={(e) => setShape(e.target.value)}
>
{SHAPE_CHOICES.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
</Field>
<Field label="Товщина рамки, px">
<input
className={`${inputClass} py-1.5`}
type="number"
min="1"
max="8"
placeholder="2"
value={border}
onChange={(e) => setBorder(e.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Заливка">
<div className="flex items-center gap-1.5">
<input
type="color"
className="h-8 w-10 rounded border border-slate-700 bg-slate-800"
value={fill || '#0f172a'}
onChange={(e) => setFill(e.target.value)}
/>
{fill && (
<button
className="text-[11px] text-slate-500 hover:text-slate-300"
onClick={() => setFill('')}
title="Повернути типову заливку"
>
скинути
</button>
)}
</div>
</Field>
<Field label="Ширина, px">
<input
className={`${inputClass} py-1.5`}
@ -185,6 +267,25 @@ export function NodeInspector({
</Field>
</div>
<div className="space-y-1">
{shape === 'dot' && (
<label className="flex items-center gap-2 text-slate-400">
<input
type="checkbox"
checked={hideLabel}
onChange={(e) => setHideLabel(e.target.checked)}
/>
Без підпису лише крапка
</label>
)}
<label className="flex items-center gap-2 text-slate-400">
<input
type="checkbox"
checked={hideMetrics}
onChange={(e) => setHideMetrics(e.target.checked)}
/>
Не показувати цифри пінга
</label>
<label className="flex items-center gap-2 text-slate-400">
<input
type="checkbox"
@ -193,6 +294,7 @@ export function NodeInspector({
/>
Закріпити (не перетягується)
</label>
</div>
<div className="flex gap-1.5 pt-0.5">
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}>

View file

@ -101,6 +101,26 @@ export function Card({ children, className = '' }: { children: ReactNode; classN
)
}
/**
* Тіло сторінки під шапкою.
*
* Відступи й прокрутка мають бути однакові скрізь, інакше кожна нова
* сторінка починає своє життя з того, що текст стоїть упритул до бічної
* панелі. Шапка (`PageHeader`) малює власні відступи й лишається на
* місці прокручується саме тіло.
*/
export function PageBody({
children,
className = '',
}: {
children: ReactNode
className?: string
}) {
return (
<div className={`min-h-0 flex-1 overflow-y-auto p-3 md:p-4 ${className}`}>{children}</div>
)
}
export interface Column<T> {
key: string
header: string

View file

@ -8,6 +8,7 @@ import {
ErrorNote,
Field,
Modal,
PageBody,
PageHeader,
Spinner,
StatusBadge,
@ -75,12 +76,8 @@ export function ChannelsPage() {
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<PageBody className="space-y-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{testResult && (
<div className="p-4 pb-0">
@ -190,7 +187,7 @@ export function ChannelsPage() {
]}
/>
)}
</div>
</PageBody>
{creating && (
<ChannelForm

View file

@ -8,6 +8,7 @@ import {
ErrorNote,
Field,
Modal,
PageBody,
PageHeader,
Spinner,
inputClass,
@ -63,7 +64,7 @@ export function CredentialsPage() {
if (loading) return <Spinner />
return (
<div className="space-y-4">
<>
<PageHeader
title="Доступи до обладнання"
subtitle="SNMP-community, паролі SSH і Telnet — зашифровані, показу не підлягають"
@ -76,6 +77,7 @@ export function CredentialsPage() {
}
/>
<PageBody className="space-y-4">
<ErrorNote>{err}</ErrorNote>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@ -156,7 +158,8 @@ export function CredentialsPage() {
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</div>
</PageBody>
</>
)
}

View file

@ -12,6 +12,7 @@ import {
Field,
Confirm,
Modal,
PageBody,
PageHeader,
Spinner,
StatusBadge,
@ -205,12 +206,8 @@ export function DevicesPage() {
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<PageBody className="space-y-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
@ -290,7 +287,7 @@ export function DevicesPage() {
]}
/>
)}
</div>
</PageBody>
{creating && (
<DeviceForm

View file

@ -4,7 +4,8 @@ import { session } from '../api/session'
import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas'
import { NodeInspector } from '../components/NodeInspector'
import { Button, ErrorNote, Field, Modal, inputClass } from '../components/ui'
import { Button, Confirm, ErrorNote, Field, Modal, inputClass } from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { MapSummary } from '../types'
export function MapPage() {
@ -13,6 +14,7 @@ export function MapPage() {
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const live = useLiveMap(mapID)
const canWrite = session.can('maps:write')
@ -79,6 +81,33 @@ export function MapPage() {
>
<span className="ml-1 hidden lg:inline">Відкотити</span>
</Button>
<Button
kind="danger"
disabled={!mapID}
title="Видалити цю мапу"
onClick={() => {
const m = maps.find((x) => x.id === mapID)
if (!m) return
setConfirm({
title: 'Видалити мапу',
message: (
<>
Мапу <b>{m.name}</b> буде видалено разом з усіма її вузлами, зв'язками
та історією ревізій.
</>
),
detail:
'Самі хости й зібрані по них дані лишаться — зникне тільки схема. Побудувати її заново можна кнопкою «Добудувати з топології».',
onConfirm: async () => {
await api.deleteMap(m.id)
setMapID(null)
await loadMaps()
},
})
}}
>
<span className="ml-1 hidden lg:inline">Видалити</span>
</Button>
</>
)}
@ -138,6 +167,8 @@ export function MapPage() {
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</div>
)
}

View file

@ -4,6 +4,7 @@ import {
Button,
Card,
ErrorNote,
PageBody,
PageHeader,
Spinner,
ago,
@ -118,7 +119,7 @@ export function MetricsPage() {
)
return (
<div className="space-y-4">
<>
<PageHeader
title="Метрики"
subtitle="Історія того, що зібрали зонди"
@ -150,6 +151,7 @@ export function MetricsPage() {
}
/>
<PageBody className="space-y-4">
<ErrorNote>{err}</ErrorNote>
{devices.length === 0 ? (
@ -230,6 +232,7 @@ export function MetricsPage() {
</div>
</div>
)}
</div>
</PageBody>
</>
)
}

View file

@ -9,6 +9,7 @@ import {
ErrorNote,
Field,
Modal,
PageBody,
PageHeader,
Spinner,
Toggle,
@ -107,12 +108,8 @@ export function RulesPage() {
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<PageBody className="space-y-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
@ -201,7 +198,7 @@ export function RulesPage() {
]}
/>
)}
</div>
</PageBody>
{(creating || editing) && (
<RuleForm

View file

@ -8,6 +8,7 @@ import {
ErrorNote,
Field,
Modal,
PageBody,
PageHeader,
Spinner,
ago,
@ -68,12 +69,8 @@ export function TeamPage() {
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<PageBody className="space-y-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
@ -185,7 +182,7 @@ export function TeamPage() {
))}
</ul>
</div>
</div>
</PageBody>
{editing && (
<EditUserForm

View file

@ -7,6 +7,7 @@ import {
ErrorNote,
Field,
Modal,
PageBody,
PageHeader,
Spinner,
Confirm,
@ -70,7 +71,7 @@ export function TemplatesPage() {
if (loading) return <Spinner />
return (
<div className="space-y-4">
<>
<PageHeader
title="Шаблони опитування"
subtitle="Набір метрик, який чіпляється до хоста одним рухом"
@ -96,6 +97,7 @@ export function TemplatesPage() {
}
/>
<PageBody className="space-y-4">
<ErrorNote>{err}</ErrorNote>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@ -189,7 +191,8 @@ export function TemplatesPage() {
}}
/>
)}
</div>
</PageBody>
</>
)
}