From 36f29e23ecd7070716938715a7319baca7534396 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Mon, 24 Aug 2026 01:17:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B2=D0=BD=D0=BE=D1=86=D1=96?= =?UTF-8?q?=D0=BD=D0=BD=D0=B8=D0=B9=20=D0=B2=D0=B5=D0=B1:=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B2=D1=96=D0=B3=D0=B0=D1=86=D1=96=D1=8F=20=D1=96=20=D0=B2?= =?UTF-8?q?=D1=96=D1=81=D1=96=D0=BC=20=D1=81=D1=82=D0=BE=D1=80=D1=96=D0=BD?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 93 ++++++ ROADMAP.md | 10 +- server/internal/store/alerts_channels.go | 7 +- web/package-lock.json | 56 +++- web/package.json | 3 +- web/src/App.tsx | 384 ++++------------------ web/src/api/client.ts | 58 ++++ web/src/api/session.ts | 10 +- web/src/api/ws.ts | 81 +++-- web/src/components/AlertsPanel.tsx | 250 -------------- web/src/components/AppShell.tsx | 183 +++++++++++ web/src/components/ui.tsx | 295 +++++++++++++++++ web/src/hooks/useAlerts.ts | 25 +- web/src/hooks/useLiveMap.ts | 35 +- web/src/hooks/useLiveRefresh.ts | 44 +++ web/src/pages/AgentsPage.tsx | 101 ++++++ web/src/pages/AlertsPage.tsx | 181 ++++++++++ web/src/pages/ChannelsPage.tsx | 379 +++++++++++++++++++++ web/src/pages/DevicesPage.tsx | 302 +++++++++++++++++ web/src/pages/MapPage.tsx | 182 ++++++++++ web/src/pages/ProfilePage.tsx | 140 ++++++++ web/src/pages/RulesPage.tsx | 402 +++++++++++++++++++++++ web/src/pages/TeamPage.tsx | 285 ++++++++++++++++ web/src/types.ts | 12 + 24 files changed, 2877 insertions(+), 641 deletions(-) delete mode 100644 web/src/components/AlertsPanel.tsx create mode 100644 web/src/components/AppShell.tsx create mode 100644 web/src/components/ui.tsx create mode 100644 web/src/hooks/useLiveRefresh.ts create mode 100644 web/src/pages/AgentsPage.tsx create mode 100644 web/src/pages/AlertsPage.tsx create mode 100644 web/src/pages/ChannelsPage.tsx create mode 100644 web/src/pages/DevicesPage.tsx create mode 100644 web/src/pages/MapPage.tsx create mode 100644 web/src/pages/ProfilePage.tsx create mode 100644 web/src/pages/RulesPage.tsx create mode 100644 web/src/pages/TeamPage.tsx diff --git a/HISTORY.md b/HISTORY.md index 5c7a756..33cccda 100644 --- a/HISTORY.md +++ b/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-режиму на телевізор. diff --git a/ROADMAP.md b/ROADMAP.md index 7234a9b..353a847 100644 --- a/ROADMAP.md +++ b/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` / diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go index 365ed8e..35a93ad 100644 --- a/server/internal/store/alerts_channels.go +++ b/server/internal/store/alerts_channels.go @@ -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) } diff --git a/web/package-lock.json b/web/package-lock.json index 7b855a5..3692caa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 4ecc23f..77c6b44 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index ba08708..3cdb901 100644 --- a/web/src/App.tsx +++ b/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 ( -
+
Відновлення сесії…
) @@ -36,326 +42,66 @@ export default function App() { return setBooting(false)} /> } - return -} - -function Workspace() { - const [maps, setMaps] = useState([]) - const [mapID, setMapID] = useState(null) - const [agents, setAgents] = useState([]) - const [selected, setSelected] = useState(null) - const [listError, setListError] = useState(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 ( -
-
- - - - NetPulse - - - - - {canWrite && ( - <> - - - - - )} - -
- - - - {canSeeAlerts && ( - setAlertsOpen((v) => !v)} /> - )} - - {live.state && ( - - ревізія {live.state.revision} - - )} - {me?.email && ( - - )} -
-
- - {(live.error || listError) && ( -
- {live.error ?? listError} -
- )} - -
- {/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - - -
- {live.state ? ( - - ) : ( -
- {live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'} -
- )} -
- - {alertsOpen && canSeeAlerts && ( - setAlertsOpen(false)} - // Клік по алерту веде до пристрою на мапі: інакше зв'язок - // між рядком у списку й вузлом доводиться шукати очима. - onFocusDevice={(deviceID) => { - const n = live.state?.nodes.find((x) => x.device_id === deviceID) - if (n) setSelected(n.id) - }} - /> - )} -
- - {/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */} - {node && ( -
-
- {node.label} - -
-
- - {node.rtt_ms !== undefined && } - {node.loss_pct !== undefined && } - -
-
- )} -
- ) -} - -function Counter({ label, value, color }: { label: string; value: number; color?: string }) { - return ( -
- {label} - {value} -
- ) -} - -function Row({ k, v }: { k: string; v: string }) { - return ( -
- {k} - {v} -
- ) -} - -function Legend() { - return ( - - - - - - - ) -} - -function Dot({ c, t }: { c: string; t: string }) { - return ( - - - {t} - + + + }> + } /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } />} /> + } /> + } /> + + + ) } /** - * Стан з'єднання показується завжди й помітно. + * Стартова сторінка залежить від ролі. * - * Мапа, яка тихо замерзла після розриву 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 +} +/** + * Захист маршруту правом. + * + * Навігація вже ховає недоступні пункти, але посилання лишається + * робочим: його можна зберегти в закладки, отримати від колеги або + * успадкувати після зміни ролі. Сервер у будь-якому разі відповість 403 — + * питання лише в тому, побачить людина зрозуміле пояснення чи порожню + * сторінку з помилкою в консолі. + */ +function Guard({ perm, element }: { perm: Permission; element: React.ReactNode }) { + if (session.can(perm)) return <>{element} return ( - - - {label} - {ago !== null && state === 'online' && ( - {ago} с тому - )} - +
+
+
🔒
+

Розділ недоступний

+

+ Вашій ролі не надано права {perm}. Попросіть + власника організації змінити роль, якщо доступ потрібен для роботи. +

+
+
) } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a611f43..42c8789 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 + selector?: Record + 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(`/api/v1/alert-rules/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled }), }), + + deleteAlertRule: (id: string) => + request(`/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 + min_severity: string + secret?: string + template?: string + }) => + request<{ id: string }>('/api/v1/channels', { + method: 'POST', + body: JSON.stringify(ch), + }), + + deleteChannel: (id: string) => request(`/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('/api/v1/auth/password', { + method: 'POST', + body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), + }) + session.clear() + }, } diff --git a/web/src/api/session.ts b/web/src/api/session.ts index a295fec..75af32d 100644 --- a/web/src/api/session.ts +++ b/web/src/api/session.ts @@ -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[], } } diff --git a/web/src/api/ws.ts b/web/src/api/ws.ts index 98036a4..7af2a61 100644 --- a/web/src/api/ws.ts +++ b/web/src/api/ws.ts @@ -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() diff --git a/web/src/components/AlertsPanel.tsx b/web/src/components/AlertsPanel.tsx deleted file mode 100644 index ed7d733..0000000 --- a/web/src/components/AlertsPanel.tsx +++ /dev/null @@ -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 = { - 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 = { - 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 ( - - ) -} - -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 ( -
  • -
    - -
    -
    - {s.label} - {ago(a.started_at)} - {acked && ( - - ✓ прийнято - - )} -
    - - - - {a.message &&

    {a.message}

    } - - {a.suppressed_by && ( -

    - придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by} -

    - )} - - {canAck && ( -
    - {!acked && ( - - Прийняти - - )} - {onMute && ( - - Заглушити 1 год - - )} - - Закрити - -
    - )} -
    -
    -
  • - ) -} - -function SmallButton({ - children, - onClick, - title, -}: { - children: React.ReactNode - onClick: () => void - title?: string -}) { - // На телефоні ці кнопки натискають пальцем і зазвичай уночі: 26 px, - // яких вистачало мишею, тут перетворюються на промахи. На десктопі - // висота повертається до щільної — список має лишатись оглядним. - return ( - - ) -} - -/** Індикатор у шапці. Мовчить, коли алертів немає. */ -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 ( - - ) -} - -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)} дн` -} diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..6c7caaa --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -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 ( +
    +
    + + + NetPulse + +
    + {/* Замерзлий інтерфейс виглядає точно так само, як справний. + Тому стан з'єднання видно з будь-якої сторінки, а не лише + з мапи. */} + + + + {connection === 'online' ? 'наживо' : connection === 'connecting' ? '…' : 'офлайн'} + + + + {alerts.counts.firing > 0 && ( + 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} активних алертів`} + > + 🔔 {alerts.counts.firing} + + )} + + {me?.email && ( +
    + + {me.email} + + +
    + )} +
    +
    + +
    + {navOpen && ( +
    setNavOpen(false)} + /> + )} + + + +
    + +
    +
    +
    + ) +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx new file mode 100644 index 0000000..9334b29 --- /dev/null +++ b/web/src/components/ui.tsx @@ -0,0 +1,295 @@ +import type { ReactNode } from 'react' + +/** + * Примітиви інтерфейсу. + * + * Зібрані в одному файлі навмисно: їх десяток, вони дрібні, і поки + * дизайн-система вміщається на екран, окрема тека на кожну кнопку + * коштує більше, ніж дає. + */ + +export function PageHeader({ + title, + subtitle, + actions, +}: { + title: string + subtitle?: string + actions?: ReactNode +}) { + return ( +
    +
    +

    {title}

    + {subtitle &&

    {subtitle}

    } +
    + {actions &&
    {actions}
    } +
    + ) +} + +type ButtonKind = 'primary' | 'default' | 'danger' | 'ghost' + +const buttonKind: Record = { + 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 ( + + ) +} + +export function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: ReactNode +}) { + return ( + + ) +} + +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 ( +
    + {children} +
    + ) +} + +export interface Column { + key: string + header: string + cell: (row: T) => ReactNode + /** Другорядні колонки ховаються на телефоні, щоб картка лишалась короткою. */ + hideOnMobile?: boolean +} + +/** + * Таблиця, що на телефоні перестає бути таблицею. + * + * Горизонтальний скрол на 375 px — це спосіб зробити дані формально + * присутніми й фактично нечитабельними, тому вузькі екрани отримують + * картки, а не звужені колонки. + */ +export function DataTable({ + rows, + columns, + rowKey, + empty = 'Порожньо', + onRowClick, +}: { + rows: T[] + columns: Column[] + rowKey: (row: T) => string + empty?: string + onRowClick?: (row: T) => void +}) { + if (rows.length === 0) { + return

    {empty}

    + } + + return ( + <> + + + + {columns.map((c) => ( + + ))} + + + + {rows.map((r) => ( + onRowClick(r) : undefined} + > + {columns.map((c) => ( + + ))} + + ))} + +
    + {c.header} +
    + {c.cell(r)} +
    + +
      + {rows.map((r) => { + const shown = columns.filter((c) => !c.hideOnMobile) + const [first, ...rest] = shown + return ( +
    • onRowClick(r) : undefined} + > + {first && ( +
      {first.cell(r)}
      + )} + {rest.map((c) => ( +
      + {c.header} + {c.cell(r)} +
      + ))} +
    • + ) + })} +
    + + ) +} + +const statusTone: Record = { + 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 ( + + {label ?? status} + + ) +} + +export function Toggle({ + checked, + onChange, + disabled, +}: { + checked: boolean + onChange: (v: boolean) => void + disabled?: boolean +}) { + return ( + + ) +} + +export function Modal({ + title, + children, + onClose, +}: { + title: string + children: ReactNode + onClose: () => void +}) { + return ( +
    +
    +
    +

    {title}

    + +
    +
    {children}
    +
    +
    + ) +} + +export function ErrorNote({ children }: { children?: ReactNode }) { + if (!children) return null + return ( +
    + {children} +
    + ) +} + +export function Spinner({ label = 'Завантаження…' }: { label?: string }) { + return

    {label}

    +} + +/** Скільки минуло. Прочерк замість вигаданих чисел для порожніх дат. */ +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)} дн` +} diff --git a/web/src/hooks/useAlerts.ts b/web/src/hooks/useAlerts.ts index c98c7e1..2c2ebad 100644 --- a/web/src/hooks/useAlerts.ts +++ b/web/src/hooks/useAlerts.ts @@ -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(null) - useEffect(() => { - if (!canRead) return - return liveEvents.subscribe((msg) => { - if (!msg.type?.startsWith('alert.')) return - if (timer.current) window.clearTimeout(timer.current) - timer.current = window.setTimeout(() => void reload(), 400) - }) - }, [reload, canRead]) - - useEffect(() => () => { - if (timer.current) window.clearTimeout(timer.current) - }, []) + useLiveRefresh(isAlertEvent, () => { + if (canRead) void reload() + }) const ack = useCallback( async (id: string) => { diff --git a/web/src/hooks/useLiveMap.ts b/web/src/hooks/useLiveMap.ts index a7eaf8e..ced957d 100644 --- a/web/src/hooks/useLiveMap.ts +++ b/web/src/hooks/useLiveMap.ts @@ -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(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [connection, setConnection] = useState('connecting') const [lastEventAt, setLastEventAt] = useState(null) - const conn = useRef(null) + // Стан з'єднання читається з самої оболонки: воно спільне, і копія + // тут розходилася б із нею після кожного реконекту. + const connection = useSyncExternalStore(live.subscribeState, live.getState) as ConnectionState // Ревізія потрібна в обробнику WebSocket, який створюється один раз; // читати її зі state означало б перепідключатись на кожну правку. const revision = useRef(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( diff --git a/web/src/hooks/useLiveRefresh.ts b/web/src/hooks/useLiveRefresh.ts new file mode 100644 index 0000000..a81f8ba --- /dev/null +++ b/web/src/hooks/useLiveRefresh.ts @@ -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, + delay = 400, +) { + const timer = useRef(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 diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx new file mode 100644 index 0000000..d84575d --- /dev/null +++ b/web/src/pages/AgentsPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( + <> + + +
    + {error && {error}} + + {loading ? ( + + ) : agents.length === 0 ? ( +

    + Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі + клієнта не потрібно. +

    + ) : ( +
      + {agents.map((a) => ( +
    • + +
      + {a.name} + + + +
      + +
      + + + + + {typeof a.health?.rss_bytes === 'number' && ( + + )} +
      + +
      + {a.enabled_modules.map((m) => ( + + {m} + + ))} + {a.enabled_modules.length === 0 && ( + модулі не активовані + )} +
      +
      +
    • + ))} +
    + )} + +

    + Керування набором модулів із інтерфейсу ще не реалізоване — транспорт для цього + готовий, але сторінка налаштувань зонда попереду. +

    +
    + + ) +} + +function Pair({ k, v }: { k: string; v: string }) { + return ( +
    +
    {k}
    +
    {v}
    +
    + ) +} diff --git a/web/src/pages/AlertsPage.tsx b/web/src/pages/AlertsPage.tsx new file mode 100644 index 0000000..7456b89 --- /dev/null +++ b/web/src/pages/AlertsPage.tsx @@ -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 = { + 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 = { + maintenance: 'вікно обслуговування', + mute: 'заглушено вручну', + topology: 'наслідок іншої аварії', + dependency: 'залежність', +} + +type Filter = 'active' | 'acked' | 'suppressed' | 'all' + +const filterLabel: Record = { + active: 'Активні', + acked: 'Прийняті', + suppressed: 'Придушені', + all: 'Усі', +} + +export function AlertsPage() { + const live = useOutletContext() + const [filter, setFilter] = useState('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 = { + active: live.counts.firing, + acked: live.counts.acknowledged, + suppressed: live.counts.suppressed, + all: live.alerts.length, + } + + return ( + <> + 0 + ? `${live.counts.disaster + live.counts.high} гострих потребують уваги` + : live.counts.firing === 0 + ? 'Активних немає' + : `${live.counts.firing} активних` + } + actions={ +
    + {(Object.keys(filterLabel) as Filter[]).map((f) => ( + + ))} +
    + } + /> + +
    + {live.error && {live.error}} + + {live.loading ? ( + + ) : rows.length === 0 ? ( +

    + {filter === 'active' && live.alerts.length > 0 + ? 'Активних немає — усе або прийнято, або придушено' + : 'Порожньо'} +

    + ) : ( +
      + {rows.map((a) => ( + void live.ack(a.id)} + onClose={() => void live.close(a.id)} + onMute={a.device_id ? () => void live.mute(a.device_id!, 60) : undefined} + /> + ))} +
    + )} +
    + + ) +} + +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 ( +
  • + +
    + + +
    +
    + {s.label} + + триває {ago(a.started_at)} + + {a.rule_name && · {a.rule_name}} + {acked && ( + + ✓ прийняв {a.acked_by_email ?? '—'} + + )} + {a.notify_count > 0 && ( + · сповіщень {a.notify_count} + )} +
    + +
    {a.title}
    + {a.message &&

    {a.message}

    } + + {a.suppressed_by && ( +

    + придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by} +

    + )} + + {canAck && ( +
    + {!acked && ( + + )} + {onMute && ( + + )} + +
    + )} +
    +
    +
    +
  • + ) +} diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx new file mode 100644 index 0000000..3afb00c --- /dev/null +++ b/web/src/pages/ChannelsPage.tsx @@ -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 = { + telegram: 'Telegram', + webhook: 'Webhook', + email: 'Email', + slack: 'Slack', + discord: 'Discord', + mattermost: 'Mattermost', +} + +const severityLabel: Record = { + info: 'Інформація і вище', + warning: 'Увага і вище', + average: 'Середня і вище', + high: 'Висока і вище', + disaster: 'Лише аварії', +} + +export function ChannelsPage() { + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [testing, setTesting] = useState(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 ( + <> + setCreating(true)}> + + Канал + + ) + } + /> + +
    + {error && ( +
    + {error} +
    + )} + + {testResult && ( +
    + {testResult.ok ? ( +
    + Пробне повідомлення надіслано. Якщо воно не дійшло — проблема на боці отримувача. +
    + ) : ( + Не вдалося: {testResult.error} + )} +
    + )} + + {loading ? ( + + ) : ( + c.id} + empty="Каналів ще немає" + columns={[ + { + key: 'name', + header: 'Назва', + cell: (c) => {c.name}, + }, + { 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 ? ( + збережено + ) : ( + + ), + }, + { + key: 'state', + header: 'Стан', + cell: (c) => ( + + ), + }, + { + key: 'act', + header: 'Дії', + cell: (c) => + canWrite && ( +
    + + +
    + ), + }, + ]} + /> + )} +
    + + {creating && ( + setCreating(false)} + onSaved={async () => { + setCreating(false) + await reload() + }} + /> + )} + + ) +} + +function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise }) { + const [kind, setKind] = useState('telegram') + const [name, setName] = useState('') + const [minSeverity, setMinSeverity] = useState('warning') + const [secret, setSecret] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(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 = {} + 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 ( + +
    +
    + + + + + + +
    + + + setName(e.target.value)} + /> + + + {kind === 'telegram' && ( + <> + + setChatID(e.target.value)} /> + + + setSecret(e.target.value)} + /> + + + )} + + {isWebhookish && ( + <> + + setUrl(e.target.value)} + /> + + + setSecret(e.target.value)} + /> + + + )} + + {kind === 'email' && ( + <> +
    +
    + + setHost(e.target.value)} /> + +
    + + setPort(e.target.value)} + /> + +
    + + setFrom(e.target.value)} /> + + + setTo(e.target.value)} /> + +
    + + setUser(e.target.value)} /> + + + setSecret(e.target.value)} + /> + +
    + + )} + + {err} + +
    + + +
    +
    +
    + ) +} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx new file mode 100644 index 0000000..051914e --- /dev/null +++ b/web/src/pages/DevicesPage.tsx @@ -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 = { + up: 'на зв’язку', + down: 'недоступний', + warning: 'проблеми', + maintenance: 'обслуговування', + unknown: 'невідомо', +} + +export function DevicesPage() { + // Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже + // тримає їх живими для лічильника в шапці, і другий такий самий + // опитувач лише подвоїв би навантаження на сервер під час аварії. + const live = useOutletContext() + + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [onlyProblems, setOnlyProblems] = useState(false) + const [detail, setDetail] = useState(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() + 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 ( + <> + !d.enabled).length + } вимкнено` + } + actions={ + <> + setQuery(e.target.value)} + /> + + + } + /> + +
    + {error && ( +
    + {error} +
    + )} + + {loading ? ( + + ) : ( + d.id} + onRowClick={setDetail} + empty={ + devices.length === 0 + ? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт' + : 'Нічого не знайдено за цим фільтром' + } + columns={[ + { + key: 'name', + header: 'Пристрій', + cell: (d) => ( + + {d.name} + {(alertsByDevice.get(d.id)?.length ?? 0) > 0 && ( + + {alertsByDevice.get(d.id)!.length} + + )} + + ), + }, + { + key: 'status', + header: 'Стан', + cell: (d) => ( + + ), + }, + { key: 'address', header: 'Адреса', cell: (d) => d.address ?? '—' }, + { + key: 'kind', + header: 'Тип', + hideOnMobile: true, + cell: (d) => ( + + {d.kind} + {d.vendor ? ` · ${d.vendor}` : ''} + + ), + }, + { + key: 'ifs', + header: 'Інтерфейси', + hideOnMobile: true, + cell: (d) => {d.interface_count}, + }, + { + key: 'seen', + header: 'Востаннє', + cell: (d) => {ago(d.last_seen_at)}, + }, + ]} + /> + )} +
    + + {detail && ( + { + 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 + onClose: () => void +}) { + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + return ( + +
    + } /> + + + + + + +
    + +

    + Активні алерти +

    + {alerts.length === 0 ? ( +

    Немає

    + ) : ( +
      + {alerts.map((a) => ( +
    • + {a.title} +

      {a.message}

      +
    • + ))} +
    + )} + + {err && ( +
    + {err} +
    + )} + + {canMute && ( +
    + {[60, 240, 1440].map((m) => ( + + ))} +
    + )} +
    + ) +} + +function Pair({ k, v }: { k: string; v: React.ReactNode }) { + return ( +
    +
    {k}
    +
    {v}
    +
    + ) +} diff --git a/web/src/pages/MapPage.tsx b/web/src/pages/MapPage.tsx new file mode 100644 index 0000000..48df27e --- /dev/null +++ b/web/src/pages/MapPage.tsx @@ -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([]) + const [mapID, setMapID] = useState(null) + const [selected, setSelected] = useState(null) + const [listError, setListError] = useState(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 ( +
    +
    + + + {canWrite && ( + <> + + + + )} + +
    + + {counts.up}/{counts.total} на звʼязку + {counts.down > 0 && · {counts.down} впало} + + + {live.state && ( + ревізія {live.state.revision} + )} +
    +
    + + {(live.error || listError) && ( +
    + {live.error ?? listError} +
    + )} + +
    + {live.state ? ( + + ) : ( +
    + {live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'} +
    + )} +
    + + {node && ( +
    +
    + {node.label} + +
    +
    + + {node.rtt_ms !== undefined && } + {node.loss_pct !== undefined && } + +
    +
    + )} +
    + ) +} + +function Row({ k, v }: { k: string; v: string }) { + return ( +
    + {k} + {v} +
    + ) +} + +/** + * Стан з'єднання показується завжди й помітно. + * + * Мапа, яка тихо замерзла після розриву 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 ( + + + {label} + {secs !== null && state === 'online' && ( + {secs} с тому + )} + + ) +} diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..5bb09af --- /dev/null +++ b/web/src/pages/ProfilePage.tsx @@ -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(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 ( + <> + + +
    +
    + +

    + Обліковий запис +

    +
    + + + +
    + +

    + Ваші права +

    +
    + {(me?.permissions ?? []).map((p) => ( + + {p} + + ))} +
    +
    + + +

    + Зміна пароля +

    + +
    { + e.preventDefault() + void submit() + }} + > + + setCurrent(e.target.value)} + /> + + + + setNext(e.target.value)} + /> + + + + setRepeat(e.target.value)} + /> + + + {mismatch && Паролі не збігаються} + {tooShort && Пароль закороткий} + {err} + + {done && ( +
    + Пароль змінено. Відкликано всі сесії, включно з цією — зараз доведеться + увійти заново. +
    + )} + + +
    +
    +
    +
    + + ) +} + +function Pair({ k, v }: { k: string; v: string }) { + return ( +
    +
    {k}
    +
    {v}
    +
    + ) +} diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx new file mode 100644 index 0000000..701ca62 --- /dev/null +++ b/web/src/pages/RulesPage.tsx @@ -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 = { + 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 = { + info: 'Інформація', + warning: 'Увага', + average: 'Середня', + high: 'Висока', + disaster: 'Аварія', +} + +const sourceLabel: Record = { + icmp: 'Пінг (ICMP)', + interface: 'Інтерфейси (SNMP)', + metric: 'Довільна метрика', +} + +export function RulesPage() { + const [rules, setRules] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( + <> + r.enabled).length} увімкнено` + } + actions={ + canWrite && ( + + ) + } + /> + +
    + {error && ( +
    + {error} +
    + )} + + {loading ? ( + + ) : ( + r.id} + empty="Правил ще немає — без них система мовчатиме про будь-яку аварію" + columns={[ + { + key: 'name', + header: 'Назва', + cell: (r) => {r.name}, + }, + { + key: 'source', + header: 'Джерело', + cell: (r) => {sourceLabel[r.source] ?? r.source}, + }, + { + key: 'cond', + header: 'Умова', + hideOnMobile: true, + cell: (r) => ( + {describeCondition(r)} + ), + }, + { + key: 'sev', + header: 'Серйозність', + cell: (r) => severityLabel[r.severity] ?? r.severity, + }, + { + key: 'active', + header: 'Активних', + cell: (r) => + r.active_alerts > 0 ? ( + + {r.active_alerts} + + ) : ( + 0 + ), + }, + { + key: 'enabled', + header: 'Увімк.', + cell: (r) => ( +
    + { + await api.setRuleEnabled(r.id, v) + await reload() + }} + /> + {canWrite && ( + + )} +
    + ), + }, + ]} + /> + )} +
    + + {creating && ( + 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 }) { + 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('warning') + const [forSec, setForSec] = useState('180') + const [topo, setTopo] = useState(true) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + const isNoData = metric === 'no_data' + const metrics = metricsBySource[source] ?? [] + + async function submit() { + setBusy(true) + setErr(null) + try { + const condition: Record = 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 ( + +
    + + setName(e.target.value)} + /> + + +
    + + + + + + + +
    + + {source === 'metric' ? ( + + setMetricKey(e.target.value)} + /> + + ) : ( + + + + )} + + {!isNoData && ( +
    + + + + + setValue(e.target.value)} + /> + + + + +
    + )} + + + setForSec(e.target.value)} + /> + + + + + {err} + +
    + + +
    +
    +
    + ) +} diff --git a/web/src/pages/TeamPage.tsx b/web/src/pages/TeamPage.tsx new file mode 100644 index 0000000..ce7056e --- /dev/null +++ b/web/src/pages/TeamPage.tsx @@ -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([]) + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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) { + try { + await fn() + await reload() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + return ( + <> + setAdding(true)}> + + Користувач + + ) + } + /> + +
    + {error && ( +
    + {error} +
    + )} + + {loading ? ( + + ) : ( + m.user_id} + empty="Учасників немає" + columns={[ + { + key: 'email', + header: 'Користувач', + cell: (m) => ( + + + {m.full_name || m.email} + + {m.user_id === me?.userID && ( + + це ви + + )} + + ), + }, + { + key: 'mail', + header: 'Email', + hideOnMobile: true, + cell: (m) => {m.email}, + }, + { + key: 'role', + header: 'Роль', + cell: (m) => { + // Роль власника не видається через API — вона й не має + // з’являтися у списку вибору. Так само не можна змінити + // роль собі: організація без власника не відновлюється. + const editable = + canWrite && m.user_id !== me?.userID && m.role_key !== 'owner' + if (!editable) return {m.role_name} + return ( + + ) + }, + }, + { + key: 'login', + header: 'Останній вхід', + cell: (m) => ( + {ago(m.last_login_at)} + ), + }, + { + key: 'act', + header: '', + cell: (m) => + canWrite && m.user_id !== me?.userID && m.role_key !== 'owner' ? ( + + ) : null, + }, + ]} + /> + )} + +
    +

    + Ролі та права +

    +
      + {roles.map((r) => ( +
    • +
      {r.name}
      + {r.description && ( +

      {r.description}

      + )} +
      + {r.permissions.map((p) => ( + + {p} + + ))} +
      +
    • + ))} +
    +
    +
    + + {adding && ( + r.key !== 'owner')} + onClose={() => setAdding(false)} + onSaved={async () => { + setAdding(false) + await reload() + }} + /> + )} + + ) +} + +function AddUserForm({ + roles, + onClose, + onSaved, +}: { + roles: Role[] + onClose: () => void + onSaved: () => Promise +}) { + 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(null) + + return ( + +
    + + setEmail(e.target.value)} + /> + + + + setFullName(e.target.value)} + /> + + + + setPassword(e.target.value)} + /> + + + + + + + {err} + +
    + + +
    +
    +
    + ) +} diff --git a/web/src/types.ts b/web/src/types.ts index 3046909..fd7510e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -255,6 +255,18 @@ export interface AlertCounts { high: number } +export interface Channel { + id: string + kind: string + name: string + config: Record + template?: string + min_severity: string + enabled: boolean + /** Чи збережено секрет. Сам секрет назад не віддається ніколи. */ + has_secret: boolean +} + export interface AlertRule { id: string name: string