diff --git a/HISTORY.md b/HISTORY.md index ce1b283..a8ad20b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1978,3 +1978,82 @@ gateway «Час відгуку» (line) → дві лінії, вісь 0 керування PATCH модулі й ліміти → 204, значення застосовані ``` + +## Зв'язки на мапі + +### Порти на вузлі + +Чотири боки, на кожному пара портів з однаковим ім'ям: `l`, `r`, `t`, +`b`. React Flow розрізняє джерело й ціль за типом, тож одне ім'я обслуговує +обидва напрямки. Розійдуться імена (наприклад `l` і `l-s`) — збережене +ребро не знайде пари, і лінія просто не намалюється, без жодної помилки. + +Обгортка портів — `Fragment`, а не ``: `` стає елементом +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 «такий запис уже існує» +``` diff --git a/server/internal/store/maps.go b/server/internal/store/maps.go index d1390bd..158f84a 100644 --- a/server/internal/store/maps.go +++ b/server/internal/store/maps.go @@ -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 { diff --git a/server/internal/store/maps_write.go b/server/internal/store/maps_write.go index e303fd9..814f464 100644 --- a/server/internal/store/maps_write.go +++ b/server/internal/store/maps_write.go @@ -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 } diff --git a/web/src/components/DeviceNode.tsx b/web/src/components/DeviceNode.tsx index 1424d1f..71bfb9b 100644 --- a/web/src/components/DeviceNode.tsx +++ b/web/src/components/DeviceNode.tsx @@ -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 = { const DOT_SIZE: Record = { 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) { 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) { .filter(Boolean) .join(', ') + // Порти з усіх чотирьох боків, кожен і джерело, і ціль. + // + // Було по одному: ціль ліворуч, джерело праворуч. Через це лінію + // можна було тягнути лише зліва направо, а всі зв'язки збігалися в + // одну точку на кожному вузлі — схема перетворювалась на віяло з + // двох крапок. Тепер зв'язок кріпиться туди, куди його ведуть. + // На кожному боці два порти з ОДНАКОВИМ ідентифікатором: джерело й + // ціль. React Flow розрізняє їх за типом, тож ребро з sourceHandle + // 'l' знаходить джерело зліва, а targetHandle 'l' — ціль там само. + // + // Ідентифікатори мають збігатись у пари: ребро, збережене з + // sourceHandle 'l', шукає ціль теж під іменем 'l'. Розійдуться імена + // (наприклад 'l' і 'l-s') — React Flow мовчки не намалює лінію. + // + // Fragment, а не : обгортка навколо портів стає елементом + // flex-розкладки й розсуває вміст вузла — чотири такі обгортки + // з'їдали підпис, і на схемі лишались картки без імен. const handles = ( <> - - + {HANDLE_SIDES.map(({ id, pos }) => ( + + + + + ))} ) @@ -194,7 +229,7 @@ export function DeviceNode({ data, selected }: NodeProps) { /> ) : ( - {icon} + {showIcon ? icon : ''} )} {/* Стан — тонка обвідка знизу, а не рамка навколо: рамка @@ -213,9 +248,9 @@ export function DeviceNode({ data, selected }: NodeProps) { // ---- без рамки ---------------------------------------------------- if (shape === 'bare') { return wrap( - + - {icon} + {showIcon && {icon}} {selected && } , ) @@ -261,7 +296,7 @@ export function DeviceNode({ data, selected }: NodeProps) {
- {icon} + {showIcon && {icon}} {data.label} @@ -270,7 +305,7 @@ export function DeviceNode({ data, selected }: NodeProps) { {/* Цифри показуємо лише коли вони є: порожній рядок «— мс» створює враження зламаного моніторингу. */} - {!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && ( + {data.hideMetrics === false && (data.rtt !== undefined || data.loss !== undefined) && (
{data.rtt !== undefined && {data.rtt.toFixed(2)} мс} {data.loss !== undefined && data.loss > 0 && ( diff --git a/web/src/components/EdgeInspector.tsx b/web/src/components/EdgeInspector.tsx new file mode 100644 index 0000000..355c12c --- /dev/null +++ b/web/src/components/EdgeInspector.tsx @@ -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 + 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 ( +
+
+ + {nameOf(edge.source_node_id)} → {nameOf(edge.target_node_id)} + + +
+ +
+ + {edge.util_pct !== undefined && } + {edge.source_port && } + {edge.target_port && } +
+ + {readOnly ? ( +

Перегляд — редагування потребує maps:write

+ ) : ( +
+ + setLabel(e.target.value)} + /> + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + setWidth(e.target.value)} + /> + + +
+ setColor(e.target.value)} + /> + {color && ( + + )} +
+
+
+ +
+ + setWarn(e.target.value)} + /> + + + setCrit(e.target.value)} + /> + +
+ + + + +
+ + +
+
+ )} +
+ ) +} + +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 ( +
+ {k} + {v} +
+ ) +} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index 87ce08c..60cd90e 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -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( () => @@ -69,27 +89,53 @@ export function MapCanvas({ [state.nodes, readOnly], ) + // Координати вузлів — щоб порахувати бік підключення для ліній без + // явної привʼязки. + const nodePos = useMemo(() => { + const m = new Map() + state.nodes.forEach((n) => m.set(n.id, { x: n.x, y: n.y })) + return m + }, [state.nodes]) + const rfEdges = useMemo( () => - 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], + 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, + 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(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 ( 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({ ) } + +/** + * Ідентифікатор порту з бази у вигляд, який розуміє полотно. + * + * Порожнє значення означає «типовий бік» і повертає 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 ( + + + + ) +} diff --git a/web/src/components/NodeInspector.tsx b/web/src/components/NodeInspector.tsx index 1448263..6224121 100644 --- a/web/src/components/NodeInspector.tsx +++ b/web/src/components/NodeInspector.tsx @@ -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: 'сервер ▤' }, diff --git a/web/src/components/TrafficEdge.tsx b/web/src/components/TrafficEdge.tsx index 06eb683..5280d37 100644 --- a/web/src/components/TrafficEdge.tsx +++ b/web/src/components/TrafficEdge.tsx @@ -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, }} /> diff --git a/web/src/pages/MapPage.tsx b/web/src/pages/MapPage.tsx index a4f5b36..6e10c60 100644 --- a/web/src/pages/MapPage.tsx +++ b/web/src/pages/MapPage.tsx @@ -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([]) + const [selectedEdge, setSelectedEdge] = useState(null) + const [fitSignal, setFitSignal] = useState(0) const [adding, setAdding] = useState(false) const [settings, setSettings] = useState(false) const [icons, setIcons] = useState([]) @@ -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 (
@@ -205,10 +209,13 @@ export function MapPage() { {live.state ? ( @@ -251,12 +258,29 @@ export function MapPage() { )} + {edge && ( + setSelectedEdge(null)} + onPatch={(patch, comment) => live.patch({ edges: { upsert: [patch] } }, comment)} + onDelete={() => { + void live.patch({ edges: { remove: [edge.id] } }, 'видалення звʼязку') + setSelectedEdge(null) + }} + /> + )} + {adding && live.state && ( setAdding(false)} onAdd={async (nodes) => { await live.patch({ nodes: { upsert: nodes } }, `додано ${nodes.length} хостів`) + // Показуємо результат: нові вузли лягають поруч із наявними, + // а ті можуть стояти далеко за краєм екрана. + setFitSignal((v) => v + 1) }} /> )} diff --git a/web/src/types.ts b/web/src/types.ts index 4eb1328..9809d19 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 + thresholds?: Record + show_metrics?: boolean } export interface MapPatch {