Повноцінний веб: навігація і вісім сторінок

До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе,
що вміло API, доводилось викликати curl-ом.

Каркас:
- бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці,
  живий лічильник алертів
- пункт, на який немає права, не показується взагалі; пряме посилання
  лишається робочим і дає пояснення з назвою потрібного права
- стартова сторінка залежить від ролі: глядача без прав на мапи вітати
  відмовою — поганий перший екран

Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з
фільтрами, правила зі створенням через форму, канали з кнопкою
перевірки, зонди, команда з керуванням ролями, профіль зі зміною
пароля.

DataTable на телефоні перестає бути таблицею: горизонтальний скрол на
375 px робить дані формально присутніми й фактично нечитабельними.

Знайдено роботою з живим UI:
- WebSocket жив усередині мапи, тому на решті сторінок живих оновлень
  не було взагалі — лічильник алертів замерзав, щойно людина йшла з
  мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка
- сторінка пристроїв перечитувала все на кожну подію алерту; зі
  злиттям сплеску й спільними алертами той самий сплеск коштує
  2 запити замість дванадцяти
- канал із секретом показувався як «секрету немає»: прапорець ставився
  лише в гілці розшифровки, а перелік для UI викликається без ключа

Перевірено в браузері проти повного стека: усі вісім сторінок із
живими даними, створення правила й користувача через форми, перевірка
каналу, обмеження глядача, мобільний вигляд на 375 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 01:17:57 +03:00
parent 215bf8c4db
commit 36f29e23ec
24 changed files with 2877 additions and 641 deletions

View file

@ -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-режиму на телевізор.

View file

@ -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` /

View file

@ -144,6 +144,12 @@ func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.
c.TenantID = tenantID
c.Config = json.RawMessage(cfg)
// Наявність секрету — факт про канал, а не наслідок того,
// чи його зараз розшифровують. Перелік для UI викликається
// без кільця навмисно, і без цього рядка канал із токеном
// показувався б у ньому як «секрету немає».
c.HasSecret = keyID != nil
if keyID != nil && ring != nil {
plain, err := ring.Decrypt(&crypto.Secret{
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
@ -155,7 +161,6 @@ func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.
return fmt.Errorf("канал %s: розшифровка секрету: %w", c.Name, err)
}
c.Secret = string(plain)
c.HasSecret = true
}
out = append(out, c)
}

56
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -1,12 +1,18 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { useEffect, useState, useSyncExternalStore } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { api, staticTokenPresent } from './api/client'
import { session } from './api/session'
import { useLiveMap } from './hooks/useLiveMap'
import { useAlerts } from './hooks/useAlerts'
import { AppShell } from './components/AppShell'
import { LoginPage } from './components/LoginPage'
import { MapCanvas } from './components/MapCanvas'
import { AlertBadge, AlertsPanel } from './components/AlertsPanel'
import type { AgentSummary, MapSummary } from './types'
import { MapPage } from './pages/MapPage'
import { DevicesPage } from './pages/DevicesPage'
import { AlertsPage } from './pages/AlertsPage'
import { RulesPage } from './pages/RulesPage'
import { ChannelsPage } from './pages/ChannelsPage'
import { AgentsPage } from './pages/AgentsPage'
import { TeamPage } from './pages/TeamPage'
import { ProfilePage } from './pages/ProfilePage'
import type { Permission } from './types'
export default function App() {
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
@ -26,7 +32,7 @@ export default function App() {
if (booting) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950 text-sm text-slate-600">
<div className="flex h-[100dvh] items-center justify-center bg-slate-950 text-sm text-slate-600">
Відновлення сесії
</div>
)
@ -36,326 +42,66 @@ export default function App() {
return <LoginPage onDone={() => setBooting(false)} />
}
return <Workspace />
}
function Workspace() {
const [maps, setMaps] = useState<MapSummary[]>([])
const [mapID, setMapID] = useState<string | null>(null)
const [agents, setAgents] = useState<AgentSummary[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
// Мобільний сайдбар відкривається поверх полотна: на 375 px ширини
// постійна колонка з'їдала б дві третини карти.
const [sidebarOpen, setSidebarOpen] = useState(false)
const [alertsOpen, setAlertsOpen] = useState(false)
const live = useLiveMap(mapID)
const alerts = useAlerts()
const canWrite = session.can('maps:write')
const canSeeAlerts = session.can('alerts:read')
useEffect(() => {
api
.listMaps()
.then((m) => {
setMaps(m)
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
})
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
api.listAgents().then(setAgents).catch(() => {})
}, [])
const counts = useMemo(() => {
const nodes = live.state?.nodes ?? []
return {
total: nodes.length,
up: nodes.filter((n) => n.status === 'up').length,
down: nodes.filter((n) => n.status === 'down').length,
unknown: nodes.filter((n) => !n.status || n.status === 'unknown').length,
}
}, [live.state])
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
const me = session.me()
return (
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
<header
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-slate-800
bg-slate-900 px-3 py-2 md:px-4"
>
<button
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
onClick={() => setSidebarOpen((v) => !v)}
aria-label="Меню"
>
</button>
<span className="text-base font-semibold tracking-tight text-slate-100 md:text-lg">
NetPulse
</span>
<select
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm
md:flex-none"
value={mapID ?? ''}
onChange={(e) => setMapID(e.target.value || null)}
>
{maps.length === 0 && <option value="">мап немає</option>}
{maps.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.node_count}/{m.edge_count})
</option>
))}
</select>
{canWrite && (
<>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
hover:bg-slate-700 disabled:opacity-40"
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
<span className="hidden lg:inline">Добудувати з топології</span>
<span className="lg:hidden">Добудувати</span>
</button>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm
hover:bg-slate-700 disabled:opacity-40"
disabled={!live.canUndo}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
<span className="ml-1 hidden lg:inline">Відкотити</span>
</button>
</>
)}
<div className="ml-auto flex items-center gap-3 text-xs">
<span className="hidden xl:block">
<Legend />
</span>
{canSeeAlerts && (
<AlertBadge counts={alerts.counts} onClick={() => setAlertsOpen((v) => !v)} />
)}
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="hidden tabular-nums text-slate-500 sm:inline">
ревізія {live.state.revision}
</span>
)}
{me?.email && (
<button
className="max-w-[10rem] truncate rounded border border-slate-700 px-2 py-1
text-slate-400 hover:bg-slate-800"
title={`${me.email} — вийти`}
onClick={() => void api.logout()}
>
{me.email}
</button>
)}
</div>
</header>
{(live.error || listError) && (
<div className="shrink-0 border-b border-amber-900/50 bg-amber-950/60 px-4 py-1.5 text-sm text-amber-200">
{live.error ?? listError}
</div>
)}
<div className="relative flex min-h-0 flex-1">
{/* Затемнення під висувним сайдбаром — лише на вузьких екранах. */}
{sidebarOpen && (
<div
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={`absolute inset-y-0 left-0 z-20 flex w-64 flex-col gap-4 overflow-y-auto
border-r border-slate-800 bg-slate-900 p-3 transition-transform
md:static md:z-0 md:w-60 md:translate-x-0 md:bg-slate-900/60
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Стан вузлів
</h2>
<div className="space-y-1 text-sm">
<Counter label="Усього" value={counts.total} />
<Counter label="Працюють" value={counts.up} color="text-emerald-400" />
<Counter label="Недоступні" value={counts.down} color="text-red-400" />
<Counter label="Невідомо" value={counts.unknown} color="text-slate-400" />
</div>
</section>
{session.can('agents:read') && (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Зонди
</h2>
{agents.length === 0 && <p className="text-xs text-slate-600">немає</p>}
<ul className="space-y-2">
{agents.map((a) => (
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<div className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 rounded-full ${
a.status === 'online' ? 'bg-emerald-400' : 'bg-slate-600'
}`}
/>
<span className="truncate font-medium text-slate-200">{a.name}</span>
</div>
<div className="mt-1 text-slate-500">
{a.os}/{a.arch} · {a.device_count} пристроїв
</div>
{typeof a.health?.rss_bytes === 'number' && (
<div className="tabular-nums text-slate-500">
RSS {((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ
</div>
)}
</li>
))}
</ul>
</section>
)}
</aside>
<main className="min-w-0 flex-1">
{live.state ? (
<MapCanvas
state={live.state}
onPatch={live.patch}
onSelect={setSelected}
readOnly={!canWrite}
/>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
</div>
)}
</main>
{alertsOpen && canSeeAlerts && (
<AlertsPanel
live={alerts}
onClose={() => setAlertsOpen(false)}
// Клік по алерту веде до пристрою на мапі: інакше зв'язок
// між рядком у списку й вузлом доводиться шукати очима.
onFocusDevice={(deviceID) => {
const n = live.state?.nodes.find((x) => x.device_id === deviceID)
if (n) setSelected(n.id)
}}
/>
)}
</div>
{/* Інспектор вузла: збоку на десктопі, нижнім аркушем на телефоні. */}
{node && (
<div
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
>
<div className="mb-1 flex items-center justify-between">
<span className="font-medium text-slate-100">{node.label}</span>
<button className="px-2 text-slate-500 hover:text-slate-300" onClick={() => setSelected(null)}>
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
<Row k="статус" v={node.status || 'unknown'} />
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
</div>
</div>
)}
</div>
)
}
function Counter({ label, value, color }: { label: string; value: number; color?: string }) {
return (
<div className="flex justify-between">
<span className="text-slate-400">{label}</span>
<span className={`tabular-nums font-medium ${color ?? 'text-slate-200'}`}>{value}</span>
</div>
)
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2 md:w-full">
<span className="text-slate-500">{k}</span>
<span className="tabular-nums text-slate-300">{v}</span>
</div>
)
}
function Legend() {
return (
<span className="flex items-center gap-2 text-slate-500">
<Dot c="bg-emerald-400" t="норма" />
<Dot c="bg-amber-400" t="навантажено" />
<Dot c="bg-red-500" t="обрив" />
<Dot c="bg-slate-500" t="невідомо" />
</span>
)
}
function Dot({ c, t }: { c: string; t: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${c}`} />
{t}
</span>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Home />} />
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
<Route path="/agents" element={<Guard perm="agents:read" element={<AgentsPage />} />} />
<Route path="/team" element={<Guard perm="users:read" element={<TeamPage />} />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="*" element={<Home />} />
</Route>
</Routes>
</BrowserRouter>
)
}
/**
* Стан з'єднання показується завжди й помітно.
* Стартова сторінка залежить від ролі.
*
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
* само, як мапа, де все гаразд, і це найнебезпечніший стан
* моніторингу.
* Глядача без права на мапи слати на «/map» означало б вітати його
* порожнім екраном із відмовою. Тому перший доступний розділ і стає
* домівкою.
*/
function ConnectionBadge({
state,
lastEventAt,
}: {
state: 'connecting' | 'online' | 'offline'
lastEventAt: number | null
}) {
const [, force] = useState(0)
useEffect(() => {
const t = setInterval(() => force((n) => n + 1), 1000)
return () => clearInterval(t)
}, [])
const label =
state === 'online' ? 'наживо' : state === 'connecting' ? 'підключення…' : 'немає зв\'язку'
const color =
state === 'online'
? 'bg-emerald-400'
: state === 'connecting'
? 'bg-amber-400'
: 'bg-red-500 animate-pulse'
const ago = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
function Home() {
const order: { perm: Permission; to: string }[] = [
{ perm: 'maps:read', to: '/map' },
{ perm: 'alerts:read', to: '/alerts' },
{ perm: 'devices:read', to: '/devices' },
{ perm: 'agents:read', to: '/agents' },
{ perm: 'users:read', to: '/team' },
]
const first = order.find((o) => session.can(o.perm))
return <Navigate to={first?.to ?? '/profile'} replace />
}
/**
* Захист маршруту правом.
*
* Навігація вже ховає недоступні пункти, але посилання лишається
* робочим: його можна зберегти в закладки, отримати від колеги або
* успадкувати після зміни ролі. Сервер у будь-якому разі відповість 403
* питання лише в тому, побачить людина зрозуміле пояснення чи порожню
* сторінку з помилкою в консолі.
*/
function Guard({ perm, element }: { perm: Permission; element: React.ReactNode }) {
if (session.can(perm)) return <>{element}</>
return (
<span className="flex items-center gap-1.5 text-slate-400">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
<span className="hidden sm:inline">{label}</span>
{ago !== null && state === 'online' && (
<span className="hidden tabular-nums text-slate-600 lg:inline">{ago} с тому</span>
)}
</span>
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-sm text-center">
<div className="mb-2 text-2xl">🔒</div>
<h2 className="text-sm font-medium text-slate-200">Розділ недоступний</h2>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Вашій ролі не надано права <code className="text-slate-400">{perm}</code>. Попросіть
власника організації змінити роль, якщо доступ потрібен для роботи.
</p>
</div>
</div>
)
}

View file

@ -5,6 +5,7 @@ import type {
AlertCounts,
AlertRule,
BuildResult,
Channel,
DeviceSummary,
LoginResponse,
MapPatch,
@ -220,9 +221,66 @@ export const api = {
listAlertRules: () =>
request<{ rules: AlertRule[] }>('/api/v1/alert-rules').then((r) => r.rules ?? []),
createAlertRule: (rule: {
name: string
source: string
severity: string
condition: Record<string, unknown>
selector?: Record<string, unknown>
for_seconds: number
depends_on_topology: boolean
}) =>
request<{ id: string }>('/api/v1/alert-rules', {
method: 'POST',
body: JSON.stringify(rule),
}),
setRuleEnabled: (id: string, enabled: boolean) =>
request<void>(`/api/v1/alert-rules/${id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
}),
deleteAlertRule: (id: string) =>
request<void>(`/api/v1/alert-rules/${id}`, { method: 'DELETE' }),
// --- канали доставки ---
listChannels: () =>
request<{ channels: Channel[] }>('/api/v1/channels').then((r) => r.channels ?? []),
createChannel: (ch: {
kind: string
name: string
config: Record<string, unknown>
min_severity: string
secret?: string
template?: string
}) =>
request<{ id: string }>('/api/v1/channels', {
method: 'POST',
body: JSON.stringify(ch),
}),
deleteChannel: (id: string) => request<void>(`/api/v1/channels/${id}`, { method: 'DELETE' }),
/** Помилка каналу — це відповідь, а не збій: сервер віддає її кодом 200. */
testChannel: (id: string) =>
request<{ ok: boolean; error?: string }>(`/api/v1/channels/${id}/test`, { method: 'POST' }),
// --- профіль ---
/**
* Зміна пароля відкликає ВСІ сесії, включно з поточною, тому одразу
* після успіху сесія чиститься локально. Лишити людину на сторінці з
* токеном, який доживає свої хвилини, означало б показати їй помилку
* через 15 хвилин замість чесного «увійдіть заново» зараз.
*/
changePassword: async (oldPassword: string, newPassword: string) => {
await request<void>('/api/v1/auth/password', {
method: 'POST',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
})
session.clear()
},
}

View file

@ -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[],
}
}

View file

@ -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()

View file

@ -1,250 +0,0 @@
import { useState } from 'react'
import { session } from '../api/session'
import type { Alert, Severity } from '../types'
import type { LiveAlerts } from '../hooks/useAlerts'
const severityStyle: Record<Severity, { dot: string; text: string; label: string }> = {
disaster: { dot: 'bg-red-500', text: 'text-red-300', label: 'аварія' },
high: { dot: 'bg-red-400', text: 'text-red-300', label: 'висока' },
average: { dot: 'bg-orange-400', text: 'text-orange-300', label: 'середня' },
warning: { dot: 'bg-amber-400', text: 'text-amber-300', label: 'увага' },
info: { dot: 'bg-sky-400', text: 'text-sky-300', label: 'інфо' },
}
const suppressLabel: Record<string, string> = {
maintenance: 'вікно обслуговування',
mute: 'заглушено вручну',
topology: 'наслідок іншої аварії',
dependency: 'залежність',
}
/**
* Панель алертів.
*
* На десктопі колонка праворуч, на телефоні аркуш на весь екран.
* Проміжного варіанта немає навмисно: список алертів або читають
* уважно, або не читають узагалі, і вузька смужка збоку на 375 px
* гарантує друге.
*/
export function AlertsPanel({
live,
onClose,
onFocusDevice,
}: {
live: LiveAlerts
onClose: () => void
onFocusDevice?: (deviceID: string) => void
}) {
const canAck = session.can('alerts:ack')
const [showSuppressed, setShowSuppressed] = useState(false)
const visible = showSuppressed
? live.alerts
: live.alerts.filter((a) => a.state !== 'suppressed')
const hidden = live.alerts.length - visible.length
return (
<aside
className="fixed inset-0 z-30 flex flex-col border-slate-800 bg-slate-900
md:absolute md:inset-y-0 md:left-auto md:right-0 md:w-96 md:border-l"
>
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 px-3 py-2">
<h2 className="text-sm font-semibold text-slate-100">Алерти</h2>
<span className="text-xs tabular-nums text-slate-500">
{live.counts.firing} активних · {live.counts.acknowledged} підтверджено
</span>
<button
className="ml-auto rounded px-2 py-1 text-slate-500 hover:bg-slate-800 hover:text-slate-300"
onClick={onClose}
aria-label="Закрити панель алертів"
>
</button>
</header>
{live.error && (
<div className="shrink-0 border-b border-amber-900/50 bg-amber-950/60 px-3 py-1.5 text-xs text-amber-200">
{live.error}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{live.loading && (
<p className="px-3 py-6 text-center text-xs text-slate-600">Завантаження</p>
)}
{!live.loading && visible.length === 0 && (
<p className="px-3 py-8 text-center text-xs text-slate-600">
{live.alerts.length === 0 ? 'Активних алертів немає' : 'Усі активні — придушені'}
</p>
)}
<ul className="divide-y divide-slate-800">
{visible.map((a) => (
<AlertRow
key={a.id}
alert={a}
canAck={canAck}
onAck={() => void live.ack(a.id)}
onClose={() => void live.close(a.id)}
onMute={a.device_id ? () => void live.mute(a.device_id!, 60) : undefined}
onFocus={
a.device_id && onFocusDevice ? () => onFocusDevice(a.device_id!) : undefined
}
/>
))}
</ul>
</div>
{hidden > 0 && (
<button
className="shrink-0 border-t border-slate-800 px-3 py-2 text-left text-xs text-slate-500
hover:bg-slate-800/50"
onClick={() => setShowSuppressed((v) => !v)}
>
{showSuppressed ? 'Сховати придушені' : `Показати придушені (${hidden})`}
</button>
)}
</aside>
)
}
function AlertRow({
alert: a,
canAck,
onAck,
onClose,
onMute,
onFocus,
}: {
alert: Alert
canAck: boolean
onAck: () => void
onClose: () => void
onMute?: () => void
onFocus?: () => void
}) {
const s = severityStyle[a.severity] ?? severityStyle.info
const acked = a.state === 'acknowledged'
return (
<li className={`px-3 py-2.5 ${a.state === 'suppressed' ? 'opacity-50' : ''}`}>
<div className="flex items-start gap-2">
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className={`text-[10px] uppercase tracking-wide ${s.text}`}>{s.label}</span>
<span className="text-[10px] tabular-nums text-slate-600">{ago(a.started_at)}</span>
{acked && (
<span className="text-[10px] text-emerald-500" title={a.acked_by_email}>
прийнято
</span>
)}
</div>
<button
className="block w-full truncate text-left text-sm font-medium text-slate-100
hover:text-sky-300 disabled:hover:text-slate-100"
onClick={onFocus}
disabled={!onFocus}
title={a.title}
>
{a.title}
</button>
{a.message && <p className="mt-0.5 text-xs leading-snug text-slate-400">{a.message}</p>}
{a.suppressed_by && (
<p className="mt-1 text-[11px] text-slate-500">
придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by}
</p>
)}
{canAck && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{!acked && (
<SmallButton onClick={onAck} title="Зупинити ескалацію — я вже дивлюсь">
Прийняти
</SmallButton>
)}
{onMute && (
<SmallButton onClick={onMute} title="Не турбувати цим пристроєм годину">
Заглушити 1 год
</SmallButton>
)}
<SmallButton onClick={onClose} title="Закрити вручну">
Закрити
</SmallButton>
</div>
)}
</div>
</div>
</li>
)
}
function SmallButton({
children,
onClick,
title,
}: {
children: React.ReactNode
onClick: () => void
title?: string
}) {
// На телефоні ці кнопки натискають пальцем і зазвичай уночі: 26 px,
// яких вистачало мишею, тут перетворюються на промахи. На десктопі
// висота повертається до щільної — список має лишатись оглядним.
return (
<button
className="min-h-[38px] rounded border border-slate-700 bg-slate-800 px-3 py-2 text-xs
text-slate-300 hover:bg-slate-700
md:min-h-0 md:px-2 md:py-1 md:text-[11px]"
onClick={onClick}
title={title}
>
{children}
</button>
)
}
/** Індикатор у шапці. Мовчить, коли алертів немає. */
export function AlertBadge({
counts,
onClick,
}: {
counts: { firing: number; acknowledged: number; disaster: number; high: number }
onClick: () => void
}) {
const total = counts.firing + counts.acknowledged
const critical = counts.disaster + counts.high
return (
<button
className={`flex items-center gap-1.5 rounded border px-2 py-1 text-xs ${
critical > 0
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
: counts.firing > 0
? 'border-amber-800 bg-amber-950/50 text-amber-200'
: 'border-slate-700 text-slate-400 hover:bg-slate-800'
}`}
onClick={onClick}
title={
total === 0
? 'Алертів немає'
: `${counts.firing} активних, ${counts.acknowledged} підтверджено`
}
>
<span>🔔</span>
<span className="tabular-nums">{total}</span>
</button>
)
}
function ago(iso: string): string {
const sec = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000))
if (sec < 60) return `${sec} с`
if (sec < 3600) return `${Math.round(sec / 60)} хв`
if (sec < 86400) return `${Math.round(sec / 3600)} год`
return `${Math.round(sec / 86400)} дн`
}

View file

@ -0,0 +1,183 @@
import { useEffect, useState, useSyncExternalStore } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { api } from '../api/client'
import { session } from '../api/session'
import { live } from '../api/ws'
import { useAlerts } from '../hooks/useAlerts'
import type { Permission } from '../types'
interface NavItem {
to: string
label: string
icon: string
perm: Permission
/** Показувати лічильник алертів на цьому пункті. */
badge?: boolean
}
const navItems: NavItem[] = [
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
{ to: '/devices', label: 'Пристрої', icon: '🖧', perm: 'devices:read' },
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
]
/**
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
*
* Пункт, на який немає права, не показується взагалі. Пункт, який
* завжди веде до 403, гірший за його відсутність: він обіцяє
* можливість, якої немає, і змушує людину гадати, що вона зробила не так.
*/
export function AppShell() {
const [navOpen, setNavOpen] = useState(false)
const location = useLocation()
const me = session.me()
const alerts = useAlerts()
const connection = useSyncExternalStore(live.subscribeState, live.getState)
// Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам:
// лічильник алертів у шапці має оновлюватись і тоді, коли людина
// дивиться на список пристроїв або взагалі на профіль.
useEffect(() => {
live.start()
return () => live.stop()
}, [])
// Перехід між сторінками закриває мобільну шухляду: інакше вона
// лишається поверх щойно відкритої сторінки.
useEffect(() => setNavOpen(false), [location.pathname])
const visible = navItems.filter((i) => session.can(i.perm))
return (
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 bg-slate-900 px-3 py-2">
<button
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
onClick={() => setNavOpen((v) => !v)}
aria-label="Меню"
>
</button>
<span className="text-base font-semibold tracking-tight text-slate-100">NetPulse</span>
<div className="ml-auto flex items-center gap-2 text-xs">
{/* Замерзлий інтерфейс виглядає точно так само, як справний.
Тому стан з'єднання видно з будь-якої сторінки, а не лише
з мапи. */}
<span
className="flex items-center gap-1.5 text-slate-500"
title={
connection === 'online'
? 'Дані оновлюються наживо'
: connection === 'connecting'
? 'Підключення…'
: "Зв'язок із сервером втрачено — дані на екрані застаріли"
}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
connection === 'online'
? 'bg-emerald-400'
: connection === 'connecting'
? 'bg-amber-400'
: 'animate-pulse bg-red-500'
}`}
/>
<span className="hidden sm:inline">
{connection === 'online' ? 'наживо' : connection === 'connecting' ? '…' : 'офлайн'}
</span>
</span>
{alerts.counts.firing > 0 && (
<NavLink
to="/alerts"
className={`flex items-center gap-1.5 rounded border px-2 py-1 ${
alerts.counts.disaster + alerts.counts.high > 0
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
: 'border-amber-800 bg-amber-950/50 text-amber-200'
}`}
title={`${alerts.counts.firing} активних алертів`}
>
🔔 <span className="tabular-nums">{alerts.counts.firing}</span>
</NavLink>
)}
{me?.email && (
<div className="flex items-center gap-1">
<NavLink
to="/profile"
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
text-slate-400 hover:bg-slate-800"
title={me.email}
>
{me.email}
</NavLink>
<button
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
onClick={() => void api.logout()}
title="Вийти"
>
</button>
</div>
)}
</div>
</header>
<div className="relative flex min-h-0 flex-1">
{navOpen && (
<div
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
onClick={() => setNavOpen(false)}
/>
)}
<nav
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col gap-0.5
overflow-y-auto border-r border-slate-800 bg-slate-900 p-2
transition-transform
md:static md:z-0 md:w-52 md:translate-x-0 md:bg-slate-900/50
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
{visible.map((i) => (
<NavLink
key={i.to}
to={i.to}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded px-3 py-2.5 text-sm md:py-2 ${
isActive
? 'bg-slate-800 font-medium text-slate-100'
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
}`
}
>
<span className="w-4 text-center opacity-80">{i.icon}</span>
<span className="flex-1">{i.label}</span>
{i.badge && alerts.counts.firing > 0 && (
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
{alerts.counts.firing}
</span>
)}
</NavLink>
))}
{visible.length === 0 && (
<p className="px-3 py-4 text-xs leading-relaxed text-slate-600">
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
</p>
)}
</nav>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet context={alerts} />
</main>
</div>
</div>
)
}

295
web/src/components/ui.tsx Normal file
View file

@ -0,0 +1,295 @@
import type { ReactNode } from 'react'
/**
* Примітиви інтерфейсу.
*
* Зібрані в одному файлі навмисно: їх десяток, вони дрібні, і поки
* дизайн-система вміщається на екран, окрема тека на кожну кнопку
* коштує більше, ніж дає.
*/
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-end gap-x-4 gap-y-2 border-b border-slate-800 px-4 py-3">
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-slate-100">{title}</h1>
{subtitle && <p className="mt-0.5 text-xs text-slate-500">{subtitle}</p>}
</div>
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
</div>
)
}
type ButtonKind = 'primary' | 'default' | 'danger' | 'ghost'
const buttonKind: Record<ButtonKind, string> = {
primary: 'border-sky-700 bg-sky-700 text-white hover:bg-sky-600',
default: 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700',
danger: 'border-red-900 bg-red-950/60 text-red-200 hover:bg-red-900/60',
ghost: 'border-transparent bg-transparent text-slate-400 hover:bg-slate-800',
}
export function Button({
children,
onClick,
kind = 'default',
disabled,
type = 'button',
title,
className = '',
}: {
children: ReactNode
onClick?: () => void
kind?: ButtonKind
disabled?: boolean
type?: 'button' | 'submit'
title?: string
className?: string
}) {
return (
<button
type={type}
title={title}
disabled={disabled}
onClick={onClick}
// min-h під палець: ці ж кнопки натискають з телефона.
className={`min-h-[38px] rounded border px-3 py-1.5 text-sm transition-colors
disabled:cursor-not-allowed disabled:opacity-40
md:min-h-0 ${buttonKind[kind]} ${className}`}
>
{children}
</button>
)
}
export function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: ReactNode
}) {
return (
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-400">{label}</span>
{children}
{hint && <span className="mt-1 block text-[11px] leading-snug text-slate-600">{hint}</span>}
</label>
)
}
export const inputClass =
'w-full rounded border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-100 ' +
'outline-none placeholder:text-slate-600 focus:border-sky-600'
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`rounded-lg border border-slate-800 bg-slate-900/60 ${className}`}>
{children}
</div>
)
}
export interface Column<T> {
key: string
header: string
cell: (row: T) => ReactNode
/** Другорядні колонки ховаються на телефоні, щоб картка лишалась короткою. */
hideOnMobile?: boolean
}
/**
* Таблиця, що на телефоні перестає бути таблицею.
*
* Горизонтальний скрол на 375 px це спосіб зробити дані формально
* присутніми й фактично нечитабельними, тому вузькі екрани отримують
* картки, а не звужені колонки.
*/
export function DataTable<T>({
rows,
columns,
rowKey,
empty = 'Порожньо',
onRowClick,
}: {
rows: T[]
columns: Column<T>[]
rowKey: (row: T) => string
empty?: string
onRowClick?: (row: T) => void
}) {
if (rows.length === 0) {
return <p className="px-4 py-10 text-center text-sm text-slate-600">{empty}</p>
}
return (
<>
<table className="hidden w-full text-sm md:table">
<thead>
<tr className="border-b border-slate-800 text-left">
{columns.map((c) => (
<th
key={c.key}
className="px-4 py-2 text-xs font-medium uppercase tracking-wide text-slate-500"
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/70">
{rows.map((r) => (
<tr
key={rowKey(r)}
className={onRowClick ? 'cursor-pointer hover:bg-slate-800/40' : ''}
onClick={onRowClick ? () => onRowClick(r) : undefined}
>
{columns.map((c) => (
<td key={c.key} className="px-4 py-2 align-middle text-slate-300">
{c.cell(r)}
</td>
))}
</tr>
))}
</tbody>
</table>
<ul className="divide-y divide-slate-800/70 md:hidden">
{rows.map((r) => {
const shown = columns.filter((c) => !c.hideOnMobile)
const [first, ...rest] = shown
return (
<li
key={rowKey(r)}
className="px-4 py-3"
onClick={onRowClick ? () => onRowClick(r) : undefined}
>
{first && (
<div className="mb-1 text-sm font-medium text-slate-100">{first.cell(r)}</div>
)}
{rest.map((c) => (
<div key={c.key} className="flex justify-between gap-3 py-0.5 text-xs">
<span className="text-slate-500">{c.header}</span>
<span className="min-w-0 truncate text-slate-300">{c.cell(r)}</span>
</div>
))}
</li>
)
})}
</ul>
</>
)
}
const statusTone: Record<string, string> = {
up: 'bg-emerald-500/15 text-emerald-300 border-emerald-800',
online: 'bg-emerald-500/15 text-emerald-300 border-emerald-800',
down: 'bg-red-500/15 text-red-300 border-red-900',
offline: 'bg-slate-700/40 text-slate-400 border-slate-700',
warning: 'bg-amber-500/15 text-amber-300 border-amber-800',
maintenance: 'bg-sky-500/15 text-sky-300 border-sky-800',
unknown: 'bg-slate-700/40 text-slate-400 border-slate-700',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
return (
<span
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[11px]
${statusTone[status] ?? statusTone.unknown}`}
>
{label ?? status}
</span>
)
}
export function Toggle({
checked,
onChange,
disabled,
}: {
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}) {
return (
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40
${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform
${checked ? 'translate-x-4' : 'translate-x-0.5'}`}
/>
</button>
)
}
export function Modal({
title,
children,
onClose,
}: {
title: string
children: ReactNode
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 md:items-center md:p-4">
<div
className="max-h-[90dvh] w-full overflow-y-auto rounded-t-xl border border-slate-800
bg-slate-900 md:max-w-lg md:rounded-xl"
>
<div className="flex items-center border-b border-slate-800 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-100">{title}</h2>
<button
className="ml-auto px-2 text-slate-500 hover:text-slate-300"
onClick={onClose}
aria-label="Закрити"
>
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
)
}
export function ErrorNote({ children }: { children?: ReactNode }) {
if (!children) return null
return (
<div className="rounded border border-red-900/60 bg-red-950/50 px-3 py-2 text-xs text-red-200">
{children}
</div>
)
}
export function Spinner({ label = 'Завантаження…' }: { label?: string }) {
return <p className="px-4 py-10 text-center text-sm text-slate-600">{label}</p>
}
/** Скільки минуло. Прочерк замість вигаданих чисел для порожніх дат. */
export function ago(iso?: string): string {
if (!iso) return '—'
const ms = new Date(iso).getTime()
if (!Number.isFinite(ms)) return '—'
const sec = Math.max(0, Math.round((Date.now() - ms) / 1000))
if (sec < 60) return `${sec} с`
if (sec < 3600) return `${Math.round(sec / 60)} хв`
if (sec < 86400) return `${Math.round(sec / 3600)} год`
return `${Math.round(sec / 86400)} дн`
}

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api/client'
import { liveEvents } from '../api/ws'
import { session } from '../api/session'
import { isAlertEvent, useLiveRefresh } from './useLiveRefresh'
import type { Alert, AlertCounts } from '../types'
const emptyCounts: AlertCounts = {
@ -60,24 +60,9 @@ export function useAlerts(): LiveAlerts {
void reload()
}, [reload])
// Злиття сплеску подій в одне перечитування.
//
// Падіння комутатора піднімає десятки алертів за один тік движка, і
// кожен приходить окремим повідомленням. Без затримки це десятки
// однакових запитів підряд саме тоді, коли сервер найзавантаженіший.
const timer = useRef<number | null>(null)
useEffect(() => {
if (!canRead) return
return liveEvents.subscribe((msg) => {
if (!msg.type?.startsWith('alert.')) return
if (timer.current) window.clearTimeout(timer.current)
timer.current = window.setTimeout(() => void reload(), 400)
})
}, [reload, canRead])
useEffect(() => () => {
if (timer.current) window.clearTimeout(timer.current)
}, [])
useLiveRefresh(isAlertEvent, () => {
if (canRead) void reload()
})
const ack = useCallback(
async (id: string) => {

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { ApiError, api } from '../api/client'
import { LiveConnection, type ConnectionState } from '../api/ws'
import { live, liveEvents, type ConnectionState } from '../api/ws'
import type { MapPatch, MapState, WsMessage } from '../types'
export interface LiveMap {
@ -29,10 +29,11 @@ export function useLiveMap(mapID: string | null): LiveMap {
const [state, setState] = useState<MapState | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [connection, setConnection] = useState<ConnectionState>('connecting')
const [lastEventAt, setLastEventAt] = useState<number | null>(null)
const conn = useRef<LiveConnection | null>(null)
// Стан з'єднання читається з самої оболонки: воно спільне, і копія
// тут розходилася б із нею після кожного реконекту.
const connection = useSyncExternalStore(live.subscribeState, live.getState) as ConnectionState
// Ревізія потрібна в обробнику WebSocket, який створюється один раз;
// читати її зі state означало б перепідключатись на кожну правку.
const revision = useRef<number>(0)
@ -58,11 +59,12 @@ export function useLiveMap(mapID: string | null): LiveMap {
if (mapID) void reload()
}, [mapID, reload])
// Живі оновлення.
useEffect(() => {
const c = new LiveConnection({
onState: setConnection,
onMessage: (msg: WsMessage) => {
// Живі оновлення. З'єднанням володіє оболонка застосунку — тут ми
// лише слухаємо спільну шину. Тримати сокет усередині мапи означало б
// не мати живих оновлень на жодній іншій сторінці.
useEffect(
() =>
liveEvents.subscribe((msg: WsMessage) => {
setLastEventAt(Date.now())
switch (msg.type) {
@ -106,19 +108,12 @@ export function useLiveMap(mapID: string | null): LiveMap {
if (msg.revision !== revision.current) void reload()
break
}
},
})
conn.current = c
c.connect()
return () => {
c.close()
conn.current = null
}
}, [reload])
}),
[reload],
)
useEffect(() => {
if (mapID) conn.current?.subscribe(mapID)
if (mapID) live.subscribe(mapID)
}, [mapID, state?.id])
const patch = useCallback(

View file

@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react'
import { liveEvents } from '../api/ws'
import type { WsMessage } from '../types'
/**
* Перечитування у відповідь на живі події зі злиттям сплеску.
*
* Падіння комутатора піднімає десятки алертів за один тік движка, і
* кожен приходить окремим повідомленням. Без затримки це десятки
* однакових запитів підряд саме тоді, коли сервер найзавантаженіший,
* тобто клієнт додає навантаження рівно в момент аварії.
*
* @param match які повідомлення цікавлять
* @param reload що робити (має бути стабільним useCallback)
* @param delay вікно злиття
*/
export function useLiveRefresh(
match: (msg: WsMessage) => boolean,
reload: () => void | Promise<void>,
delay = 400,
) {
const timer = useRef<number | null>(null)
// Через ref, щоб зміна reload не перепідписувала з'єднання.
const fn = useRef(reload)
fn.current = reload
useEffect(() => {
const off = liveEvents.subscribe((msg) => {
if (!match(msg)) return
if (timer.current) window.clearTimeout(timer.current)
timer.current = window.setTimeout(() => void fn.current(), delay)
})
return () => {
off()
if (timer.current) window.clearTimeout(timer.current)
}
// match задається інлайн-функцією на кожен рендер, тому в залежності
// не йде: підписка має жити стільки ж, скільки компонент.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [delay])
}
/** Найчастіший фільтр: усе, що стосується алертів. */
export const isAlertEvent = (msg: WsMessage) => msg.type?.startsWith('alert.') ?? false

View file

@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Card, ErrorNote, PageHeader, Spinner, StatusBadge, ago } from '../components/ui'
import type { AgentSummary } from '../types'
export function AgentsPage() {
const [agents, setAgents] = useState<AgentSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
api
.listAgents()
.then(setAgents)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false))
}, [])
const online = agents.filter((a) => a.status === 'online').length
return (
<>
<PageHeader
title="Зонди"
subtitle={loading ? undefined : `${online} з ${agents.length} на зв’язку`}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
{error && <ErrorNote>{error}</ErrorNote>}
{loading ? (
<Spinner />
) : agents.length === 0 ? (
<p className="px-4 py-16 text-center text-sm text-slate-600">
Зондів ще немає. Зонд сам підключається до сервера відкривати порти в мережі
клієнта не потрібно.
</p>
) : (
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{agents.map((a) => (
<li key={a.id}>
<Card className="p-3">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-100">{a.name}</span>
<span className="ml-auto">
<StatusBadge
status={a.status}
label={a.status === 'online' ? 'на зв’язку' : 'офлайн'}
/>
</span>
</div>
<dl className="mt-2.5 space-y-1 text-xs">
<Pair k="Платформа" v={`${a.os ?? '?'}/${a.arch ?? '?'}`} />
<Pair k="Версія" v={a.version ?? '—'} />
<Pair k="Пристроїв" v={String(a.device_count)} />
<Pair k="Останній пульс" v={ago(a.last_heartbeat_at)} />
{typeof a.health?.rss_bytes === 'number' && (
<Pair
k="Памʼять"
v={`${((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ`}
/>
)}
</dl>
<div className="mt-2.5 flex flex-wrap gap-1">
{a.enabled_modules.map((m) => (
<span
key={m}
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
>
{m}
</span>
))}
{a.enabled_modules.length === 0 && (
<span className="text-[11px] text-slate-600">модулі не активовані</span>
)}
</div>
</Card>
</li>
))}
</ul>
)}
<p className="mt-4 text-xs leading-relaxed text-slate-600">
Керування набором модулів із інтерфейсу ще не реалізоване транспорт для цього
готовий, але сторінка налаштувань зонда попереду.
</p>
</div>
</>
)
}
function Pair({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-3">
<dt className="text-slate-500">{k}</dt>
<dd className="tabular-nums text-slate-300">{v}</dd>
</div>
)
}

View file

@ -0,0 +1,181 @@
import { useMemo, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { session } from '../api/session'
import { Button, Card, ErrorNote, PageHeader, Spinner, ago } from '../components/ui'
import type { LiveAlerts } from '../hooks/useAlerts'
import type { Alert, Severity } from '../types'
const severityStyle: Record<Severity, { dot: string; text: string; label: string; rank: number }> = {
disaster: { dot: 'bg-red-500', text: 'text-red-300', label: 'аварія', rank: 4 },
high: { dot: 'bg-red-400', text: 'text-red-300', label: 'висока', rank: 3 },
average: { dot: 'bg-orange-400', text: 'text-orange-300', label: 'середня', rank: 2 },
warning: { dot: 'bg-amber-400', text: 'text-amber-300', label: 'увага', rank: 1 },
info: { dot: 'bg-sky-400', text: 'text-sky-300', label: 'інфо', rank: 0 },
}
const suppressLabel: Record<string, string> = {
maintenance: 'вікно обслуговування',
mute: 'заглушено вручну',
topology: 'наслідок іншої аварії',
dependency: 'залежність',
}
type Filter = 'active' | 'acked' | 'suppressed' | 'all'
const filterLabel: Record<Filter, string> = {
active: 'Активні',
acked: 'Прийняті',
suppressed: 'Придушені',
all: 'Усі',
}
export function AlertsPage() {
const live = useOutletContext<LiveAlerts>()
const [filter, setFilter] = useState<Filter>('active')
const canAck = session.can('alerts:ack')
const rows = useMemo(() => {
const byFilter = live.alerts.filter((a) => {
if (filter === 'all') return true
if (filter === 'active') return a.state === 'firing'
if (filter === 'acked') return a.state === 'acknowledged'
return a.state === 'suppressed'
})
// Найгостріше — вгорі; за рівної гостроти новіше вище.
return byFilter.sort((x, y) => {
const d = severityStyle[y.severity].rank - severityStyle[x.severity].rank
return d !== 0 ? d : y.started_at.localeCompare(x.started_at)
})
}, [live.alerts, filter])
const counts: Record<Filter, number> = {
active: live.counts.firing,
acked: live.counts.acknowledged,
suppressed: live.counts.suppressed,
all: live.alerts.length,
}
return (
<>
<PageHeader
title="Алерти"
subtitle={
live.counts.disaster + live.counts.high > 0
? `${live.counts.disaster + live.counts.high} гострих потребують уваги`
: live.counts.firing === 0
? 'Активних немає'
: `${live.counts.firing} активних`
}
actions={
<div className="flex flex-wrap gap-1">
{(Object.keys(filterLabel) as Filter[]).map((f) => (
<Button key={f} kind={filter === f ? 'primary' : 'default'} onClick={() => setFilter(f)}>
{filterLabel[f]}
<span className="ml-1.5 tabular-nums opacity-70">{counts[f]}</span>
</Button>
))}
</div>
}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
{live.error && <ErrorNote>{live.error}</ErrorNote>}
{live.loading ? (
<Spinner />
) : rows.length === 0 ? (
<p className="px-4 py-16 text-center text-sm text-slate-600">
{filter === 'active' && live.alerts.length > 0
? 'Активних немає — усе або прийнято, або придушено'
: 'Порожньо'}
</p>
) : (
<ul className="space-y-2">
{rows.map((a) => (
<AlertCard
key={a.id}
alert={a}
canAck={canAck}
onAck={() => void live.ack(a.id)}
onClose={() => void live.close(a.id)}
onMute={a.device_id ? () => void live.mute(a.device_id!, 60) : undefined}
/>
))}
</ul>
)}
</div>
</>
)
}
function AlertCard({
alert: a,
canAck,
onAck,
onClose,
onMute,
}: {
alert: Alert
canAck: boolean
onAck: () => void
onClose: () => void
onMute?: () => void
}) {
const s = severityStyle[a.severity] ?? severityStyle.info
const acked = a.state === 'acknowledged'
return (
<li>
<Card className={`p-3 ${a.state === 'suppressed' ? 'opacity-60' : ''}`}>
<div className="flex items-start gap-2.5">
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className={`text-[10px] uppercase tracking-wide ${s.text}`}>{s.label}</span>
<span className="text-[10px] tabular-nums text-slate-600">
триває {ago(a.started_at)}
</span>
{a.rule_name && <span className="text-[10px] text-slate-600">· {a.rule_name}</span>}
{acked && (
<span className="text-[10px] text-emerald-500" title={a.acked_by_email}>
прийняв {a.acked_by_email ?? '—'}
</span>
)}
{a.notify_count > 0 && (
<span className="text-[10px] text-slate-600">· сповіщень {a.notify_count}</span>
)}
</div>
<div className="mt-0.5 text-sm font-medium text-slate-100">{a.title}</div>
{a.message && <p className="mt-0.5 text-xs leading-snug text-slate-400">{a.message}</p>}
{a.suppressed_by && (
<p className="mt-1 text-[11px] text-slate-500">
придушено: {suppressLabel[a.suppressed_by] ?? a.suppressed_by}
</p>
)}
{canAck && (
<div className="mt-2 flex flex-wrap gap-1.5">
{!acked && (
<Button kind="default" onClick={onAck} title="Зупинити ескалацію — я вже дивлюсь">
Прийняти
</Button>
)}
{onMute && (
<Button onClick={onMute} title="Не турбувати цим пристроєм годину">
Заглушити 1 год
</Button>
)}
<Button kind="ghost" onClick={onClose} title="Закрити вручну">
Закрити
</Button>
</div>
)}
</div>
</div>
</Card>
</li>
)
}

View file

@ -0,0 +1,379 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
DataTable,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
StatusBadge,
inputClass,
} from '../components/ui'
import type { Channel, Severity } from '../types'
const kindLabel: Record<string, string> = {
telegram: 'Telegram',
webhook: 'Webhook',
email: 'Email',
slack: 'Slack',
discord: 'Discord',
mattermost: 'Mattermost',
}
const severityLabel: Record<Severity, string> = {
info: 'Інформація і вище',
warning: 'Увага і вище',
average: 'Середня і вище',
high: 'Висока і вище',
disaster: 'Лише аварії',
}
export function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>(
null,
)
const canWrite = session.can('alerts:write')
const reload = async () => {
try {
setChannels(await api.listChannels())
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
void reload()
}, [])
return (
<>
<PageHeader
title="Канали сповіщень"
subtitle="Поки каналів немає, алерти видно лише в інтерфейсі"
actions={
canWrite && (
<Button kind="primary" onClick={() => setCreating(true)}>
+ Канал
</Button>
)
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{testResult && (
<div className="p-4 pb-0">
{testResult.ok ? (
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
Пробне повідомлення надіслано. Якщо воно не дійшло проблема на боці отримувача.
</div>
) : (
<ErrorNote>Не вдалося: {testResult.error}</ErrorNote>
)}
</div>
)}
{loading ? (
<Spinner />
) : (
<DataTable
rows={channels}
rowKey={(c) => c.id}
empty="Каналів ще немає"
columns={[
{
key: 'name',
header: 'Назва',
cell: (c) => <span className="font-medium text-slate-100">{c.name}</span>,
},
{ key: 'kind', header: 'Тип', cell: (c) => kindLabel[c.kind] ?? c.kind },
{
key: 'sev',
header: 'Поріг',
hideOnMobile: true,
cell: (c) => severityLabel[c.min_severity as Severity] ?? c.min_severity,
},
{
key: 'secret',
header: 'Секрет',
hideOnMobile: true,
cell: (c) =>
c.has_secret ? (
<span className="text-slate-500">збережено</span>
) : (
<span className="text-slate-600"></span>
),
},
{
key: 'state',
header: 'Стан',
cell: (c) => (
<StatusBadge
status={c.enabled ? 'up' : 'offline'}
label={c.enabled ? 'увімкнено' : 'вимкнено'}
/>
),
},
{
key: 'act',
header: 'Дії',
cell: (c) =>
canWrite && (
<div className="flex items-center gap-1.5">
<Button
disabled={testing === c.id}
onClick={async () => {
setTesting(c.id)
setTestResult(null)
try {
const r = await api.testChannel(c.id)
setTestResult({ id: c.id, ok: r.ok, error: r.error })
} catch (e) {
setTestResult({
id: c.id,
ok: false,
error: e instanceof Error ? e.message : String(e),
})
} finally {
setTesting(null)
}
}}
>
{testing === c.id ? '…' : 'Перевірити'}
</Button>
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Видалити канал разом із його секретом"
onClick={async () => {
await api.deleteChannel(c.id)
await reload()
}}
>
</button>
</div>
),
},
]}
/>
)}
</div>
{creating && (
<ChannelForm
onClose={() => setCreating(false)}
onSaved={async () => {
setCreating(false)
await reload()
}}
/>
)}
</>
)
}
function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
const [kind, setKind] = useState('telegram')
const [name, setName] = useState('')
const [minSeverity, setMinSeverity] = useState<Severity>('warning')
const [secret, setSecret] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
// Telegram
const [chatID, setChatID] = useState('')
// Webhook і похідні
const [url, setUrl] = useState('')
// Email
const [host, setHost] = useState('')
const [port, setPort] = useState('587')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [user, setUser] = useState('')
const isWebhookish = ['webhook', 'slack', 'discord', 'mattermost'].includes(kind)
async function submit() {
setBusy(true)
setErr(null)
try {
let config: Record<string, unknown> = {}
if (kind === 'telegram') config = { chat_id: chatID }
else if (isWebhookish) config = { url, format: kind === 'webhook' ? 'netpulse' : 'text' }
else if (kind === 'email')
config = {
host,
port: Number(port) || 587,
from,
to: to
.split(',')
.map((x) => x.trim())
.filter(Boolean),
user,
}
await api.createChannel({
kind,
name: name.trim(),
config,
min_severity: minSeverity,
secret,
})
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Modal title="Новий канал" onClose={onClose}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Тип">
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
{Object.keys(kindLabel).map((k) => (
<option key={k} value={k}>
{kindLabel[k]}
</option>
))}
</select>
</Field>
<Field label="Надсилати від">
<select
className={inputClass}
value={minSeverity}
onChange={(e) => setMinSeverity(e.target.value as Severity)}
>
{(Object.keys(severityLabel) as Severity[]).map((s) => (
<option key={s} value={s}>
{severityLabel[s]}
</option>
))}
</select>
</Field>
</div>
<Field label="Назва">
<input
className={inputClass}
autoFocus
placeholder="Черговий інженер"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
{kind === 'telegram' && (
<>
<Field label="Chat ID" hint="Для груп — від’ємне число, наприклад -1001234567890">
<input className={inputClass} value={chatID} onChange={(e) => setChatID(e.target.value)} />
</Field>
<Field label="Токен бота" hint="Зберігається зашифрованим і більше ніколи не показується">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
</>
)}
{isWebhookish && (
<>
<Field
label="URL"
hint="Адреси у внутрішній мережі сервер відхиляє, якщо адміністратор не дозволив їх явно"
>
<input
className={inputClass}
placeholder="https://hooks.example.com/…"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</Field>
<Field label="Токен (необов’язково)" hint="Піде заголовком Authorization: Bearer">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
</>
)}
{kind === 'email' && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="SMTP-сервер">
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} />
</Field>
</div>
<Field label="Порт">
<input
className={inputClass}
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
/>
</Field>
</div>
<Field label="Від кого">
<input className={inputClass} value={from} onChange={(e) => setFrom(e.target.value)} />
</Field>
<Field label="Кому" hint="Кілька адрес — через кому">
<input className={inputClass} value={to} onChange={(e) => setTo(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Логін">
<input className={inputClass} value={user} onChange={(e) => setUser(e.target.value)} />
</Field>
<Field label="Пароль">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
</div>
</>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button kind="primary" disabled={busy || !name.trim()} onClick={() => void submit()}>
{busy ? 'Збереження…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}

View file

@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import { api } from '../api/client'
import { session } from '../api/session'
import { liveEvents } from '../api/ws'
import type { LiveAlerts } from '../hooks/useAlerts'
import {
Button,
DataTable,
ErrorNote,
Modal,
PageHeader,
Spinner,
StatusBadge,
ago,
inputClass,
} from '../components/ui'
import type { Alert, DeviceSummary } from '../types'
const statusLabel: Record<string, string> = {
up: 'на зв’язку',
down: 'недоступний',
warning: 'проблеми',
maintenance: 'обслуговування',
unknown: 'невідомо',
}
export function DevicesPage() {
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
// тримає їх живими для лічильника в шапці, і другий такий самий
// опитувач лише подвоїв би навантаження на сервер під час аварії.
const live = useOutletContext<LiveAlerts>()
const [devices, setDevices] = useState<DeviceSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [onlyProblems, setOnlyProblems] = useState(false)
const [detail, setDetail] = useState<DeviceSummary | null>(null)
const canMute = session.can('alerts:ack')
const alerts = live.alerts
const reload = useCallback(async () => {
try {
setDevices(await api.listDevices())
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
// Статус пристрою міняється не тут, а на зонді. Без підписки список
// показував би стан на момент відкриття сторінки й тихо старів.
//
// Зміна застосовується точково: це один рядок, і перечитувати заради
// нього весь список немає сенсу.
useEffect(
() =>
liveEvents.subscribe((msg) => {
if (msg.type !== 'device.status') return
setDevices((prev) =>
prev.map((d) =>
d.id === msg.payload.device_id ? { ...d, status: msg.payload.status } : d,
),
)
}),
[],
)
const alertsByDevice = useMemo(() => {
const m = new Map<string, Alert[]>()
for (const a of alerts) {
if (!a.device_id) continue
const list = m.get(a.device_id) ?? []
list.push(a)
m.set(a.device_id, list)
}
return m
}, [alerts])
const rows = useMemo(() => {
const q = query.trim().toLowerCase()
return devices.filter((d) => {
if (onlyProblems && d.status !== 'down' && d.status !== 'warning') return false
if (!q) return true
return (
d.name.toLowerCase().includes(q) ||
(d.address ?? '').toLowerCase().includes(q) ||
(d.vendor ?? '').toLowerCase().includes(q)
)
})
}, [devices, query, onlyProblems])
const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length
return (
<>
<PageHeader
title="Пристрої"
subtitle={
loading
? undefined
: `${devices.length} усього · ${problems} з проблемами · ${
devices.filter((d) => !d.enabled).length
} вимкнено`
}
actions={
<>
<input
className={`${inputClass} w-40 md:w-56`}
placeholder="пошук за іменем, IP…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Button
kind={onlyProblems ? 'primary' : 'default'}
onClick={() => setOnlyProblems((v) => !v)}
title="Показати лише недоступні й проблемні"
>
Тільки проблеми
</Button>
</>
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
) : (
<DataTable
rows={rows}
rowKey={(d) => d.id}
onRowClick={setDetail}
empty={
devices.length === 0
? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт'
: 'Нічого не знайдено за цим фільтром'
}
columns={[
{
key: 'name',
header: 'Пристрій',
cell: (d) => (
<span className="flex items-center gap-2">
<span className="truncate font-medium text-slate-100">{d.name}</span>
{(alertsByDevice.get(d.id)?.length ?? 0) > 0 && (
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
{alertsByDevice.get(d.id)!.length}
</span>
)}
</span>
),
},
{
key: 'status',
header: 'Стан',
cell: (d) => (
<StatusBadge status={d.status || 'unknown'} label={statusLabel[d.status || 'unknown']} />
),
},
{ key: 'address', header: 'Адреса', cell: (d) => d.address ?? '—' },
{
key: 'kind',
header: 'Тип',
hideOnMobile: true,
cell: (d) => (
<span className="text-slate-400">
{d.kind}
{d.vendor ? ` · ${d.vendor}` : ''}
</span>
),
},
{
key: 'ifs',
header: 'Інтерфейси',
hideOnMobile: true,
cell: (d) => <span className="tabular-nums">{d.interface_count}</span>,
},
{
key: 'seen',
header: 'Востаннє',
cell: (d) => <span className="tabular-nums text-slate-400">{ago(d.last_seen_at)}</span>,
},
]}
/>
)}
</div>
{detail && (
<DeviceDetail
device={detail}
alerts={alertsByDevice.get(detail.id) ?? []}
canMute={canMute}
onMute={async (minutes) => {
await live.mute(detail.id, minutes)
}}
onClose={() => setDetail(null)}
/>
)}
</>
)
}
function DeviceDetail({
device,
alerts,
canMute,
onMute,
onClose,
}: {
device: DeviceSummary
alerts: Alert[]
canMute: boolean
onMute: (minutes: number) => Promise<void>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
return (
<Modal title={device.name} onClose={onClose}>
<dl className="space-y-1.5 text-sm">
<Pair k="Стан" v={<StatusBadge status={device.status || 'unknown'} label={statusLabel[device.status || 'unknown']} />} />
<Pair k="Адреса" v={device.address ?? '—'} />
<Pair k="Тип" v={device.kind} />
<Pair k="Виробник" v={device.vendor ?? '—'} />
<Pair k="Інтерфейсів" v={String(device.interface_count)} />
<Pair k="Востаннє на зв’язку" v={ago(device.last_seen_at)} />
<Pair k="Опитується" v={device.enabled ? 'так' : 'ні'} />
</dl>
<h3 className="mb-2 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-500">
Активні алерти
</h3>
{alerts.length === 0 ? (
<p className="text-xs text-slate-600">Немає</p>
) : (
<ul className="space-y-1.5">
{alerts.map((a) => (
<li key={a.id} className="rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<span className="text-slate-100">{a.title}</span>
<p className="mt-0.5 text-slate-500">{a.message}</p>
</li>
))}
</ul>
)}
{err && (
<div className="mt-3">
<ErrorNote>{err}</ErrorNote>
</div>
)}
{canMute && (
<div className="mt-5 flex flex-wrap gap-2">
{[60, 240, 1440].map((m) => (
<Button
key={m}
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onMute(m)
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
Заглушити на {m >= 1440 ? '24 год' : m >= 240 ? '4 год' : '1 год'}
</Button>
))}
</div>
)}
</Modal>
)
}
function Pair({ k, v }: { k: string; v: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 border-b border-slate-800/60 pb-1.5">
<dt className="text-xs text-slate-500">{k}</dt>
<dd className="text-slate-200">{v}</dd>
</div>
)
}

182
web/src/pages/MapPage.tsx Normal file
View file

@ -0,0 +1,182 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas'
import { Button, ErrorNote } from '../components/ui'
import type { MapSummary } from '../types'
export function MapPage() {
const [maps, setMaps] = useState<MapSummary[]>([])
const [mapID, setMapID] = useState<string | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [listError, setListError] = useState<string | null>(null)
const live = useLiveMap(mapID)
const canWrite = session.can('maps:write')
useEffect(() => {
api
.listMaps()
.then((m) => {
setMaps(m)
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
})
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
}, [])
const counts = useMemo(() => {
const nodes = live.state?.nodes ?? []
return {
total: nodes.length,
up: nodes.filter((n) => n.status === 'up').length,
down: nodes.filter((n) => n.status === 'down').length,
}
}, [live.state])
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-slate-800 px-3 py-2">
<select
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:flex-none"
value={mapID ?? ''}
onChange={(e) => setMapID(e.target.value || null)}
>
{maps.length === 0 && <option value="">мап немає</option>}
{maps.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.node_count}/{m.edge_count})
</option>
))}
</select>
{canWrite && (
<>
<Button
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
<span className="hidden lg:inline">Добудувати з топології</span>
<span className="lg:hidden">Добудувати</span>
</Button>
<Button
disabled={!live.canUndo}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
<span className="ml-1 hidden lg:inline">Відкотити</span>
</Button>
</>
)}
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
<span className="hidden sm:inline tabular-nums">
{counts.up}/{counts.total} на звʼязку
{counts.down > 0 && <span className="ml-1 text-red-400">· {counts.down} впало</span>}
</span>
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="hidden tabular-nums md:inline">ревізія {live.state.revision}</span>
)}
</div>
</div>
{(live.error || listError) && (
<div className="shrink-0 px-3 py-2">
<ErrorNote>{live.error ?? listError}</ErrorNote>
</div>
)}
<div className="relative min-h-0 flex-1">
{live.state ? (
<MapCanvas
state={live.state}
onPatch={live.patch}
onSelect={setSelected}
readOnly={!canWrite}
/>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
</div>
)}
</div>
{node && (
<div
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
>
<div className="mb-1 flex items-center justify-between">
<span className="font-medium text-slate-100">{node.label}</span>
<button
className="px-2 text-slate-500 hover:text-slate-300"
onClick={() => setSelected(null)}
>
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
<Row k="статус" v={node.status || 'unknown'} />
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
</div>
</div>
)}
</div>
)
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2 md:w-full">
<span className="text-slate-500">{k}</span>
<span className="tabular-nums text-slate-300">{v}</span>
</div>
)
}
/**
* Стан з'єднання показується завжди й помітно.
*
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
* само, як мапа, де все гаразд, і це найнебезпечніший стан моніторингу.
*/
function ConnectionBadge({
state,
lastEventAt,
}: {
state: 'connecting' | 'online' | 'offline'
lastEventAt: number | null
}) {
const [, force] = useState(0)
useEffect(() => {
const t = setInterval(() => force((n) => n + 1), 1000)
return () => clearInterval(t)
}, [])
const label =
state === 'online' ? 'наживо' : state === 'connecting' ? 'підключення…' : "немає зв'язку"
const color =
state === 'online'
? 'bg-emerald-400'
: state === 'connecting'
? 'bg-amber-400'
: 'bg-red-500 animate-pulse'
const secs = lastEventAt ? Math.round((Date.now() - lastEventAt) / 1000) : null
return (
<span className="flex items-center gap-1.5 text-slate-400">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
<span className="hidden sm:inline">{label}</span>
{secs !== null && state === 'online' && (
<span className="hidden tabular-nums text-slate-600 lg:inline">{secs} с тому</span>
)}
</span>
)
}

View file

@ -0,0 +1,140 @@
import { useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import { Button, Card, ErrorNote, Field, PageHeader, inputClass } from '../components/ui'
export function ProfilePage() {
const me = session.me()
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [repeat, setRepeat] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [done, setDone] = useState(false)
const mismatch = repeat !== '' && next !== repeat
const tooShort = next !== '' && next.length < 8
async function submit() {
setBusy(true)
setErr(null)
try {
await api.changePassword(current, next)
setDone(true)
setCurrent('')
setNext('')
setRepeat('')
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<>
<PageHeader title="Профіль" subtitle={me?.email} />
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="grid max-w-3xl gap-4 md:grid-cols-2">
<Card className="p-4">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">
Обліковий запис
</h2>
<dl className="space-y-1.5 text-sm">
<Pair k="Email" v={me?.email ?? '—'} />
<Pair k="Організація" v={me?.tenantName ?? '—'} />
<Pair k="Роль" v={me?.roleName ?? '—'} />
</dl>
<h3 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wide text-slate-500">
Ваші права
</h3>
<div className="flex flex-wrap gap-1">
{(me?.permissions ?? []).map((p) => (
<span
key={p}
className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-500"
>
{p}
</span>
))}
</div>
</Card>
<Card className="p-4">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-500">
Зміна пароля
</h2>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
<Field label="Поточний пароль">
<input
className={inputClass}
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</Field>
<Field label="Новий пароль" hint="Щонайменше 8 символів">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</Field>
<Field label="Ще раз">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
/>
</Field>
{mismatch && <ErrorNote>Паролі не збігаються</ErrorNote>}
{tooShort && <ErrorNote>Пароль закороткий</ErrorNote>}
<ErrorNote>{err}</ErrorNote>
{done && (
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
Пароль змінено. Відкликано всі сесії, включно з цією зараз доведеться
увійти заново.
</div>
)}
<Button
type="submit"
kind="primary"
disabled={busy || !current || next.length < 8 || mismatch}
>
{busy ? 'Збереження…' : 'Змінити пароль'}
</Button>
</form>
</Card>
</div>
</div>
</>
)
}
function Pair({ k, v }: { k: string; v: string }) {
return (
<div className="flex items-center justify-between gap-4 border-b border-slate-800/60 pb-1.5">
<dt className="text-xs text-slate-500">{k}</dt>
<dd className="text-slate-200">{v}</dd>
</div>
)
}

402
web/src/pages/RulesPage.tsx Normal file
View file

@ -0,0 +1,402 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
DataTable,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
Toggle,
inputClass,
} from '../components/ui'
import type { AlertRule, Severity } from '../types'
// Списки метрик повторюють whitelist сервера. Дублювання свідоме: без
// нього людина дізнавалась би про друкарську помилку не з форми, а з
// правила, яке мовчки нічого не знаходить.
const metricsBySource: Record<string, { key: string; label: string; unit?: string }[]> = {
icmp: [
{ key: 'rtt_avg_ms', label: 'Затримка, середня', unit: 'мс' },
{ key: 'rtt_max_ms', label: 'Затримка, максимальна', unit: 'мс' },
{ key: 'jitter_ms', label: 'Джитер', unit: 'мс' },
{ key: 'loss_pct', label: 'Втрати пакетів', unit: '%' },
{ key: 'reachable', label: 'Доступність (1/0)' },
{ key: 'no_data', label: 'Даних немає взагалі' },
],
interface: [
{ key: 'util_in_pct', label: 'Завантаження вхідне', unit: '%' },
{ key: 'util_out_pct', label: 'Завантаження вихідне', unit: '%' },
{ key: 'in_errors', label: 'Помилки на вході' },
{ key: 'out_errors', label: 'Помилки на виході' },
{ key: 'in_discards', label: 'Відкинуто на вході' },
{ key: 'out_discards', label: 'Відкинуто на виході' },
{ key: 'in_bps', label: 'Трафік вхідний', unit: 'біт/с' },
{ key: 'out_bps', label: 'Трафік вихідний', unit: 'біт/с' },
{ key: 'oper_up', label: 'Порт піднято (1/0)' },
],
metric: [{ key: '', label: '(довільний ключ серії)' }],
}
const severityLabel: Record<Severity, string> = {
info: 'Інформація',
warning: 'Увага',
average: 'Середня',
high: 'Висока',
disaster: 'Аварія',
}
const sourceLabel: Record<string, string> = {
icmp: 'Пінг (ICMP)',
interface: 'Інтерфейси (SNMP)',
metric: 'Довільна метрика',
}
export function RulesPage() {
const [rules, setRules] = useState<AlertRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const canWrite = session.can('alerts:write')
const reload = async () => {
try {
setRules(await api.listAlertRules())
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
void reload()
}, [])
return (
<>
<PageHeader
title="Правила алертів"
subtitle={
loading
? undefined
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено`
}
actions={
canWrite && (
<Button kind="primary" onClick={() => setCreating(true)}>
+ Правило
</Button>
)
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
) : (
<DataTable
rows={rules}
rowKey={(r) => r.id}
empty="Правил ще немає — без них система мовчатиме про будь-яку аварію"
columns={[
{
key: 'name',
header: 'Назва',
cell: (r) => <span className="font-medium text-slate-100">{r.name}</span>,
},
{
key: 'source',
header: 'Джерело',
cell: (r) => <span className="text-slate-400">{sourceLabel[r.source] ?? r.source}</span>,
},
{
key: 'cond',
header: 'Умова',
hideOnMobile: true,
cell: (r) => (
<code className="text-[11px] text-slate-400">{describeCondition(r)}</code>
),
},
{
key: 'sev',
header: 'Серйозність',
cell: (r) => severityLabel[r.severity] ?? r.severity,
},
{
key: 'active',
header: 'Активних',
cell: (r) =>
r.active_alerts > 0 ? (
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
{r.active_alerts}
</span>
) : (
<span className="text-slate-600">0</span>
),
},
{
key: 'enabled',
header: 'Увімк.',
cell: (r) => (
<div className="flex items-center gap-2">
<Toggle
checked={r.enabled}
disabled={!canWrite}
onChange={async (v) => {
await api.setRuleEnabled(r.id, v)
await reload()
}}
/>
{canWrite && (
<button
className="text-xs text-slate-600 hover:text-red-400"
title="Видалити правило разом з його алертами"
onClick={async () => {
await api.deleteAlertRule(r.id)
await reload()
}}
>
</button>
)}
</div>
),
},
]}
/>
)}
</div>
{creating && (
<RuleForm
onClose={() => setCreating(false)}
onSaved={async () => {
setCreating(false)
await reload()
}}
/>
)}
</>
)
}
function describeCondition(r: AlertRule): string {
const c = r.condition as {
metric?: string
metric_key?: string
op?: string
value?: number
agg?: string
}
if (!c) return '—'
const name = c.metric_key || c.metric || '?'
if (name === 'no_data') return `даних немає ${r.for_seconds} с`
const scope = c.agg ? `${c.agg} за ${r.for_seconds} с` : `всі виміри ${r.for_seconds} с`
return `${name} ${c.op ?? ''} ${c.value ?? ''} (${scope})`
}
function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
const [name, setName] = useState('')
const [source, setSource] = useState('icmp')
const [metric, setMetric] = useState('loss_pct')
const [metricKey, setMetricKey] = useState('')
const [op, setOp] = useState('>')
const [value, setValue] = useState('20')
const [agg, setAgg] = useState('')
const [severity, setSeverity] = useState<Severity>('warning')
const [forSec, setForSec] = useState('180')
const [topo, setTopo] = useState(true)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const isNoData = metric === 'no_data'
const metrics = metricsBySource[source] ?? []
async function submit() {
setBusy(true)
setErr(null)
try {
const condition: Record<string, unknown> = isNoData
? { metric: 'no_data' }
: source === 'metric'
? { metric_key: metricKey, op, value: Number(value) }
: { metric, op, value: Number(value) }
if (!isNoData && agg) condition.agg = agg
await api.createAlertRule({
name: name.trim(),
source,
severity,
condition,
for_seconds: Number(forSec) || 60,
depends_on_topology: topo,
})
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Modal title="Нове правило" onClose={onClose}>
<div className="space-y-3">
<Field label="Назва">
<input
className={inputClass}
autoFocus
placeholder="Наприклад: Втрати пакетів на магістралі"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Джерело">
<select
className={inputClass}
value={source}
onChange={(e) => {
setSource(e.target.value)
const first = metricsBySource[e.target.value]?.[0]
setMetric(first?.key ?? '')
}}
>
{Object.keys(metricsBySource).map((s) => (
<option key={s} value={s}>
{sourceLabel[s]}
</option>
))}
</select>
</Field>
<Field label="Серйозність">
<select
className={inputClass}
value={severity}
onChange={(e) => setSeverity(e.target.value as Severity)}
>
{(Object.keys(severityLabel) as Severity[]).map((s) => (
<option key={s} value={s}>
{severityLabel[s]}
</option>
))}
</select>
</Field>
</div>
{source === 'metric' ? (
<Field label="Ключ метрики" hint="Як у ts.series.metric_key — наприклад cpu.util">
<input
className={inputClass}
value={metricKey}
onChange={(e) => setMetricKey(e.target.value)}
/>
</Field>
) : (
<Field label="Метрика">
<select className={inputClass} value={metric} onChange={(e) => setMetric(e.target.value)}>
{metrics.map((m) => (
<option key={m.key} value={m.key}>
{m.label}
{m.unit ? `, ${m.unit}` : ''}
</option>
))}
</select>
</Field>
)}
{!isNoData && (
<div className="grid grid-cols-3 gap-3">
<Field label="Оператор">
<select className={inputClass} value={op} onChange={(e) => setOp(e.target.value)}>
{['>', '>=', '<', '<=', '==', '!='].map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</Field>
<Field label="Поріг">
<input
className={inputClass}
type="number"
step="any"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Field>
<Field label="Агрегація">
<select className={inputClass} value={agg} onChange={(e) => setAgg(e.target.value)}>
<option value="">усі виміри</option>
{['avg', 'min', 'max', 'sum', 'last'].map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Field>
</div>
)}
<Field
label="Триматися, секунд"
hint={
agg
? 'Вікно, за яке рахується агрегат.'
: 'Умова має виконуватись усі виміри цього вікна — це й є захист від мигтіння.'
}
>
<input
className={inputClass}
type="number"
min="10"
value={forSec}
onChange={(e) => setForSec(e.target.value)}
/>
</Field>
<label className="flex items-start gap-2.5 rounded border border-slate-800 bg-slate-900 p-2.5">
<input
type="checkbox"
className="mt-0.5"
checked={topo}
onChange={(e) => setTopo(e.target.checked)}
/>
<span className="text-xs leading-snug text-slate-400">
Враховувати топологію
<span className="mt-0.5 block text-slate-600">
Пристрої за впалим маршрутизатором не піднімуть власних алертів інакше одна
аварія дає десятки однакових сповіщень.
</span>
</span>
</label>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim() || (source === 'metric' && !metricKey.trim())}
onClick={() => void submit()}
>
{busy ? 'Збереження…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}

285
web/src/pages/TeamPage.tsx Normal file
View file

@ -0,0 +1,285 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
DataTable,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
ago,
inputClass,
} from '../components/ui'
import type { Role, TeamMember } from '../types'
export function TeamPage() {
const [members, setMembers] = useState<TeamMember[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
const canWrite = session.can('users:write')
const me = session.me()
const reload = async () => {
try {
const [m, r] = await Promise.all([api.team(), api.roles()])
setMembers(m)
setRoles(r)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
void reload()
}, [])
async function act(fn: () => Promise<unknown>) {
try {
await fn()
await reload()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<>
<PageHeader
title="Команда"
subtitle={loading ? undefined : `${members.length} учасників`}
actions={
canWrite && (
<Button kind="primary" onClick={() => setAdding(true)}>
+ Користувач
</Button>
)
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{loading ? (
<Spinner />
) : (
<DataTable
rows={members}
rowKey={(m) => m.user_id}
empty="Учасників немає"
columns={[
{
key: 'email',
header: 'Користувач',
cell: (m) => (
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-slate-100">
{m.full_name || m.email}
</span>
{m.user_id === me?.userID && (
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-400">
це ви
</span>
)}
</span>
),
},
{
key: 'mail',
header: 'Email',
hideOnMobile: true,
cell: (m) => <span className="text-slate-400">{m.email}</span>,
},
{
key: 'role',
header: 'Роль',
cell: (m) => {
// Роль власника не видається через API — вона й не має
// з’являтися у списку вибору. Так само не можна змінити
// роль собі: організація без власника не відновлюється.
const editable =
canWrite && m.user_id !== me?.userID && m.role_key !== 'owner'
if (!editable) return <span className="text-slate-400">{m.role_name}</span>
return (
<select
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-200"
value={m.role_id}
onChange={(e) => void act(() => api.setRole(m.user_id, e.target.value))}
>
{roles
.filter((r) => r.key !== 'owner')
.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)
},
},
{
key: 'login',
header: 'Останній вхід',
cell: (m) => (
<span className="tabular-nums text-slate-400">{ago(m.last_login_at)}</span>
),
},
{
key: 'act',
header: '',
cell: (m) =>
canWrite && m.user_id !== me?.userID && m.role_key !== 'owner' ? (
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Прибрати з організації — усі його сесії тут буде відкликано"
onClick={() => void act(() => api.removeMember(m.user_id))}
>
</button>
) : null,
},
]}
/>
)}
<div className="border-t border-slate-800 p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Ролі та права
</h2>
<ul className="grid gap-2 md:grid-cols-2">
{roles.map((r) => (
<li key={r.id} className="rounded border border-slate-800 bg-slate-900/60 p-2.5">
<div className="text-sm text-slate-200">{r.name}</div>
{r.description && (
<p className="mt-0.5 text-[11px] text-slate-600">{r.description}</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1">
{r.permissions.map((p) => (
<span
key={p}
className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-500"
>
{p}
</span>
))}
</div>
</li>
))}
</ul>
</div>
</div>
{adding && (
<AddUserForm
roles={roles.filter((r) => r.key !== 'owner')}
onClose={() => setAdding(false)}
onSaved={async () => {
setAdding(false)
await reload()
}}
/>
)}
</>
)
}
function AddUserForm({
roles,
onClose,
onSaved,
}: {
roles: Role[]
onClose: () => void
onSaved: () => Promise<void>
}) {
const [email, setEmail] = useState('')
const [fullName, setFullName] = useState('')
const [password, setPassword] = useState('')
const [roleKey, setRoleKey] = useState(roles.find((r) => r.key === 'viewer')?.key ?? roles[0]?.key ?? '')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
return (
<Modal title="Новий користувач" onClose={onClose}>
<div className="space-y-3">
<Field label="Email">
<input
className={inputClass}
type="email"
autoFocus
autoComplete="off"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field label="Імʼя">
<input
className={inputClass}
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
</Field>
<Field
label="Пароль"
hint="Запрошень поштою ще немає — пароль доведеться передати людині самому."
>
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<Field label="Роль">
<select className={inputClass} value={roleKey} onChange={(e) => setRoleKey(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.key}>
{r.name}
</option>
))}
</select>
</Field>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !email.trim() || password.length < 8}
title={password.length < 8 ? 'Пароль щонайменше 8 символів' : undefined}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.addMember(email.trim(), password, roleKey, fullName.trim())
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Створення…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}

View file

@ -255,6 +255,18 @@ export interface AlertCounts {
high: number
}
export interface Channel {
id: string
kind: string
name: string
config: Record<string, unknown>
template?: string
min_severity: string
enabled: boolean
/** Чи збережено секрет. Сам секрет назад не віддається ніколи. */
has_secret: boolean
}
export interface AlertRule {
id: string
name: string