Мапа: підпис ребра більше не друкує «? → ?» і «— з 10.0 Гбіт/с» — невідоме краще не обіцяти. Вузол став карткою з градієнтом, кольоровою смугою стану зліва й крапкою стану праворуч; смуга несе стан навіть тоді, коли рамку перефарбували під майданчик. Пульсація лише за обривом, порти — на наведення. Фон: радіальна підсвітка й дві сітки. Вбудовані шаблони редагуються: правка створює власну копію тенанта з тим самим ключем, вона замінює оригінал у списку, оригінал лишається недоторканим для решти. Плюс окрема кнопка «Копія» з суфіксом у ключі. Ключі елементів і графіків тепер виводяться на сервері — прямий виклик API без key давав 500 від core.slug. Вибір шаблонів у формі хоста став картками з описом і лічильником метрик; обрані спливають угору. Мобільна сітка: min-w-0 на картці. Елемент грід-сітки типово має min-width:auto й не стискається нижче ширини вмісту — картка на 410 px обрізалась у 375 px екрані. Межа помилок навколо полотна: цикл рендеру в React Flow одного разу вже поклав увесь застосунок, тепер падатиме лише мапа. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
import {
|
||
BaseEdge,
|
||
EdgeLabelRenderer,
|
||
getBezierPath,
|
||
type Edge,
|
||
type EdgeProps,
|
||
} from '@xyflow/react'
|
||
import type { DeviceStatus } from '../types'
|
||
|
||
export type TrafficEdgeData = {
|
||
label?: string
|
||
sourcePort?: string
|
||
targetPort?: string
|
||
linkStatus?: DeviceStatus
|
||
utilPct?: number
|
||
capacityBps?: number
|
||
warnPct: number
|
||
critPct: number
|
||
animated: boolean
|
||
showMetrics: boolean
|
||
}
|
||
|
||
export type TrafficEdgeType = Edge<TrafficEdgeData, 'traffic'>
|
||
|
||
/**
|
||
* Швидкість анімації від завантаження каналу.
|
||
*
|
||
* Обернена залежність, а не пряма: точка має бігти швидше, коли трафіку
|
||
* більше. Стеля 0.6 с — далі рух зливається в смугу й перестає читатись;
|
||
* підлога 6 с — щоб ледь завантажений канал не виглядав мертвим.
|
||
*/
|
||
function animationDuration(utilPct: number): number {
|
||
const u = Math.max(0, Math.min(100, utilPct))
|
||
return 6 - (u / 100) * 5.4
|
||
}
|
||
|
||
function strokeColor(d: TrafficEdgeData): string {
|
||
if (d.linkStatus === 'down') return '#ef4444'
|
||
if (d.linkStatus === 'unknown' || d.linkStatus === undefined) return '#475569'
|
||
|
||
const u = d.utilPct ?? 0
|
||
if (u >= d.critPct) return '#ef4444'
|
||
if (u >= d.warnPct) return '#f59e0b'
|
||
return '#10b981'
|
||
}
|
||
|
||
function formatBps(bps: number): string {
|
||
if (bps >= 1e9) return `${(bps / 1e9).toFixed(1)} Гбіт/с`
|
||
if (bps >= 1e6) return `${(bps / 1e6).toFixed(0)} Мбіт/с`
|
||
if (bps >= 1e3) return `${(bps / 1e3).toFixed(0)} кбіт/с`
|
||
return `${bps.toFixed(0)} біт/с`
|
||
}
|
||
|
||
export function TrafficEdge({
|
||
id,
|
||
sourceX,
|
||
sourceY,
|
||
targetX,
|
||
targetY,
|
||
sourcePosition,
|
||
targetPosition,
|
||
data,
|
||
selected,
|
||
}: EdgeProps<TrafficEdgeType>) {
|
||
const d = data as TrafficEdgeData
|
||
const [path, labelX, labelY] = getBezierPath({
|
||
sourceX,
|
||
sourceY,
|
||
targetX,
|
||
targetY,
|
||
sourcePosition,
|
||
targetPosition,
|
||
})
|
||
|
||
const color = strokeColor(d)
|
||
const util = d.utilPct ?? 0
|
||
// Анімуємо лише коли трафік справді є: рухома точка на порожньому
|
||
// каналі означала б рух, якого немає.
|
||
const moving = d.animated && d.linkStatus !== 'down' && util > 0
|
||
|
||
// Невідомий порт не друкується взагалі.
|
||
//
|
||
// «? → ?» на кожній лінії виглядає як зламаний інтерфейс, хоча
|
||
// насправді означає лише те, що автовиявлення ще не зіставило порти.
|
||
// Порожнеча чесніша: вона нічого не обіцяє.
|
||
const ports =
|
||
d.sourcePort && d.targetPort
|
||
? `${d.sourcePort} → ${d.targetPort}`
|
||
: (d.sourcePort ?? d.targetPort ?? '')
|
||
|
||
// Так само зі швидкістю: «— з 10.0 Гбіт/с» читається як помилка.
|
||
// Показуємо рядок лише тоді, коли трафік справді виміряний.
|
||
const rate =
|
||
d.capacityBps !== undefined && util > 0
|
||
? `${util < 0.01 ? '<0.01' : util.toFixed(2)}% з ${formatBps(d.capacityBps)}`
|
||
: ''
|
||
|
||
const label = d.showMetrics && (ports || rate)
|
||
|
||
return (
|
||
<>
|
||
<BaseEdge
|
||
id={id}
|
||
path={path}
|
||
style={{
|
||
stroke: color,
|
||
strokeWidth: selected ? 3.5 : 2.2,
|
||
opacity: d.linkStatus === 'down' ? 0.9 : 0.85,
|
||
strokeDasharray: d.linkStatus === 'down' ? '6 4' : undefined,
|
||
}}
|
||
/>
|
||
|
||
{moving && (
|
||
<circle r="3.5" fill={color}>
|
||
<animateMotion dur={`${animationDuration(util)}s`} repeatCount="indefinite">
|
||
<mpath href={`#${id}`} />
|
||
</animateMotion>
|
||
</circle>
|
||
)}
|
||
|
||
{label && (
|
||
<EdgeLabelRenderer>
|
||
<div
|
||
className="pointer-events-none absolute rounded-md border border-slate-700/60
|
||
bg-slate-900/90 px-2 py-1 text-[10px] leading-tight text-slate-300
|
||
shadow-lg backdrop-blur-sm"
|
||
style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
|
||
>
|
||
{ports && <div className="whitespace-nowrap font-medium">{ports}</div>}
|
||
{rate && (
|
||
<div className="whitespace-nowrap tabular-nums" style={{ color }}>
|
||
{rate}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</EdgeLabelRenderer>
|
||
)}
|
||
</>
|
||
)
|
||
}
|