Етап 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:
parent
8bf2902522
commit
3de5643b98
18 changed files with 1415 additions and 3 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -27,3 +27,7 @@ netpulse-server
|
|||
# Дані локального стенду
|
||||
/data/
|
||||
/var/
|
||||
|
||||
# Фронтенд
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
|
|
|
|||
62
HISTORY.md
62
HISTORY.md
|
|
@ -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
103
web/README.md
Normal 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
12
web/index.html
Normal 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
26
web/package.json
Normal 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
239
web/src/App.tsx
Normal 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
84
web/src/api/client.ts
Normal 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
80
web/src/api/ws.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
69
web/src/components/DeviceNode.tsx
Normal file
69
web/src/components/DeviceNode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
web/src/components/MapCanvas.tsx
Normal file
152
web/src/components/MapCanvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
web/src/components/TrafficEdge.tsx
Normal file
123
web/src/components/TrafficEdge.tsx
Normal 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
191
web/src/hooks/useLiveMap.ts
Normal 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
28
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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
185
web/src/types.ts
Normal 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
9
web/src/vite-env.d.ts
vendored
Normal 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
20
web/tsconfig.json
Normal 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
21
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue