Сесія переживає перезавантаження, однакові відступи, вигляд вузлів мапи
«Просить логін після кожного оновлення» — дві поломки в одному симптомі. Перша: 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-токен спрацює щонайбільше один раз, і законний власник
|
// 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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue