Повноцінний веб: навігація і вісім сторінок
До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
215bf8c4db
commit
36f29e23ec
24 changed files with 2877 additions and 641 deletions
93
HISTORY.md
93
HISTORY.md
|
|
@ -804,3 +804,96 @@ Telegram — має працювати. Вимагати ще й маршрут
|
||||||
приймача `callback_data` не написано. Web Push відкладено до Етапу 10
|
приймача `callback_data` не написано. Web Push відкладено до Етапу 10
|
||||||
разом із PWA. Правила з джерел `syslog`/`trap`/`ncm` движок пропускає:
|
разом із PWA. Правила з джерел `syslog`/`trap`/`ncm` движок пропускає:
|
||||||
вони обробляються подіями, а не опитуванням.
|
вони обробляються подіями, а не опитуванням.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-15 — Етап 5+: повноцінний веб
|
||||||
|
|
||||||
|
Питання «а повноцінний веб коли?» було справедливим. До цього моменту
|
||||||
|
«веб» — це був **один екран**: логін, мапа й панель алертів. Ані
|
||||||
|
навігації, ані сторінок; усе, що вміло API, доводилось викликати
|
||||||
|
curl-ом. Цей етап закриває розрив між готовим бекендом і тим, що людина
|
||||||
|
може натиснути.
|
||||||
|
|
||||||
|
### Створено
|
||||||
|
|
||||||
|
- `web/src/components/AppShell.tsx` — каркас: бічна навігація, шапка,
|
||||||
|
індикатор зв'язку, лічильник алертів.
|
||||||
|
- `web/src/components/ui.tsx` — примітиви: `DataTable`, `Modal`,
|
||||||
|
`Button`, `Field`, `StatusBadge`, `Toggle`, `Card`.
|
||||||
|
- Сім сторінок у `web/src/pages/`: `MapPage`, `DevicesPage`,
|
||||||
|
`AlertsPage`, `RulesPage`, `ChannelsPage`, `AgentsPage`, `TeamPage`,
|
||||||
|
`ProfilePage`.
|
||||||
|
- `web/src/hooks/useLiveRefresh.ts` — перечитування зі злиттям сплеску.
|
||||||
|
- `react-router-dom` як залежність; `App.tsx` переписано на маршрути.
|
||||||
|
|
||||||
|
### Прийняті рішення
|
||||||
|
|
||||||
|
**Пункт меню, на який немає права, не показується взагалі.** Кнопка,
|
||||||
|
що завжди дає 403, гірша за її відсутність: вона обіцяє можливість,
|
||||||
|
якої немає, і змушує людину гадати, що вона зробила не так. Пряме
|
||||||
|
посилання при цьому лишається робочим — його можна отримати від колеги
|
||||||
|
чи успадкувати після зміни ролі, тому маршрут показує зрозуміле
|
||||||
|
пояснення з назвою потрібного права, а не порожній екран.
|
||||||
|
|
||||||
|
**Стартова сторінка залежить від ролі.** Глядача без права на мапи
|
||||||
|
вітати відмовою — поганий перший екран, тому домівкою стає перший
|
||||||
|
доступний розділ.
|
||||||
|
|
||||||
|
**Таблиця на телефоні перестає бути таблицею.** Горизонтальний скрол на
|
||||||
|
375 px — це спосіб зробити дані формально присутніми й фактично
|
||||||
|
нечитабельними. Тому `DataTable` малює картки, а другорядні колонки
|
||||||
|
позначаються `hideOnMobile`, щоб картка лишалась короткою.
|
||||||
|
|
||||||
|
**Форма правила показує списком те саме, що приймає сервер.** Списки
|
||||||
|
метрик дублюють whitelist бекенда свідомо: без цього про друкарську
|
||||||
|
помилку людина дізнавалась би не з форми, а з правила, яке мовчки
|
||||||
|
нічого не знаходить.
|
||||||
|
|
||||||
|
### Знайдено роботою з живим UI (три справжні помилки)
|
||||||
|
|
||||||
|
**WebSocket жив усередині мапи.** Найсерйозніша з трьох. Сокет
|
||||||
|
створював `useLiveMap`, тому на кожній сторінці, крім мапи, живих
|
||||||
|
оновлень не було взагалі: лічильник алертів у шапці замерзав, щойно
|
||||||
|
людина йшла з мапи, і показував стан на момент входу. Помітно це стало
|
||||||
|
лише тоді, коли сторінок стало більше однієї. З'єднання винесено в
|
||||||
|
модуль-одинак `live`, яким володіє оболонка; `useLiveMap` тепер лише
|
||||||
|
слухає спільну шину. Заодно стан зв'язку видно з будь-якої сторінки —
|
||||||
|
замерзлий інтерфейс виглядає точно так само, як справний.
|
||||||
|
|
||||||
|
**Сторінка пристроїв перечитувала все на кожну подію алерту.** Виміряно
|
||||||
|
лічильником запитів у браузері під штучним сплеском: одна аварія з
|
||||||
|
десятком алертів давала десяток пар запитів — клієнт додавав
|
||||||
|
навантаження рівно тоді, коли серверу найважче. Виправлено спільним
|
||||||
|
хуком зі злиттям сплеску плюс переходом на алерти з оболонки замість
|
||||||
|
власного опитувача: **той самий сплеск тепер коштує 2 запити замість
|
||||||
|
дванадцяти**.
|
||||||
|
|
||||||
|
**Канал із секретом показувався як «секрету немає».** `HasSecret`
|
||||||
|
ставився всередині гілки розшифровки, а перелік для UI викликається без
|
||||||
|
ключа навмисно. Наявність секрету — факт про канал, а не наслідок того,
|
||||||
|
чи його зараз читають.
|
||||||
|
|
||||||
|
### Перевірено в браузері проти повного стека
|
||||||
|
|
||||||
|
- Усі вісім сторінок рендеряться з живими даними: 2 пристрої, 5 правил,
|
||||||
|
1 канал, 1 зонд, 4 учасники.
|
||||||
|
- Створення правила через форму: правил 4 → 5, умова записалась
|
||||||
|
коректно (`loss_pct > 5, всі виміри 120 с`).
|
||||||
|
- Створення користувача через форму: учасників 3 → 4.
|
||||||
|
- Кнопка «Перевірити» на каналі — пробне повідомлення дійшло у приймач.
|
||||||
|
- Глядач: у меню 5 пунктів замість 7, кнопки «+ Правило» немає, усі
|
||||||
|
10 перемикачів заблоковані, `/team` прямим посиланням дає екран із
|
||||||
|
поясненням.
|
||||||
|
- Мобільний 375 px: меню закрите, бургер висуває, перехід закриває його
|
||||||
|
сам; таблиці стають картками; горизонтального скролу немає.
|
||||||
|
- Мапа після переносу сокета працює: вузли з живим RTT, «наживо»,
|
||||||
|
лічильник часу оновлюється.
|
||||||
|
|
||||||
|
### Чого ще немає у вебі
|
||||||
|
|
||||||
|
Дашбордів і графіків історії (потрібен `GET /api/v1/metrics`, якого
|
||||||
|
немає), сторінки налаштувань зонда, редагування правила (лише
|
||||||
|
створення й вимкнення), маршрутів сповіщень і вікон обслуговування
|
||||||
|
(є в схемі й у движку, у UI — ні), NOC-режиму на телевізор.
|
||||||
|
|
|
||||||
10
ROADMAP.md
10
ROADMAP.md
|
|
@ -28,6 +28,7 @@ API віддає готове полотно з живими статусами,
|
||||||
| **Керування зондом із UI** | ✅ | ⚠️ транспорт є |
|
| **Керування зондом із UI** | ✅ | ⚠️ транспорт є |
|
||||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||||
|
| Веб-інтерфейс (навігація, сторінки) | — | ✅ |
|
||||||
| Дашборди, NOC TV | ✅ | ❌ |
|
| Дашборди, NOC TV | ✅ | ❌ |
|
||||||
| Білінг, ліцензії | ✅ | ❌ |
|
| Білінг, ліцензії | ✅ | ❌ |
|
||||||
|
|
||||||
|
|
@ -40,11 +41,16 @@ API віддає готове полотно з живими статусами,
|
||||||
> права, мобільний адаптив. Подробиці — [HISTORY.md](HISTORY.md),
|
> права, мобільний адаптив. Подробиці — [HISTORY.md](HISTORY.md),
|
||||||
> контракт — [server/API.md](server/API.md).
|
> контракт — [server/API.md](server/API.md).
|
||||||
>
|
>
|
||||||
|
> **Доповнено 2026-08-15:** повноцінний веб — навігація й вісім сторінок
|
||||||
|
> (мапа, пристрої, алерти, правила, канали, зонди, команда, профіль),
|
||||||
|
> примітиви UI з мобільними картками замість таблиць, захист маршрутів
|
||||||
|
> правами.
|
||||||
|
>
|
||||||
> **Відкладено з цього етапу:** TOTP (`totp_secret_enc` є в схемі, коду немає),
|
> **Відкладено з цього етапу:** TOTP (`totp_secret_enc` є в схемі, коду немає),
|
||||||
> запрошення поштою (`core.invitations` порожня — користувача заводять із
|
> запрошення поштою (`core.invitations` порожня — користувача заводять із
|
||||||
> паролем одразу), кастомні ролі (`POST /api/v1/roles`), фільтр за
|
> паролем одразу), кастомні ролі (`POST /api/v1/roles`), фільтр за
|
||||||
> `memberships.scope_group_ids`, UI-сторінки «Команда» й «Ролі» (API готове,
|
> `memberships.scope_group_ids`, редагування умови правила (лише створення
|
||||||
> сторінок немає).
|
> й вимкнення), маршрути сповіщень і вікна обслуговування в UI.
|
||||||
|
|
||||||
**Навіщо перше.** Зараз API автентифікує лише машинні токени
|
**Навіщо перше.** Зараз API автентифікує лише машинні токени
|
||||||
(`core.api_tokens`). Людина увійти не може, а `core.roles` / `permissions` /
|
(`core.api_tokens`). Людина увійти не може, а `core.roles` / `permissions` /
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,12 @@ func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.
|
||||||
c.TenantID = tenantID
|
c.TenantID = tenantID
|
||||||
c.Config = json.RawMessage(cfg)
|
c.Config = json.RawMessage(cfg)
|
||||||
|
|
||||||
|
// Наявність секрету — факт про канал, а не наслідок того,
|
||||||
|
// чи його зараз розшифровують. Перелік для UI викликається
|
||||||
|
// без кільця навмисно, і без цього рядка канал із токеном
|
||||||
|
// показувався б у ньому як «секрету немає».
|
||||||
|
c.HasSecret = keyID != nil
|
||||||
|
|
||||||
if keyID != nil && ring != nil {
|
if keyID != nil && ring != nil {
|
||||||
plain, err := ring.Decrypt(&crypto.Secret{
|
plain, err := ring.Decrypt(&crypto.Secret{
|
||||||
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||||||
|
|
@ -155,7 +161,6 @@ func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.
|
||||||
return fmt.Errorf("канал %s: розшифровка секрету: %w", c.Name, err)
|
return fmt.Errorf("канал %s: розшифровка секрету: %w", c.Name, err)
|
||||||
}
|
}
|
||||||
c.Secret = string(plain)
|
c.Secret = string(plain)
|
||||||
c.HasSecret = true
|
|
||||||
}
|
}
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
web/package-lock.json
generated
56
web/package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.3.6",
|
"@xyflow/react": "^12.3.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|
@ -1601,6 +1602,18 @@
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -2271,6 +2284,42 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.4",
|
"version": "4.62.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||||
|
|
@ -2333,6 +2382,11 @@
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.3.6",
|
"@xyflow/react": "^12.3.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|
|
||||||
384
web/src/App.tsx
384
web/src/App.tsx
|
|
@ -1,12 +1,18 @@
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { api, staticTokenPresent } from './api/client'
|
import { api, staticTokenPresent } from './api/client'
|
||||||
import { session } from './api/session'
|
import { session } from './api/session'
|
||||||
import { useLiveMap } from './hooks/useLiveMap'
|
import { AppShell } from './components/AppShell'
|
||||||
import { useAlerts } from './hooks/useAlerts'
|
|
||||||
import { LoginPage } from './components/LoginPage'
|
import { LoginPage } from './components/LoginPage'
|
||||||
import { MapCanvas } from './components/MapCanvas'
|
import { MapPage } from './pages/MapPage'
|
||||||
import { AlertBadge, AlertsPanel } from './components/AlertsPanel'
|
import { DevicesPage } from './pages/DevicesPage'
|
||||||
import type { AgentSummary, MapSummary } from './types'
|
import { AlertsPage } from './pages/AlertsPage'
|
||||||
|
import { RulesPage } from './pages/RulesPage'
|
||||||
|
import { ChannelsPage } from './pages/ChannelsPage'
|
||||||
|
import { AgentsPage } from './pages/AgentsPage'
|
||||||
|
import { TeamPage } from './pages/TeamPage'
|
||||||
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
|
import type { Permission } from './types'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
|
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
|
||||||
|
|
@ -26,7 +32,7 @@ export default function App() {
|
||||||
|
|
||||||
if (booting) {
|
if (booting) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-slate-950 text-sm text-slate-600">
|
<div className="flex h-[100dvh] items-center justify-center bg-slate-950 text-sm text-slate-600">
|
||||||
Відновлення сесії…
|
Відновлення сесії…
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -36,326 +42,66 @@ export default function App() {
|
||||||
return <LoginPage onDone={() => setBooting(false)} />
|
return <LoginPage onDone={() => setBooting(false)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Workspace />
|
|
||||||
}
|
|
||||||
|
|
||||||
function Workspace() {
|
|
||||||
const [maps, setMaps] = useState<MapSummary[]>([])
|
|
||||||
const [mapID, setMapID] = useState<string | null>(null)
|
|
||||||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
|
||||||
const [listError, setListError] = useState<string | null>(null)
|
|
||||||
// Мобільний сайдбар відкривається поверх полотна: на 375 px ширини
|
|
||||||
// постійна колонка з'їдала б дві третини карти.
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
||||||
const [alertsOpen, setAlertsOpen] = useState(false)
|
|
||||||
|
|
||||||
const live = useLiveMap(mapID)
|
|
||||||
const alerts = useAlerts()
|
|
||||||
const canWrite = session.can('maps:write')
|
|
||||||
const canSeeAlerts = session.can('alerts:read')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api
|
|
||||||
.listMaps()
|
|
||||||
.then((m) => {
|
|
||||||
setMaps(m)
|
|
||||||
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
|
|
||||||
})
|
|
||||||
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
|
|
||||||
api.listAgents().then(setAgents).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const nodes = live.state?.nodes ?? []
|
|
||||||
return {
|
|
||||||
total: nodes.length,
|
|
||||||
up: nodes.filter((n) => n.status === 'up').length,
|
|
||||||
down: nodes.filter((n) => n.status === 'down').length,
|
|
||||||
unknown: nodes.filter((n) => !n.status || n.status === 'unknown').length,
|
|
||||||
}
|
|
||||||
}, [live.state])
|
|
||||||
|
|
||||||
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
|
|
||||||
const me = session.me()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
|
<BrowserRouter>
|
||||||
<header
|
<Routes>
|
||||||
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-800
|
<Route element={<AppShell />}>
|
||||||
bg-slate-900 px-3 py-2 md:px-4"
|
<Route index element={<Home />} />
|
||||||
>
|
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||||
<button
|
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
||||||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
|
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||||
onClick={() => setSidebarOpen((v) => !v)}
|
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||||
aria-label="Меню"
|
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
||||||
>
|
<Route path="/agents" element={<Guard perm="agents:read" element={<AgentsPage />} />} />
|
||||||
☰
|
<Route path="/team" element={<Guard perm="users:read" element={<TeamPage />} />} />
|
||||||
</button>
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="*" element={<Home />} />
|
||||||
<span className="text-base font-semibold tracking-tight text-slate-100 md:text-lg">
|
</Route>
|
||||||
NetPulse
|
</Routes>
|
||||||
</span>
|
</BrowserRouter>
|
||||||
|
|
||||||
<select
|
|
||||||
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm
|
|
||||||
md:flex-none"
|
|
||||||
value={mapID ?? ''}
|
|
||||||
onChange={(e) => setMapID(e.target.value || null)}
|
|
||||||
>
|
|
||||||
{maps.length === 0 && <option value="">мап немає</option>}
|
|
||||||
{maps.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name} ({m.node_count}/{m.edge_count})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{canWrite && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
|
|
||||||
hover:bg-slate-700 disabled:opacity-40"
|
|
||||||
disabled={!mapID}
|
|
||||||
onClick={() => void live.build()}
|
|
||||||
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
|
|
||||||
>
|
|
||||||
<span className="hidden lg:inline">Добудувати з топології</span>
|
|
||||||
<span className="lg:hidden">Добудувати</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
|
|
||||||
hover:bg-slate-700 disabled:opacity-40"
|
|
||||||
disabled={!live.canUndo}
|
|
||||||
onClick={() => void live.undo()}
|
|
||||||
title="Відкотити останню зміну полотна"
|
|
||||||
>
|
|
||||||
↶<span className="ml-1 hidden lg:inline">Відкотити</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3 text-xs">
|
|
||||||
<span className="hidden xl:block">
|
|
||||||
<Legend />
|
|
||||||
</span>
|
|
||||||
{canSeeAlerts && (
|
|
||||||
<AlertBadge counts={alerts.counts} onClick={() => setAlertsOpen((v) => !v)} />
|
|
||||||
)}
|
|
||||||
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
|
|
||||||
{live.state && (
|
|
||||||
<span className="hidden tabular-nums text-slate-500 sm:inline">
|
|
||||||
ревізія {live.state.revision}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{me?.email && (
|
|
||||||
<button
|
|
||||||
className="max-w-[10rem] truncate rounded border border-slate-700 px-2 py-1
|
|
||||||
text-slate-400 hover:bg-slate-800"
|
|
||||||
title={`${me.email} — вийти`}
|
|
||||||
onClick={() => void api.logout()}
|
|
||||||
>
|
|
||||||
{me.email}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{(live.error || listError) && (
|
|
||||||
<div className="shrink-0 border-b border-amber-900/50 bg-amber-950/60 px-4 py-1.5 text-sm text-amber-200">
|
|
||||||
{live.error ?? listError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1">
|
|
||||||
{/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={`absolute inset-y-0 left-0 z-20 flex w-64 flex-col gap-4 overflow-y-auto
|
|
||||||
border-r border-slate-800 bg-slate-900 p-3 transition-transform
|
|
||||||
md:static md:z-0 md:w-60 md:translate-x-0 md:bg-slate-900/60
|
|
||||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
|
||||||
>
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Стан вузлів
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<Counter label="Усього" value={counts.total} />
|
|
||||||
<Counter label="Працюють" value={counts.up} color="text-emerald-400" />
|
|
||||||
<Counter label="Недоступні" value={counts.down} color="text-red-400" />
|
|
||||||
<Counter label="Невідомо" value={counts.unknown} color="text-slate-400" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{session.can('agents:read') && (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Зонди
|
|
||||||
</h2>
|
|
||||||
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{agents.map((a) => (
|
|
||||||
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className={`h-1.5 w-1.5 rounded-full ${
|
|
||||||
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className="truncate font-medium text-slate-200">{a.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-slate-500">
|
|
||||||
{a.os}/{a.arch} · {a.device_count} пристроїв
|
|
||||||
</div>
|
|
||||||
{typeof a.health?.rss_bytes === 'number' && (
|
|
||||||
<div className="tabular-nums text-slate-500">
|
|
||||||
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="min-w-0 flex-1">
|
|
||||||
{live.state ? (
|
|
||||||
<MapCanvas
|
|
||||||
state={live.state}
|
|
||||||
onPatch={live.patch}
|
|
||||||
onSelect={setSelected}
|
|
||||||
readOnly={!canWrite}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
|
|
||||||
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{alertsOpen && canSeeAlerts && (
|
|
||||||
<AlertsPanel
|
|
||||||
live={alerts}
|
|
||||||
onClose={() => setAlertsOpen(false)}
|
|
||||||
// Клік по алерту веде до пристрою на мапі: інакше зв'язок
|
|
||||||
// між рядком у списку й вузлом доводиться шукати очима.
|
|
||||||
onFocusDevice={(deviceID) => {
|
|
||||||
const n = live.state?.nodes.find((x) => x.device_id === deviceID)
|
|
||||||
if (n) setSelected(n.id)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */}
|
|
||||||
{node && (
|
|
||||||
<div
|
|
||||||
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
|
|
||||||
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
|
|
||||||
>
|
|
||||||
<div className="mb-1 flex items-center justify-between">
|
|
||||||
<span className="font-medium text-slate-100">{node.label}</span>
|
|
||||||
<button className="px-2 text-slate-500 hover:text-slate-300" onClick={() => setSelected(null)}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
|
|
||||||
<Row k="статус" v={node.status || 'unknown'} />
|
|
||||||
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
|
|
||||||
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
|
||||||
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Counter({ label, value, color }: { label: string; value: number; color?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-400">{label}</span>
|
|
||||||
<span className={`tabular-nums font-medium ${color ?? 'text-slate-200'}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ k, v }: { k: string; v: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between gap-2 md:w-full">
|
|
||||||
<span className="text-slate-500">{k}</span>
|
|
||||||
<span className="tabular-nums text-slate-300">{v}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Legend() {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-2 text-slate-500">
|
|
||||||
<Dot c="bg-emerald-400" t="норма" />
|
|
||||||
<Dot c="bg-amber-400" t="навантажено" />
|
|
||||||
<Dot c="bg-red-500" t="обрив" />
|
|
||||||
<Dot c="bg-slate-500" t="невідомо" />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dot({ c, t }: { c: string; t: string }) {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${c}`} />
|
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стан з'єднання показується завжди й помітно.
|
* Стартова сторінка залежить від ролі.
|
||||||
*
|
*
|
||||||
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
|
* Глядача без права на мапи слати на «/map» означало б вітати його
|
||||||
* само, як мапа, де все гаразд, — і це найнебезпечніший стан
|
* порожнім екраном із відмовою. Тому перший доступний розділ і стає
|
||||||
* моніторингу.
|
* домівкою.
|
||||||
*/
|
*/
|
||||||
function ConnectionBadge({
|
function Home() {
|
||||||
state,
|
const order: { perm: Permission; to: string }[] = [
|
||||||
lastEventAt,
|
{ perm: 'maps:read', to: '/map' },
|
||||||
}: {
|
{ perm: 'alerts:read', to: '/alerts' },
|
||||||
state: 'connecting' | 'online' | 'offline'
|
{ perm: 'devices:read', to: '/devices' },
|
||||||
lastEventAt: number | null
|
{ perm: 'agents:read', to: '/agents' },
|
||||||
}) {
|
{ perm: 'users:read', to: '/team' },
|
||||||
const [, force] = useState(0)
|
]
|
||||||
useEffect(() => {
|
const first = order.find((o) => session.can(o.perm))
|
||||||
const t = setInterval(() => force((n) => n + 1), 1000)
|
return <Navigate to={first?.to ?? '/profile'} replace />
|
||||||
return () => clearInterval(t)
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
const label =
|
|
||||||
state === 'online' ? 'наживо' : state === 'connecting' ? 'підключення…' : 'немає зв\'язку'
|
|
||||||
const color =
|
|
||||||
state === 'online'
|
|
||||||
? 'bg-emerald-400'
|
|
||||||
: state === 'connecting'
|
|
||||||
? 'bg-amber-400'
|
|
||||||
: 'bg-red-500 animate-pulse'
|
|
||||||
|
|
||||||
const ago = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Захист маршруту правом.
|
||||||
|
*
|
||||||
|
* Навігація вже ховає недоступні пункти, але посилання лишається
|
||||||
|
* робочим: його можна зберегти в закладки, отримати від колеги або
|
||||||
|
* успадкувати після зміни ролі. Сервер у будь-якому разі відповість 403 —
|
||||||
|
* питання лише в тому, побачить людина зрозуміле пояснення чи порожню
|
||||||
|
* сторінку з помилкою в консолі.
|
||||||
|
*/
|
||||||
|
function Guard({ perm, element }: { perm: Permission; element: React.ReactNode }) {
|
||||||
|
if (session.can(perm)) return <>{element}</>
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 text-slate-400">
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
|
<div className="max-w-sm text-center">
|
||||||
<span className="hidden sm:inline">{label}</span>
|
<div className="mb-2 text-2xl">🔒</div>
|
||||||
{ago !== null && state === 'online' && (
|
<h2 className="text-sm font-medium text-slate-200">Розділ недоступний</h2>
|
||||||
<span className="hidden tabular-nums text-slate-600 lg:inline">{ago} с тому</span>
|
<p className="mt-1 text-xs leading-relaxed text-slate-500">
|
||||||
)}
|
Вашій ролі не надано права <code className="text-slate-400">{perm}</code>. Попросіть
|
||||||
</span>
|
власника організації змінити роль, якщо доступ потрібен для роботи.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
AlertCounts,
|
AlertCounts,
|
||||||
AlertRule,
|
AlertRule,
|
||||||
BuildResult,
|
BuildResult,
|
||||||
|
Channel,
|
||||||
DeviceSummary,
|
DeviceSummary,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
MapPatch,
|
MapPatch,
|
||||||
|
|
@ -220,9 +221,66 @@ export const api = {
|
||||||
listAlertRules: () =>
|
listAlertRules: () =>
|
||||||
request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []),
|
request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []),
|
||||||
|
|
||||||
|
createAlertRule: (rule: {
|
||||||
|
name: string
|
||||||
|
source: string
|
||||||
|
severity: string
|
||||||
|
condition: Record<string, unknown>
|
||||||
|
selector?: Record<string, unknown>
|
||||||
|
for_seconds: number
|
||||||
|
depends_on_topology: boolean
|
||||||
|
}) =>
|
||||||
|
request<{ id: string }>('/api/v1/alert-rules', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(rule),
|
||||||
|
}),
|
||||||
|
|
||||||
setRuleEnabled: (id: string, enabled: boolean) =>
|
setRuleEnabled: (id: string, enabled: boolean) =>
|
||||||
request<void>(`/api/v1/alert-rules/${id}`, {
|
request<void>(`/api/v1/alert-rules/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
deleteAlertRule: (id: string) =>
|
||||||
|
request<void>(`/api/v1/alert-rules/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// --- канали доставки ---
|
||||||
|
|
||||||
|
listChannels: () =>
|
||||||
|
request<{ channels: Channel[] }>('/api/v1/channels').then((r) => r.channels ?? []),
|
||||||
|
|
||||||
|
createChannel: (ch: {
|
||||||
|
kind: string
|
||||||
|
name: string
|
||||||
|
config: Record<string, unknown>
|
||||||
|
min_severity: string
|
||||||
|
secret?: string
|
||||||
|
template?: string
|
||||||
|
}) =>
|
||||||
|
request<{ id: string }>('/api/v1/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ch),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteChannel: (id: string) => request<void>(`/api/v1/channels/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
/** Помилка каналу — це відповідь, а не збій: сервер віддає її кодом 200. */
|
||||||
|
testChannel: (id: string) =>
|
||||||
|
request<{ ok: boolean; error?: string }>(`/api/v1/channels/${id}/test`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// --- профіль ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Зміна пароля відкликає ВСІ сесії, включно з поточною, тому одразу
|
||||||
|
* після успіху сесія чиститься локально. Лишити людину на сторінці з
|
||||||
|
* токеном, який доживає свої хвилини, означало б показати їй помилку
|
||||||
|
* через 15 хвилин замість чесного «увійдіть заново» зараз.
|
||||||
|
*/
|
||||||
|
changePassword: async (oldPassword: string, newPassword: string) => {
|
||||||
|
await request<void>('/api/v1/auth/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||||||
|
})
|
||||||
|
session.clear()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@ import type { Permission } from '../types'
|
||||||
export interface Me {
|
export interface Me {
|
||||||
userID?: string
|
userID?: string
|
||||||
email?: string
|
email?: string
|
||||||
|
fullName?: string
|
||||||
tokenName?: string
|
tokenName?: string
|
||||||
tenantID: string
|
tenantID: string
|
||||||
|
tenantName?: string
|
||||||
|
roleName?: string
|
||||||
permissions: Permission[]
|
permissions: Permission[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,15 +59,18 @@ export const session = {
|
||||||
|
|
||||||
/** Розбір відповіді входу в єдину форму. */
|
/** Розбір відповіді входу в єдину форму. */
|
||||||
export function meFromLogin(body: {
|
export function meFromLogin(body: {
|
||||||
user?: { id: string; email: string }
|
user?: { id: string; email: string; full_name?: string }
|
||||||
tenant?: { tenant_id: string } | null
|
tenant?: { tenant_id: string; tenant_name?: string; role_name?: string } | null
|
||||||
permissions?: string[]
|
permissions?: string[]
|
||||||
tenant_id?: string
|
tenant_id?: string
|
||||||
}): Me {
|
}): Me {
|
||||||
return {
|
return {
|
||||||
userID: body.user?.id,
|
userID: body.user?.id,
|
||||||
email: body.user?.email,
|
email: body.user?.email,
|
||||||
|
fullName: body.user?.full_name,
|
||||||
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
|
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
|
||||||
|
tenantName: body.tenant?.tenant_name,
|
||||||
|
roleName: body.tenant?.role_name,
|
||||||
permissions: (body.permissions ?? []) as Permission[],
|
permissions: (body.permissions ?? []) as Permission[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,14 @@ import type { WsMessage } from '../types'
|
||||||
|
|
||||||
export type ConnectionState = 'connecting' | 'online' | 'offline'
|
export type ConnectionState = 'connecting' | 'online' | 'offline'
|
||||||
|
|
||||||
interface Options {
|
|
||||||
onMessage: (msg: WsMessage) => void
|
|
||||||
onState: (state: ConnectionState) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Спільна шина подій.
|
* Спільна шина подій.
|
||||||
*
|
*
|
||||||
* З'єднання одне на вкладку, а слухачів у нього кілька: мапа, панель
|
* З'єднання одне на вкладку, а слухачів у нього кілька: мапа, список
|
||||||
* алертів і далі буде більше. Другий WebSocket заради алертів коштував
|
* пристроїв, лічильник алертів у шапці. Другий WebSocket заради
|
||||||
* би ще одного з'єднання й ще одного циклу перепідключень на кожен
|
* алертів коштував би ще одного з'єднання й ще одного циклу
|
||||||
* відкритий NOC-екран — при тому, що сервер шле обом однаковий потік.
|
* перепідключень на кожен відкритий NOC-екран — при тому, що сервер
|
||||||
|
* шле обом однаковий потік.
|
||||||
*/
|
*/
|
||||||
type Listener = (msg: WsMessage) => void
|
type Listener = (msg: WsMessage) => void
|
||||||
|
|
||||||
|
|
@ -37,25 +33,47 @@ export const liveEvents = {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Живе з'єднання з сервером.
|
* Живе з'єднання з сервером — одне на застосунок.
|
||||||
|
*
|
||||||
|
* Володіє ним оболонка, а не сторінка. Спроба тримати сокет усередині
|
||||||
|
* мапи вже коштувала того, що на всіх інших сторінках живих оновлень не
|
||||||
|
* було взагалі: лічильник алертів у шапці замерзав, щойно людина йшла
|
||||||
|
* з мапи, і показував стан на момент входу.
|
||||||
*
|
*
|
||||||
* Реконект обов'язковий і не є деталлю: NOC-екран висить тижнями, і
|
* Реконект обов'язковий і не є деталлю: NOC-екран висить тижнями, і
|
||||||
* будь-який проксі рано чи пізно розірве з'єднання. Без автоматичного
|
* будь-який проксі рано чи пізно розірве з'єднання. Без автоматичного
|
||||||
* повернення мапа замерзне з останнім кадром, виглядаючи цілком живою —
|
* повернення мапа замерзне з останнім кадром, виглядаючи цілком живою —
|
||||||
* найгірший з можливих станів моніторингу.
|
* найгірший з можливих станів моніторингу.
|
||||||
*/
|
*/
|
||||||
export class LiveConnection {
|
class LiveConnection {
|
||||||
private ws: WebSocket | null = null
|
private ws: WebSocket | null = null
|
||||||
private backoff = 1000
|
private backoff = 1000
|
||||||
private closed = false
|
private started = false
|
||||||
private wantMap: string | null = null
|
private wantMap: string | null = null
|
||||||
private timer: number | null = null
|
private timer: number | null = null
|
||||||
|
private state: ConnectionState = 'connecting'
|
||||||
|
private lastEventAt: number | null = null
|
||||||
|
private stateListeners = new Set<() => void>()
|
||||||
|
|
||||||
constructor(private readonly opts: Options) {}
|
/** Ідемпотентний старт: оболонка кличе його при монтуванні. */
|
||||||
|
start() {
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
this.open()
|
||||||
|
}
|
||||||
|
|
||||||
connect() {
|
/** Розрив при виході з акаунта — інакше сокет житиме з мертвим токеном. */
|
||||||
if (this.closed) return
|
stop() {
|
||||||
this.opts.onState('connecting')
|
this.started = false
|
||||||
|
this.wantMap = null
|
||||||
|
if (this.timer) window.clearTimeout(this.timer)
|
||||||
|
this.ws?.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private open() {
|
||||||
|
if (!this.started) return
|
||||||
|
this.setState('connecting')
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
// Браузерний WebSocket не дозволяє довільні заголовки, тому токен
|
// Браузерний WebSocket не дозволяє довільні заголовки, тому токен
|
||||||
|
|
@ -69,14 +87,14 @@ export class LiveConnection {
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
this.backoff = 1000
|
this.backoff = 1000
|
||||||
this.opts.onState('online')
|
this.setState('online')
|
||||||
if (this.wantMap) this.subscribe(this.wantMap)
|
if (this.wantMap) this.subscribe(this.wantMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data) as WsMessage
|
const msg = JSON.parse(ev.data) as WsMessage
|
||||||
this.opts.onMessage(msg)
|
this.lastEventAt = Date.now()
|
||||||
liveEvents.emit(msg)
|
liveEvents.emit(msg)
|
||||||
} catch {
|
} catch {
|
||||||
// Пошкоджене повідомлення не має рвати з'єднання.
|
// Пошкоджене повідомлення не має рвати з'єднання.
|
||||||
|
|
@ -85,9 +103,9 @@ export class LiveConnection {
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
this.ws = null
|
this.ws = null
|
||||||
if (this.closed) return
|
if (!this.started) return
|
||||||
this.opts.onState('offline')
|
this.setState('offline')
|
||||||
this.timer = window.setTimeout(() => this.connect(), this.backoff)
|
this.timer = window.setTimeout(() => this.open(), this.backoff)
|
||||||
// Стеля 15 с: далі затримка вже помітна оператору, а користі
|
// Стеля 15 с: далі затримка вже помітна оператору, а користі
|
||||||
// від рідших спроб немає.
|
// від рідших спроб немає.
|
||||||
this.backoff = Math.min(this.backoff * 2, 15000)
|
this.backoff = Math.min(this.backoff * 2, 15000)
|
||||||
|
|
@ -104,9 +122,22 @@ export class LiveConnection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
private setState(s: ConnectionState) {
|
||||||
this.closed = true
|
if (this.state === s) return
|
||||||
if (this.timer) window.clearTimeout(this.timer)
|
this.state = s
|
||||||
this.ws?.close()
|
for (const fn of this.stateListeners) fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Формат під useSyncExternalStore.
|
||||||
|
subscribeState = (fn: () => void) => {
|
||||||
|
this.stateListeners.add(fn)
|
||||||
|
return () => {
|
||||||
|
this.stateListeners.delete(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getState = () => this.state
|
||||||
|
getLastEventAt = () => this.lastEventAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const live = new LiveConnection()
|
||||||
|
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import { session } from '../api/session'
|
|
||||||
import type { Alert, Severity } from '../types'
|
|
||||||
import type { LiveAlerts } from '../hooks/useAlerts'
|
|
||||||
|
|
||||||
const severityStyle: Record<Severity, { dot: string; text: string; label: string }> = {
|
|
||||||
disaster: { dot: 'bg-red-500', text: 'text-red-300', label: 'аварія' },
|
|
||||||
high: { dot: 'bg-red-400', text: 'text-red-300', label: 'висока' },
|
|
||||||
average: { dot: 'bg-orange-400', text: 'text-orange-300', label: 'середня' },
|
|
||||||
warning: { dot: 'bg-amber-400', text: 'text-amber-300', label: 'увага' },
|
|
||||||
info: { dot: 'bg-sky-400', text: 'text-sky-300', label: 'інфо' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const suppressLabel: Record<string, string> = {
|
|
||||||
maintenance: 'вікно обслуговування',
|
|
||||||
mute: 'заглушено вручну',
|
|
||||||
topology: 'наслідок іншої аварії',
|
|
||||||
dependency: 'залежність',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Панель алертів.
|
|
||||||
*
|
|
||||||
* На десктопі — колонка праворуч, на телефоні — аркуш на весь екран.
|
|
||||||
* Проміжного варіанта немає навмисно: список алертів або читають
|
|
||||||
* уважно, або не читають узагалі, і вузька смужка збоку на 375 px
|
|
||||||
* гарантує друге.
|
|
||||||
*/
|
|
||||||
export function AlertsPanel({
|
|
||||||
live,
|
|
||||||
onClose,
|
|
||||||
onFocusDevice,
|
|
||||||
}: {
|
|
||||||
live: LiveAlerts
|
|
||||||
onClose: () => void
|
|
||||||
onFocusDevice?: (deviceID: string) => void
|
|
||||||
}) {
|
|
||||||
const canAck = session.can('alerts:ack')
|
|
||||||
const [showSuppressed, setShowSuppressed] = useState(false)
|
|
||||||
|
|
||||||
const visible = showSuppressed
|
|
||||||
? live.alerts
|
|
||||||
: live.alerts.filter((a) => a.state !== 'suppressed')
|
|
||||||
const hidden = live.alerts.length - visible.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className="fixed inset-0 z-30 flex flex-col border-slate-800 bg-slate-900
|
|
||||||
md:absolute md:inset-y-0 md:left-auto md:right-0 md:w-96 md:border-l"
|
|
||||||
>
|
|
||||||
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 px-3 py-2">
|
|
||||||
<h2 className="text-sm font-semibold text-slate-100">Алерти</h2>
|
|
||||||
<span className="text-xs tabular-nums text-slate-500">
|
|
||||||
{live.counts.firing} активних · {live.counts.acknowledged} підтверджено
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="ml-auto rounded px-2 py-1 text-slate-500 hover:bg-slate-800 hover:text-slate-300"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Закрити панель алертів"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{live.error && (
|
|
||||||
<div className="shrink-0 border-b border-amber-900/50 bg-amber-950/60 px-3 py-1.5 text-xs text-amber-200">
|
|
||||||
{live.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
{live.loading && (
|
|
||||||
<p className="px-3 py-6 text-center text-xs text-slate-600">Завантаження…</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!live.loading && visible.length === 0 && (
|
|
||||||
<p className="px-3 py-8 text-center text-xs text-slate-600">
|
|
||||||
{live.alerts.length === 0 ? 'Активних алертів немає' : 'Усі активні — придушені'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="divide-y divide-slate-800">
|
|
||||||
{visible.map((a) => (
|
|
||||||
<AlertRow
|
|
||||||
key={a.id}
|
|
||||||
alert={a}
|
|
||||||
canAck={canAck}
|
|
||||||
onAck={() => void live.ack(a.id)}
|
|
||||||
onClose={() => void live.close(a.id)}
|
|
||||||
onMute={a.device_id ? () => void live.mute(a.device_id!, 60) : undefined}
|
|
||||||
onFocus={
|
|
||||||
a.device_id && onFocusDevice ? () => onFocusDevice(a.device_id!) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hidden > 0 && (
|
|
||||||
<button
|
|
||||||
className="shrink-0 border-t border-slate-800 px-3 py-2 text-left text-xs text-slate-500
|
|
||||||
hover:bg-slate-800/50"
|
|
||||||
onClick={() => setShowSuppressed((v) => !v)}
|
|
||||||
>
|
|
||||||
{showSuppressed ? 'Сховати придушені' : `Показати придушені (${hidden})`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertRow({
|
|
||||||
alert: a,
|
|
||||||
canAck,
|
|
||||||
onAck,
|
|
||||||
onClose,
|
|
||||||
onMute,
|
|
||||||
onFocus,
|
|
||||||
}: {
|
|
||||||
alert: Alert
|
|
||||||
canAck: boolean
|
|
||||||
onAck: () => void
|
|
||||||
onClose: () => void
|
|
||||||
onMute?: () => void
|
|
||||||
onFocus?: () => void
|
|
||||||
}) {
|
|
||||||
const s = severityStyle[a.severity] ?? severityStyle.info
|
|
||||||
const acked = a.state === 'acknowledged'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={`px-3 py-2.5 ${a.state === 'suppressed' ? 'opacity-50' : ''}`}>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className={`text-[10px] uppercase tracking-wide ${s.text}`}>{s.label}</span>
|
|
||||||
<span className="text-[10px] tabular-nums text-slate-600">{ago(a.started_at)}</span>
|
|
||||||
{acked && (
|
|
||||||
<span className="text-[10px] text-emerald-500" title={a.acked_by_email}>
|
|
||||||
✓ прийнято
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="block w-full truncate text-left text-sm font-medium text-slate-100
|
|
||||||
hover:text-sky-300 disabled:hover:text-slate-100"
|
|
||||||
onClick={onFocus}
|
|
||||||
disabled={!onFocus}
|
|
||||||
title={a.title}
|
|
||||||
>
|
|
||||||
{a.title}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{a.message && <p className="mt-0.5 text-xs leading-snug text-slate-400">{a.message}</p>}
|
|
||||||
|
|
||||||
{a.suppressed_by && (
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500">
|
|
||||||
придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canAck && (
|
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
||||||
{!acked && (
|
|
||||||
<SmallButton onClick={onAck} title="Зупинити ескалацію — я вже дивлюсь">
|
|
||||||
Прийняти
|
|
||||||
</SmallButton>
|
|
||||||
)}
|
|
||||||
{onMute && (
|
|
||||||
<SmallButton onClick={onMute} title="Не турбувати цим пристроєм годину">
|
|
||||||
Заглушити 1 год
|
|
||||||
</SmallButton>
|
|
||||||
)}
|
|
||||||
<SmallButton onClick={onClose} title="Закрити вручну">
|
|
||||||
Закрити
|
|
||||||
</SmallButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SmallButton({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick: () => void
|
|
||||||
title?: string
|
|
||||||
}) {
|
|
||||||
// На телефоні ці кнопки натискають пальцем і зазвичай уночі: 26 px,
|
|
||||||
// яких вистачало мишею, тут перетворюються на промахи. На десктопі
|
|
||||||
// висота повертається до щільної — список має лишатись оглядним.
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="min-h-[38px] rounded border border-slate-700 bg-slate-800 px-3 py-2 text-xs
|
|
||||||
text-slate-300 hover:bg-slate-700
|
|
||||||
md:min-h-0 md:px-2 md:py-1 md:text-[11px]"
|
|
||||||
onClick={onClick}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Індикатор у шапці. Мовчить, коли алертів немає. */
|
|
||||||
export function AlertBadge({
|
|
||||||
counts,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
counts: { firing: number; acknowledged: number; disaster: number; high: number }
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
const total = counts.firing + counts.acknowledged
|
|
||||||
const critical = counts.disaster + counts.high
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`flex items-center gap-1.5 rounded border px-2 py-1 text-xs ${
|
|
||||||
critical > 0
|
|
||||||
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
|
|
||||||
: counts.firing > 0
|
|
||||||
? 'border-amber-800 bg-amber-950/50 text-amber-200'
|
|
||||||
: 'border-slate-700 text-slate-400 hover:bg-slate-800'
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
title={
|
|
||||||
total === 0
|
|
||||||
? 'Алертів немає'
|
|
||||||
: `${counts.firing} активних, ${counts.acknowledged} підтверджено`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>🔔</span>
|
|
||||||
<span className="tabular-nums">{total}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ago(iso: string): string {
|
|
||||||
const sec = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000))
|
|
||||||
if (sec < 60) return `${sec} с`
|
|
||||||
if (sec < 3600) return `${Math.round(sec / 60)} хв`
|
|
||||||
if (sec < 86400) return `${Math.round(sec / 3600)} год`
|
|
||||||
return `${Math.round(sec / 86400)} дн`
|
|
||||||
}
|
|
||||||
183
web/src/components/AppShell.tsx
Normal file
183
web/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { live } from '../api/ws'
|
||||||
|
import { useAlerts } from '../hooks/useAlerts'
|
||||||
|
import type { Permission } from '../types'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
perm: Permission
|
||||||
|
/** Показувати лічильник алертів на цьому пункті. */
|
||||||
|
badge?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||||||
|
{ to: '/devices', label: 'Пристрої', icon: '🖧', perm: 'devices:read' },
|
||||||
|
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||||||
|
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
||||||
|
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
||||||
|
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
|
||||||
|
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
|
||||||
|
*
|
||||||
|
* Пункт, на який немає права, не показується взагалі. Пункт, який
|
||||||
|
* завжди веде до 403, гірший за його відсутність: він обіцяє
|
||||||
|
* можливість, якої немає, і змушує людину гадати, що вона зробила не так.
|
||||||
|
*/
|
||||||
|
export function AppShell() {
|
||||||
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
const me = session.me()
|
||||||
|
const alerts = useAlerts()
|
||||||
|
const connection = useSyncExternalStore(live.subscribeState, live.getState)
|
||||||
|
|
||||||
|
// Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам:
|
||||||
|
// лічильник алертів у шапці має оновлюватись і тоді, коли людина
|
||||||
|
// дивиться на список пристроїв або взагалі на профіль.
|
||||||
|
useEffect(() => {
|
||||||
|
live.start()
|
||||||
|
return () => live.stop()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Перехід між сторінками закриває мобільну шухляду: інакше вона
|
||||||
|
// лишається поверх щойно відкритої сторінки.
|
||||||
|
useEffect(() => setNavOpen(false), [location.pathname])
|
||||||
|
|
||||||
|
const visible = navItems.filter((i) => session.can(i.perm))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
|
||||||
|
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 bg-slate-900 px-3 py-2">
|
||||||
|
<button
|
||||||
|
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
|
||||||
|
onClick={() => setNavOpen((v) => !v)}
|
||||||
|
aria-label="Меню"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-base font-semibold tracking-tight text-slate-100">NetPulse</span>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs">
|
||||||
|
{/* Замерзлий інтерфейс виглядає точно так само, як справний.
|
||||||
|
Тому стан з'єднання видно з будь-якої сторінки, а не лише
|
||||||
|
з мапи. */}
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-slate-500"
|
||||||
|
title={
|
||||||
|
connection === 'online'
|
||||||
|
? 'Дані оновлюються наживо'
|
||||||
|
: connection === 'connecting'
|
||||||
|
? 'Підключення…'
|
||||||
|
: "Зв'язок із сервером втрачено — дані на екрані застаріли"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
connection === 'online'
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: connection === 'connecting'
|
||||||
|
? 'bg-amber-400'
|
||||||
|
: 'animate-pulse bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{connection === 'online' ? 'наживо' : connection === 'connecting' ? '…' : 'офлайн'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{alerts.counts.firing > 0 && (
|
||||||
|
<NavLink
|
||||||
|
to="/alerts"
|
||||||
|
className={`flex items-center gap-1.5 rounded border px-2 py-1 ${
|
||||||
|
alerts.counts.disaster + alerts.counts.high > 0
|
||||||
|
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
|
||||||
|
: 'border-amber-800 bg-amber-950/50 text-amber-200'
|
||||||
|
}`}
|
||||||
|
title={`${alerts.counts.firing} активних алертів`}
|
||||||
|
>
|
||||||
|
🔔 <span className="tabular-nums">{alerts.counts.firing}</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{me?.email && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<NavLink
|
||||||
|
to="/profile"
|
||||||
|
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
|
||||||
|
text-slate-400 hover:bg-slate-800"
|
||||||
|
title={me.email}
|
||||||
|
>
|
||||||
|
{me.email}
|
||||||
|
</NavLink>
|
||||||
|
<button
|
||||||
|
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
|
||||||
|
onClick={() => void api.logout()}
|
||||||
|
title="Вийти"
|
||||||
|
>
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1">
|
||||||
|
{navOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
|
||||||
|
onClick={() => setNavOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col gap-0.5
|
||||||
|
overflow-y-auto border-r border-slate-800 bg-slate-900 p-2
|
||||||
|
transition-transform
|
||||||
|
md:static md:z-0 md:w-52 md:translate-x-0 md:bg-slate-900/50
|
||||||
|
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||||
|
>
|
||||||
|
{visible.map((i) => (
|
||||||
|
<NavLink
|
||||||
|
key={i.to}
|
||||||
|
to={i.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2.5 rounded px-3 py-2.5 text-sm md:py-2 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-800 font-medium text-slate-100'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="w-4 text-center opacity-80">{i.icon}</span>
|
||||||
|
<span className="flex-1">{i.label}</span>
|
||||||
|
{i.badge && alerts.counts.firing > 0 && (
|
||||||
|
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
|
||||||
|
{alerts.counts.firing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<p className="px-3 py-4 text-xs leading-relaxed text-slate-600">
|
||||||
|
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<Outlet context={alerts} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
295
web/src/components/ui.tsx
Normal file
295
web/src/components/ui.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Примітиви інтерфейсу.
|
||||||
|
*
|
||||||
|
* Зібрані в одному файлі навмисно: їх десяток, вони дрібні, і поки
|
||||||
|
* дизайн-система вміщається на екран, окрема тека на кожну кнопку
|
||||||
|
* коштує більше, ніж дає.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end gap-x-4 gap-y-2 border-b border-slate-800 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-slate-100">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-0.5 text-xs text-slate-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonKind = 'primary' | 'default' | 'danger' | 'ghost'
|
||||||
|
|
||||||
|
const buttonKind: Record<ButtonKind, string> = {
|
||||||
|
primary: 'border-sky-700 bg-sky-700 text-white hover:bg-sky-600',
|
||||||
|
default: 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700',
|
||||||
|
danger: 'border-red-900 bg-red-950/60 text-red-200 hover:bg-red-900/60',
|
||||||
|
ghost: 'border-transparent bg-transparent text-slate-400 hover:bg-slate-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
kind = 'default',
|
||||||
|
disabled,
|
||||||
|
type = 'button',
|
||||||
|
title,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
kind?: ButtonKind
|
||||||
|
disabled?: boolean
|
||||||
|
type?: 'button' | 'submit'
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
// min-h під палець: ці ж кнопки натискають з телефона.
|
||||||
|
className={`min-h-[38px] rounded border px-3 py-1.5 text-sm transition-colors
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-40
|
||||||
|
md:min-h-0 ${buttonKind[kind]} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-slate-400">{label}</span>
|
||||||
|
{children}
|
||||||
|
{hint && <span className="mt-1 block text-[11px] leading-snug text-slate-600">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
'w-full rounded border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-100 ' +
|
||||||
|
'outline-none placeholder:text-slate-600 focus:border-sky-600'
|
||||||
|
|
||||||
|
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-slate-800 bg-slate-900/60 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string
|
||||||
|
header: string
|
||||||
|
cell: (row: T) => ReactNode
|
||||||
|
/** Другорядні колонки ховаються на телефоні, щоб картка лишалась короткою. */
|
||||||
|
hideOnMobile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таблиця, що на телефоні перестає бути таблицею.
|
||||||
|
*
|
||||||
|
* Горизонтальний скрол на 375 px — це спосіб зробити дані формально
|
||||||
|
* присутніми й фактично нечитабельними, тому вузькі екрани отримують
|
||||||
|
* картки, а не звужені колонки.
|
||||||
|
*/
|
||||||
|
export function DataTable<T>({
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
rowKey,
|
||||||
|
empty = 'Порожньо',
|
||||||
|
onRowClick,
|
||||||
|
}: {
|
||||||
|
rows: T[]
|
||||||
|
columns: Column<T>[]
|
||||||
|
rowKey: (row: T) => string
|
||||||
|
empty?: string
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <p className="px-4 py-10 text-center text-sm text-slate-600">{empty}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<table className="hidden w-full text-sm md:table">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 text-left">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th
|
||||||
|
key={c.key}
|
||||||
|
className="px-4 py-2 text-xs font-medium uppercase tracking-wide text-slate-500"
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/70">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={rowKey(r)}
|
||||||
|
className={onRowClick ? 'cursor-pointer hover:bg-slate-800/40' : ''}
|
||||||
|
onClick={onRowClick ? () => onRowClick(r) : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<td key={c.key} className="px-4 py-2 align-middle text-slate-300">
|
||||||
|
{c.cell(r)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<ul className="divide-y divide-slate-800/70 md:hidden">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const shown = columns.filter((c) => !c.hideOnMobile)
|
||||||
|
const [first, ...rest] = shown
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={rowKey(r)}
|
||||||
|
className="px-4 py-3"
|
||||||
|
onClick={onRowClick ? () => onRowClick(r) : undefined}
|
||||||
|
>
|
||||||
|
{first && (
|
||||||
|
<div className="mb-1 text-sm font-medium text-slate-100">{first.cell(r)}</div>
|
||||||
|
)}
|
||||||
|
{rest.map((c) => (
|
||||||
|
<div key={c.key} className="flex justify-between gap-3 py-0.5 text-xs">
|
||||||
|
<span className="text-slate-500">{c.header}</span>
|
||||||
|
<span className="min-w-0 truncate text-slate-300">{c.cell(r)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTone: Record<string, string> = {
|
||||||
|
up: 'bg-emerald-500/15 text-emerald-300 border-emerald-800',
|
||||||
|
online: 'bg-emerald-500/15 text-emerald-300 border-emerald-800',
|
||||||
|
down: 'bg-red-500/15 text-red-300 border-red-900',
|
||||||
|
offline: 'bg-slate-700/40 text-slate-400 border-slate-700',
|
||||||
|
warning: 'bg-amber-500/15 text-amber-300 border-amber-800',
|
||||||
|
maintenance: 'bg-sky-500/15 text-sky-300 border-sky-800',
|
||||||
|
unknown: 'bg-slate-700/40 text-slate-400 border-slate-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]
|
||||||
|
${statusTone[status] ?? statusTone.unknown}`}
|
||||||
|
>
|
||||||
|
{label ?? status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40
|
||||||
|
${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform
|
||||||
|
${checked ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 md:items-center md:p-4">
|
||||||
|
<div
|
||||||
|
className="max-h-[90dvh] w-full overflow-y-auto rounded-t-xl border border-slate-800
|
||||||
|
bg-slate-900 md:max-w-lg md:rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center border-b border-slate-800 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-100">{title}</h2>
|
||||||
|
<button
|
||||||
|
className="ml-auto px-2 text-slate-500 hover:text-slate-300"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрити"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorNote({ children }: { children?: ReactNode }) {
|
||||||
|
if (!children) return null
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-red-900/60 bg-red-950/50 px-3 py-2 text-xs text-red-200">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ label = 'Завантаження…' }: { label?: string }) {
|
||||||
|
return <p className="px-4 py-10 text-center text-sm text-slate-600">{label}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скільки минуло. Прочерк замість вигаданих чисел для порожніх дат. */
|
||||||
|
export function ago(iso?: string): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const ms = new Date(iso).getTime()
|
||||||
|
if (!Number.isFinite(ms)) return '—'
|
||||||
|
const sec = Math.max(0, Math.round((Date.now() - ms) / 1000))
|
||||||
|
if (sec < 60) return `${sec} с`
|
||||||
|
if (sec < 3600) return `${Math.round(sec / 60)} хв`
|
||||||
|
if (sec < 86400) return `${Math.round(sec / 3600)} год`
|
||||||
|
return `${Math.round(sec / 86400)} дн`
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { liveEvents } from '../api/ws'
|
|
||||||
import { session } from '../api/session'
|
import { session } from '../api/session'
|
||||||
|
import { isAlertEvent, useLiveRefresh } from './useLiveRefresh'
|
||||||
import type { Alert, AlertCounts } from '../types'
|
import type { Alert, AlertCounts } from '../types'
|
||||||
|
|
||||||
const emptyCounts: AlertCounts = {
|
const emptyCounts: AlertCounts = {
|
||||||
|
|
@ -60,24 +60,9 @@ export function useAlerts(): LiveAlerts {
|
||||||
void reload()
|
void reload()
|
||||||
}, [reload])
|
}, [reload])
|
||||||
|
|
||||||
// Злиття сплеску подій в одне перечитування.
|
useLiveRefresh(isAlertEvent, () => {
|
||||||
//
|
if (canRead) void reload()
|
||||||
// Падіння комутатора піднімає десятки алертів за один тік движка, і
|
})
|
||||||
// кожен приходить окремим повідомленням. Без затримки це десятки
|
|
||||||
// однакових запитів підряд саме тоді, коли сервер найзавантаженіший.
|
|
||||||
const timer = useRef<number | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canRead) return
|
|
||||||
return liveEvents.subscribe((msg) => {
|
|
||||||
if (!msg.type?.startsWith('alert.')) return
|
|
||||||
if (timer.current) window.clearTimeout(timer.current)
|
|
||||||
timer.current = window.setTimeout(() => void reload(), 400)
|
|
||||||
})
|
|
||||||
}, [reload, canRead])
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (timer.current) window.clearTimeout(timer.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const ack = useCallback(
|
const ack = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||||
import { ApiError, api } from '../api/client'
|
import { ApiError, api } from '../api/client'
|
||||||
import { LiveConnection, type ConnectionState } from '../api/ws'
|
import { live, liveEvents, type ConnectionState } from '../api/ws'
|
||||||
import type { MapPatch, MapState, WsMessage } from '../types'
|
import type { MapPatch, MapState, WsMessage } from '../types'
|
||||||
|
|
||||||
export interface LiveMap {
|
export interface LiveMap {
|
||||||
|
|
@ -29,10 +29,11 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
||||||
const [state, setState] = useState<MapState | null>(null)
|
const [state, setState] = useState<MapState | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [connection, setConnection] = useState<ConnectionState>('connecting')
|
|
||||||
const [lastEventAt, setLastEventAt] = useState<number | null>(null)
|
const [lastEventAt, setLastEventAt] = useState<number | null>(null)
|
||||||
|
|
||||||
const conn = useRef<LiveConnection | null>(null)
|
// Стан з'єднання читається з самої оболонки: воно спільне, і копія
|
||||||
|
// тут розходилася б із нею після кожного реконекту.
|
||||||
|
const connection = useSyncExternalStore(live.subscribeState, live.getState) as ConnectionState
|
||||||
// Ревізія потрібна в обробнику WebSocket, який створюється один раз;
|
// Ревізія потрібна в обробнику WebSocket, який створюється один раз;
|
||||||
// читати її зі state означало б перепідключатись на кожну правку.
|
// читати її зі state означало б перепідключатись на кожну правку.
|
||||||
const revision = useRef<number>(0)
|
const revision = useRef<number>(0)
|
||||||
|
|
@ -58,11 +59,12 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
||||||
if (mapID) void reload()
|
if (mapID) void reload()
|
||||||
}, [mapID, reload])
|
}, [mapID, reload])
|
||||||
|
|
||||||
// Живі оновлення.
|
// Живі оновлення. З'єднанням володіє оболонка застосунку — тут ми
|
||||||
useEffect(() => {
|
// лише слухаємо спільну шину. Тримати сокет усередині мапи означало б
|
||||||
const c = new LiveConnection({
|
// не мати живих оновлень на жодній іншій сторінці.
|
||||||
onState: setConnection,
|
useEffect(
|
||||||
onMessage: (msg: WsMessage) => {
|
() =>
|
||||||
|
liveEvents.subscribe((msg: WsMessage) => {
|
||||||
setLastEventAt(Date.now())
|
setLastEventAt(Date.now())
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
|
|
@ -106,19 +108,12 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
||||||
if (msg.revision !== revision.current) void reload()
|
if (msg.revision !== revision.current) void reload()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
})
|
[reload],
|
||||||
|
)
|
||||||
conn.current = c
|
|
||||||
c.connect()
|
|
||||||
return () => {
|
|
||||||
c.close()
|
|
||||||
conn.current = null
|
|
||||||
}
|
|
||||||
}, [reload])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapID) conn.current?.subscribe(mapID)
|
if (mapID) live.subscribe(mapID)
|
||||||
}, [mapID, state?.id])
|
}, [mapID, state?.id])
|
||||||
|
|
||||||
const patch = useCallback(
|
const patch = useCallback(
|
||||||
|
|
|
||||||
44
web/src/hooks/useLiveRefresh.ts
Normal file
44
web/src/hooks/useLiveRefresh.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { liveEvents } from '../api/ws'
|
||||||
|
import type { WsMessage } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перечитування у відповідь на живі події — зі злиттям сплеску.
|
||||||
|
*
|
||||||
|
* Падіння комутатора піднімає десятки алертів за один тік движка, і
|
||||||
|
* кожен приходить окремим повідомленням. Без затримки це десятки
|
||||||
|
* однакових запитів підряд саме тоді, коли сервер найзавантаженіший, —
|
||||||
|
* тобто клієнт додає навантаження рівно в момент аварії.
|
||||||
|
*
|
||||||
|
* @param match які повідомлення цікавлять
|
||||||
|
* @param reload що робити (має бути стабільним — useCallback)
|
||||||
|
* @param delay вікно злиття
|
||||||
|
*/
|
||||||
|
export function useLiveRefresh(
|
||||||
|
match: (msg: WsMessage) => boolean,
|
||||||
|
reload: () => void | Promise<void>,
|
||||||
|
delay = 400,
|
||||||
|
) {
|
||||||
|
const timer = useRef<number | null>(null)
|
||||||
|
// Через ref, щоб зміна reload не перепідписувала з'єднання.
|
||||||
|
const fn = useRef(reload)
|
||||||
|
fn.current = reload
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const off = liveEvents.subscribe((msg) => {
|
||||||
|
if (!match(msg)) return
|
||||||
|
if (timer.current) window.clearTimeout(timer.current)
|
||||||
|
timer.current = window.setTimeout(() => void fn.current(), delay)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
off()
|
||||||
|
if (timer.current) window.clearTimeout(timer.current)
|
||||||
|
}
|
||||||
|
// match задається інлайн-функцією на кожен рендер, тому в залежності
|
||||||
|
// не йде: підписка має жити стільки ж, скільки компонент.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [delay])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Найчастіший фільтр: усе, що стосується алертів. */
|
||||||
|
export const isAlertEvent = (msg: WsMessage) => msg.type?.startsWith('alert.') ?? false
|
||||||
101
web/src/pages/AgentsPage.tsx
Normal file
101
web/src/pages/AgentsPage.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { Card, ErrorNote, PageHeader, Spinner, StatusBadge, ago } from '../components/ui'
|
||||||
|
import type { AgentSummary } from '../types'
|
||||||
|
|
||||||
|
export function AgentsPage() {
|
||||||
|
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.listAgents()
|
||||||
|
.then(setAgents)
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const online = agents.filter((a) => a.status === 'online').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Зонди"
|
||||||
|
subtitle={loading ? undefined : `${online} з ${agents.length} на зв’язку`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||||
|
{error && <ErrorNote>{error}</ErrorNote>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : agents.length === 0 ? (
|
||||||
|
<p className="px-4 py-16 text-center text-sm text-slate-600">
|
||||||
|
Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі
|
||||||
|
клієнта не потрібно.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<Card className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-medium text-slate-100">{a.name}</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<StatusBadge
|
||||||
|
status={a.status}
|
||||||
|
label={a.status === 'online' ? 'на зв’язку' : 'офлайн'}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-2.5 space-y-1 text-xs">
|
||||||
|
<Pair k="Платформа" v={`${a.os ?? '?'}/${a.arch ?? '?'}`} />
|
||||||
|
<Pair k="Версія" v={a.version ?? '—'} />
|
||||||
|
<Pair k="Пристроїв" v={String(a.device_count)} />
|
||||||
|
<Pair k="Останній пульс" v={ago(a.last_heartbeat_at)} />
|
||||||
|
{typeof a.health?.rss_bytes === 'number' && (
|
||||||
|
<Pair
|
||||||
|
k="Памʼять"
|
||||||
|
v={`${((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1">
|
||||||
|
{a.enabled_modules.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m}
|
||||||
|
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{a.enabled_modules.length === 0 && (
|
||||||
|
<span className="text-[11px] text-slate-600">модулі не активовані</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs leading-relaxed text-slate-600">
|
||||||
|
Керування набором модулів із інтерфейсу ще не реалізоване — транспорт для цього
|
||||||
|
готовий, але сторінка налаштувань зонда попереду.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pair({ k, v }: { k: string; v: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<dt className="text-slate-500">{k}</dt>
|
||||||
|
<dd className="tabular-nums text-slate-300">{v}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
181
web/src/pages/AlertsPage.tsx
Normal file
181
web/src/pages/AlertsPage.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useOutletContext } from 'react-router-dom'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { Button, Card, ErrorNote, PageHeader, Spinner, ago } from '../components/ui'
|
||||||
|
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||||
|
import type { Alert, Severity } from '../types'
|
||||||
|
|
||||||
|
const severityStyle: Record<Severity, { dot: string; text: string; label: string; rank: number }> = {
|
||||||
|
disaster: { dot: 'bg-red-500', text: 'text-red-300', label: 'аварія', rank: 4 },
|
||||||
|
high: { dot: 'bg-red-400', text: 'text-red-300', label: 'висока', rank: 3 },
|
||||||
|
average: { dot: 'bg-orange-400', text: 'text-orange-300', label: 'середня', rank: 2 },
|
||||||
|
warning: { dot: 'bg-amber-400', text: 'text-amber-300', label: 'увага', rank: 1 },
|
||||||
|
info: { dot: 'bg-sky-400', text: 'text-sky-300', label: 'інфо', rank: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppressLabel: Record<string, string> = {
|
||||||
|
maintenance: 'вікно обслуговування',
|
||||||
|
mute: 'заглушено вручну',
|
||||||
|
topology: 'наслідок іншої аварії',
|
||||||
|
dependency: 'залежність',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filter = 'active' | 'acked' | 'suppressed' | 'all'
|
||||||
|
|
||||||
|
const filterLabel: Record<Filter, string> = {
|
||||||
|
active: 'Активні',
|
||||||
|
acked: 'Прийняті',
|
||||||
|
suppressed: 'Придушені',
|
||||||
|
all: 'Усі',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsPage() {
|
||||||
|
const live = useOutletContext<LiveAlerts>()
|
||||||
|
const [filter, setFilter] = useState<Filter>('active')
|
||||||
|
const canAck = session.can('alerts:ack')
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const byFilter = live.alerts.filter((a) => {
|
||||||
|
if (filter === 'all') return true
|
||||||
|
if (filter === 'active') return a.state === 'firing'
|
||||||
|
if (filter === 'acked') return a.state === 'acknowledged'
|
||||||
|
return a.state === 'suppressed'
|
||||||
|
})
|
||||||
|
// Найгостріше — вгорі; за рівної гостроти новіше вище.
|
||||||
|
return byFilter.sort((x, y) => {
|
||||||
|
const d = severityStyle[y.severity].rank - severityStyle[x.severity].rank
|
||||||
|
return d !== 0 ? d : y.started_at.localeCompare(x.started_at)
|
||||||
|
})
|
||||||
|
}, [live.alerts, filter])
|
||||||
|
|
||||||
|
const counts: Record<Filter, number> = {
|
||||||
|
active: live.counts.firing,
|
||||||
|
acked: live.counts.acknowledged,
|
||||||
|
suppressed: live.counts.suppressed,
|
||||||
|
all: live.alerts.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Алерти"
|
||||||
|
subtitle={
|
||||||
|
live.counts.disaster + live.counts.high > 0
|
||||||
|
? `${live.counts.disaster + live.counts.high} гострих потребують уваги`
|
||||||
|
: live.counts.firing === 0
|
||||||
|
? 'Активних немає'
|
||||||
|
: `${live.counts.firing} активних`
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(Object.keys(filterLabel) as Filter[]).map((f) => (
|
||||||
|
<Button key={f} kind={filter === f ? 'primary' : 'default'} onClick={() => setFilter(f)}>
|
||||||
|
{filterLabel[f]}
|
||||||
|
<span className="ml-1.5 tabular-nums opacity-70">{counts[f]}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||||
|
{live.error && <ErrorNote>{live.error}</ErrorNote>}
|
||||||
|
|
||||||
|
{live.loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-16 text-center text-sm text-slate-600">
|
||||||
|
{filter === 'active' && live.alerts.length > 0
|
||||||
|
? 'Активних немає — усе або прийнято, або придушено'
|
||||||
|
: 'Порожньо'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rows.map((a) => (
|
||||||
|
<AlertCard
|
||||||
|
key={a.id}
|
||||||
|
alert={a}
|
||||||
|
canAck={canAck}
|
||||||
|
onAck={() => void live.ack(a.id)}
|
||||||
|
onClose={() => void live.close(a.id)}
|
||||||
|
onMute={a.device_id ? () => void live.mute(a.device_id!, 60) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertCard({
|
||||||
|
alert: a,
|
||||||
|
canAck,
|
||||||
|
onAck,
|
||||||
|
onClose,
|
||||||
|
onMute,
|
||||||
|
}: {
|
||||||
|
alert: Alert
|
||||||
|
canAck: boolean
|
||||||
|
onAck: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onMute?: () => void
|
||||||
|
}) {
|
||||||
|
const s = severityStyle[a.severity] ?? severityStyle.info
|
||||||
|
const acked = a.state === 'acknowledged'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Card className={`p-3 ${a.state === 'suppressed' ? 'opacity-60' : ''}`}>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span className={`text-[10px] uppercase tracking-wide ${s.text}`}>{s.label}</span>
|
||||||
|
<span className="text-[10px] tabular-nums text-slate-600">
|
||||||
|
триває {ago(a.started_at)}
|
||||||
|
</span>
|
||||||
|
{a.rule_name && <span className="text-[10px] text-slate-600">· {a.rule_name}</span>}
|
||||||
|
{acked && (
|
||||||
|
<span className="text-[10px] text-emerald-500" title={a.acked_by_email}>
|
||||||
|
✓ прийняв {a.acked_by_email ?? '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{a.notify_count > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-600">· сповіщень {a.notify_count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-0.5 text-sm font-medium text-slate-100">{a.title}</div>
|
||||||
|
{a.message && <p className="mt-0.5 text-xs leading-snug text-slate-400">{a.message}</p>}
|
||||||
|
|
||||||
|
{a.suppressed_by && (
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAck && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{!acked && (
|
||||||
|
<Button kind="default" onClick={onAck} title="Зупинити ескалацію — я вже дивлюсь">
|
||||||
|
Прийняти
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onMute && (
|
||||||
|
<Button onClick={onMute} title="Не турбувати цим пристроєм годину">
|
||||||
|
Заглушити 1 год
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button kind="ghost" onClick={onClose} title="Закрити вручну">
|
||||||
|
Закрити
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
379
web/src/pages/ChannelsPage.tsx
Normal file
379
web/src/pages/ChannelsPage.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
ErrorNote,
|
||||||
|
Field,
|
||||||
|
Modal,
|
||||||
|
PageHeader,
|
||||||
|
Spinner,
|
||||||
|
StatusBadge,
|
||||||
|
inputClass,
|
||||||
|
} from '../components/ui'
|
||||||
|
import type { Channel, Severity } from '../types'
|
||||||
|
|
||||||
|
const kindLabel: Record<string, string> = {
|
||||||
|
telegram: 'Telegram',
|
||||||
|
webhook: 'Webhook',
|
||||||
|
email: 'Email',
|
||||||
|
slack: 'Slack',
|
||||||
|
discord: 'Discord',
|
||||||
|
mattermost: 'Mattermost',
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityLabel: Record<Severity, string> = {
|
||||||
|
info: 'Інформація і вище',
|
||||||
|
warning: 'Увага і вище',
|
||||||
|
average: 'Середня і вище',
|
||||||
|
high: 'Висока і вище',
|
||||||
|
disaster: 'Лише аварії',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelsPage() {
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [testing, setTesting] = useState<string | null>(null)
|
||||||
|
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canWrite = session.can('alerts:write')
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
try {
|
||||||
|
setChannels(await api.listChannels())
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Канали сповіщень"
|
||||||
|
subtitle="Поки каналів немає, алерти видно лише в інтерфейсі"
|
||||||
|
actions={
|
||||||
|
canWrite && (
|
||||||
|
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||||
|
+ Канал
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
{testResult.ok ? (
|
||||||
|
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
|
||||||
|
Пробне повідомлення надіслано. Якщо воно не дійшло — проблема на боці отримувача.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ErrorNote>Не вдалося: {testResult.error}</ErrorNote>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
rows={channels}
|
||||||
|
rowKey={(c) => c.id}
|
||||||
|
empty="Каналів ще немає"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Назва',
|
||||||
|
cell: (c) => <span className="font-medium text-slate-100">{c.name}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'kind', header: 'Тип', cell: (c) => kindLabel[c.kind] ?? c.kind },
|
||||||
|
{
|
||||||
|
key: 'sev',
|
||||||
|
header: 'Поріг',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (c) => severityLabel[c.min_severity as Severity] ?? c.min_severity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'secret',
|
||||||
|
header: 'Секрет',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (c) =>
|
||||||
|
c.has_secret ? (
|
||||||
|
<span className="text-slate-500">збережено</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
header: 'Стан',
|
||||||
|
cell: (c) => (
|
||||||
|
<StatusBadge
|
||||||
|
status={c.enabled ? 'up' : 'offline'}
|
||||||
|
label={c.enabled ? 'увімкнено' : 'вимкнено'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'act',
|
||||||
|
header: 'Дії',
|
||||||
|
cell: (c) =>
|
||||||
|
canWrite && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
disabled={testing === c.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setTesting(c.id)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
const r = await api.testChannel(c.id)
|
||||||
|
setTestResult({ id: c.id, ok: r.ok, error: r.error })
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult({
|
||||||
|
id: c.id,
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setTesting(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testing === c.id ? '…' : 'Перевірити'}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||||
|
title="Видалити канал разом із його секретом"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.deleteChannel(c.id)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<ChannelForm
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setCreating(false)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
|
||||||
|
const [kind, setKind] = useState('telegram')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [minSeverity, setMinSeverity] = useState<Severity>('warning')
|
||||||
|
const [secret, setSecret] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Telegram
|
||||||
|
const [chatID, setChatID] = useState('')
|
||||||
|
// Webhook і похідні
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
// Email
|
||||||
|
const [host, setHost] = useState('')
|
||||||
|
const [port, setPort] = useState('587')
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [user, setUser] = useState('')
|
||||||
|
|
||||||
|
const isWebhookish = ['webhook', 'slack', 'discord', 'mattermost'].includes(kind)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
let config: Record<string, unknown> = {}
|
||||||
|
if (kind === 'telegram') config = { chat_id: chatID }
|
||||||
|
else if (isWebhookish) config = { url, format: kind === 'webhook' ? 'netpulse' : 'text' }
|
||||||
|
else if (kind === 'email')
|
||||||
|
config = {
|
||||||
|
host,
|
||||||
|
port: Number(port) || 587,
|
||||||
|
from,
|
||||||
|
to: to
|
||||||
|
.split(',')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
user,
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.createChannel({
|
||||||
|
kind,
|
||||||
|
name: name.trim(),
|
||||||
|
config,
|
||||||
|
min_severity: minSeverity,
|
||||||
|
secret,
|
||||||
|
})
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Новий канал" onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Тип">
|
||||||
|
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||||
|
{Object.keys(kindLabel).map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{kindLabel[k]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Надсилати від">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={minSeverity}
|
||||||
|
onChange={(e) => setMinSeverity(e.target.value as Severity)}
|
||||||
|
>
|
||||||
|
{(Object.keys(severityLabel) as Severity[]).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{severityLabel[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Назва">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Черговий інженер"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{kind === 'telegram' && (
|
||||||
|
<>
|
||||||
|
<Field label="Chat ID" hint="Для груп — від’ємне число, наприклад -1001234567890">
|
||||||
|
<input className={inputClass} value={chatID} onChange={(e) => setChatID(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Токен бота" hint="Зберігається зашифрованим і більше ніколи не показується">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isWebhookish && (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="URL"
|
||||||
|
hint="Адреси у внутрішній мережі сервер відхиляє, якщо адміністратор не дозволив їх явно"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="https://hooks.example.com/…"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Токен (необов’язково)" hint="Піде заголовком Authorization: Bearer">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'email' && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Field label="SMTP-сервер">
|
||||||
|
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Порт">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Від кого">
|
||||||
|
<input className={inputClass} value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Кому" hint="Кілька адрес — через кому">
|
||||||
|
<input className={inputClass} value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Логін">
|
||||||
|
<input className={inputClass} value={user} onChange={(e) => setUser(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Пароль">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button onClick={onClose}>Скасувати</Button>
|
||||||
|
<Button kind="primary" disabled={busy || !name.trim()} onClick={() => void submit()}>
|
||||||
|
{busy ? 'Збереження…' : 'Створити'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
web/src/pages/DevicesPage.tsx
Normal file
302
web/src/pages/DevicesPage.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useOutletContext } from 'react-router-dom'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { liveEvents } from '../api/ws'
|
||||||
|
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
ErrorNote,
|
||||||
|
Modal,
|
||||||
|
PageHeader,
|
||||||
|
Spinner,
|
||||||
|
StatusBadge,
|
||||||
|
ago,
|
||||||
|
inputClass,
|
||||||
|
} from '../components/ui'
|
||||||
|
import type { Alert, DeviceSummary } from '../types'
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
up: 'на зв’язку',
|
||||||
|
down: 'недоступний',
|
||||||
|
warning: 'проблеми',
|
||||||
|
maintenance: 'обслуговування',
|
||||||
|
unknown: 'невідомо',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevicesPage() {
|
||||||
|
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
|
||||||
|
// тримає їх живими для лічильника в шапці, і другий такий самий
|
||||||
|
// опитувач лише подвоїв би навантаження на сервер під час аварії.
|
||||||
|
const live = useOutletContext<LiveAlerts>()
|
||||||
|
|
||||||
|
const [devices, setDevices] = useState<DeviceSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [onlyProblems, setOnlyProblems] = useState(false)
|
||||||
|
const [detail, setDetail] = useState<DeviceSummary | null>(null)
|
||||||
|
|
||||||
|
const canMute = session.can('alerts:ack')
|
||||||
|
const alerts = live.alerts
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDevices(await api.listDevices())
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
// Статус пристрою міняється не тут, а на зонді. Без підписки список
|
||||||
|
// показував би стан на момент відкриття сторінки й тихо старів.
|
||||||
|
//
|
||||||
|
// Зміна застосовується точково: це один рядок, і перечитувати заради
|
||||||
|
// нього весь список немає сенсу.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
liveEvents.subscribe((msg) => {
|
||||||
|
if (msg.type !== 'device.status') return
|
||||||
|
setDevices((prev) =>
|
||||||
|
prev.map((d) =>
|
||||||
|
d.id === msg.payload.device_id ? { ...d, status: msg.payload.status } : d,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const alertsByDevice = useMemo(() => {
|
||||||
|
const m = new Map<string, Alert[]>()
|
||||||
|
for (const a of alerts) {
|
||||||
|
if (!a.device_id) continue
|
||||||
|
const list = m.get(a.device_id) ?? []
|
||||||
|
list.push(a)
|
||||||
|
m.set(a.device_id, list)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}, [alerts])
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return devices.filter((d) => {
|
||||||
|
if (onlyProblems && d.status !== 'down' && d.status !== 'warning') return false
|
||||||
|
if (!q) return true
|
||||||
|
return (
|
||||||
|
d.name.toLowerCase().includes(q) ||
|
||||||
|
(d.address ?? '').toLowerCase().includes(q) ||
|
||||||
|
(d.vendor ?? '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [devices, query, onlyProblems])
|
||||||
|
|
||||||
|
const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Пристрої"
|
||||||
|
subtitle={
|
||||||
|
loading
|
||||||
|
? undefined
|
||||||
|
: `${devices.length} усього · ${problems} з проблемами · ${
|
||||||
|
devices.filter((d) => !d.enabled).length
|
||||||
|
} вимкнено`
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className={`${inputClass} w-40 md:w-56`}
|
||||||
|
placeholder="пошук за іменем, IP…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
kind={onlyProblems ? 'primary' : 'default'}
|
||||||
|
onClick={() => setOnlyProblems((v) => !v)}
|
||||||
|
title="Показати лише недоступні й проблемні"
|
||||||
|
>
|
||||||
|
Тільки проблеми
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(d) => d.id}
|
||||||
|
onRowClick={setDetail}
|
||||||
|
empty={
|
||||||
|
devices.length === 0
|
||||||
|
? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт'
|
||||||
|
: 'Нічого не знайдено за цим фільтром'
|
||||||
|
}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Пристрій',
|
||||||
|
cell: (d) => (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-medium text-slate-100">{d.name}</span>
|
||||||
|
{(alertsByDevice.get(d.id)?.length ?? 0) > 0 && (
|
||||||
|
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
|
||||||
|
{alertsByDevice.get(d.id)!.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Стан',
|
||||||
|
cell: (d) => (
|
||||||
|
<StatusBadge status={d.status || 'unknown'} label={statusLabel[d.status || 'unknown']} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'address', header: 'Адреса', cell: (d) => d.address ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
header: 'Тип',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (d) => (
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{d.kind}
|
||||||
|
{d.vendor ? ` · ${d.vendor}` : ''}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ifs',
|
||||||
|
header: 'Інтерфейси',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (d) => <span className="tabular-nums">{d.interface_count}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'seen',
|
||||||
|
header: 'Востаннє',
|
||||||
|
cell: (d) => <span className="tabular-nums text-slate-400">{ago(d.last_seen_at)}</span>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail && (
|
||||||
|
<DeviceDetail
|
||||||
|
device={detail}
|
||||||
|
alerts={alertsByDevice.get(detail.id) ?? []}
|
||||||
|
canMute={canMute}
|
||||||
|
onMute={async (minutes) => {
|
||||||
|
await live.mute(detail.id, minutes)
|
||||||
|
}}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceDetail({
|
||||||
|
device,
|
||||||
|
alerts,
|
||||||
|
canMute,
|
||||||
|
onMute,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
device: DeviceSummary
|
||||||
|
alerts: Alert[]
|
||||||
|
canMute: boolean
|
||||||
|
onMute: (minutes: number) => Promise<void>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={device.name} onClose={onClose}>
|
||||||
|
<dl className="space-y-1.5 text-sm">
|
||||||
|
<Pair k="Стан" v={<StatusBadge status={device.status || 'unknown'} label={statusLabel[device.status || 'unknown']} />} />
|
||||||
|
<Pair k="Адреса" v={device.address ?? '—'} />
|
||||||
|
<Pair k="Тип" v={device.kind} />
|
||||||
|
<Pair k="Виробник" v={device.vendor ?? '—'} />
|
||||||
|
<Pair k="Інтерфейсів" v={String(device.interface_count)} />
|
||||||
|
<Pair k="Востаннє на зв’язку" v={ago(device.last_seen_at)} />
|
||||||
|
<Pair k="Опитується" v={device.enabled ? 'так' : 'ні'} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3 className="mb-2 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Активні алерти
|
||||||
|
</h3>
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-600">Немає</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{alerts.map((a) => (
|
||||||
|
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
|
||||||
|
<span className="text-slate-100">{a.title}</span>
|
||||||
|
<p className="mt-0.5 text-slate-500">{a.message}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canMute && (
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{[60, 240, 1440].map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
await onMute(m)
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Заглушити на {m >= 1440 ? '24 год' : m >= 240 ? '4 год' : '1 год'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pair({ k, v }: { k: string; v: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-slate-800/60 pb-1.5">
|
||||||
|
<dt className="text-xs text-slate-500">{k}</dt>
|
||||||
|
<dd className="text-slate-200">{v}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
web/src/pages/MapPage.tsx
Normal file
182
web/src/pages/MapPage.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { useLiveMap } from '../hooks/useLiveMap'
|
||||||
|
import { MapCanvas } from '../components/MapCanvas'
|
||||||
|
import { Button, ErrorNote } from '../components/ui'
|
||||||
|
import type { MapSummary } from '../types'
|
||||||
|
|
||||||
|
export function MapPage() {
|
||||||
|
const [maps, setMaps] = useState<MapSummary[]>([])
|
||||||
|
const [mapID, setMapID] = useState<string | null>(null)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [listError, setListError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const live = useLiveMap(mapID)
|
||||||
|
const canWrite = session.can('maps:write')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.listMaps()
|
||||||
|
.then((m) => {
|
||||||
|
setMaps(m)
|
||||||
|
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
|
||||||
|
})
|
||||||
|
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const nodes = live.state?.nodes ?? []
|
||||||
|
return {
|
||||||
|
total: nodes.length,
|
||||||
|
up: nodes.filter((n) => n.status === 'up').length,
|
||||||
|
down: nodes.filter((n) => n.status === 'down').length,
|
||||||
|
}
|
||||||
|
}, [live.state])
|
||||||
|
|
||||||
|
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-slate-800 px-3 py-2">
|
||||||
|
<select
|
||||||
|
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:flex-none"
|
||||||
|
value={mapID ?? ''}
|
||||||
|
onChange={(e) => setMapID(e.target.value || null)}
|
||||||
|
>
|
||||||
|
{maps.length === 0 && <option value="">мап немає</option>}
|
||||||
|
{maps.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.node_count}/{m.edge_count})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={!mapID}
|
||||||
|
onClick={() => void live.build()}
|
||||||
|
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
|
||||||
|
>
|
||||||
|
<span className="hidden lg:inline">Добудувати з топології</span>
|
||||||
|
<span className="lg:hidden">Добудувати</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!live.canUndo}
|
||||||
|
onClick={() => void live.undo()}
|
||||||
|
title="Відкотити останню зміну полотна"
|
||||||
|
>
|
||||||
|
↶<span className="ml-1 hidden lg:inline">Відкотити</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
|
||||||
|
<span className="hidden sm:inline tabular-nums">
|
||||||
|
{counts.up}/{counts.total} на звʼязку
|
||||||
|
{counts.down > 0 && <span className="ml-1 text-red-400">· {counts.down} впало</span>}
|
||||||
|
</span>
|
||||||
|
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
|
||||||
|
{live.state && (
|
||||||
|
<span className="hidden tabular-nums md:inline">ревізія {live.state.revision}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(live.error || listError) && (
|
||||||
|
<div className="shrink-0 px-3 py-2">
|
||||||
|
<ErrorNote>{live.error ?? listError}</ErrorNote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
|
{live.state ? (
|
||||||
|
<MapCanvas
|
||||||
|
state={live.state}
|
||||||
|
onPatch={live.patch}
|
||||||
|
onSelect={setSelected}
|
||||||
|
readOnly={!canWrite}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
|
||||||
|
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node && (
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
|
||||||
|
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-slate-100">{node.label}</span>
|
||||||
|
<button
|
||||||
|
className="px-2 text-slate-500 hover:text-slate-300"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
|
||||||
|
<Row k="статус" v={node.status || 'unknown'} />
|
||||||
|
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
|
||||||
|
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
||||||
|
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v }: { k: string; v: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between gap-2 md:w-full">
|
||||||
|
<span className="text-slate-500">{k}</span>
|
||||||
|
<span className="tabular-nums text-slate-300">{v}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стан з'єднання показується завжди й помітно.
|
||||||
|
*
|
||||||
|
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
|
||||||
|
* само, як мапа, де все гаразд, — і це найнебезпечніший стан моніторингу.
|
||||||
|
*/
|
||||||
|
function ConnectionBadge({
|
||||||
|
state,
|
||||||
|
lastEventAt,
|
||||||
|
}: {
|
||||||
|
state: 'connecting' | 'online' | 'offline'
|
||||||
|
lastEventAt: number | null
|
||||||
|
}) {
|
||||||
|
const [, force] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => force((n) => n + 1), 1000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const label =
|
||||||
|
state === 'online' ? 'наживо' : state === 'connecting' ? 'підключення…' : "немає зв'язку"
|
||||||
|
const color =
|
||||||
|
state === 'online'
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: state === 'connecting'
|
||||||
|
? 'bg-amber-400'
|
||||||
|
: 'bg-red-500 animate-pulse'
|
||||||
|
|
||||||
|
const secs = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 text-slate-400">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
{secs !== null && state === 'online' && (
|
||||||
|
<span className="hidden tabular-nums text-slate-600 lg:inline">{secs} с тому</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
web/src/pages/ProfilePage.tsx
Normal file
140
web/src/pages/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { Button, Card, ErrorNote, Field, PageHeader, inputClass } from '../components/ui'
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const me = session.me()
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [repeat, setRepeat] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const mismatch = repeat !== '' && next !== repeat
|
||||||
|
const tooShort = next !== '' && next.length < 8
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
await api.changePassword(current, next)
|
||||||
|
setDone(true)
|
||||||
|
setCurrent('')
|
||||||
|
setNext('')
|
||||||
|
setRepeat('')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Профіль" subtitle={me?.email} />
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="grid max-w-3xl gap-4 md:grid-cols-2">
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Обліковий запис
|
||||||
|
</h2>
|
||||||
|
<dl className="space-y-1.5 text-sm">
|
||||||
|
<Pair k="Email" v={me?.email ?? '—'} />
|
||||||
|
<Pair k="Організація" v={me?.tenantName ?? '—'} />
|
||||||
|
<Pair k="Роль" v={me?.roleName ?? '—'} />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Ваші права
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(me?.permissions ?? []).map((p) => (
|
||||||
|
<span
|
||||||
|
key={p}
|
||||||
|
className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-500"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Зміна пароля
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void submit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Поточний пароль">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Новий пароль" hint="Щонайменше 8 символів">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Ще раз">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(e) => setRepeat(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{mismatch && <ErrorNote>Паролі не збігаються</ErrorNote>}
|
||||||
|
{tooShort && <ErrorNote>Пароль закороткий</ErrorNote>}
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
|
{done && (
|
||||||
|
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
|
||||||
|
Пароль змінено. Відкликано всі сесії, включно з цією — зараз доведеться
|
||||||
|
увійти заново.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
kind="primary"
|
||||||
|
disabled={busy || !current || next.length < 8 || mismatch}
|
||||||
|
>
|
||||||
|
{busy ? 'Збереження…' : 'Змінити пароль'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pair({ k, v }: { k: string; v: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-slate-800/60 pb-1.5">
|
||||||
|
<dt className="text-xs text-slate-500">{k}</dt>
|
||||||
|
<dd className="text-slate-200">{v}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
402
web/src/pages/RulesPage.tsx
Normal file
402
web/src/pages/RulesPage.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
ErrorNote,
|
||||||
|
Field,
|
||||||
|
Modal,
|
||||||
|
PageHeader,
|
||||||
|
Spinner,
|
||||||
|
Toggle,
|
||||||
|
inputClass,
|
||||||
|
} from '../components/ui'
|
||||||
|
import type { AlertRule, Severity } from '../types'
|
||||||
|
|
||||||
|
// Списки метрик повторюють whitelist сервера. Дублювання свідоме: без
|
||||||
|
// нього людина дізнавалась би про друкарську помилку не з форми, а з
|
||||||
|
// правила, яке мовчки нічого не знаходить.
|
||||||
|
const metricsBySource: Record<string, { key: string; label: string; unit?: string }[]> = {
|
||||||
|
icmp: [
|
||||||
|
{ key: 'rtt_avg_ms', label: 'Затримка, середня', unit: 'мс' },
|
||||||
|
{ key: 'rtt_max_ms', label: 'Затримка, максимальна', unit: 'мс' },
|
||||||
|
{ key: 'jitter_ms', label: 'Джитер', unit: 'мс' },
|
||||||
|
{ key: 'loss_pct', label: 'Втрати пакетів', unit: '%' },
|
||||||
|
{ key: 'reachable', label: 'Доступність (1/0)' },
|
||||||
|
{ key: 'no_data', label: 'Даних немає взагалі' },
|
||||||
|
],
|
||||||
|
interface: [
|
||||||
|
{ key: 'util_in_pct', label: 'Завантаження вхідне', unit: '%' },
|
||||||
|
{ key: 'util_out_pct', label: 'Завантаження вихідне', unit: '%' },
|
||||||
|
{ key: 'in_errors', label: 'Помилки на вході' },
|
||||||
|
{ key: 'out_errors', label: 'Помилки на виході' },
|
||||||
|
{ key: 'in_discards', label: 'Відкинуто на вході' },
|
||||||
|
{ key: 'out_discards', label: 'Відкинуто на виході' },
|
||||||
|
{ key: 'in_bps', label: 'Трафік вхідний', unit: 'біт/с' },
|
||||||
|
{ key: 'out_bps', label: 'Трафік вихідний', unit: 'біт/с' },
|
||||||
|
{ key: 'oper_up', label: 'Порт піднято (1/0)' },
|
||||||
|
],
|
||||||
|
metric: [{ key: '', label: '(довільний ключ серії)' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityLabel: Record<Severity, string> = {
|
||||||
|
info: 'Інформація',
|
||||||
|
warning: 'Увага',
|
||||||
|
average: 'Середня',
|
||||||
|
high: 'Висока',
|
||||||
|
disaster: 'Аварія',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel: Record<string, string> = {
|
||||||
|
icmp: 'Пінг (ICMP)',
|
||||||
|
interface: 'Інтерфейси (SNMP)',
|
||||||
|
metric: 'Довільна метрика',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RulesPage() {
|
||||||
|
const [rules, setRules] = useState<AlertRule[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const canWrite = session.can('alerts:write')
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
try {
|
||||||
|
setRules(await api.listAlertRules())
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Правила алертів"
|
||||||
|
subtitle={
|
||||||
|
loading
|
||||||
|
? undefined
|
||||||
|
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено`
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
canWrite && (
|
||||||
|
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||||
|
+ Правило
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
rows={rules}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
empty="Правил ще немає — без них система мовчатиме про будь-яку аварію"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Назва',
|
||||||
|
cell: (r) => <span className="font-medium text-slate-100">{r.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
header: 'Джерело',
|
||||||
|
cell: (r) => <span className="text-slate-400">{sourceLabel[r.source] ?? r.source}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cond',
|
||||||
|
header: 'Умова',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (r) => (
|
||||||
|
<code className="text-[11px] text-slate-400">{describeCondition(r)}</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sev',
|
||||||
|
header: 'Серйозність',
|
||||||
|
cell: (r) => severityLabel[r.severity] ?? r.severity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'Активних',
|
||||||
|
cell: (r) =>
|
||||||
|
r.active_alerts > 0 ? (
|
||||||
|
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
|
||||||
|
{r.active_alerts}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">0</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: 'Увімк.',
|
||||||
|
cell: (r) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Toggle
|
||||||
|
checked={r.enabled}
|
||||||
|
disabled={!canWrite}
|
||||||
|
onChange={async (v) => {
|
||||||
|
await api.setRuleEnabled(r.id, v)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{canWrite && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-slate-600 hover:text-red-400"
|
||||||
|
title="Видалити правило разом з його алертами"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.deleteAlertRule(r.id)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<RuleForm
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setCreating(false)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCondition(r: AlertRule): string {
|
||||||
|
const c = r.condition as {
|
||||||
|
metric?: string
|
||||||
|
metric_key?: string
|
||||||
|
op?: string
|
||||||
|
value?: number
|
||||||
|
agg?: string
|
||||||
|
}
|
||||||
|
if (!c) return '—'
|
||||||
|
const name = c.metric_key || c.metric || '?'
|
||||||
|
if (name === 'no_data') return `даних немає ${r.for_seconds} с`
|
||||||
|
const scope = c.agg ? `${c.agg} за ${r.for_seconds} с` : `всі виміри ${r.for_seconds} с`
|
||||||
|
return `${name} ${c.op ?? ''} ${c.value ?? ''} (${scope})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [source, setSource] = useState('icmp')
|
||||||
|
const [metric, setMetric] = useState('loss_pct')
|
||||||
|
const [metricKey, setMetricKey] = useState('')
|
||||||
|
const [op, setOp] = useState('>')
|
||||||
|
const [value, setValue] = useState('20')
|
||||||
|
const [agg, setAgg] = useState('')
|
||||||
|
const [severity, setSeverity] = useState<Severity>('warning')
|
||||||
|
const [forSec, setForSec] = useState('180')
|
||||||
|
const [topo, setTopo] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isNoData = metric === 'no_data'
|
||||||
|
const metrics = metricsBySource[source] ?? []
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const condition: Record<string, unknown> = isNoData
|
||||||
|
? { metric: 'no_data' }
|
||||||
|
: source === 'metric'
|
||||||
|
? { metric_key: metricKey, op, value: Number(value) }
|
||||||
|
: { metric, op, value: Number(value) }
|
||||||
|
if (!isNoData && agg) condition.agg = agg
|
||||||
|
|
||||||
|
await api.createAlertRule({
|
||||||
|
name: name.trim(),
|
||||||
|
source,
|
||||||
|
severity,
|
||||||
|
condition,
|
||||||
|
for_seconds: Number(forSec) || 60,
|
||||||
|
depends_on_topology: topo,
|
||||||
|
})
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Нове правило" onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Назва">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Наприклад: Втрати пакетів на магістралі"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Джерело">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSource(e.target.value)
|
||||||
|
const first = metricsBySource[e.target.value]?.[0]
|
||||||
|
setMetric(first?.key ?? '')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.keys(metricsBySource).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{sourceLabel[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Серйозність">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={severity}
|
||||||
|
onChange={(e) => setSeverity(e.target.value as Severity)}
|
||||||
|
>
|
||||||
|
{(Object.keys(severityLabel) as Severity[]).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{severityLabel[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source === 'metric' ? (
|
||||||
|
<Field label="Ключ метрики" hint="Як у ts.series.metric_key — наприклад cpu.util">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={metricKey}
|
||||||
|
onChange={(e) => setMetricKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field label="Метрика">
|
||||||
|
<select className={inputClass} value={metric} onChange={(e) => setMetric(e.target.value)}>
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<option key={m.key} value={m.key}>
|
||||||
|
{m.label}
|
||||||
|
{m.unit ? `, ${m.unit}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isNoData && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Field label="Оператор">
|
||||||
|
<select className={inputClass} value={op} onChange={(e) => setOp(e.target.value)}>
|
||||||
|
{['>', '>=', '<', '<=', '==', '!='].map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Поріг">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Агрегація">
|
||||||
|
<select className={inputClass} value={agg} onChange={(e) => setAgg(e.target.value)}>
|
||||||
|
<option value="">усі виміри</option>
|
||||||
|
{['avg', 'min', 'max', 'sum', 'last'].map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Триматися, секунд"
|
||||||
|
hint={
|
||||||
|
agg
|
||||||
|
? 'Вікно, за яке рахується агрегат.'
|
||||||
|
: 'Умова має виконуватись усі виміри цього вікна — це й є захист від мигтіння.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value={forSec}
|
||||||
|
onChange={(e) => setForSec(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2.5 rounded border border-slate-800 bg-slate-900 p-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={topo}
|
||||||
|
onChange={(e) => setTopo(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs leading-snug text-slate-400">
|
||||||
|
Враховувати топологію
|
||||||
|
<span className="mt-0.5 block text-slate-600">
|
||||||
|
Пристрої за впалим маршрутизатором не піднімуть власних алертів — інакше одна
|
||||||
|
аварія дає десятки однакових сповіщень.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button onClick={onClose}>Скасувати</Button>
|
||||||
|
<Button
|
||||||
|
kind="primary"
|
||||||
|
disabled={busy || !name.trim() || (source === 'metric' && !metricKey.trim())}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{busy ? 'Збереження…' : 'Створити'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
285
web/src/pages/TeamPage.tsx
Normal file
285
web/src/pages/TeamPage.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
ErrorNote,
|
||||||
|
Field,
|
||||||
|
Modal,
|
||||||
|
PageHeader,
|
||||||
|
Spinner,
|
||||||
|
ago,
|
||||||
|
inputClass,
|
||||||
|
} from '../components/ui'
|
||||||
|
import type { Role, TeamMember } from '../types'
|
||||||
|
|
||||||
|
export function TeamPage() {
|
||||||
|
const [members, setMembers] = useState<TeamMember[]>([])
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
const canWrite = session.can('users:write')
|
||||||
|
const me = session.me()
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
try {
|
||||||
|
const [m, r] = await Promise.all([api.team(), api.roles()])
|
||||||
|
setMembers(m)
|
||||||
|
setRoles(r)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function act(fn: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
await reload()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Команда"
|
||||||
|
subtitle={loading ? undefined : `${members.length} учасників`}
|
||||||
|
actions={
|
||||||
|
canWrite && (
|
||||||
|
<Button kind="primary" onClick={() => setAdding(true)}>
|
||||||
|
+ Користувач
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
rows={members}
|
||||||
|
rowKey={(m) => m.user_id}
|
||||||
|
empty="Учасників немає"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
header: 'Користувач',
|
||||||
|
cell: (m) => (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-medium text-slate-100">
|
||||||
|
{m.full_name || m.email}
|
||||||
|
</span>
|
||||||
|
{m.user_id === me?.userID && (
|
||||||
|
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-400">
|
||||||
|
це ви
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mail',
|
||||||
|
header: 'Email',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (m) => <span className="text-slate-400">{m.email}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
header: 'Роль',
|
||||||
|
cell: (m) => {
|
||||||
|
// Роль власника не видається через API — вона й не має
|
||||||
|
// з’являтися у списку вибору. Так само не можна змінити
|
||||||
|
// роль собі: організація без власника не відновлюється.
|
||||||
|
const editable =
|
||||||
|
canWrite && m.user_id !== me?.userID && m.role_key !== 'owner'
|
||||||
|
if (!editable) return <span className="text-slate-400">{m.role_name}</span>
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-200"
|
||||||
|
value={m.role_id}
|
||||||
|
onChange={(e) => void act(() => api.setRole(m.user_id, e.target.value))}
|
||||||
|
>
|
||||||
|
{roles
|
||||||
|
.filter((r) => r.key !== 'owner')
|
||||||
|
.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'login',
|
||||||
|
header: 'Останній вхід',
|
||||||
|
cell: (m) => (
|
||||||
|
<span className="tabular-nums text-slate-400">{ago(m.last_login_at)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'act',
|
||||||
|
header: '',
|
||||||
|
cell: (m) =>
|
||||||
|
canWrite && m.user_id !== me?.userID && m.role_key !== 'owner' ? (
|
||||||
|
<button
|
||||||
|
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||||
|
title="Прибрати з організації — усі його сесії тут буде відкликано"
|
||||||
|
onClick={() => void act(() => api.removeMember(m.user_id))}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800 p-4">
|
||||||
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Ролі та права
|
||||||
|
</h2>
|
||||||
|
<ul className="grid gap-2 md:grid-cols-2">
|
||||||
|
{roles.map((r) => (
|
||||||
|
<li key={r.id} className="rounded border border-slate-800 bg-slate-900/60 p-2.5">
|
||||||
|
<div className="text-sm text-slate-200">{r.name}</div>
|
||||||
|
{r.description && (
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-600">{r.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{r.permissions.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p}
|
||||||
|
className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-500"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<AddUserForm
|
||||||
|
roles={roles.filter((r) => r.key !== 'owner')}
|
||||||
|
onClose={() => setAdding(false)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setAdding(false)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUserForm({
|
||||||
|
roles,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
roles: Role[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [fullName, setFullName] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [roleKey, setRoleKey] = useState(roles.find((r) => r.key === 'viewer')?.key ?? roles[0]?.key ?? '')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Новий користувач" onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Email">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="email"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Імʼя">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Пароль"
|
||||||
|
hint="Запрошень поштою ще немає — пароль доведеться передати людині самому."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Роль">
|
||||||
|
<select className={inputClass} value={roleKey} onChange={(e) => setRoleKey(e.target.value)}>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r.id} value={r.key}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button onClick={onClose}>Скасувати</Button>
|
||||||
|
<Button
|
||||||
|
kind="primary"
|
||||||
|
disabled={busy || !email.trim() || password.length < 8}
|
||||||
|
title={password.length < 8 ? 'Пароль щонайменше 8 символів' : undefined}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
await api.addMember(email.trim(), password, roleKey, fullName.trim())
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Створення…' : 'Створити'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -255,6 +255,18 @@ export interface AlertCounts {
|
||||||
high: number
|
high: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
name: string
|
||||||
|
config: Record<string, unknown>
|
||||||
|
template?: string
|
||||||
|
min_severity: string
|
||||||
|
enabled: boolean
|
||||||
|
/** Чи збережено секрет. Сам секрет назад не віддається ніколи. */
|
||||||
|
has_secret: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlertRule {
|
export interface AlertRule {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue