Етап 4: фронтенд — мапа мережі в браузері

React Flow поверх наявного API: вузли фарбуються статусом, ребра
кольором із порогів, точка на ребрі біжить зі швидкістю, оберненою до
завантаження каналу. WebSocket дає живі статуси й util, реконект
повертає підписку сам.

Робота з живим UI знайшла дві вади:

1. Ревізія мапи росла від самих кліків — React Flow віддає
   onNodeDragStop на будь-яке натискання, і кожен клік писав порожню
   ревізію, змушуючи інші відкриті полотна перечитуватись.
2. Після збереження хук оновлював лише номер ревізії, тож наступна
   подія device.status відкидала щойно перетягнутий вузол назад.

Перевірено в браузері проти повного стека зі справжнім snmpd: два
зелені вузли з живим RTT, ребро eth0 з util і capacity 10 Гбіт/с,
індикатор "наживо", кнопка автопобудови з topo.links.

Драг через автоматизацію відтворити не вдалося — синтетичні події не
запускають d3-drag; цей шлях покритий тестами сервера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zotac 2026-08-14 16:28:37 +03:00
parent 8bf2902522
commit 3de5643b98
18 changed files with 1415 additions and 3 deletions

4
.gitignore vendored
View file

@ -27,3 +27,7 @@ netpulse-server
# Дані локального стенду
/data/
/var/
# Фронтенд
/web/node_modules/
/web/dist/

View file

@ -484,9 +484,65 @@ server/internal/httpapi/maps_write.go POST/PATCH/DELETE + /build
(+2 вузли, +1 ребро) → драг (ревізія 3) → патч зі старою ревізією (409) → повторна
побудова (+0/+0) → координати x=1500 y=640 збережені, 2 знімки в історії.
---
## 2026-08-14 — Етап 4: фронтенд, мапа в браузері
### Створено
```
web/
├── README.md
├── package.json, vite.config.ts, tsconfig.json
└── src/
├── types.ts типи API
├── api/client.ts REST + ApiError (isConflict/isPlanLimit)
├── api/ws.ts WebSocket із реконектом
├── hooks/useLiveMap.ts завантаження, живі оновлення, запис
└── components/ MapCanvas, DeviceNode, TrafficEdge
```
React 18 + React Flow 12 + Tailwind 4 + Vite 6. Збірка 345 КБ (112 КБ gzip).
### Прийняті рішення
1. **Сервер — джерело істини, крім моменту перетягування.** Поки вузол тягнуть,
позицію диктує миша; синхронізація пропускає вузли з `dragging`.
2. **Зберігаємо на відпусканні й лише якщо вузол зрушив.**
3. **Успішний патч застосовується до локального стану** — інакше вузол
«повертається» при першій же події.
4. **Конфлікт ревізій не приховується:** 409 → повідомлення + перечитування.
5. **Швидкість анімації обернено пропорційна завантаженню**, при нулі анімації
немає взагалі.
6. **Стан з'єднання завжди на екрані:** замерзла мапа виглядає як здорова.
### Знайдено роботою з живим UI (обидві виправлені)
1. **Ревізія росла від самих кліків.** За час, поки я робив скріншоти, мапа
пройшла 2 → 5: React Flow віддає `onNodeDragStop` на будь-яке натискання, і
кожен клік писав порожню ревізію, змушуючи всі відкриті полотна перечитуватись.
2. **Вузол відкочувався після збереження.** Хук оновлював лише номер ревізії, тож
наступна подія `device.status` перебудовувала список зі старими координатами.
### Перевірено в браузері проти повного стека
Агент + сервер + API + справжній `snmpd`: два зелені вузли з живим RTT (0.05 і
1.04 мс), ребро `eth0 → ?` із `<0.01% з 10.0 Гбіт/с`, індикатор «наживо, 1 с тому»,
зонд `probe-snmp` linux/amd64 з RSS 11.1 МБ, кнопка «Добудувати з топології»
додала 2 вузли й 1 ребро з `topo.links`.
### Обмеження перевірки
- **Драг не відтворюється автоматизацією:** синтетичні події вказівника не
запускають d3-drag у React Flow. Шлях «драг → PATCH → БД» покритий тестами
сервера, але саме через UI лишається неперевіреним автоматично.
- **Ребра не малюються у прихованій вкладці:** React Flow міряє вузли в
`requestAnimationFrame`, який там не викликається. Вузли рендеряться, бо це
звичайний DOM. Скріншот у згорнутій панелі непридатний для перевірки.
### Далі
- Фронтенд: React Flow поверх цього API — усе для нього вже є.
- Undo: знімки пишуться, ендпоїнта відкату немає.
- Малювання зв'язків мишею (PATCH це вміє, UI ще ні).
- Підкладки-плани приміщень на полотні.
- Undo поверх `topo.map_revisions`.
- `EnrollmentService` + видача сертифікатів зондам.
- Планувальник NCM: бекап за cron і за Syslog-подією.

103
web/README.md Normal file
View file

@ -0,0 +1,103 @@
# NetPulse Web — мапа мережі
React + TypeScript + React Flow + Tailwind поверх [server/API.md](../server/API.md).
```bash
npm install
VITE_API_TOKEN=<токен> npm run dev # проксі на http://127.0.0.1:8080
npm run build
```
Dev-сервер проксіює `/api` (разом із WebSocket) на бекенд. Це знімає питання CORS і
робить адресу API однаковою в розробці й за зворотним проксі у проді.
## Будова
```
src/
types.ts типи API — дзеркало server/API.md
api/client.ts REST, ApiError з isConflict / isPlanLimit
api/ws.ts WebSocket із реконектом і повторною підпискою
hooks/useLiveMap.ts стан полотна: завантаження, живі оновлення, запис
components/
MapCanvas.tsx React Flow: полотно, сітка, мінімапа
DeviceNode.tsx вузол, колір із статусу, RTT і втрати
TrafficEdge.tsx ребро: колір із порогів, точка рухається за завантаженням
App.tsx шапка, лічильники, зонди, інспектор вузла
```
## Рішення, які варто розуміти
**Сервер — джерело істини, але не під час перетягування.** Поки вузол тягнуть, його
позицію диктує миша: інакше кожне оновлення по WebSocket смикало б вузол назад
під курсором. Синхронізація пропускає саме ті вузли, у яких `dragging`.
**Зберігаємо на відпусканні, і лише якщо вузол зрушив.** Один драг інакше
перетворюється на сотню запитів і сотню ревізій. А React Flow вважає драгом будь-яке
натискання, тож без перевірки зсуву простий клік писав би порожню ревізію: історія
засмічується, а всі інші відкриті полотна дарма перечитують мапу.
**Успішний патч застосовується до локального стану.** Без цього вузол після
перетягування лишався б на новому місці лише візуально — перша ж подія про зміну
статусу перебудувала б список вузлів зі старими координатами й відкинула б його назад.
**Конфлікт ревізій не приховується.** `409` показує повідомлення й перечитує полотно:
мовчки затирати чужу правку гірше, ніж змусити повторити свою.
**Швидкість анімації обернено пропорційна завантаженню.** Точка біжить швидше, коли
трафіку більше: стеля 0.6 с (далі рух зливається в смугу), підлога 6 с (щоб ледь
завантажений канал не виглядав мертвим). При нульовому трафіку анімації немає взагалі —
рухома точка на порожньому каналі показувала б рух, якого нема.
**Стан з'єднання показується завжди.** Мапа, що тихо замерзла після розриву WebSocket,
виглядає точно так само, як мапа, де все гаразд, — і це найнебезпечніший стан
моніторингу. Тому окремий індикатор і «N с тому» від останньої події.
**Червоний лишається за обривом.** Якщо ним підсвічувати ще й «невідомо», оператор
перестане на нього реагувати.
## Стан перевірки
`tsc --noEmit` і `vite build` — чисто (345 КБ JS, 112 КБ gzip).
Перевірено в живому браузері проти повного стека (агент + `netpulse-server` +
`netpulse-api` + справжній `snmpd` на стенді):
| Що видно | Результат |
|----------|-----------|
| Вузли з даних автовиявлення | `snmp-host` і `gateway`, обидва зелені |
| RTT із живого ICMP | 0.05 мс і 1.04 мс, оновлюється |
| Ребро з портами | `eth0 → ?` (ARP не дає порт віддаленої сторони) |
| Завантаження каналу | `<0.01% з 10.0 Гбіт/с` — знаменник із `ifHighSpeed` |
| Індикатор з'єднання | «наживо, 1 с тому» |
| Лічильники й зонд | 2/2 працюють, `probe-snmp` linux/amd64, RSS 11.1 МБ |
| Кнопка «Добудувати з топології» | +2 вузли, +1 ребро з `topo.links` |
**Знайдено під час роботи з живим UI** (обидві вади виправлені):
1. Ревізія мапи виросла з 2 до 5, поки я лише робив скріншоти: React Flow віддає
`onNodeDragStop` на будь-яке натискання, і кожен клік писав порожню ревізію.
2. Після збереження хук оновлював лише номер ревізії — тож перша ж подія
`device.status` відкидала щойно перетягнутий вузол на стару позицію.
## Обмеження перевірки
**Перетягування не вдалося відтворити автоматизацією.** Синтетичні події вказівника
не запускають drag у React Flow (d3-drag не сприймає недовірені події без повного
потоку `pointerdown → pointermove → pointerup` від справжнього пристрою вводу).
Шлях «драг → PATCH → БД» покритий інтеграційними тестами сервера
(`TestPatchMoveNodeKeepsOtherFields`, `TestPatchRevisionConflict`), але саме
через UI він лишається неперевіреним автоматично — потрібен ручний клік.
**Ребра не малюються, поки вкладка прихована.** React Flow міряє вузли в
`requestAnimationFrame`, а він у прихованій вкладці не викликається; вузли при цьому
рендеряться, бо це звичайний DOM. Це поведінка бібліотеки, не помилка — але вона
робить скріншот у згорнутій панелі непридатним для перевірки.
## Чого ще немає
- **Входу**: токен береться зі змінної збірки або `localStorage`.
- **Малювання зв'язків мишею**: `PATCH` це вміє, UI ще ні.
- **Підкладок**: план приміщення віддається API, але полотно його не малює.
- **Графіків**: історія метрик не віддається сервером.
- **Кластеризації при віддаленні** — поле в схемі є, реалізації немає.

12
web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetPulse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
web/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "netpulse-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@xyflow/react": "^12.3.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

239
web/src/App.tsx Normal file
View file

@ -0,0 +1,239 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from './api/client'
import { useLiveMap } from './hooks/useLiveMap'
import { MapCanvas } from './components/MapCanvas'
import type { AgentSummary, MapSummary } from './types'
export default function App() {
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)
const live = useLiveMap(mapID)
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
return (
<div className="flex h-screen w-screen flex-col bg-slate-950 text-slate-200">
<header className="flex shrink-0 items-center gap-4 border-b border-slate-800 bg-slate-900 px-4 py-2">
<div className="flex items-baseline gap-2">
<span className="text-lg font-semibold tracking-tight text-slate-100">NetPulse</span>
<span className="text-xs text-slate-500">мапа мережі</span>
</div>
<select
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-sm"
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>
<button
className="rounded border border-slate-700 bg-slate-800 px-3 py-1 text-sm hover:bg-slate-700
disabled:opacity-40"
disabled={!mapID}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
Добудувати з топології
</button>
<div className="ml-auto flex items-center gap-4 text-xs">
<Legend />
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
<span className="tabular-nums text-slate-500">ревізія {live.state.revision}</span>
)}
</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="flex min-h-0 flex-1">
<aside className="flex w-60 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 bg-slate-900/60 p-3">
<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>
<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>
{node && (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Вузол
</h2>
<div className="space-y-1 rounded border border-slate-800 bg-slate-900 p-2 text-xs">
<div className="font-medium text-slate-100">{node.label}</div>
<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)}`} />
{node.last_seen_at && (
<Row k="бачили" v={new Date(node.last_seen_at).toLocaleTimeString('uk')} />
)}
</div>
</section>
)}
</aside>
<main className="min-w-0 flex-1">
{live.state ? (
<MapCanvas state={live.state} onPatch={live.patch} onSelect={setSelected} />
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
</div>
)}
</main>
</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">
<span className="text-slate-500">{k}</span>
<span className="tabular-nums text-slate-300">{v}</span>
</div>
)
}
function Legend() {
return (
<div 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="невідомо" />
</div>
)
}
function Dot({ c, t }: { c: string; t: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${c}`} />
{t}
</span>
)
}
/**
* Стан з'єднання показується завжди й помітно.
*
* Мапа, яка тихо замерзла після розриву WebSocket, виглядає точно так
* само, як мапа, де все гаразд, і це найнебезпечніший стан
* моніторингу. Тому окремий індикатор і час останньої події.
*/
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
return (
<span className="flex items-center gap-1.5 text-slate-400">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
{label}
{ago !== null && state === 'online' && (
<span className="tabular-nums text-slate-600">{ago} с тому</span>
)}
</span>
)
}

84
web/src/api/client.ts Normal file
View file

@ -0,0 +1,84 @@
import type {
AgentSummary,
BuildResult,
DeviceSummary,
MapPatch,
MapState,
MapSummary,
PatchResult,
} from '../types'
// Токен береться зі змінної збірки або з localStorage. Повноцінного
// входу ще немає — коли з'явиться, зміниться лише це місце.
const TOKEN = import.meta.env.VITE_API_TOKEN ?? localStorage.getItem('np_token') ?? ''
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message)
}
/** Мапу змінив хтось інший — треба перечитати й повторити правку. */
get isConflict() {
return this.status === 409
}
/** Уперлись у ліміт тарифу: UI має показати пропозицію оновитись. */
get isPlanLimit() {
return this.status === 402
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
...(init?.headers ?? {}),
},
})
if (res.status === 204) return undefined as T
const text = await res.text()
const body = text ? JSON.parse(text) : {}
if (!res.ok) {
const err = body?.error ?? {}
throw new ApiError(res.status, err.code ?? 'unknown', err.message ?? res.statusText)
}
return body as T
}
export const api = {
listMaps: () => request<{ maps: MapSummary[] }>('/api/v1/maps').then((r) => r.maps ?? []),
getMap: (id: string) => request<MapState>(`/api/v1/maps/${id}`),
createMap: (name: string, slug: string, kind = 'logical') =>
request<MapSummary>('/api/v1/maps', {
method: 'POST',
body: JSON.stringify({ name, slug, kind }),
}),
patchMap: (id: string, patch: MapPatch) =>
request<PatchResult>(`/api/v1/maps/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
}),
buildMap: (id: string) =>
request<BuildResult>(`/api/v1/maps/${id}/build`, { method: 'POST' }),
listDevices: () =>
request<{ devices: DeviceSummary[] }>('/api/v1/devices').then((r) => r.devices ?? []),
listAgents: () =>
request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []),
}
export const apiToken = TOKEN

80
web/src/api/ws.ts Normal file
View file

@ -0,0 +1,80 @@
import { apiToken } from './client'
import type { WsMessage } from '../types'
export type ConnectionState = 'connecting' | 'online' | 'offline'
interface Options {
onMessage: (msg: WsMessage) => void
onState: (state: ConnectionState) => void
}
/**
* Живе з'єднання з сервером.
*
* Реконект обов'язковий і не є деталлю: NOC-екран висить тижнями, і
* будь-який проксі рано чи пізно розірве з'єднання. Без автоматичного
* повернення мапа замерзне з останнім кадром, виглядаючи цілком живою
* найгірший з можливих станів моніторингу.
*/
export class LiveConnection {
private ws: WebSocket | null = null
private backoff = 1000
private closed = false
private wantMap: string | null = null
private timer: number | null = null
constructor(private readonly opts: Options) {}
connect() {
if (this.closed) return
this.opts.onState('connecting')
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
// Браузерний WebSocket не дозволяє довільні заголовки, тому токен
// їде підпротоколом — так він не потрапляє в URL і в логи проксі.
const ws = new WebSocket(`${proto}://${location.host}/api/v1/ws`, [
`netpulse.token.${apiToken}`,
])
this.ws = ws
ws.onopen = () => {
this.backoff = 1000
this.opts.onState('online')
if (this.wantMap) this.subscribe(this.wantMap)
}
ws.onmessage = (ev) => {
try {
this.opts.onMessage(JSON.parse(ev.data) as WsMessage)
} catch {
// Пошкоджене повідомлення не має рвати з'єднання.
}
}
ws.onclose = () => {
this.ws = null
if (this.closed) return
this.opts.onState('offline')
this.timer = window.setTimeout(() => this.connect(), this.backoff)
// Стеля 15 с: далі затримка вже помітна оператору, а користі
// від рідших спроб немає.
this.backoff = Math.min(this.backoff * 2, 15000)
}
ws.onerror = () => ws.close()
}
/** Підписка запам'ятовується й повторюється після кожного реконекту. */
subscribe(mapID: string) {
this.wantMap = mapID
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'subscribe', map_id: mapID }))
}
}
close() {
this.closed = true
if (this.timer) window.clearTimeout(this.timer)
this.ws?.close()
}
}

View file

@ -0,0 +1,69 @@
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { DeviceStatus } from '../types'
export type DeviceNodeData = {
label: string
kind: string
status: DeviceStatus
rtt?: number
loss?: number
icon?: string
}
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
// Кольори статусів. Червоний лишається лише за обривом — якщо ним
// підсвічувати ще й «невідомо», оператор перестане на нього реагувати.
const STATUS: Record<string, { ring: string; dot: string; text: string }> = {
up: { ring: 'border-emerald-500/70', dot: 'bg-emerald-400', text: 'text-emerald-300' },
down: { ring: 'border-red-500', dot: 'bg-red-500', text: 'text-red-300' },
warning: { ring: 'border-amber-500/80', dot: 'bg-amber-400', text: 'text-amber-300' },
maintenance: { ring: 'border-sky-500/60', dot: 'bg-sky-400', text: 'text-sky-300' },
unknown: { ring: 'border-slate-600', dot: 'bg-slate-500', text: 'text-slate-400' },
}
const ICONS: Record<string, string> = {
switch: '⇄',
router: '⇉',
server: '▤',
firewall: '🛡',
ap: '((•))',
ups: '⚡',
cloud: '☁',
olt: '⌸',
other: '□',
}
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other
return (
<div
className={`min-w-[150px] rounded-lg border-2 bg-slate-900/95 px-3 py-2 shadow-lg backdrop-blur
${s.ring} ${selected ? 'ring-2 ring-sky-400' : ''}`}
>
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" />
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-slate-500" />
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<span className="text-base leading-none text-slate-400">{icon}</span>
<span className="truncate text-sm font-medium text-slate-100">{data.label}</span>
</div>
{/* Цифри показуємо лише коли вони є: порожній рядок « мс»
створює враження зламаного моніторингу. */}
{(data.rtt !== undefined || data.loss !== undefined) && (
<div className="mt-1 flex gap-3 text-[11px] tabular-nums">
{data.rtt !== undefined && (
<span className={s.text}>{data.rtt.toFixed(2)} мс</span>
)}
{data.loss !== undefined && data.loss > 0 && (
<span className="text-red-300">втрати {data.loss.toFixed(0)}%</span>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,152 @@
import { useCallback, useEffect, useMemo } from 'react'
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
useEdgesState,
useNodesState,
type Node,
type NodeChange,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { DeviceNode, type DeviceNodeType } from './DeviceNode'
import { TrafficEdge, type TrafficEdgeType } from './TrafficEdge'
import type { MapPatch, MapState } from '../types'
const nodeTypes = { device: DeviceNode }
const edgeTypes = { traffic: TrafficEdge }
interface Props {
state: MapState
onPatch: (p: MapPatch, comment?: string) => void
onSelect: (nodeID: string | null) => void
}
export function MapCanvas({ state, onPatch, onSelect }: Props) {
const rfNodes = useMemo<DeviceNodeType[]>(
() =>
state.nodes.map((n) => ({
id: n.id,
type: 'device' as const,
position: { x: n.x, y: n.y },
draggable: !n.locked,
data: {
label: n.label ?? '—',
kind: (n.style?.icon as string) ?? n.kind,
status: n.status ?? 'unknown',
rtt: n.rtt_ms,
loss: n.loss_pct,
icon: n.style?.icon as string | undefined,
},
})),
[state.nodes],
)
const rfEdges = useMemo<TrafficEdgeType[]>(
() =>
state.edges.map((e) => ({
id: e.id,
type: 'traffic' as const,
source: e.source_node_id,
target: e.target_node_id,
data: {
label: e.label,
sourcePort: e.source_port,
targetPort: e.target_port,
linkStatus: e.link_status,
utilPct: e.util_pct,
capacityBps: e.capacity_bps,
warnPct: e.thresholds?.warn_pct ?? 70,
critPct: e.thresholds?.crit_pct ?? 90,
animated: e.animation?.enabled !== false,
showMetrics: e.show_metrics,
},
})),
[state.edges],
)
const [nodes, setNodes, onNodesChange] = useNodesState<DeviceNodeType>(rfNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState<TrafficEdgeType>(rfEdges)
// Сервер — джерело істини. Але поки вузол тягнуть, його позицію
// диктує миша: інакше кожне оновлення по WebSocket смикало б вузол
// назад під курсором.
useEffect(() => {
setNodes((prev) => {
const dragging = new Set(prev.filter((n) => n.dragging).map((n) => n.id))
return rfNodes.map((n) => {
const old = prev.find((p) => p.id === n.id)
return old && dragging.has(n.id) ? { ...n, position: old.position } : n
})
})
}, [rfNodes, setNodes])
useEffect(() => setEdges(rfEdges), [rfEdges, setEdges])
const handleNodesChange = useCallback(
(changes: NodeChange<DeviceNodeType>[]) => {
onNodesChange(changes)
for (const c of changes) {
if (c.type === 'select') onSelect(c.selected ? c.id : null)
}
},
[onNodesChange, onSelect],
)
// Зберігаємо після відпускання, а не під час перетягування: інакше
// один драг — це сотня запитів і сотня ревізій в історії.
//
// І тільки якщо вузол справді зрушив. React Flow вважає драгом будь-яке
// натискання на вузол, тож простий клік інакше писав би порожню ревізію:
// історія засмічується, а всі інші відкриті полотна отримують
// map.updated і дарма перечитують мапу.
const handleDragStop = useCallback(
(_: unknown, node: Node) => {
const x = Math.round(node.position.x)
const y = Math.round(node.position.y)
const before = state.nodes.find((n) => n.id === node.id)
if (before && Math.round(before.x) === x && Math.round(before.y) === y) return
onPatch({ nodes: { upsert: [{ id: node.id, x, y }] } }, 'перетягування вузла')
},
[onPatch, state.nodes],
)
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={handleDragStop}
defaultViewport={state.viewport}
snapToGrid={state.grid?.snap ?? true}
snapGrid={[state.grid?.size ?? 16, state.grid?.size ?? 16]}
minZoom={0.1}
maxZoom={2.5}
proOptions={{ hideAttribution: false }}
className="bg-slate-950"
>
<Background variant={BackgroundVariant.Dots} gap={state.grid?.size ?? 16} color="#1e293b" />
<Controls className="!bg-slate-900 !text-slate-200 [&>button]:!border-slate-700 [&>button]:!bg-slate-900" />
<MiniMap
pannable
zoomable
className="!bg-slate-900"
maskColor="rgba(2,6,23,0.7)"
nodeColor={(n) => {
const s = (n.data as { status?: string }).status
if (s === 'down') return '#ef4444'
if (s === 'warning') return '#f59e0b'
if (s === 'up') return '#10b981'
return '#475569'
}}
/>
</ReactFlow>
)
}

View file

@ -0,0 +1,123 @@
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
type Edge,
type EdgeProps,
} from '@xyflow/react'
import type { DeviceStatus } from '../types'
export type TrafficEdgeData = {
label?: string
sourcePort?: string
targetPort?: string
linkStatus?: DeviceStatus
utilPct?: number
capacityBps?: number
warnPct: number
critPct: number
animated: boolean
showMetrics: boolean
}
export type TrafficEdgeType = Edge<TrafficEdgeData, 'traffic'>
/**
* Швидкість анімації від завантаження каналу.
*
* Обернена залежність, а не пряма: точка має бігти швидше, коли трафіку
* більше. Стеля 0.6 с далі рух зливається в смугу й перестає читатись;
* підлога 6 с щоб ледь завантажений канал не виглядав мертвим.
*/
function animationDuration(utilPct: number): number {
const u = Math.max(0, Math.min(100, utilPct))
return 6 - (u / 100) * 5.4
}
function strokeColor(d: TrafficEdgeData): string {
if (d.linkStatus === 'down') return '#ef4444'
if (d.linkStatus === 'unknown' || d.linkStatus === undefined) return '#475569'
const u = d.utilPct ?? 0
if (u >= d.critPct) return '#ef4444'
if (u >= d.warnPct) return '#f59e0b'
return '#10b981'
}
function formatBps(bps: number): string {
if (bps >= 1e9) return `${(bps / 1e9).toFixed(1)} Гбіт/с`
if (bps >= 1e6) return `${(bps / 1e6).toFixed(0)} Мбіт/с`
if (bps >= 1e3) return `${(bps / 1e3).toFixed(0)} кбіт/с`
return `${bps.toFixed(0)} біт/с`
}
export function TrafficEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps<TrafficEdgeType>) {
const d = data as TrafficEdgeData
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
const color = strokeColor(d)
const util = d.utilPct ?? 0
// Анімуємо лише коли трафік справді є: рухома точка на порожньому
// каналі означала б рух, якого немає.
const moving = d.animated && d.linkStatus !== 'down' && util > 0
return (
<>
<BaseEdge
id={id}
path={path}
style={{
stroke: color,
strokeWidth: selected ? 3.5 : 2.2,
opacity: d.linkStatus === 'down' ? 0.9 : 0.85,
strokeDasharray: d.linkStatus === 'down' ? '6 4' : undefined,
}}
/>
{moving && (
<circle r="3.5" fill={color}>
<animateMotion dur={`${animationDuration(util)}s`} repeatCount="indefinite">
<mpath href={`#${id}`} />
</animateMotion>
</circle>
)}
{d.showMetrics && (
<EdgeLabelRenderer>
<div
className="pointer-events-none absolute rounded bg-slate-900/85 px-1.5 py-0.5
text-[10px] leading-tight text-slate-300 shadow"
style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
>
<div className="whitespace-nowrap">
{d.sourcePort ?? '?'} {d.targetPort ?? '?'}
</div>
{d.capacityBps !== undefined && (
<div className="whitespace-nowrap tabular-nums" style={{ color }}>
{util > 0 ? `${util < 0.01 ? '<0.01' : util.toFixed(2)}%` : '—'} з{' '}
{formatBps(d.capacityBps)}
</div>
)}
</div>
</EdgeLabelRenderer>
)}
</>
)
}

191
web/src/hooks/useLiveMap.ts Normal file
View file

@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError, api } from '../api/client'
import { LiveConnection, type ConnectionState } from '../api/ws'
import type { MapPatch, MapState, WsMessage } from '../types'
export interface LiveMap {
state: MapState | null
loading: boolean
error: string | null
connection: ConnectionState
/** Востаннє щось прилетіло по WebSocket — індикатор «дані живі». */
lastEventAt: number | null
reload: () => Promise<void>
patch: (p: MapPatch, comment?: string) => Promise<void>
build: () => Promise<void>
}
/**
* Тримає стан полотна: початкове завантаження, живі оновлення й запис.
*
* Правило одне: сервер джерело істини. Локальні зміни застосовуються
* одразу (інакше перетягування смикається), але будь-яка розбіжність
* вирішується перечитуванням, а не спробою домогти локальний стан.
*/
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)
// Ревізія потрібна в обробнику WebSocket, який створюється один раз;
// читати її зі state означало б перепідключатись на кожну правку.
const revision = useRef<number>(0)
const reload = useCallback(async () => {
if (!mapID) return
setLoading(true)
try {
const s = await api.getMap(mapID)
revision.current = s.revision
setState(s)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [mapID])
// Початкове завантаження.
useEffect(() => {
setState(null)
if (mapID) void reload()
}, [mapID, reload])
// Живі оновлення.
useEffect(() => {
const c = new LiveConnection({
onState: setConnection,
onMessage: (msg: WsMessage) => {
setLastEventAt(Date.now())
switch (msg.type) {
case 'device.status':
// Точкове оновлення, без перечитування полотна: зміна
// статусу стосується кольору вузла й нічого більше.
setState((prev) =>
prev
? {
...prev,
nodes: prev.nodes.map((n) =>
n.device_id === msg.payload.device_id
? { ...n, status: msg.payload.status }
: n,
),
}
: prev,
)
break
case 'link.load': {
const byLink = new Map(msg.links.map((l) => [l.link_id, l]))
setState((prev) =>
prev
? {
...prev,
edges: prev.edges.map((e) => {
const l = e.link_id ? byLink.get(e.link_id) : undefined
return l ? { ...e, util_pct: l.util_pct, link_status: l.status } : e
}),
}
: prev,
)
break
}
case 'map.updated':
// Полотно змінив хтось інший. Перечитуємо повністю: дельту
// сервер не шле навмисно, а вгадувати її на клієнті —
// найкоротший шлях до розходження станів.
if (msg.revision !== revision.current) void reload()
break
}
},
})
conn.current = c
c.connect()
return () => {
c.close()
conn.current = null
}
}, [reload])
useEffect(() => {
if (mapID) conn.current?.subscribe(mapID)
}, [mapID, state?.id])
const patch = useCallback(
async (p: MapPatch, comment?: string) => {
if (!mapID) return
try {
const res = await api.patchMap(mapID, {
...p,
revision: revision.current,
comment,
})
revision.current = res.revision
// Застосовуємо збережене до локального стану.
//
// Без цього вузол після перетягування лишався б на новому місці
// лише візуально: перша ж подія про зміну статусу перебудувала б
// список вузлів зі старими координатами й відкинула б його назад.
setState((prev) => {
if (!prev) return prev
const moved = new Map(
(p.nodes?.upsert ?? [])
.filter((n) => n.id !== undefined)
.map((n) => [n.id as string, n]),
)
if (moved.size === 0) return { ...prev, revision: res.revision }
return {
...prev,
revision: res.revision,
nodes: prev.nodes.map((n) => {
const m = moved.get(n.id)
if (!m) return n
return {
...n,
x: m.x ?? n.x,
y: m.y ?? n.y,
label: m.label ?? n.label,
}
}),
}
})
setError(null)
} catch (e) {
if (e instanceof ApiError && e.isConflict) {
// Хтось випередив. Мовчки затирати чуже не можна, тому
// перечитуємо — правку доведеться повторити.
setError('Мапу змінив інший користувач — полотно перечитано')
await reload()
return
}
if (e instanceof ApiError && e.isPlanLimit) {
setError('Досягнуто ліміт тарифу')
return
}
setError(e instanceof Error ? e.message : String(e))
}
},
[mapID, reload],
)
const build = useCallback(async () => {
if (!mapID) return
try {
await api.buildMap(mapID)
await reload()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [mapID, reload])
return { state, loading, error, connection, lastEventAt, reload, patch, build }
}

28
web/src/index.css Normal file
View file

@ -0,0 +1,28 @@
@import 'tailwindcss';
/* Темна тема без перемикача: мапа мережі живе на стіні NOC, де світлий
фон о третій ночі це не дизайн, а знущання. */
html,
body,
#root {
height: 100%;
margin: 0;
background: #020617;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
/* React Flow малює власні контроли світлими — приводимо до теми. */
.react-flow__controls-button {
border-bottom-color: #334155 !important;
fill: #cbd5e1;
}
.react-flow__controls-button:hover {
background: #1e293b !important;
}
.react-flow__attribution {
background: transparent !important;
color: #334155 !important;
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

185
web/src/types.ts Normal file
View file

@ -0,0 +1,185 @@
// Типи повторюють те, що віддає API. Генератор із OpenAPI тут був би
// зайвим шаром: контракт описаний у server/API.md і змінюється разом
// зі схемою БД, а не окремо від неї.
export type DeviceStatus = 'up' | 'down' | 'warning' | 'unknown' | 'maintenance' | ''
export interface MapSummary {
id: string
name: string
slug: string
kind: string
is_default: boolean
node_count: number
edge_count: number
revision: number
updated_at: string
}
export interface MapBackground {
id: string
kind: string
storage_key?: string
mime_type?: string
x: number
y: number
width?: number
height?: number
rotation: number
opacity: number
locked: boolean
z_index: number
}
export interface MapNode {
id: string
kind: string
label?: string
device_id?: string
parent_id?: string
x: number
y: number
width?: number
height?: number
z_index: number
style: Record<string, unknown>
data: Record<string, unknown>
collapsed: boolean
locked: boolean
status?: DeviceStatus
last_seen_at?: string
rtt_ms?: number
loss_pct?: number
}
export interface MapEdge {
id: string
source_node_id: string
target_node_id: string
source_port?: string
target_port?: string
link_id?: string
label?: string
style: string
dash: string
color?: string
width_px: number
waypoints: unknown[]
animation: { enabled?: boolean; speed_source?: string; max_speed?: number }
thresholds: { warn_pct?: number; crit_pct?: number }
show_metrics: boolean
link_status?: DeviceStatus
util_pct?: number
capacity_bps?: number
}
export interface MapState {
id: string
name: string
slug: string
kind: string
layout_algo: string
viewport: { x: number; y: number; zoom: number }
grid: { enabled?: boolean; size?: number; snap?: boolean }
clustering: Record<string, unknown>
revision: number
backgrounds: MapBackground[]
nodes: MapNode[]
edges: MapEdge[]
}
export interface DeviceSummary {
id: string
name: string
address?: string
kind: string
vendor?: string
status: DeviceStatus
enabled: boolean
last_seen_at?: string
interface_count: number
}
export interface AgentSummary {
id: string
name: string
status: string
version?: string
os?: string
arch?: string
last_heartbeat_at?: string
enabled_modules: string[]
health: Record<string, unknown>
device_count: number
}
// ---------------------------------------------------------------------
// Патч мапи. Кожне скалярне поле необов'язкове: відсутнє означає
// «не чіпати». Саме тому перетягування шле лише x/y й не стирає стиль.
// ---------------------------------------------------------------------
export interface NodeInput {
id?: string
client_id?: string
kind?: string
device_id?: string
label?: string
x?: number
y?: number
style?: Record<string, unknown>
}
export interface EdgeInput {
id?: string
client_id?: string
source_node_id?: string
target_node_id?: string
label?: string
style?: string
}
export interface MapPatch {
revision?: number
viewport?: { x: number; y: number; zoom: number }
nodes?: { upsert?: NodeInput[]; remove?: string[] }
edges?: { upsert?: EdgeInput[]; remove?: string[] }
comment?: string
}
export interface PatchResult {
revision: number
node_ids?: Record<string, string>
edge_ids?: Record<string, string>
}
export interface BuildResult {
nodes_added: number
edges_added: number
revision: number
}
// ---------------------------------------------------------------------
// Повідомлення WebSocket
// ---------------------------------------------------------------------
export type WsMessage =
| { type: 'hello'; tenant_id: string }
| { type: 'subscribed'; map_id: string }
| { type: 'error'; code: string; map_id?: string }
| {
type: 'device.status'
payload: {
device_id: string
status: DeviceStatus
previous_status: DeviceStatus
reason?: string
}
}
| {
type: 'link.load'
map_id: string
links: { link_id: string; status: DeviceStatus; util_pct?: number }[]
}
| { type: 'map.updated'; map_id: string; revision: number }

9
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_TOKEN?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

20
web/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

21
web/vite.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// Стенд і браузер розробника — різні машини, тому не 127.0.0.1.
host: '0.0.0.0',
port: 5173,
// Проксі знімає питання CORS і робить адресу API однаковою
// в розробці й за зворотним проксі у проді.
proxy: {
'/api': {
target: process.env.NETPULSE_API ?? 'http://127.0.0.1:8080',
changeOrigin: true,
ws: true,
},
},
},
})