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 (
-
-
- setSidebarOpen((v) => !v)}
- aria-label="Меню"
- >
- ☰
-
-
-
- NetPulse
-
-
- setMapID(e.target.value || null)}
- >
- {maps.length === 0 && мап немає }
- {maps.map((m) => (
-
- {m.name} ({m.node_count}/{m.edge_count})
-
- ))}
-
-
- {canWrite && (
- <>
- void live.build()}
- title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
- >
- Добудувати з топології
- Добудувати
-
-
- void live.undo()}
- title="Відкотити останню зміну полотна"
- >
- ↶Відкотити
-
- >
- )}
-
-
-
-
-
- {canSeeAlerts && (
-
setAlertsOpen((v) => !v)} />
- )}
-
- {live.state && (
-
- ревізія {live.state.revision}
-
- )}
- {me?.email && (
- void api.logout()}
- >
- {me.email}
-
- )}
-
-
-
- {(live.error || listError) && (
-
- {live.error ?? listError}
-
- )}
-
-
- {/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */}
- {sidebarOpen && (
-
setSidebarOpen(false)}
- />
- )}
-
-
-
-
- Стан вузлів
-
-
-
-
-
-
-
-
-
- {session.can('agents:read') && (
-
-
- Зонди
-
- {agents.length === 0 && немає
}
-
-
- )}
-
-
-
- {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}
- setSelected(null)}>
- ✕
-
-
-
-
- {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.title}
-
-
- {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 (
-
- {children}
-
- )
-}
-
-/** Індикатор у шапці. Мовчить, коли алертів немає. */
-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 (
- 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} підтверджено`
- }
- >
- 🔔
- {total}
-
- )
-}
-
-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 (
+
+
+ setNavOpen((v) => !v)}
+ aria-label="Меню"
+ >
+ ☰
+
+
+ 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}
+
+ void api.logout()}
+ title="Вийти"
+ >
+ ⏻
+
+
+ )}
+
+
+
+
+ {navOpen && (
+
setNavOpen(false)}
+ />
+ )}
+
+
+ {visible.map((i) => (
+
+ `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'
+ }`
+ }
+ >
+ {i.icon}
+ {i.label}
+ {i.badge && alerts.counts.firing > 0 && (
+
+ {alerts.counts.firing}
+
+ )}
+
+ ))}
+
+ {visible.length === 0 && (
+
+ Вашій ролі не надано жодного розділу. Зверніться до власника організації.
+
+ )}
+
+
+
+
+
+
+
+ )
+}
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 (
+
+ {children}
+
+ )
+}
+
+export function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string
+ hint?: string
+ children: ReactNode
+}) {
+ return (
+
+ {label}
+ {children}
+ {hint && {hint} }
+
+ )
+}
+
+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) => (
+
+ {c.header}
+
+ ))}
+
+
+
+ {rows.map((r) => (
+ onRowClick(r) : undefined}
+ >
+ {columns.map((c) => (
+
+ {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 (
+ onChange(!checked)}
+ className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40
+ ${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
+ >
+
+
+ )
+}
+
+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) => (
+ setFilter(f)}>
+ {filterLabel[f]}
+ {counts[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 && (
+
+ Заглушити 1 год
+
+ )}
+
+ Закрити
+
+
+ )}
+
+
+
+
+ )
+}
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 && (
+
+ {
+ 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 ? '…' : 'Перевірити'}
+
+ {
+ await api.deleteChannel(c.id)
+ await reload()
+ }}
+ >
+ ✕
+
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+
+ {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 (
+
+
+
+ )
+}
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)}
+ />
+ setOnlyProblems((v) => !v)}
+ title="Показати лише недоступні й проблемні"
+ >
+ Тільки проблеми
+
+ >
+ }
+ />
+
+
+ {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) => (
+ {
+ 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 год'}
+
+ ))}
+
+ )}
+
+ )
+}
+
+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 (
+
+
+
setMapID(e.target.value || null)}
+ >
+ {maps.length === 0 && мап немає }
+ {maps.map((m) => (
+
+ {m.name} ({m.node_count}/{m.edge_count})
+
+ ))}
+
+
+ {canWrite && (
+ <>
+
void live.build()}
+ title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
+ >
+ Добудувати з топології
+ Добудувати
+
+
void live.undo()}
+ title="Відкотити останню зміну полотна"
+ >
+ ↶Відкотити
+
+ >
+ )}
+
+
+
+ {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}
+ setSelected(null)}
+ >
+ ✕
+
+
+
+
+ {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}
+
+ ))}
+
+
+
+
+
+ Зміна пароля
+
+
+
+
+
+
+ >
+ )
+}
+
+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 && (
+ setCreating(true)}>
+ + Правило
+
+ )
+ }
+ />
+
+
+ {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 && (
+ {
+ await api.deleteAlertRule(r.id)
+ await reload()
+ }}
+ >
+ ✕
+
+ )}
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+
+ {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)}
+ />
+
+
+
+
+ {
+ setSource(e.target.value)
+ const first = metricsBySource[e.target.value]?.[0]
+ setMetric(first?.key ?? '')
+ }}
+ >
+ {Object.keys(metricsBySource).map((s) => (
+
+ {sourceLabel[s]}
+
+ ))}
+
+
+
+
+ setSeverity(e.target.value as Severity)}
+ >
+ {(Object.keys(severityLabel) as Severity[]).map((s) => (
+
+ {severityLabel[s]}
+
+ ))}
+
+
+
+
+ {source === 'metric' ? (
+
+ setMetricKey(e.target.value)}
+ />
+
+ ) : (
+
+ setMetric(e.target.value)}>
+ {metrics.map((m) => (
+
+ {m.label}
+ {m.unit ? `, ${m.unit}` : ''}
+
+ ))}
+
+
+ )}
+
+ {!isNoData && (
+
+
+ setOp(e.target.value)}>
+ {['>', '>=', '<', '<=', '==', '!='].map((o) => (
+
+ {o}
+
+ ))}
+
+
+
+ setValue(e.target.value)}
+ />
+
+
+ setAgg(e.target.value)}>
+ усі виміри
+ {['avg', 'min', 'max', 'sum', 'last'].map((a) => (
+
+ {a}
+
+ ))}
+
+
+
+ )}
+
+
+ setForSec(e.target.value)}
+ />
+
+
+
+ setTopo(e.target.checked)}
+ />
+
+ Враховувати топологію
+
+ Пристрої за впалим маршрутизатором не піднімуть власних алертів — інакше одна
+ аварія дає десятки однакових сповіщень.
+
+
+
+
+
{err}
+
+
+ Скасувати
+ void submit()}
+ >
+ {busy ? 'Збереження…' : 'Створити'}
+
+
+
+
+ )
+}
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 (
+ void act(() => api.setRole(m.user_id, e.target.value))}
+ >
+ {roles
+ .filter((r) => r.key !== 'owner')
+ .map((r) => (
+
+ {r.name}
+
+ ))}
+
+ )
+ },
+ },
+ {
+ 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' ? (
+ void act(() => api.removeMember(m.user_id))}
+ >
+ ✕
+
+ ) : null,
+ },
+ ]}
+ />
+ )}
+
+
+
+
+ {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 (
+
+
+
+ )
+}
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