Сесія переживає перезавантаження, однакові відступи, вигляд вузлів мапи
«Просить логін після кожного оновлення» — дві поломки в одному симптомі. Перша: 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:
parent
05adce6e7b
commit
a4a44b1d76
16 changed files with 494 additions and 79 deletions
59
HISTORY.md
59
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` у два шари) видно навіть на
|
||||
віддаленому масштабі, коли підписи вже не читаються. Червоний лишається
|
||||
тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор
|
||||
перестане на нього реагувати.
|
||||
|
||||
|
|
|
|||
21
db/migrations/0019_session_rotation_grace.sql
Normal file
21
db/migrations/0019_session_rotation_grace.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,14 +267,34 @@ export function NodeInspector({
|
|||
</Field>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={locked}
|
||||
onChange={(e) => setLocked(e.target.checked)}
|
||||
/>
|
||||
Закріпити (не перетягується)
|
||||
</label>
|
||||
<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"
|
||||
checked={locked}
|
||||
onChange={(e) => setLocked(e.target.checked)}
|
||||
/>
|
||||
Закріпити (не перетягується)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
</div>
|
||||
)}
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
|
||||
{testResult && (
|
||||
<div className="p-4 pb-0">
|
||||
|
|
@ -190,7 +187,7 @@ export function ChannelsPage() {
|
|||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
{creating && (
|
||||
<ChannelForm
|
||||
|
|
|
|||
|
|
@ -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,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">
|
||||
{creds.map((c) => (
|
||||
|
|
@ -156,7 +158,8 @@ export function CredentialsPage() {
|
|||
)}
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
</div>
|
||||
)}
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
|
@ -290,7 +287,7 @@ export function DevicesPage() {
|
|||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
{creating && (
|
||||
<DeviceForm
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 +151,8 @@ export function MetricsPage() {
|
|||
}
|
||||
/>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-slate-600">Хостів ще немає</p>
|
||||
|
|
@ -230,6 +232,7 @@ export function MetricsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
</div>
|
||||
)}
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
|
@ -201,7 +198,7 @@ export function RulesPage() {
|
|||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
{(creating || editing) && (
|
||||
<RuleForm
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
</div>
|
||||
)}
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{error}</ErrorNote>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
|
@ -185,7 +182,7 @@ export function TeamPage() {
|
|||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
{editing && (
|
||||
<EditUserForm
|
||||
|
|
|
|||
|
|
@ -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,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">
|
||||
{list.map((t) => (
|
||||
|
|
@ -189,7 +191,8 @@ export function TemplatesPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue