Повноцінний веб: навігація і вісім сторінок
До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло 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
|
||||
разом із 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** | ✅ | ⚠️ транспорт є |
|
||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||
| Веб-інтерфейс (навігація, сторінки) | — | ✅ |
|
||||
| Дашборди, NOC TV | ✅ | ❌ |
|
||||
| Білінг, ліцензії | ✅ | ❌ |
|
||||
|
||||
|
|
@ -40,11 +41,16 @@ API віддає готове полотно з живими статусами,
|
|||
> права, мобільний адаптив. Подробиці — [HISTORY.md](HISTORY.md),
|
||||
> контракт — [server/API.md](server/API.md).
|
||||
>
|
||||
> **Доповнено 2026-08-15:** повноцінний веб — навігація й вісім сторінок
|
||||
> (мапа, пристрої, алерти, правила, канали, зонди, команда, профіль),
|
||||
> примітиви UI з мобільними картками замість таблиць, захист маршрутів
|
||||
> правами.
|
||||
>
|
||||
> **Відкладено з цього етапу:** TOTP (`totp_secret_enc` є в схемі, коду немає),
|
||||
> запрошення поштою (`core.invitations` порожня — користувача заводять із
|
||||
> паролем одразу), кастомні ролі (`POST /api/v1/roles`), фільтр за
|
||||
> `memberships.scope_group_ids`, UI-сторінки «Команда» й «Ролі» (API готове,
|
||||
> сторінок немає).
|
||||
> `memberships.scope_group_ids`, редагування умови правила (лише створення
|
||||
> й вимкнення), маршрути сповіщень і вікна обслуговування в UI.
|
||||
|
||||
**Навіщо перше.** Зараз API автентифікує лише машинні токени
|
||||
(`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.Config = json.RawMessage(cfg)
|
||||
|
||||
// Наявність секрету — факт про канал, а не наслідок того,
|
||||
// чи його зараз розшифровують. Перелік для UI викликається
|
||||
// без кільця навмисно, і без цього рядка канал із токеном
|
||||
// показувався б у ньому як «секрету немає».
|
||||
c.HasSecret = keyID != nil
|
||||
|
||||
if keyID != nil && ring != nil {
|
||||
plain, err := ring.Decrypt(&crypto.Secret{
|
||||
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)
|
||||
}
|
||||
c.Secret = string(plain)
|
||||
c.HasSecret = true
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
|
|
|
|||
56
web/package-lock.json
generated
56
web/package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
|||
"dependencies": {
|
||||
"@xyflow/react": "^12.3.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
|
@ -1601,6 +1602,18 @@
|
|||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
|
|
@ -2271,6 +2284,42 @@
|
|||
"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": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||
|
|
@ -2333,6 +2382,11 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
"dependencies": {
|
||||
"@xyflow/react": "^12.3.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 { session } from './api/session'
|
||||
import { useLiveMap } from './hooks/useLiveMap'
|
||||
import { useAlerts } from './hooks/useAlerts'
|
||||
import { AppShell } from './components/AppShell'
|
||||
import { LoginPage } from './components/LoginPage'
|
||||
import { MapCanvas } from './components/MapCanvas'
|
||||
import { AlertBadge, AlertsPanel } from './components/AlertsPanel'
|
||||
import type { AgentSummary, MapSummary } from './types'
|
||||
import { MapPage } from './pages/MapPage'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
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() {
|
||||
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
|
||||
|
|
@ -26,7 +32,7 @@ export default function App() {
|
|||
|
||||
if (booting) {
|
||||
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>
|
||||
)
|
||||
|
|
@ -36,326 +42,66 @@ export default function App() {
|
|||
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 (
|
||||
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
|
||||
<header
|
||||
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-800
|
||||
bg-slate-900 px-3 py-2 md:px-4"
|
||||
>
|
||||
<button
|
||||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
aria-label="Меню"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<span className="text-base font-semibold tracking-tight text-slate-100 md:text-lg">
|
||||
NetPulse
|
||||
</span>
|
||||
|
||||
<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>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
||||
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||
<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 />} />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Стан з'єднання показується завжди й помітно.
|
||||
* Стартова сторінка залежить від ролі.
|
||||
*
|
||||
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
|
||||
* само, як мапа, де все гаразд, — і це найнебезпечніший стан
|
||||
* моніторингу.
|
||||
* Глядача без права на мапи слати на «/map» означало б вітати його
|
||||
* порожнім екраном із відмовою. Тому перший доступний розділ і стає
|
||||
* домівкою.
|
||||
*/
|
||||
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 ago = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
|
||||
function Home() {
|
||||
const order: { perm: Permission; to: string }[] = [
|
||||
{ perm: 'maps:read', to: '/map' },
|
||||
{ perm: 'alerts:read', to: '/alerts' },
|
||||
{ perm: 'devices:read', to: '/devices' },
|
||||
{ perm: 'agents:read', to: '/agents' },
|
||||
{ perm: 'users:read', to: '/team' },
|
||||
]
|
||||
const first = order.find((o) => session.can(o.perm))
|
||||
return <Navigate to={first?.to ?? '/profile'} replace />
|
||||
}
|
||||
|
||||
/**
|
||||
* Захист маршруту правом.
|
||||
*
|
||||
* Навігація вже ховає недоступні пункти, але посилання лишається
|
||||
* робочим: його можна зберегти в закладки, отримати від колеги або
|
||||
* успадкувати після зміни ролі. Сервер у будь-якому разі відповість 403 —
|
||||
* питання лише в тому, побачить людина зрозуміле пояснення чи порожню
|
||||
* сторінку з помилкою в консолі.
|
||||
*/
|
||||
function Guard({ perm, element }: { perm: Permission; element: React.ReactNode }) {
|
||||
if (session.can(perm)) return <>{element}</>
|
||||
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>
|
||||
{ago !== null && state === 'online' && (
|
||||
<span className="hidden tabular-nums text-slate-600 lg:inline">{ago} с тому</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mb-2 text-2xl">🔒</div>
|
||||
<h2 className="text-sm font-medium text-slate-200">Розділ недоступний</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500">
|
||||
Вашій ролі не надано права <code className="text-slate-400">{perm}</code>. Попросіть
|
||||
власника організації змінити роль, якщо доступ потрібен для роботи.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
AlertCounts,
|
||||
AlertRule,
|
||||
BuildResult,
|
||||
Channel,
|
||||
DeviceSummary,
|
||||
LoginResponse,
|
||||
MapPatch,
|
||||
|
|
@ -220,9 +221,66 @@ export const api = {
|
|||
listAlertRules: () =>
|
||||
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) =>
|
||||
request<void>(`/api/v1/alert-rules/${id}`, {
|
||||
method: 'PATCH',
|
||||
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 {
|
||||
userID?: string
|
||||
email?: string
|
||||
fullName?: string
|
||||
tokenName?: string
|
||||
tenantID: string
|
||||
tenantName?: string
|
||||
roleName?: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
|
|
@ -56,15 +59,18 @@ export const session = {
|
|||
|
||||
/** Розбір відповіді входу в єдину форму. */
|
||||
export function meFromLogin(body: {
|
||||
user?: { id: string; email: string }
|
||||
tenant?: { tenant_id: string } | null
|
||||
user?: { id: string; email: string; full_name?: string }
|
||||
tenant?: { tenant_id: string; tenant_name?: string; role_name?: string } | null
|
||||
permissions?: string[]
|
||||
tenant_id?: string
|
||||
}): Me {
|
||||
return {
|
||||
userID: body.user?.id,
|
||||
email: body.user?.email,
|
||||
fullName: body.user?.full_name,
|
||||
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
|
||||
tenantName: body.tenant?.tenant_name,
|
||||
roleName: body.tenant?.role_name,
|
||||
permissions: (body.permissions ?? []) as Permission[],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,14 @@ import type { WsMessage } from '../types'
|
|||
|
||||
export type ConnectionState = 'connecting' | 'online' | 'offline'
|
||||
|
||||
interface Options {
|
||||
onMessage: (msg: WsMessage) => void
|
||||
onState: (state: ConnectionState) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Спільна шина подій.
|
||||
*
|
||||
* З'єднання одне на вкладку, а слухачів у нього кілька: мапа, панель
|
||||
* алертів і далі буде більше. Другий WebSocket заради алертів коштував
|
||||
* би ще одного з'єднання й ще одного циклу перепідключень на кожен
|
||||
* відкритий NOC-екран — при тому, що сервер шле обом однаковий потік.
|
||||
* З'єднання одне на вкладку, а слухачів у нього кілька: мапа, список
|
||||
* пристроїв, лічильник алертів у шапці. Другий WebSocket заради
|
||||
* алертів коштував би ще одного з'єднання й ще одного циклу
|
||||
* перепідключень на кожен відкритий NOC-екран — при тому, що сервер
|
||||
* шле обом однаковий потік.
|
||||
*/
|
||||
type Listener = (msg: WsMessage) => void
|
||||
|
||||
|
|
@ -37,25 +33,47 @@ export const liveEvents = {
|
|||
}
|
||||
|
||||
/**
|
||||
* Живе з'єднання з сервером.
|
||||
* Живе з'єднання з сервером — одне на застосунок.
|
||||
*
|
||||
* Володіє ним оболонка, а не сторінка. Спроба тримати сокет усередині
|
||||
* мапи вже коштувала того, що на всіх інших сторінках живих оновлень не
|
||||
* було взагалі: лічильник алертів у шапці замерзав, щойно людина йшла
|
||||
* з мапи, і показував стан на момент входу.
|
||||
*
|
||||
* Реконект обов'язковий і не є деталлю: NOC-екран висить тижнями, і
|
||||
* будь-який проксі рано чи пізно розірве з'єднання. Без автоматичного
|
||||
* повернення мапа замерзне з останнім кадром, виглядаючи цілком живою —
|
||||
* найгірший з можливих станів моніторингу.
|
||||
*/
|
||||
export class LiveConnection {
|
||||
class LiveConnection {
|
||||
private ws: WebSocket | null = null
|
||||
private backoff = 1000
|
||||
private closed = false
|
||||
private started = false
|
||||
private wantMap: string | 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
|
||||
this.opts.onState('connecting')
|
||||
/** Розрив при виході з акаунта — інакше сокет житиме з мертвим токеном. */
|
||||
stop() {
|
||||
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'
|
||||
// Браузерний WebSocket не дозволяє довільні заголовки, тому токен
|
||||
|
|
@ -69,14 +87,14 @@ export class LiveConnection {
|
|||
|
||||
ws.onopen = () => {
|
||||
this.backoff = 1000
|
||||
this.opts.onState('online')
|
||||
this.setState('online')
|
||||
if (this.wantMap) this.subscribe(this.wantMap)
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as WsMessage
|
||||
this.opts.onMessage(msg)
|
||||
this.lastEventAt = Date.now()
|
||||
liveEvents.emit(msg)
|
||||
} catch {
|
||||
// Пошкоджене повідомлення не має рвати з'єднання.
|
||||
|
|
@ -85,9 +103,9 @@ export class LiveConnection {
|
|||
|
||||
ws.onclose = () => {
|
||||
this.ws = null
|
||||
if (this.closed) return
|
||||
this.opts.onState('offline')
|
||||
this.timer = window.setTimeout(() => this.connect(), this.backoff)
|
||||
if (!this.started) return
|
||||
this.setState('offline')
|
||||
this.timer = window.setTimeout(() => this.open(), this.backoff)
|
||||
// Стеля 15 с: далі затримка вже помітна оператору, а користі
|
||||
// від рідших спроб немає.
|
||||
this.backoff = Math.min(this.backoff * 2, 15000)
|
||||
|
|
@ -104,9 +122,22 @@ export class LiveConnection {
|
|||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true
|
||||
if (this.timer) window.clearTimeout(this.timer)
|
||||
this.ws?.close()
|
||||
private setState(s: ConnectionState) {
|
||||
if (this.state === s) return
|
||||
this.state = s
|
||||
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 { liveEvents } from '../api/ws'
|
||||
import { session } from '../api/session'
|
||||
import { isAlertEvent, useLiveRefresh } from './useLiveRefresh'
|
||||
import type { Alert, AlertCounts } from '../types'
|
||||
|
||||
const emptyCounts: AlertCounts = {
|
||||
|
|
@ -60,24 +60,9 @@ export function useAlerts(): LiveAlerts {
|
|||
void reload()
|
||||
}, [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)
|
||||
useLiveRefresh(isAlertEvent, () => {
|
||||
if (canRead) void reload()
|
||||
})
|
||||
}, [reload, canRead])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current) window.clearTimeout(timer.current)
|
||||
}, [])
|
||||
|
||||
const ack = useCallback(
|
||||
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 { LiveConnection, type ConnectionState } from '../api/ws'
|
||||
import { live, liveEvents, type ConnectionState } from '../api/ws'
|
||||
import type { MapPatch, MapState, WsMessage } from '../types'
|
||||
|
||||
export interface LiveMap {
|
||||
|
|
@ -29,10 +29,11 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
|||
const [state, setState] = useState<MapState | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [connection, setConnection] = useState<ConnectionState>('connecting')
|
||||
const [lastEventAt, setLastEventAt] = useState<number | null>(null)
|
||||
|
||||
const conn = useRef<LiveConnection | null>(null)
|
||||
// Стан з'єднання читається з самої оболонки: воно спільне, і копія
|
||||
// тут розходилася б із нею після кожного реконекту.
|
||||
const connection = useSyncExternalStore(live.subscribeState, live.getState) as ConnectionState
|
||||
// Ревізія потрібна в обробнику WebSocket, який створюється один раз;
|
||||
// читати її зі state означало б перепідключатись на кожну правку.
|
||||
const revision = useRef<number>(0)
|
||||
|
|
@ -58,11 +59,12 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
|||
if (mapID) void reload()
|
||||
}, [mapID, reload])
|
||||
|
||||
// Живі оновлення.
|
||||
useEffect(() => {
|
||||
const c = new LiveConnection({
|
||||
onState: setConnection,
|
||||
onMessage: (msg: WsMessage) => {
|
||||
// Живі оновлення. З'єднанням володіє оболонка застосунку — тут ми
|
||||
// лише слухаємо спільну шину. Тримати сокет усередині мапи означало б
|
||||
// не мати живих оновлень на жодній іншій сторінці.
|
||||
useEffect(
|
||||
() =>
|
||||
liveEvents.subscribe((msg: WsMessage) => {
|
||||
setLastEventAt(Date.now())
|
||||
|
||||
switch (msg.type) {
|
||||
|
|
@ -106,19 +108,12 @@ export function useLiveMap(mapID: string | null): LiveMap {
|
|||
if (msg.revision !== revision.current) void reload()
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
conn.current = c
|
||||
c.connect()
|
||||
return () => {
|
||||
c.close()
|
||||
conn.current = null
|
||||
}
|
||||
}, [reload])
|
||||
}),
|
||||
[reload],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (mapID) conn.current?.subscribe(mapID)
|
||||
if (mapID) live.subscribe(mapID)
|
||||
}, [mapID, state?.id])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue