Зв'язки на мапі: боки підключення, інспектор лінії, типовий вигляд вузла
Порти на вузлі отримали імена (l/r/t/b) з парою «джерело + ціль» під спільним іменем — інакше збережене ребро не знаходить пари й лінія не малюється. Бік підключення тепер правиться: UPDATE ребра розрізняє три стани — не чіпати, зняти прив'язку (auto), задати конкретний бік. Раніше UPDATE боків не торкався, і криво причеплену лінію лишалось видаляти. Лінія без прив'язки бере бік із взаємного розташування вузлів, а не перший-ліпший порт. Типовий вузол — крапка з підписом знизу, без значка й цифр. Підпис бере ім'я хоста, якщо власного немає. Полотно возиться лівою кнопкою в обох режимах; рамка виділення — на Ctrl/Shift. fitView при відкритті та після масового додавання хостів. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
205dd5e079
commit
2c12bed017
10 changed files with 695 additions and 61 deletions
79
HISTORY.md
79
HISTORY.md
|
|
@ -1978,3 +1978,82 @@ gateway «Час відгуку» (line) → дві лінії, вісь 0
|
|||
керування PATCH модулі й ліміти → 204, значення застосовані
|
||||
```
|
||||
|
||||
|
||||
## Зв'язки на мапі
|
||||
|
||||
### Порти на вузлі
|
||||
|
||||
Чотири боки, на кожному пара портів з однаковим ім'ям: `l`, `r`, `t`,
|
||||
`b`. React Flow розрізняє джерело й ціль за типом, тож одне ім'я обслуговує
|
||||
обидва напрямки. Розійдуться імена (наприклад `l` і `l-s`) — збережене
|
||||
ребро не знайде пари, і лінія просто не намалюється, без жодної помилки.
|
||||
|
||||
Обгортка портів — `Fragment`, а не `<span>`: `<span>` стає елементом
|
||||
flex-розкладки й розсуває вміст вузла. Чотири такі обгортки з'їдали підпис,
|
||||
і на схемі лишались картки без імен.
|
||||
|
||||
### Бік підключення
|
||||
|
||||
Лінія без явної прив'язки більше не чіпляється до лівого краю навпомацки.
|
||||
`autoSides()` рахує бік із взаємного розташування вузлів: більша різниця по
|
||||
X — пара «праворуч → ліворуч», більша по Y — «знизу → зверху». React Flow
|
||||
сам найкоротший бік не шукає.
|
||||
|
||||
Бік правиться в інспекторі: `авто | ліворуч | праворуч | зверху | знизу`.
|
||||
Три стани в патчі, а не два — порожньо не чіпає, `auto` знімає прив'язку,
|
||||
решта задає конкретний бік:
|
||||
|
||||
```sql
|
||||
source_handle = CASE $19 WHEN '' THEN source_handle
|
||||
WHEN 'auto' THEN NULL
|
||||
ELSE $19 END
|
||||
```
|
||||
|
||||
Раніше `UPDATE` боків не чіпав узагалі: лінію, що причепилась не до того
|
||||
краю, лишалось хіба видалити й намалювати заново.
|
||||
|
||||
### Типовий вигляд вузла
|
||||
|
||||
Крапка, підпис знизу, дрібний текст, без значка й без цифр пінгу. Картка з
|
||||
рамкою читається на схемі з десяти вузлів і перетворюється на сітку
|
||||
прямокутників уже на п'ятдесяти.
|
||||
|
||||
Підпис вузла бере ім'я хоста, якщо власного немає:
|
||||
`COALESCE(NULLIF(n.label,''), d.name, '')`. Хости, додані кнопкою
|
||||
«+ Хости», приходили без `label` і виглядали як безіменні крапки.
|
||||
|
||||
### Полотно
|
||||
|
||||
Ліва кнопка возить полотно завжди — і в перегляді, і в редагуванні. Рамка
|
||||
виділення вмикається Ctrl або Shift. Спершу було навпаки, і це зламало
|
||||
найчастішу дію: гортання схеми, більшої за екран.
|
||||
|
||||
`fitView` при відкритті та `fitSignal` після масового додавання хостів.
|
||||
Збережений viewport не годиться як типовий: вузли, додані з іншого екрана,
|
||||
опиняються за його межами, і мапа відкривається порожньою, хоча в ній
|
||||
десяток хостів.
|
||||
|
||||
### Хибний слід
|
||||
|
||||
Півдня пішло на «ребра не малюються»: у полотні нуль шляхів, усі вузли з
|
||||
`visibility: hidden`. Причина виявилась не в коді — вкладка браузера, якою
|
||||
я перевіряв, не компонувала кадри (`document.hidden === true`). Без кадрів
|
||||
не спрацьовує ані `requestAnimationFrame`, ані `ResizeObserver`; React Flow
|
||||
не міряє вузли, а невиміряний вузол лишається схованим і не дає порахувати
|
||||
геометрію лінії.
|
||||
|
||||
Проба показала це прямо: `observe` — 12 викликів, `fire` — жодного.
|
||||
|
||||
Висновок на майбутнє: перед тим як шукати винного в коді, перевіряти, чи
|
||||
взагалі сторінка малюється.
|
||||
|
||||
### Перевірено наскрізно
|
||||
|
||||
```
|
||||
створення POST боки b/t → збережено b/t
|
||||
правка боки r/l → застосовано (раніше лишались b/t)
|
||||
скидання боки auto → NULL, підпис не втрачено
|
||||
колір "" → NULL, повертається розрахунок за станом
|
||||
видалення edges.remove → ребра немає
|
||||
дублікат та сама пара вузлів → 400 «такий запис уже існує»
|
||||
```
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ type MapEdge struct {
|
|||
TargetNodeID string `json:"target_node_id"`
|
||||
SourcePort string `json:"source_port,omitempty"`
|
||||
TargetPort string `json:"target_port,omitempty"`
|
||||
// До якого боку вузла кріпиться лінія. Без цього після
|
||||
// перезавантаження всі зв'язки перескакують на типові порти, і
|
||||
// розкладка, яку людина розводила руками, розсипається.
|
||||
SourceHandle string `json:"source_handle,omitempty"`
|
||||
TargetHandle string `json:"target_handle,omitempty"`
|
||||
LinkID string `json:"link_id,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Style string `json:"style"`
|
||||
|
|
@ -232,7 +237,11 @@ func mapNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapNode
|
|||
// дані не характеризують поточний стан, і показувати їх на мапі
|
||||
// означало б брехати про живість пристрою.
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT n.id::text, n.kind::text, COALESCE(n.label,''),
|
||||
-- Підпис вузла-пристрою — це ім'я самого пристрою, доки його не
|
||||
-- перевизначили руками. Без COALESCE вузол, доданий кнопкою
|
||||
-- «+ Хости», приходив без підпису й малювався прочерком: на
|
||||
-- схемі з десятка таких неможливо зрозуміти, що є що.
|
||||
SELECT n.id::text, n.kind::text, COALESCE(NULLIF(n.label,''), d.name, ''),
|
||||
COALESCE(n.device_id::text,''), COALESCE(n.parent_node_id::text,''),
|
||||
n.x, n.y, n.width, n.height, n.z_index,
|
||||
n.style::text, n.data::text, n.collapsed, n.locked,
|
||||
|
|
@ -288,6 +297,7 @@ func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge
|
|||
rows, err := tx.Query(ctx, `
|
||||
SELECT e.id::text, e.source_node_id::text, e.target_node_id::text,
|
||||
COALESCE(si.name,''), COALESCE(ti.name,''),
|
||||
COALESCE(e.source_handle,''), COALESCE(e.target_handle,''),
|
||||
COALESCE(e.link_id::text,''), COALESCE(e.label,''),
|
||||
e.style::text, e.dash::text, COALESCE(e.color,''), e.width_px,
|
||||
e.waypoints::text, e.animation::text, e.thresholds::text, e.show_metrics,
|
||||
|
|
@ -314,7 +324,8 @@ func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge
|
|||
for rows.Next() {
|
||||
var e MapEdge
|
||||
if err := rows.Scan(&e.ID, &e.SourceNodeID, &e.TargetNodeID,
|
||||
&e.SourcePort, &e.TargetPort, &e.LinkID, &e.Label,
|
||||
&e.SourcePort, &e.TargetPort, &e.SourceHandle, &e.TargetHandle,
|
||||
&e.LinkID, &e.Label,
|
||||
&e.Style, &e.Dash, &e.Color, &e.WidthPx,
|
||||
&e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics,
|
||||
&e.LinkStatus, &e.UtilPct, &e.CapacityBps); err != nil {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ type EdgeInput struct {
|
|||
TargetNodeID string `json:"target_node_id,omitempty"`
|
||||
SourceInterfaceID string `json:"source_interface_id,omitempty"`
|
||||
TargetInterfaceID string `json:"target_interface_id,omitempty"`
|
||||
SourceHandle string `json:"source_handle,omitempty"`
|
||||
TargetHandle string `json:"target_handle,omitempty"`
|
||||
LinkID string `json:"link_id,omitempty"`
|
||||
|
||||
Label *string `json:"label,omitempty"`
|
||||
|
|
@ -363,7 +365,8 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP
|
|||
(tenant_id, map_id, source_node_id, target_node_id,
|
||||
source_interface_id, target_interface_id, link_id, label,
|
||||
style, dash, color, width_px, waypoints, animation, thresholds,
|
||||
show_metrics, z_index, locked, hidden)
|
||||
show_metrics, z_index, locked, hidden,
|
||||
source_handle, target_handle)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
|
||||
COALESCE($9::topo.edge_style,'smoothstep'),
|
||||
COALESCE($10::topo.edge_dash,'solid'),
|
||||
|
|
@ -372,13 +375,15 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP
|
|||
COALESCE($14::jsonb,'{"enabled":true,"speed_source":"utilization"}'::jsonb),
|
||||
COALESCE($15::jsonb,'{}'::jsonb),
|
||||
COALESCE($16,true), COALESCE($17,0),
|
||||
COALESCE($18,false), COALESCE($19,false))
|
||||
COALESCE($18,false), COALESCE($19,false),
|
||||
NULLIF($20,''), NULLIF($21,''))
|
||||
RETURNING id::text
|
||||
`, tenantID, mapID, src, dst,
|
||||
nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID),
|
||||
nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
|
||||
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
|
||||
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden).Scan(&id); err != nil {
|
||||
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden,
|
||||
e.SourceHandle, e.TargetHandle).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.ClientID != "" {
|
||||
|
|
@ -404,12 +409,22 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP
|
|||
source_interface_id = COALESCE($16, source_interface_id),
|
||||
target_interface_id = COALESCE($17, target_interface_id),
|
||||
link_id = COALESCE($18, link_id),
|
||||
-- Бік підключення міняється трьома станами, а не двома:
|
||||
-- порожньо — не чіпати, 'auto' — зняти привʼязку й
|
||||
-- віддати вибік боку полотну, решта — конкретний бік.
|
||||
-- Без цього лінію, що причепилась не до того краю,
|
||||
-- лишалось хіба видалити й намалювати заново.
|
||||
source_handle = CASE $19 WHEN '' THEN source_handle
|
||||
WHEN 'auto' THEN NULL ELSE $19 END,
|
||||
target_handle = CASE $20 WHEN '' THEN target_handle
|
||||
WHEN 'auto' THEN NULL ELSE $20 END,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND map_id = $2 AND tenant_id = $3
|
||||
`, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
|
||||
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
|
||||
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden,
|
||||
nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.LinkID))
|
||||
nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.LinkID),
|
||||
e.SourceHandle, e.TargetHandle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Fragment } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
|
||||
import type { DeviceStatus } from '../types'
|
||||
|
|
@ -93,13 +94,36 @@ const TEXT_SIZE: Record<string, string> = {
|
|||
|
||||
const DOT_SIZE: Record<string, number> = { sm: 12, md: 18, lg: 26 }
|
||||
|
||||
// Чотири боки вузла. Джерело й ціль лежать одна на одній: React Flow
|
||||
// сам вирішує, яку взяти, за напрямком, у якому тягнуть лінію.
|
||||
const HANDLE_SIDES: { id: string; pos: Position }[] = [
|
||||
{ id: 'l', pos: Position.Left },
|
||||
{ id: 'r', pos: Position.Right },
|
||||
{ id: 't', pos: Position.Top },
|
||||
{ id: 'b', pos: Position.Bottom },
|
||||
]
|
||||
|
||||
const HANDLE_CLASS =
|
||||
'!h-3 !w-3 !border-2 !border-slate-950 !bg-slate-600 opacity-0 ' +
|
||||
'transition-opacity group-hover:opacity-100 hover:!bg-sky-400'
|
||||
|
||||
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
||||
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
|
||||
// 'none' — свідома відсутність значка, а не «не задано»: на схемі з
|
||||
// власними картинками типовий квадратик поруч із ними лише заважає.
|
||||
// Значка типово немає: поруч із крапкою він лише додає шуму, а сенс
|
||||
// несе колір стану.
|
||||
const showIcon = data.icon !== undefined && data.icon !== '' && data.icon !== 'none'
|
||||
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other
|
||||
const size = data.size ?? 'md'
|
||||
const size = data.size ?? 'sm'
|
||||
const textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md
|
||||
const shape = data.shape ?? 'card'
|
||||
const labelPos = data.labelPos ?? 'right'
|
||||
// Типовий вигляд — крапка з підписом знизу, без значка й цифр.
|
||||
//
|
||||
// Картка з рамкою добре читається на схемі з десяти вузлів і
|
||||
// перетворює схему на сітку прямокутників уже на п'ятдесяти. Крапка
|
||||
// лишає читабельним головне: де вузол і якого він стану.
|
||||
const shape = data.shape ?? 'dot'
|
||||
const labelPos = data.labelPos ?? 'bottom'
|
||||
const accent = data.color ?? s.raw
|
||||
|
||||
// Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх
|
||||
|
|
@ -113,20 +137,31 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
|||
.filter(Boolean)
|
||||
.join(', ')
|
||||
|
||||
// Порти з усіх чотирьох боків, кожен і джерело, і ціль.
|
||||
//
|
||||
// Було по одному: ціль ліворуч, джерело праворуч. Через це лінію
|
||||
// можна було тягнути лише зліва направо, а всі зв'язки збігалися в
|
||||
// одну точку на кожному вузлі — схема перетворювалась на віяло з
|
||||
// двох крапок. Тепер зв'язок кріпиться туди, куди його ведуть.
|
||||
// На кожному боці два порти з ОДНАКОВИМ ідентифікатором: джерело й
|
||||
// ціль. React Flow розрізняє їх за типом, тож ребро з sourceHandle
|
||||
// 'l' знаходить джерело зліва, а targetHandle 'l' — ціль там само.
|
||||
//
|
||||
// Ідентифікатори мають збігатись у пари: ребро, збережене з
|
||||
// sourceHandle 'l', шукає ціль теж під іменем 'l'. Розійдуться імена
|
||||
// (наприклад 'l' і 'l-s') — React Flow мовчки не намалює лінію.
|
||||
//
|
||||
// Fragment, а не <span>: обгортка навколо портів стає елементом
|
||||
// flex-розкладки й розсуває вміст вузла — чотири такі обгортки
|
||||
// з'їдали підпис, і на схемі лишались картки без імен.
|
||||
const handles = (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-slate-950 !bg-slate-500
|
||||
opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-slate-950 !bg-slate-500
|
||||
opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
{HANDLE_SIDES.map(({ id, pos }) => (
|
||||
<Fragment key={id}>
|
||||
<Handle id={id} type="target" position={pos} className={HANDLE_CLASS} />
|
||||
<Handle id={id} type="source" position={pos} className={HANDLE_CLASS} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
@ -194,7 +229,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
|||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-2xl text-slate-500">
|
||||
{icon}
|
||||
{showIcon ? icon : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* Стан — тонка обвідка знизу, а не рамка навколо: рамка
|
||||
|
|
@ -213,9 +248,9 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
|||
// ---- без рамки ----------------------------------------------------
|
||||
if (shape === 'bare') {
|
||||
return wrap(
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="relative flex shrink-0 items-center gap-1.5">
|
||||
<StatusDot color={accent} pulse={s.pulse} />
|
||||
<span className="text-lg leading-none text-slate-300">{icon}</span>
|
||||
{showIcon && <span className="text-lg leading-none text-slate-300">{icon}</span>}
|
||||
{selected && <span className="absolute -inset-1 rounded ring-2 ring-sky-400" />}
|
||||
</span>,
|
||||
)
|
||||
|
|
@ -261,7 +296,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
|||
|
||||
<div className="py-2 pl-3.5 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base leading-none text-slate-400">{icon}</span>
|
||||
{showIcon && <span className="text-base leading-none text-slate-400">{icon}</span>}
|
||||
<span className={`truncate font-medium tracking-tight text-slate-100 ${textSize}`}>
|
||||
{data.label}
|
||||
</span>
|
||||
|
|
@ -270,7 +305,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
|||
|
||||
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
||||
створює враження зламаного моніторингу. */}
|
||||
{!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
|
||||
{data.hideMetrics === false && (data.rtt !== undefined || data.loss !== undefined) && (
|
||||
<div className="mt-1 flex gap-2.5 text-[11px] tabular-nums">
|
||||
{data.rtt !== undefined && <span className={s.text}>{data.rtt.toFixed(2)} мс</span>}
|
||||
{data.loss !== undefined && data.loss > 0 && (
|
||||
|
|
|
|||
315
web/src/components/EdgeInspector.tsx
Normal file
315
web/src/components/EdgeInspector.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Button, Field, Toggle, inputClass } from './ui'
|
||||
import type { EdgeInput, MapEdge, MapNode } from '../types'
|
||||
|
||||
// Форма лінії. smoothstep — типова: прямі кути читаються як схема, а не
|
||||
// як павутина, і саме так малюють мережі на папері.
|
||||
const STYLES: { key: string; label: string }[] = [
|
||||
{ key: 'smoothstep', label: 'кутами' },
|
||||
{ key: 'bezier', label: 'плавна' },
|
||||
{ key: 'straight', label: 'пряма' },
|
||||
{ key: 'step', label: 'східцями' },
|
||||
]
|
||||
|
||||
// Бік вузла, до якого кріпиться лінія. 'auto' знімає привʼязку: тоді
|
||||
// полотно саме обирає найкоротший шлях і лінія переходить на інший бік
|
||||
// разом із вузлом, коли той переїхав.
|
||||
const SIDES: { key: string; label: string }[] = [
|
||||
{ key: 'auto', label: 'авто' },
|
||||
{ key: 'l', label: 'ліворуч' },
|
||||
{ key: 'r', label: 'праворуч' },
|
||||
{ key: 't', label: 'зверху' },
|
||||
{ key: 'b', label: 'знизу' },
|
||||
]
|
||||
|
||||
const DASHES: { key: string; label: string }[] = [
|
||||
{ key: 'solid', label: 'суцільна' },
|
||||
{ key: 'dashed', label: 'пунктир' },
|
||||
{ key: 'dotted', label: 'крапками' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Інспектор зв'язку.
|
||||
*
|
||||
* Колір лінії рахується зі стану: зелений — працює, жовтий і червоний —
|
||||
* за порогами завантаження, червоний пунктир — обрив. Власний колір
|
||||
* перекриває цей розрахунок, і саме тому він тут окремим полем із
|
||||
* кнопкою «скинути»: на схемі, де кольором позначені майданчики, стан
|
||||
* читають за товщиною й пунктиром.
|
||||
*/
|
||||
export function EdgeInspector({
|
||||
edge,
|
||||
nodes,
|
||||
readOnly,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
edge: MapEdge
|
||||
nodes: MapNode[]
|
||||
readOnly: boolean
|
||||
onPatch: (patch: EdgeInput, comment: string) => Promise<void> | void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [label, setLabel] = useState(edge.label ?? '')
|
||||
const [style, setStyle] = useState(edge.style)
|
||||
const [dash, setDash] = useState(edge.dash)
|
||||
const [color, setColor] = useState(edge.color ?? '')
|
||||
const [width, setWidth] = useState(String(edge.width_px ?? 2))
|
||||
const [warn, setWarn] = useState(String(edge.thresholds?.warn_pct ?? 70))
|
||||
const [crit, setCrit] = useState(String(edge.thresholds?.crit_pct ?? 90))
|
||||
const [animated, setAnimated] = useState(edge.animation?.enabled !== false)
|
||||
const [showMetrics, setShowMetrics] = useState(edge.show_metrics)
|
||||
const [srcSide, setSrcSide] = useState(edge.source_handle || 'auto')
|
||||
const [dstSide, setDstSide] = useState(edge.target_handle || 'auto')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Вибір іншої лінії має перезаряджати форму, інакше вона показувала б
|
||||
// поля попередньої.
|
||||
useEffect(() => {
|
||||
setLabel(edge.label ?? '')
|
||||
setStyle(edge.style)
|
||||
setDash(edge.dash)
|
||||
setColor(edge.color ?? '')
|
||||
setWidth(String(edge.width_px ?? 2))
|
||||
setWarn(String(edge.thresholds?.warn_pct ?? 70))
|
||||
setCrit(String(edge.thresholds?.crit_pct ?? 90))
|
||||
setAnimated(edge.animation?.enabled !== false)
|
||||
setShowMetrics(edge.show_metrics)
|
||||
setSrcSide(edge.source_handle || 'auto')
|
||||
setDstSide(edge.target_handle || 'auto')
|
||||
}, [edge])
|
||||
|
||||
const nameOf = (id: string) => nodes.find((n) => n.id === id)?.label ?? '—'
|
||||
|
||||
const dirty =
|
||||
label !== (edge.label ?? '') ||
|
||||
style !== edge.style ||
|
||||
dash !== edge.dash ||
|
||||
color !== (edge.color ?? '') ||
|
||||
width !== String(edge.width_px ?? 2) ||
|
||||
warn !== String(edge.thresholds?.warn_pct ?? 70) ||
|
||||
crit !== String(edge.thresholds?.crit_pct ?? 90) ||
|
||||
animated !== (edge.animation?.enabled !== false) ||
|
||||
showMetrics !== edge.show_metrics ||
|
||||
srcSide !== (edge.source_handle || 'auto') ||
|
||||
dstSide !== (edge.target_handle || 'auto')
|
||||
|
||||
async function apply() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await onPatch(
|
||||
{
|
||||
id: edge.id,
|
||||
label,
|
||||
style,
|
||||
dash,
|
||||
// Порожній колір означає «за станом»: сервер зберігає NULL,
|
||||
// і розрахунок повертається.
|
||||
color,
|
||||
width_px: Number(width) || 2,
|
||||
thresholds: { warn_pct: Number(warn) || 70, crit_pct: Number(crit) || 90 },
|
||||
animation: { ...edge.animation, enabled: animated },
|
||||
show_metrics: showMetrics,
|
||||
source_handle: srcSide,
|
||||
target_handle: dstSide,
|
||||
},
|
||||
'зміна вигляду звʼязку',
|
||||
)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2.5 text-xs
|
||||
md:absolute md:bottom-3 md:right-3 md:z-10 md:w-72 md:rounded-lg md:border"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium text-slate-100">
|
||||
{nameOf(edge.source_node_id)} → {nameOf(edge.target_node_id)}
|
||||
</span>
|
||||
<button className="px-1 text-slate-500 hover:text-slate-300" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
<Row k="стан" v={statusLabel(edge.link_status)} />
|
||||
{edge.util_pct !== undefined && <Row k="завантаження" v={`${edge.util_pct.toFixed(2)}%`} />}
|
||||
{edge.source_port && <Row k="порт A" v={edge.source_port} />}
|
||||
{edge.target_port && <Row k="порт Б" v={edge.target_port} />}
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<p className="text-[11px] text-slate-600">Перегляд — редагування потребує maps:write</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Field label="Підпис" hint="Порожньо — покажуться порти й швидкість">
|
||||
<input
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Форма">
|
||||
<select
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={style}
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
>
|
||||
{STYLES.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Штрих">
|
||||
<select
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={dash}
|
||||
onChange={(e) => setDash(e.target.value)}
|
||||
>
|
||||
{DASHES.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Бік A">
|
||||
<select
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={srcSide}
|
||||
onChange={(e) => setSrcSide(e.target.value)}
|
||||
>
|
||||
{SIDES.map((x) => (
|
||||
<option key={x.key} value={x.key}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Бік Б">
|
||||
<select
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={dstSide}
|
||||
onChange={(e) => setDstSide(e.target.value)}
|
||||
>
|
||||
{SIDES.map((x) => (
|
||||
<option key={x.key} value={x.key}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Товщина, px">
|
||||
<input
|
||||
className={`${inputClass} py-1.5`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Колір" hint="скинути — за станом">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
className="h-8 w-10 rounded border border-slate-700 bg-slate-800"
|
||||
value={color || '#10b981'}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
{color && (
|
||||
<button
|
||||
className="text-[11px] text-slate-500 hover:text-slate-300"
|
||||
onClick={() => setColor('')}
|
||||
>
|
||||
скинути
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Жовтий від, %">
|
||||
<input
|
||||
className={`${inputClass} py-1.5`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={warn}
|
||||
onChange={(e) => setWarn(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Червоний від, %">
|
||||
<input
|
||||
className={`${inputClass} py-1.5`}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={crit}
|
||||
onChange={(e) => setCrit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-slate-400">
|
||||
<Toggle checked={animated} onChange={setAnimated} />
|
||||
Анімація трафіку
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-slate-400">
|
||||
<Toggle checked={showMetrics} onChange={setShowMetrics} />
|
||||
Підпис на лінії
|
||||
</label>
|
||||
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}>
|
||||
{busy ? 'Збереження…' : 'Застосувати'}
|
||||
</Button>
|
||||
<Button kind="danger" onClick={onDelete} title="Прибрати звʼязок із полотна">
|
||||
Видалити
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function statusLabel(s?: string): string {
|
||||
switch (s) {
|
||||
case 'up':
|
||||
return 'працює'
|
||||
case 'down':
|
||||
return 'обрив'
|
||||
case 'warning':
|
||||
return 'проблеми'
|
||||
default:
|
||||
// Порожній статус — не помилка: так виглядає лінія, намальована
|
||||
// рукою й не привʼязана до фізичного лінка.
|
||||
return 'без привʼязки'
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ import {
|
|||
type Node,
|
||||
type NodeChange,
|
||||
SelectionMode,
|
||||
ConnectionMode,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
|
|
@ -28,16 +31,33 @@ interface Props {
|
|||
onSelect: (nodeID: string | null) => void
|
||||
/** Виділені вузли — для групових дій в інспекторі. */
|
||||
onSelectionChange?: (nodeIDs: string[]) => void
|
||||
/** Обране ребро — щоб відкрити його налаштування. */
|
||||
onSelectEdge?: (edgeID: string | null) => void
|
||||
selectedEdge?: string | null
|
||||
|
||||
/** Глядач без maps:write бачить полотно, але не змінює його. */
|
||||
readOnly?: boolean
|
||||
|
||||
/**
|
||||
* Лічильник: збільшення підганяє вигляд під усі вузли.
|
||||
*
|
||||
* Потрібен після масового додавання хостів. Нові вузли лягають поруч
|
||||
* із наявними, а ті можуть стояти далеко за краєм екрана — тоді
|
||||
* додавання виглядає як «нічого не сталося», хоча повторна спроба
|
||||
* чесно каже «вже на мапі».
|
||||
*/
|
||||
fitSignal?: number
|
||||
}
|
||||
|
||||
export function MapCanvas({
|
||||
function MapCanvasInner({
|
||||
state,
|
||||
onPatch,
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onSelectEdge,
|
||||
selectedEdge = null,
|
||||
readOnly = false,
|
||||
fitSignal = 0,
|
||||
}: Props) {
|
||||
const rfNodes = useMemo<DeviceNodeType[]>(
|
||||
() =>
|
||||
|
|
@ -69,15 +89,40 @@ export function MapCanvas({
|
|||
[state.nodes, readOnly],
|
||||
)
|
||||
|
||||
// Координати вузлів — щоб порахувати бік підключення для ліній без
|
||||
// явної привʼязки.
|
||||
const nodePos = useMemo(() => {
|
||||
const m = new Map<string, { x: number; y: number }>()
|
||||
state.nodes.forEach((n) => m.set(n.id, { x: n.x, y: n.y }))
|
||||
return m
|
||||
}, [state.nodes])
|
||||
|
||||
const rfEdges = useMemo<TrafficEdgeType[]>(
|
||||
() =>
|
||||
state.edges.map((e) => ({
|
||||
state.edges.map((e) => {
|
||||
const [autoSrc, autoDst] = autoSides(
|
||||
nodePos.get(e.source_node_id),
|
||||
nodePos.get(e.target_node_id),
|
||||
)
|
||||
return {
|
||||
id: e.id,
|
||||
type: 'traffic' as const,
|
||||
source: e.source_node_id,
|
||||
target: e.target_node_id,
|
||||
// Ім'я порту обов'язкове: щойно у вузла з'явились іменовані
|
||||
// порти, ребро без імені React Flow просто не малює.
|
||||
//
|
||||
// normalizeHandle прибирає суфікс '-s' зі старих записів: у
|
||||
// першій версії порти були парою «джерело + ціль», і збережені
|
||||
// тоді ребра посилаються на ідентифікатори, яких уже немає.
|
||||
sourceHandle: normalizeHandle(e.source_handle) ?? autoSrc,
|
||||
targetHandle: normalizeHandle(e.target_handle) ?? autoDst,
|
||||
selected: e.id === selectedEdge,
|
||||
data: {
|
||||
label: e.label,
|
||||
dash: e.dash,
|
||||
widthPx: e.width_px,
|
||||
color: e.color,
|
||||
sourcePort: e.source_port,
|
||||
targetPort: e.target_port,
|
||||
linkStatus: e.link_status,
|
||||
|
|
@ -88,8 +133,9 @@ export function MapCanvas({
|
|||
animated: e.animation?.enabled !== false,
|
||||
showMetrics: e.show_metrics,
|
||||
},
|
||||
})),
|
||||
[state.edges],
|
||||
}
|
||||
}),
|
||||
[state.edges, selectedEdge, nodePos],
|
||||
)
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<DeviceNodeType>(rfNodes)
|
||||
|
|
@ -173,6 +219,11 @@ export function MapCanvas({
|
|||
client_id: `e-${Date.now()}`,
|
||||
source_node_id: c.source,
|
||||
target_node_id: c.target,
|
||||
// Боки зберігаємо: інакше після перезавантаження лінія
|
||||
// перескочить на типові порти й розкладка, яку людина
|
||||
// розводила руками, розсиплеться.
|
||||
source_handle: c.sourceHandle ?? undefined,
|
||||
target_handle: c.targetHandle ?? undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -217,6 +268,12 @@ export function MapCanvas({
|
|||
[onPatch, state.nodes],
|
||||
)
|
||||
|
||||
const rf = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (fitSignal > 0) rf.fitView({ padding: 0.2, duration: 400 })
|
||||
}, [fitSignal, rf])
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
|
|
@ -226,28 +283,43 @@ export function MapCanvas({
|
|||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={handleDragStop}
|
||||
onEdgeClick={onSelectEdge ? (_, e) => onSelectEdge(e.id) : undefined}
|
||||
// Клік по порожньому полотну знімає вибір ребра: інакше панель
|
||||
// налаштувань лишається висіти над схемою, до якої вже не
|
||||
// стосується.
|
||||
onPaneClick={onSelectEdge ? () => onSelectEdge(null) : undefined}
|
||||
onSelectionDragStop={handleSelectionDragStop}
|
||||
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
|
||||
onConnect={readOnly ? undefined : handleConnect}
|
||||
onDelete={readOnly ? undefined : handleDelete}
|
||||
nodesConnectable={!readOnly}
|
||||
// У режимі редагування ліва кнопка тягне рамку виділення, а
|
||||
// панорамує середня або права. У режимі перегляду навпаки: там
|
||||
// нічого не виділяють, і ліва кнопка має возити полотно.
|
||||
// Ліва кнопка возить полотно завжди — і в перегляді, і в
|
||||
// редагуванні. Рамка виділення вмикається Ctrl або Shift.
|
||||
//
|
||||
// Спершу було навпаки: у редагуванні ліва кнопка тягла рамку.
|
||||
// Це зламало найчастішу дію — гортання схеми, яка більша за
|
||||
// екран. Виділяють рамкою рідко, возять полотно постійно.
|
||||
//
|
||||
// Partial, а не Full: рамка бере й ті вузли, які зачепила краєм.
|
||||
// Вимагати накрити вузол цілком означає промахуватись повз
|
||||
// крайні вузли рівно тоді, коли виділяють «усе в цьому кутку».
|
||||
selectionOnDrag={!readOnly}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
selectionOnDrag={false}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
selectionKeyCode={null}
|
||||
selectionKeyCode={readOnly ? null : ['Control', 'Meta', 'Shift']}
|
||||
multiSelectionKeyCode={readOnly ? null : ['Control', 'Meta', 'Shift']}
|
||||
panOnDrag={readOnly ? true : [1, 2]}
|
||||
panOnDrag
|
||||
nodesDraggable={!readOnly}
|
||||
elementsSelectable
|
||||
deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']}
|
||||
connectionRadius={30}
|
||||
defaultViewport={state.viewport}
|
||||
// Підганяємо вигляд під усі вузли при відкритті.
|
||||
//
|
||||
// Збережений viewport не годиться як типовий: вузли, додані з
|
||||
// іншого екрана, опиняються за його межами, і мапа відкривається
|
||||
// порожньою, хоча в ній десяток хостів.
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
||||
snapToGrid={state.grid?.snap ?? true}
|
||||
snapGrid={[state.grid?.size ?? 16, state.grid?.size ?? 16]}
|
||||
minZoom={0.1}
|
||||
|
|
@ -295,3 +367,46 @@ export function MapCanvas({
|
|||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ідентифікатор порту з бази у вигляд, який розуміє полотно.
|
||||
*
|
||||
* Порожнє значення означає «типовий бік» і повертає null, щоб виклик
|
||||
* сам підставив свій запасний варіант.
|
||||
*/
|
||||
function normalizeHandle(h?: string): string | null {
|
||||
if (!h || h === 'auto') return null
|
||||
return h.endsWith('-s') ? h.slice(0, -2) : h
|
||||
}
|
||||
|
||||
/**
|
||||
* Боки підключення для лінії без явної привʼязки.
|
||||
*
|
||||
* React Flow сам найкоротший бік не шукає — без імені порту він бере
|
||||
* перший-ліпший, і лінія чіпляється до лівого краю навіть коли сусід
|
||||
* стоїть праворуч. Тому рахуємо самі: що більше — різниця по осі X чи
|
||||
* по Y — та вісь і диктує пару боків.
|
||||
*/
|
||||
function autoSides(
|
||||
a?: { x: number; y: number },
|
||||
b?: { x: number; y: number },
|
||||
): [string, string] {
|
||||
if (!a || !b) return ['r', 'l']
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? ['r', 'l'] : ['l', 'r']
|
||||
return dy >= 0 ? ['b', 't'] : ['t', 'b']
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow тримає стан у контексті, і useReactFlow працює лише
|
||||
* всередині нього. Провайдер тут, а не в сторінці, щоб виклик мапи
|
||||
* лишався одним тегом і не тягнув за собою обгортку.
|
||||
*/
|
||||
export function MapCanvas(props: Props) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<MapCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Icon, MapNode, NodeInput } from '../types'
|
|||
|
||||
const ICON_CHOICES = [
|
||||
{ key: '', label: 'за типом' },
|
||||
{ key: 'none', label: 'без значка' },
|
||||
{ key: 'switch', label: 'комутатор ⇄' },
|
||||
{ key: 'router', label: 'маршрутизатор ⇉' },
|
||||
{ key: 'server', label: 'сервер ▤' },
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ import type { DeviceStatus } from '../types'
|
|||
|
||||
export type TrafficEdgeData = {
|
||||
label?: string
|
||||
/** solid | dashed | dotted — задається в інспекторі. */
|
||||
dash?: string
|
||||
/** Товщина лінії в пікселях. */
|
||||
widthPx?: number
|
||||
/** Власний колір: перекриває розрахунок за станом. */
|
||||
color?: string
|
||||
sourcePort?: string
|
||||
targetPort?: string
|
||||
linkStatus?: DeviceStatus
|
||||
|
|
@ -34,8 +40,25 @@ function animationDuration(utilPct: number): number {
|
|||
return 6 - (u / 100) * 5.4
|
||||
}
|
||||
|
||||
// Штрих лінії. Обрив завжди пунктиром, навіть якщо задали суцільну:
|
||||
// це не оформлення, а стан, і плутати їх не можна.
|
||||
function dashArray(d: TrafficEdgeData): string | undefined {
|
||||
if (d.linkStatus === 'down') return '6 4'
|
||||
switch (d.dash) {
|
||||
case 'dashed':
|
||||
return '8 5'
|
||||
case 'dotted':
|
||||
return '1 5'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function strokeColor(d: TrafficEdgeData): string {
|
||||
if (d.linkStatus === 'down') return '#ef4444'
|
||||
// Власний колір перекриває розрахунок: на схемі, де кольором
|
||||
// позначені майданчики, стан читають за товщиною й пунктиром.
|
||||
if (d.color) return d.color
|
||||
if (d.linkStatus === 'down') return '#f43f5e'
|
||||
if (d.linkStatus === 'unknown' || d.linkStatus === undefined) return '#475569'
|
||||
|
||||
const u = d.utilPct ?? 0
|
||||
|
|
@ -104,9 +127,13 @@ export function TrafficEdge({
|
|||
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,
|
||||
strokeWidth: (d.widthPx ?? 2) + (selected ? 1.5 : 0),
|
||||
opacity: d.linkStatus === 'down' ? 0.95 : 0.9,
|
||||
strokeDasharray: dashArray(d),
|
||||
strokeLinecap: 'round',
|
||||
// Виділена лінія світиться тим же кольором: обвідка іншого
|
||||
// кольору поверх статусного читається як ще один стан.
|
||||
filter: selected ? `drop-shadow(0 0 6px ${color})` : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useLiveMap } from '../hooks/useLiveMap'
|
|||
import { MapCanvas } from '../components/MapCanvas'
|
||||
import { CanvasBoundary } from '../components/CanvasBoundary'
|
||||
import { NodeInspector } from '../components/NodeInspector'
|
||||
import { EdgeInspector } from '../components/EdgeInspector'
|
||||
import { MapAddHosts } from '../components/MapAddHosts'
|
||||
import { MapSettings } from '../components/MapSettings'
|
||||
import { Button, Confirm, ErrorNote, Field, Modal, inputClass } from '../components/ui'
|
||||
|
|
@ -26,6 +27,8 @@ export function MapPage() {
|
|||
// перегляд, і навіть той, хто має право писати, спершу це підтверджує.
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [selection, setSelection] = useState<string[]>([])
|
||||
const [selectedEdge, setSelectedEdge] = useState<string | null>(null)
|
||||
const [fitSignal, setFitSignal] = useState(0)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [settings, setSettings] = useState(false)
|
||||
const [icons, setIcons] = useState<Icon[]>([])
|
||||
|
|
@ -68,6 +71,7 @@ export function MapPage() {
|
|||
}, [live.state])
|
||||
|
||||
const node = live.state?.nodes.find((n) => n.id === selected) ?? null
|
||||
const edge = live.state?.edges.find((e) => e.id === selectedEdge) ?? null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
|
|
@ -209,6 +213,9 @@ export function MapPage() {
|
|||
onPatch={live.patch}
|
||||
onSelect={setSelected}
|
||||
onSelectionChange={setSelection}
|
||||
onSelectEdge={setSelectedEdge}
|
||||
selectedEdge={selectedEdge}
|
||||
fitSignal={fitSignal}
|
||||
readOnly={!editing}
|
||||
/>
|
||||
</CanvasBoundary>
|
||||
|
|
@ -251,12 +258,29 @@ export function MapPage() {
|
|||
)}
|
||||
|
||||
|
||||
{edge && (
|
||||
<EdgeInspector
|
||||
edge={edge}
|
||||
nodes={live.state?.nodes ?? []}
|
||||
readOnly={!editing}
|
||||
onClose={() => setSelectedEdge(null)}
|
||||
onPatch={(patch, comment) => live.patch({ edges: { upsert: [patch] } }, comment)}
|
||||
onDelete={() => {
|
||||
void live.patch({ edges: { remove: [edge.id] } }, 'видалення звʼязку')
|
||||
setSelectedEdge(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adding && live.state && (
|
||||
<MapAddHosts
|
||||
state={live.state}
|
||||
onClose={() => setAdding(false)}
|
||||
onAdd={async (nodes) => {
|
||||
await live.patch({ nodes: { upsert: nodes } }, `додано ${nodes.length} хостів`)
|
||||
// Показуємо результат: нові вузли лягають поруч із наявними,
|
||||
// а ті можуть стояти далеко за краєм екрана.
|
||||
setFitSignal((v) => v + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,9 @@ export interface MapEdge {
|
|||
target_node_id: string
|
||||
source_port?: string
|
||||
target_port?: string
|
||||
/** До якого боку вузла кріпиться лінія: l | r | t | b. */
|
||||
source_handle?: string
|
||||
target_handle?: string
|
||||
link_id?: string
|
||||
label?: string
|
||||
style: string
|
||||
|
|
@ -340,8 +343,17 @@ export interface EdgeInput {
|
|||
client_id?: string
|
||||
source_node_id?: string
|
||||
target_node_id?: string
|
||||
/** До якого боку вузла кріпиться лінія: l | r | t | b (+ '-s' для джерела). */
|
||||
source_handle?: string
|
||||
target_handle?: string
|
||||
label?: string
|
||||
style?: string
|
||||
dash?: string
|
||||
color?: string
|
||||
width_px?: number
|
||||
animation?: Record<string, unknown>
|
||||
thresholds?: Record<string, unknown>
|
||||
show_metrics?: boolean
|
||||
}
|
||||
|
||||
export interface MapPatch {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue