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

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

Перша: 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-токен спрацює щонайбільше один раз, і законний власник // 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) { func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.IP, ua string) (*Session, *User, error) {
sum := sha256.Sum256([]byte(refreshToken)) 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 oldID, userID, tenantID string
expires time.Time expires time.Time
revoked *time.Time revoked *time.Time
replacedBy *string
) )
err := s.pool.QueryRow(ctx, ` 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 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrSessionInvalid return nil, nil, ErrSessionInvalid
@ -255,13 +279,44 @@ func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.I
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if revoked != nil || time.Now().After(expires) { if time.Now().After(expires) {
return nil, nil, ErrSessionInvalid 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 var u User
if err := s.pool.QueryRow(ctx, ` 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 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 { `, userID).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA); err != nil {
return nil, nil, ErrSessionInvalid return nil, nil, ErrSessionInvalid
@ -279,13 +334,16 @@ func (s *Store) RotateSession(ctx context.Context, refreshToken string, ip net.I
return nil, nil, ErrSessionInvalid return nil, nil, ErrSessionInvalid
} }
if _, err := s.pool.Exec(ctx, sess, err := s.CreateSession(ctx, userID, tenantID, ip, ua)
`UPDATE core.sessions SET revoked_at = now() WHERE id = $1`, oldID); err != nil { if err != nil {
return nil, nil, err 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 nil, nil, err
} }
return sess, &u, nil 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 refreshing: Promise<boolean> | null = null
// Відновлення сесії на старті — одне на завантаження сторінки.
let restoring: Promise<boolean> | null = null
async function refreshOnce(): Promise<boolean> { async function refreshOnce(): Promise<boolean> {
if (!refreshing) { if (!refreshing) {
refreshing = raw<LoginResponse>('/api/v1/auth/refresh', { method: 'POST' }) refreshing = raw<LoginResponse>('/api/v1/auth/refresh', { method: 'POST' })
@ -167,8 +170,18 @@ export const api = {
body: JSON.stringify({ login, password, tenant_id: tenantID }), body: JSON.stringify({ login, password, tenant_id: tenantID }),
}), }),
/** Тихе відновлення сесії після перезавантаження сторінки. */ /**
restore: () => refreshOnce(), * Тихе відновлення сесії після перезавантаження сторінки.
*
* Рівно одне на завантаження: React у режимі розробки виконує ефекти
* двічі, а обмін refresh-токена одноразовий другий виклик приносив
* би вже відкликаний токен. Засувка на рівні модуля, а не ref у
* компоненті, бо переживає розмонтування.
*/
restore: () => {
if (!restoring) restoring = refreshOnce()
return restoring
},
logout: async () => { logout: async () => {
try { try {
@ -232,6 +245,8 @@ export const api = {
getMap: (id: string) => request<MapState>(`/api/v1/maps/${id}`), 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') => createMap: (name: string, slug: string, kind = 'logical') =>
request<MapSummary>('/api/v1/maps', { request<MapSummary>('/api/v1/maps', {
method: 'POST', method: 'POST',

View file

@ -12,19 +12,63 @@ export type DeviceNodeData = {
width?: number width?: number
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */ /** Масштаб підпису: 'sm' | 'md' | 'lg'. */
size?: string size?: string
/** Колір рамки. Перекриває статусний. */
color?: string color?: string
/** Форма вузла: card | pill | dot. */
shape?: string
/** Товщина рамки в пікселях. */
borderWidth?: number
/** Колір заливки. */
fill?: string
/** Показувати підпис поруч із крапкою (для shape=dot). */
hideLabel?: boolean
/** Показувати цифри пінга. */
hideMetrics?: boolean
} }
export type DeviceNodeType = Node<DeviceNodeData, 'device'> export type DeviceNodeType = Node<DeviceNodeData, 'device'>
// Кольори статусів. Червоний лишається лише за обривом — якщо ним // Кольори статусів. Червоний лишається лише за обривом — якщо ним
// підсвічувати ще й «невідомо», оператор перестане на нього реагувати. // підсвічувати ще й «невідомо», оператор перестане на нього реагувати.
const STATUS: Record<string, { ring: string; dot: string; text: string }> = { const STATUS: Record<
up: { ring: 'border-emerald-500/70', dot: 'bg-emerald-400', text: 'text-emerald-300' }, string,
down: { ring: 'border-red-500', dot: 'bg-red-500', text: 'text-red-300' }, { ring: string; dot: string; text: string; halo: string; raw: string }
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' }, up: {
unknown: { ring: 'border-slate-600', dot: 'bg-slate-500', text: 'text-slate-400' }, 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> = { const ICONS: Record<string, string> = {
@ -45,25 +89,90 @@ const TEXT_SIZE: Record<string, string> = {
lg: 'text-base', lg: 'text-base',
} }
const DOT_SIZE: Record<string, number> = { sm: 12, md: 18, lg: 26 }
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) { export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other 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' : ''}`} const halo = s.halo
// Ширину задає людина в інспекторі; колір рамки — теж, і тоді він const ring = selected ? 'ring-2 ring-sky-400 ring-offset-2 ring-offset-slate-950' : ''
// перекриває статусний. Це свідомо: на схемі, де кольором
// позначені майданчики, статус читають за крапкою. const handles = (
style={{ <>
minWidth: data.width ?? 150,
...(data.color ? { borderColor: data.color } : {}),
}}
>
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" /> <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" /> <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"> <div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} /> <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"> <div className="mt-1 flex gap-3 text-[11px] tabular-nums">
{data.rtt !== undefined && ( {data.rtt !== undefined && <span className={s.text}>{data.rtt.toFixed(2)} мс</span>}
<span className={s.text}>{data.rtt.toFixed(2)} мс</span>
)}
{data.loss !== undefined && data.loss > 0 && ( {data.loss !== undefined && data.loss > 0 && (
<span className="text-red-300">втрати {data.loss.toFixed(0)}%</span> <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, width: n.width,
size: n.style?.size as string | undefined, size: n.style?.size as string | undefined,
color: n.style?.color 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], [state.nodes, readOnly],

View file

@ -21,6 +21,14 @@ const SIZE_CHOICES = [
{ key: 'lg', label: 'великий' }, { 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 [icon, setIcon] = useState((node.style?.icon as string) ?? '')
const [size, setSize] = useState((node.style?.size as string) ?? 'md') const [size, setSize] = useState((node.style?.size as string) ?? 'md')
const [color, setColor] = useState((node.style?.color as string) ?? '') 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 [width, setWidth] = useState(node.width ? String(Math.round(node.width)) : '')
const [locked, setLocked] = useState(node.locked) const [locked, setLocked] = useState(node.locked)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -58,6 +73,11 @@ export function NodeInspector({
setIcon((node.style?.icon as string) ?? '') setIcon((node.style?.icon as string) ?? '')
setSize((node.style?.size as string) ?? 'md') setSize((node.style?.size as string) ?? 'md')
setColor((node.style?.color as string) ?? '') 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)) : '') setWidth(node.width ? String(Math.round(node.width)) : '')
setLocked(node.locked) setLocked(node.locked)
}, [node.id, node.label, node.style, node.width, 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) ?? '') || icon !== ((node.style?.icon as string) ?? '') ||
size !== ((node.style?.size as string) ?? 'md') || size !== ((node.style?.size as string) ?? 'md') ||
color !== ((node.style?.color as string) ?? '') || 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)) : '') || width !== (node.width ? String(Math.round(node.width)) : '') ||
locked !== node.locked locked !== node.locked
@ -82,6 +107,17 @@ export function NodeInspector({
else delete style.size else delete style.size
if (color) style.color = color if (color) style.color = color
else delete style.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 patch: NodeInput = { id: node.id, label, style, locked }
const w = Number(width) const w = Number(width)
@ -154,6 +190,52 @@ export function NodeInspector({
</div> </div>
<div className="grid grid-cols-2 gap-2"> <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"> <Field label="Ширина, px">
<input <input
className={`${inputClass} py-1.5`} className={`${inputClass} py-1.5`}
@ -185,14 +267,34 @@ export function NodeInspector({
</Field> </Field>
</div> </div>
<label className="flex items-center gap-2 text-slate-400"> <div className="space-y-1">
<input {shape === 'dot' && (
type="checkbox" <label className="flex items-center gap-2 text-slate-400">
checked={locked} <input
onChange={(e) => setLocked(e.target.checked)} type="checkbox"
/> checked={hideLabel}
Закріпити (не перетягується) onChange={(e) => setHideLabel(e.target.checked)}
</label> />
Без підпису лише крапка
</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"
checked={locked}
onChange={(e) => setLocked(e.target.checked)}
/>
Закріпити (не перетягується)
</label>
</div>
<div className="flex gap-1.5 pt-0.5"> <div className="flex gap-1.5 pt-0.5">
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}> <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> { export interface Column<T> {
key: string key: string
header: string header: string

View file

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

View file

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

View file

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

View file

@ -4,7 +4,8 @@ import { session } from '../api/session'
import { useLiveMap } from '../hooks/useLiveMap' import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas' import { MapCanvas } from '../components/MapCanvas'
import { NodeInspector } from '../components/NodeInspector' 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' import type { MapSummary } from '../types'
export function MapPage() { export function MapPage() {
@ -13,6 +14,7 @@ export function MapPage() {
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null) const [listError, setListError] = useState<string | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const live = useLiveMap(mapID) const live = useLiveMap(mapID)
const canWrite = session.can('maps:write') const canWrite = session.can('maps:write')
@ -79,6 +81,33 @@ export function MapPage() {
> >
<span className="ml-1 hidden lg:inline">Відкотити</span> <span className="ml-1 hidden lg:inline">Відкотити</span>
</Button> </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> </div>
) )
} }

View file

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

View file

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

View file

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

View file

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