Зв'язки на мапі: боки підключення, інспектор лінії, типовий вигляд вузла

Порти на вузлі отримали імена (l/r/t/b) з парою «джерело + ціль» під
спільним іменем — інакше збережене ребро не знаходить пари й лінія
не малюється.

Бік підключення тепер правиться: UPDATE ребра розрізняє три стани —
не чіпати, зняти прив'язку (auto), задати конкретний бік. Раніше
UPDATE боків не торкався, і криво причеплену лінію лишалось видаляти.

Лінія без прив'язки бере бік із взаємного розташування вузлів, а не
перший-ліпший порт.

Типовий вузол — крапка з підписом знизу, без значка й цифр. Підпис
бере ім'я хоста, якщо власного немає.

Полотно возиться лівою кнопкою в обох режимах; рамка виділення — на
Ctrl/Shift. fitView при відкритті та після масового додавання хостів.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 01:52:57 +03:00
parent 205dd5e079
commit 2c12bed017
10 changed files with 695 additions and 61 deletions

View file

@ -1978,3 +1978,82 @@ gateway «Час відгуку» (line) → дві лінії, вісь 0
керування PATCH модулі й ліміти → 204, значення застосовані керування 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 «такий запис уже існує»
```

View file

@ -94,6 +94,11 @@ type MapEdge struct {
TargetNodeID string `json:"target_node_id"` TargetNodeID string `json:"target_node_id"`
SourcePort string `json:"source_port,omitempty"` SourcePort string `json:"source_port,omitempty"`
TargetPort string `json:"target_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"` LinkID string `json:"link_id,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Style string `json:"style"` 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, ` 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,''), COALESCE(n.device_id::text,''), COALESCE(n.parent_node_id::text,''),
n.x, n.y, n.width, n.height, n.z_index, n.x, n.y, n.width, n.height, n.z_index,
n.style::text, n.data::text, n.collapsed, n.locked, 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, ` rows, err := tx.Query(ctx, `
SELECT e.id::text, e.source_node_id::text, e.target_node_id::text, SELECT e.id::text, e.source_node_id::text, e.target_node_id::text,
COALESCE(si.name,''), COALESCE(ti.name,''), COALESCE(si.name,''), COALESCE(ti.name,''),
COALESCE(e.source_handle,''), COALESCE(e.target_handle,''),
COALESCE(e.link_id::text,''), COALESCE(e.label,''), COALESCE(e.link_id::text,''), COALESCE(e.label,''),
e.style::text, e.dash::text, COALESCE(e.color,''), e.width_px, e.style::text, e.dash::text, COALESCE(e.color,''), e.width_px,
e.waypoints::text, e.animation::text, e.thresholds::text, e.show_metrics, 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() { for rows.Next() {
var e MapEdge var e MapEdge
if err := rows.Scan(&e.ID, &e.SourceNodeID, &e.TargetNodeID, 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.Style, &e.Dash, &e.Color, &e.WidthPx,
&e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics, &e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics,
&e.LinkStatus, &e.UtilPct, &e.CapacityBps); err != nil { &e.LinkStatus, &e.UtilPct, &e.CapacityBps); err != nil {

View file

@ -92,6 +92,8 @@ type EdgeInput struct {
TargetNodeID string `json:"target_node_id,omitempty"` TargetNodeID string `json:"target_node_id,omitempty"`
SourceInterfaceID string `json:"source_interface_id,omitempty"` SourceInterfaceID string `json:"source_interface_id,omitempty"`
TargetInterfaceID string `json:"target_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"` LinkID string `json:"link_id,omitempty"`
Label *string `json:"label,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, (tenant_id, map_id, source_node_id, target_node_id,
source_interface_id, target_interface_id, link_id, label, source_interface_id, target_interface_id, link_id, label,
style, dash, color, width_px, waypoints, animation, thresholds, 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, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,
COALESCE($9::topo.edge_style,'smoothstep'), COALESCE($9::topo.edge_style,'smoothstep'),
COALESCE($10::topo.edge_dash,'solid'), 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($14::jsonb,'{"enabled":true,"speed_source":"utilization"}'::jsonb),
COALESCE($15::jsonb,'{}'::jsonb), COALESCE($15::jsonb,'{}'::jsonb),
COALESCE($16,true), COALESCE($17,0), 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 RETURNING id::text
`, tenantID, mapID, src, dst, `, tenantID, mapID, src, dst,
nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID),
nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx, nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), 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 return err
} }
if e.ClientID != "" { 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), source_interface_id = COALESCE($16, source_interface_id),
target_interface_id = COALESCE($17, target_interface_id), target_interface_id = COALESCE($17, target_interface_id),
link_id = COALESCE($18, link_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() updated_at = now()
WHERE id = $1 AND map_id = $2 AND tenant_id = $3 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, `, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx,
rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds),
e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden, 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 { if err != nil {
return err return err
} }

View file

@ -1,3 +1,4 @@
import { Fragment } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react' import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { DeviceStatus } from '../types' 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 } 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>) { export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown 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 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 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 const accent = data.color ?? s.raw
// Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх // Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх
@ -113,20 +137,31 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
.filter(Boolean) .filter(Boolean)
.join(', ') .join(', ')
// Порти з усіх чотирьох боків, кожен і джерело, і ціль.
//
// Було по одному: ціль ліворуч, джерело праворуч. Через це лінію
// можна було тягнути лише зліва направо, а всі зв'язки збігалися в
// одну точку на кожному вузлі — схема перетворювалась на віяло з
// двох крапок. Тепер зв'язок кріпиться туди, куди його ведуть.
// На кожному боці два порти з ОДНАКОВИМ ідентифікатором: джерело й
// ціль. React Flow розрізняє їх за типом, тож ребро з sourceHandle
// 'l' знаходить джерело зліва, а targetHandle 'l' — ціль там само.
//
// Ідентифікатори мають збігатись у пари: ребро, збережене з
// sourceHandle 'l', шукає ціль теж під іменем 'l'. Розійдуться імена
// (наприклад 'l' і 'l-s') — React Flow мовчки не намалює лінію.
//
// Fragment, а не <span>: обгортка навколо портів стає елементом
// flex-розкладки й розсуває вміст вузла — чотири такі обгортки
// з'їдали підпис, і на схемі лишались картки без імен.
const handles = ( const handles = (
<> <>
<Handle {HANDLE_SIDES.map(({ id, pos }) => (
type="target" <Fragment key={id}>
position={Position.Left} <Handle id={id} type="target" position={pos} className={HANDLE_CLASS} />
className="!h-2.5 !w-2.5 !border-2 !border-slate-950 !bg-slate-500 <Handle id={id} type="source" position={pos} className={HANDLE_CLASS} />
opacity-0 transition-opacity group-hover:opacity-100" </Fragment>
/> ))}
<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"
/>
</> </>
) )
@ -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"> <span className="flex h-full w-full items-center justify-center text-2xl text-slate-500">
{icon} {showIcon ? icon : ''}
</span> </span>
)} )}
{/* Стан тонка обвідка знизу, а не рамка навколо: рамка {/* Стан тонка обвідка знизу, а не рамка навколо: рамка
@ -213,9 +248,9 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
// ---- без рамки ---------------------------------------------------- // ---- без рамки ----------------------------------------------------
if (shape === 'bare') { if (shape === 'bare') {
return wrap( 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} /> <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" />} {selected && <span className="absolute -inset-1 rounded ring-2 ring-sky-400" />}
</span>, </span>,
) )
@ -261,7 +296,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
<div className="py-2 pl-3.5 pr-3"> <div className="py-2 pl-3.5 pr-3">
<div className="flex items-center gap-2"> <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}`}> <span className={`truncate font-medium tracking-tight text-slate-100 ${textSize}`}>
{data.label} {data.label}
</span> </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"> <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.rtt !== undefined && <span className={s.text}>{data.rtt.toFixed(2)} мс</span>}
{data.loss !== undefined && data.loss > 0 && ( {data.loss !== undefined && data.loss > 0 && (

View 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>
)
}

View file

@ -12,6 +12,9 @@ import {
type Node, type Node,
type NodeChange, type NodeChange,
SelectionMode, SelectionMode,
ConnectionMode,
ReactFlowProvider,
useReactFlow,
} from '@xyflow/react' } from '@xyflow/react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
@ -28,16 +31,33 @@ interface Props {
onSelect: (nodeID: string | null) => void onSelect: (nodeID: string | null) => void
/** Виділені вузли — для групових дій в інспекторі. */ /** Виділені вузли — для групових дій в інспекторі. */
onSelectionChange?: (nodeIDs: string[]) => void onSelectionChange?: (nodeIDs: string[]) => void
/** Обране ребро — щоб відкрити його налаштування. */
onSelectEdge?: (edgeID: string | null) => void
selectedEdge?: string | null
/** Глядач без maps:write бачить полотно, але не змінює його. */ /** Глядач без maps:write бачить полотно, але не змінює його. */
readOnly?: boolean readOnly?: boolean
/**
* Лічильник: збільшення підганяє вигляд під усі вузли.
*
* Потрібен після масового додавання хостів. Нові вузли лягають поруч
* із наявними, а ті можуть стояти далеко за краєм екрана тоді
* додавання виглядає як «нічого не сталося», хоча повторна спроба
* чесно каже «вже на мапі».
*/
fitSignal?: number
} }
export function MapCanvas({ function MapCanvasInner({
state, state,
onPatch, onPatch,
onSelect, onSelect,
onSelectionChange, onSelectionChange,
onSelectEdge,
selectedEdge = null,
readOnly = false, readOnly = false,
fitSignal = 0,
}: Props) { }: Props) {
const rfNodes = useMemo<DeviceNodeType[]>( const rfNodes = useMemo<DeviceNodeType[]>(
() => () =>
@ -69,27 +89,53 @@ export function MapCanvas({
[state.nodes, readOnly], [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[]>( const rfEdges = useMemo<TrafficEdgeType[]>(
() => () =>
state.edges.map((e) => ({ state.edges.map((e) => {
id: e.id, const [autoSrc, autoDst] = autoSides(
type: 'traffic' as const, nodePos.get(e.source_node_id),
source: e.source_node_id, nodePos.get(e.target_node_id),
target: e.target_node_id, )
data: { return {
label: e.label, id: e.id,
sourcePort: e.source_port, type: 'traffic' as const,
targetPort: e.target_port, source: e.source_node_id,
linkStatus: e.link_status, target: e.target_node_id,
utilPct: e.util_pct, // Ім'я порту обов'язкове: щойно у вузла з'явились іменовані
capacityBps: e.capacity_bps, // порти, ребро без імені React Flow просто не малює.
warnPct: e.thresholds?.warn_pct ?? 70, //
critPct: e.thresholds?.crit_pct ?? 90, // normalizeHandle прибирає суфікс '-s' зі старих записів: у
animated: e.animation?.enabled !== false, // першій версії порти були парою «джерело + ціль», і збережені
showMetrics: e.show_metrics, // тоді ребра посилаються на ідентифікатори, яких уже немає.
}, sourceHandle: normalizeHandle(e.source_handle) ?? autoSrc,
})), targetHandle: normalizeHandle(e.target_handle) ?? autoDst,
[state.edges], 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,
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, selectedEdge, nodePos],
) )
const [nodes, setNodes, onNodesChange] = useNodesState<DeviceNodeType>(rfNodes) const [nodes, setNodes, onNodesChange] = useNodesState<DeviceNodeType>(rfNodes)
@ -173,6 +219,11 @@ export function MapCanvas({
client_id: `e-${Date.now()}`, client_id: `e-${Date.now()}`,
source_node_id: c.source, source_node_id: c.source,
target_node_id: c.target, 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], [onPatch, state.nodes],
) )
const rf = useReactFlow()
useEffect(() => {
if (fitSignal > 0) rf.fitView({ padding: 0.2, duration: 400 })
}, [fitSignal, rf])
return ( return (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@ -226,28 +283,43 @@ export function MapCanvas({
onNodesChange={handleNodesChange} onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeDragStop={handleDragStop} onNodeDragStop={handleDragStop}
onEdgeClick={onSelectEdge ? (_, e) => onSelectEdge(e.id) : undefined}
// Клік по порожньому полотну знімає вибір ребра: інакше панель
// налаштувань лишається висіти над схемою, до якої вже не
// стосується.
onPaneClick={onSelectEdge ? () => onSelectEdge(null) : undefined}
onSelectionDragStop={handleSelectionDragStop} onSelectionDragStop={handleSelectionDragStop}
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined} onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
onConnect={readOnly ? undefined : handleConnect} onConnect={readOnly ? undefined : handleConnect}
onDelete={readOnly ? undefined : handleDelete} onDelete={readOnly ? undefined : handleDelete}
nodesConnectable={!readOnly} nodesConnectable={!readOnly}
// У режимі редагування ліва кнопка тягне рамку виділення, а // Ліва кнопка возить полотно завжди — і в перегляді, і в
// панорамує середня або права. У режимі перегляду навпаки: там // редагуванні. Рамка виділення вмикається Ctrl або Shift.
// нічого не виділяють, і ліва кнопка має возити полотно. //
// Спершу було навпаки: у редагуванні ліва кнопка тягла рамку.
// Це зламало найчастішу дію — гортання схеми, яка більша за
// екран. Виділяють рамкою рідко, возять полотно постійно.
// //
// Partial, а не Full: рамка бере й ті вузли, які зачепила краєм. // Partial, а не Full: рамка бере й ті вузли, які зачепила краєм.
// Вимагати накрити вузол цілком означає промахуватись повз // Вимагати накрити вузол цілком означає промахуватись повз
// крайні вузли рівно тоді, коли виділяють «усе в цьому кутку». // крайні вузли рівно тоді, коли виділяють «усе в цьому кутку».
selectionOnDrag={!readOnly} connectionMode={ConnectionMode.Loose}
selectionOnDrag={false}
selectionMode={SelectionMode.Partial} selectionMode={SelectionMode.Partial}
selectionKeyCode={null} selectionKeyCode={readOnly ? null : ['Control', 'Meta', 'Shift']}
multiSelectionKeyCode={readOnly ? null : ['Control', 'Meta', 'Shift']} multiSelectionKeyCode={readOnly ? null : ['Control', 'Meta', 'Shift']}
panOnDrag={readOnly ? true : [1, 2]} panOnDrag
nodesDraggable={!readOnly} nodesDraggable={!readOnly}
elementsSelectable elementsSelectable
deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']} deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']}
connectionRadius={30} connectionRadius={30}
defaultViewport={state.viewport} // Підганяємо вигляд під усі вузли при відкритті.
//
// Збережений viewport не годиться як типовий: вузли, додані з
// іншого екрана, опиняються за його межами, і мапа відкривається
// порожньою, хоча в ній десяток хостів.
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
snapToGrid={state.grid?.snap ?? true} snapToGrid={state.grid?.snap ?? true}
snapGrid={[state.grid?.size ?? 16, state.grid?.size ?? 16]} snapGrid={[state.grid?.size ?? 16, state.grid?.size ?? 16]}
minZoom={0.1} minZoom={0.1}
@ -295,3 +367,46 @@ export function MapCanvas({
</ReactFlow> </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>
)
}

View file

@ -4,6 +4,7 @@ import type { Icon, MapNode, NodeInput } from '../types'
const ICON_CHOICES = [ const ICON_CHOICES = [
{ key: '', label: 'за типом' }, { key: '', label: 'за типом' },
{ key: 'none', label: 'без значка' },
{ key: 'switch', label: 'комутатор ⇄' }, { key: 'switch', label: 'комутатор ⇄' },
{ key: 'router', label: 'маршрутизатор ⇉' }, { key: 'router', label: 'маршрутизатор ⇉' },
{ key: 'server', label: 'сервер ▤' }, { key: 'server', label: 'сервер ▤' },

View file

@ -9,6 +9,12 @@ import type { DeviceStatus } from '../types'
export type TrafficEdgeData = { export type TrafficEdgeData = {
label?: string label?: string
/** solid | dashed | dotted — задається в інспекторі. */
dash?: string
/** Товщина лінії в пікселях. */
widthPx?: number
/** Власний колір: перекриває розрахунок за станом. */
color?: string
sourcePort?: string sourcePort?: string
targetPort?: string targetPort?: string
linkStatus?: DeviceStatus linkStatus?: DeviceStatus
@ -34,8 +40,25 @@ function animationDuration(utilPct: number): number {
return 6 - (u / 100) * 5.4 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 { 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' if (d.linkStatus === 'unknown' || d.linkStatus === undefined) return '#475569'
const u = d.utilPct ?? 0 const u = d.utilPct ?? 0
@ -104,9 +127,13 @@ export function TrafficEdge({
path={path} path={path}
style={{ style={{
stroke: color, stroke: color,
strokeWidth: selected ? 3.5 : 2.2, strokeWidth: (d.widthPx ?? 2) + (selected ? 1.5 : 0),
opacity: d.linkStatus === 'down' ? 0.9 : 0.85, opacity: d.linkStatus === 'down' ? 0.95 : 0.9,
strokeDasharray: d.linkStatus === 'down' ? '6 4' : undefined, strokeDasharray: dashArray(d),
strokeLinecap: 'round',
// Виділена лінія світиться тим же кольором: обвідка іншого
// кольору поверх статусного читається як ще один стан.
filter: selected ? `drop-shadow(0 0 6px ${color})` : undefined,
}} }}
/> />

View file

@ -5,6 +5,7 @@ import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas' import { MapCanvas } from '../components/MapCanvas'
import { CanvasBoundary } from '../components/CanvasBoundary' import { CanvasBoundary } from '../components/CanvasBoundary'
import { NodeInspector } from '../components/NodeInspector' import { NodeInspector } from '../components/NodeInspector'
import { EdgeInspector } from '../components/EdgeInspector'
import { MapAddHosts } from '../components/MapAddHosts' import { MapAddHosts } from '../components/MapAddHosts'
import { MapSettings } from '../components/MapSettings' import { MapSettings } from '../components/MapSettings'
import { Button, Confirm, ErrorNote, Field, Modal, inputClass } from '../components/ui' import { Button, Confirm, ErrorNote, Field, Modal, inputClass } from '../components/ui'
@ -26,6 +27,8 @@ export function MapPage() {
// перегляд, і навіть той, хто має право писати, спершу це підтверджує. // перегляд, і навіть той, хто має право писати, спершу це підтверджує.
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false)
const [selection, setSelection] = useState<string[]>([]) const [selection, setSelection] = useState<string[]>([])
const [selectedEdge, setSelectedEdge] = useState<string | null>(null)
const [fitSignal, setFitSignal] = useState(0)
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [settings, setSettings] = useState(false) const [settings, setSettings] = useState(false)
const [icons, setIcons] = useState<Icon[]>([]) const [icons, setIcons] = useState<Icon[]>([])
@ -68,6 +71,7 @@ export function MapPage() {
}, [live.state]) }, [live.state])
const node = live.state?.nodes.find((n) => n.id === selected) ?? null const node = live.state?.nodes.find((n) => n.id === selected) ?? null
const edge = live.state?.edges.find((e) => e.id === selectedEdge) ?? null
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
@ -205,10 +209,13 @@ export function MapPage() {
{live.state ? ( {live.state ? (
<CanvasBoundary> <CanvasBoundary>
<MapCanvas <MapCanvas
state={live.state} state={live.state}
onPatch={live.patch} onPatch={live.patch}
onSelect={setSelected} onSelect={setSelected}
onSelectionChange={setSelection} onSelectionChange={setSelection}
onSelectEdge={setSelectedEdge}
selectedEdge={selectedEdge}
fitSignal={fitSignal}
readOnly={!editing} readOnly={!editing}
/> />
</CanvasBoundary> </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 && ( {adding && live.state && (
<MapAddHosts <MapAddHosts
state={live.state} state={live.state}
onClose={() => setAdding(false)} onClose={() => setAdding(false)}
onAdd={async (nodes) => { onAdd={async (nodes) => {
await live.patch({ nodes: { upsert: nodes } }, `додано ${nodes.length} хостів`) await live.patch({ nodes: { upsert: nodes } }, `додано ${nodes.length} хостів`)
// Показуємо результат: нові вузли лягають поруч із наявними,
// а ті можуть стояти далеко за краєм екрана.
setFitSignal((v) => v + 1)
}} }}
/> />
)} )}

View file

@ -132,6 +132,9 @@ export interface MapEdge {
target_node_id: string target_node_id: string
source_port?: string source_port?: string
target_port?: string target_port?: string
/** До якого боку вузла кріпиться лінія: l | r | t | b. */
source_handle?: string
target_handle?: string
link_id?: string link_id?: string
label?: string label?: string
style: string style: string
@ -340,8 +343,17 @@ export interface EdgeInput {
client_id?: string client_id?: string
source_node_id?: string source_node_id?: string
target_node_id?: string target_node_id?: string
/** До якого боку вузла кріпиться лінія: l | r | t | b (+ '-s' для джерела). */
source_handle?: string
target_handle?: string
label?: string label?: string
style?: string style?: string
dash?: string
color?: string
width_px?: number
animation?: Record<string, unknown>
thresholds?: Record<string, unknown>
show_metrics?: boolean
} }
export interface MapPatch { export interface MapPatch {