Вигляд мапи, редаговані вбудовані шаблони, мобільні картки

Мапа: підпис ребра більше не друкує «? → ?» і «— з 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>
This commit is contained in:
byrsapty 2026-08-24 23:53:58 +03:00
parent 387980d655
commit edc6209c98
15 changed files with 730 additions and 161 deletions

View file

@ -1831,3 +1831,89 @@ gateway «Час відгуку» (line) → дві лінії, вісь 0
Тепер набір віддається назовні лише коли справді змінився — порівняння
за склеєним рядком ідентифікаторів.
---
## Вигляд мапи
Схема виглядала як сітка сірих прямокутників на чорному. Причини були
конкретні, і кожна лікується окремо.
**«? → ?» на кожній лінії.** Так виглядав підпис ребра, коли
автовиявлення ще не зіставило порти. Читалося це як зламаний інтерфейс,
хоча означало лише «поки невідомо». Тепер невідомий порт не друкується
взагалі — порожнеча чесніша, вона нічого не обіцяє. Так само зі
швидкістю: «— з 10.0 Гбіт/с» зникає, поки трафік не виміряний.
**Вузол став карткою з ієрархією.** Градієнт замість пласкої заливки,
кольорова смуга стану зліва, значок і підпис в одному рядку, крапка
стану праворуч. Смуга несе стан навіть тоді, коли рамку перефарбували
під майданчик: колір рамки — рішення про схему, колір смуги — факт про
пристрій.
**Пульсація лише за обривом.** Рух там, де все гаразд, навчає не
дивитись на рух узагалі.
**Порти показуються на наведення.** Вісім завжди видимих кружечків на
кожному вузлі перетворюють схему на россип точок.
**Фон — радіальна підсвітка й дві сітки.** Дрібна дає відчуття масштабу,
велика — орієнтацію; одна або рябить, або не читається, залежно від
кроку, а крок задає людина.
**Специфічність довелося підняти.** Стилі React Flow приходять з .tsx і
потрапляють у документ пізніше за index.css, тож одного класу було
замало — правила стали `.react-flow.netpulse-canvas`.
---
## Вбудовані шаблони редагуються
Раніше відповіддю було «вбудований не редагується — зробіть копію». Це
межа нашої моделі даних, а не відповідь на питання людини, яка хоче
підправити OID під своє залізо.
Тепер правка вбудованого шаблону створює **власну копію тенанта з тим
самим ключем**, а список показує її замість оригіналу. Оригінал лишається
недоторканим для решти тенантів. Форма попереджає про це прямим текстом
до збереження.
Плюс окрема кнопка «Копія» — для випадку, коли потрібні обидва варіанти:
«як було» і «як хочу». Ключ отримує суфікс, бо копія з тим самим ключем
сховала б оригінал.
**Ключі елементів і графіків виводяться на сервері.** Прямий виклик API
без `key` давав 500 від перевірки домену `core.slug`. Ключ потрібен базі,
але жодного рішення не несе — вимагати його від клієнта означало
відповідати помилкою там, де можна просто зберегти.
---
## Вибір шаблонів картками
Пошуковий рядок із чипами добре працює для груп — там важлива лише
назва. Для шаблонів важливо ще й що всередині: скільки метрик, для якого
виробника, що воно робить. Список чипів цього не показує, тож людина
відмічала навмання й ішла перевіряти на іншу сторінку.
Тепер картка з назвою, описом і лічильником метрик. Обрані спливають
угору — інакше після вибору картка лишається десь у середині списку, і
незрозуміло, чи вибір зарахувався.
---
## Мобільна сітка карток
На телефоні картка вилазила за екран і обрізалась. Причина не в
брейкпойнтах: елемент грід-сітки типово має `min-width: auto` й не
стискається нижче ширини вмісту. `min-w-0` на картці — і 410 px стали
351 px рівно за шириною сітки.
---
## Межа помилок навколо полотна
Цикл рендеру в React Flow одного разу вже поклав увесь застосунок:
мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку.
Причину виправлено, але наслідок треба було обмежити — тепер полотно
падає саме, а навігація й решта інтерфейсу лишаються живими.

View file

@ -128,6 +128,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate))
mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate))
mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
mux.Handle("POST /api/v1/templates/{id}/clone", s.authenticated(s.handleCloneTemplate))
mux.Handle("PUT /api/v1/templates/{id}/graphs", s.authenticated(s.handleSaveTemplateGraphs))
mux.Handle("GET /api/v1/devices/{id}/graphs", s.authenticated(s.handleDeviceGraphs))
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))

View file

@ -121,8 +121,22 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P
if i.Enabled != nil {
enabled = *i.Enabled
}
// Ключ елемента виводимо самі, якщо його не прислали: він
// потрібен базі, але жодного рішення не несе, і вимагати його
// від клієнта означало б 500 замість збереження.
key := strings.TrimSpace(i.Key)
if key == "" {
key = slugify(metric)
}
if key == "" {
key = slugify(strings.TrimSpace(i.Name))
}
if key == "" {
key = fmt.Sprintf("item-%d", len(t.Items)+1)
}
t.Items = append(t.Items, store.TemplateItem{
Key: strings.TrimSpace(i.Key),
Key: key,
Name: strings.TrimSpace(i.Name),
CheckType: checkType,
OID: oid,
@ -302,6 +316,12 @@ func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "bad_request", "графік без назви")
return
}
if g.Key == "" {
g.Key = slugify(g.Name)
}
if g.Key == "" {
g.Key = fmt.Sprintf("graph-%d", i+1)
}
if g.Kind == "" {
g.Kind = "line"
}
@ -347,3 +367,15 @@ func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *P
writeJSON(w, http.StatusOK, map[string]any{"graphs": list})
}
func (s *Server) handleCloneTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
id, err := s.store.CloneTemplate(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "клонування шаблону", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}

View file

@ -67,14 +67,24 @@ func groupsByOID(checkType string) bool {
func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) {
var out []Template
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Власна копія ховає вбудований оригінал.
//
// Так виглядає «редагування вбудованого»: правка не змінює
// спільний для всіх тенантів рядок, а створює власний із тим
// самим ключем. Показувати обидва означало б питати людину, який
// із двох однакових шаблонів вона щойно змінила.
rows, err := tx.Query(ctx, `
SELECT t.id::text, t.key, t.name, COALESCE(t.description,''),
COALESCE(t.vendor,''), t.is_builtin,
(SELECT count(*) FROM tpl.items i WHERE i.template_id = t.id),
(SELECT count(*) FROM tpl.device_templates dt WHERE dt.template_id = t.id)
FROM tpl.templates t
WHERE t.tenant_id IS NOT NULL
OR NOT EXISTS (
SELECT 1 FROM tpl.templates o
WHERE o.tenant_id = $1 AND o.key = t.key)
ORDER BY t.is_builtin DESC, t.name
`)
`, tenantID)
if err != nil {
return err
}
@ -171,17 +181,30 @@ func (s *Store) SaveTemplate(ctx context.Context, tenantID string, t Template) (
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if t.ID != "" {
var builtin bool
var owner *string
if err := tx.QueryRow(ctx, `
SELECT is_builtin FROM tpl.templates WHERE id = $1
`, t.ID).Scan(&builtin); err != nil {
SELECT is_builtin, tenant_id::text FROM tpl.templates WHERE id = $1
`, t.ID).Scan(&builtin, &owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if builtin {
return ErrBuiltin
// Правка вбудованого не змінює спільний рядок, а створює
// власну копію тенанта з тим самим ключем. Оригінал лишається
// недоторканим для решти тенантів, а список показує копію
// замість нього.
//
// Це чесніше за «вбудований не редагується»: людина хоче
// підправити OID під своє залізо, а не дізнатися про межі
// нашої моделі даних.
if owner == nil {
t.ID = ""
}
}
if t.ID != "" {
if err := tx.QueryRow(ctx, `
UPDATE tpl.templates
SET key = $2, name = $3, description = NULLIF($4,''),
@ -708,15 +731,18 @@ func templateGraphs(ctx context.Context, tx pgx.Tx, templateID string) ([]Templa
// SaveTemplateGraphs замінює графіки шаблону цілком.
func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var builtin bool
var owner *string
if err := tx.QueryRow(ctx,
`SELECT is_builtin FROM tpl.templates WHERE id = $1`, templateID).Scan(&builtin); err != nil {
`SELECT tenant_id::text FROM tpl.templates WHERE id = $1`, templateID).Scan(&owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if builtin {
// Графіки вбудованого шаблону писати нікуди: сам шаблон при
// збереженні вже перетворився на власну копію, і клієнт присилає
// її id. Якщо все ж прийшов вбудований — це помилка виклику.
if owner == nil {
return ErrBuiltin
}
if _, err := tx.Exec(ctx, `DELETE FROM tpl.graphs WHERE template_id = $1`, templateID); err != nil {
@ -824,3 +850,68 @@ func (s *Store) DeviceGraphs(ctx context.Context, tenantID, deviceID string) ([]
})
return out, err
}
// CloneTemplate робить власну копію шаблону.
//
// Ключ отримує суфікс: копія з тим самим ключем ховала б оригінал, а
// клонують саме тоді, коли потрібні обидва — «як було» і «як хочу».
func (s *Store) CloneTemplate(ctx context.Context, tenantID, id string) (string, error) {
var newID string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var t Template
if err := tx.QueryRow(ctx, `
SELECT key, name, COALESCE(description,''), COALESCE(vendor,'')
FROM tpl.templates WHERE id = $1
`, id).Scan(&t.Key, &t.Name, &t.Description, &t.Vendor); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Вільний ключ шукаємо перебором, а не випадковим суфіксом:
// «snmp-mikrotik-2» читається, «snmp-mikrotik-a7f3» — ні.
base := t.Key
key := base + "-copy"
for i := 2; ; i++ {
var taken bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM tpl.templates
WHERE key = $1 AND tenant_id = $2)
`, key, tenantID).Scan(&taken); err != nil {
return err
}
if !taken {
break
}
key = fmt.Sprintf("%s-copy-%d", base, i)
}
if err := tx.QueryRow(ctx, `
INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin)
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false)
RETURNING id::text
`, tenantID, key, t.Name+" (копія)", t.Description, t.Vendor).Scan(&newID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params)
SELECT $1, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params
FROM tpl.items WHERE template_id = $2
`, newID, id); err != nil {
return err
}
_, err := tx.Exec(ctx, `
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
SELECT $1, key, name, kind, metric_keys, options, position
FROM tpl.graphs WHERE template_id = $2
`, newID, id)
return err
})
return newID, err
}

View file

@ -546,6 +546,9 @@ export const api = {
body: JSON.stringify(t),
}),
cloneTemplate: (id: string) =>
request<{ id: string }>(`/api/v1/templates/${id}/clone`, { method: 'POST' }),
deleteTemplate: (id: string) =>
request<void>(`/api/v1/templates/${id}`, { method: 'DELETE' }),

View file

@ -0,0 +1,53 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from './ui'
/**
* Межа помилок навколо полотна мапи.
*
* Причина конкретна: збій усередині React Flow одного разу вже поклав
* увесь застосунок обробник виділення зациклив оновлення, цикл зʼїв
* головний потік, і мертвими стали ВСІ сторінки, не лише мапа. Людині
* лишалося перезавантажувати вкладку.
*
* Межа не лікує причину вона обмежує наслідки: полотно падає саме, а
* навігація й решта інтерфейсу лишаються живими.
*
* Класовий компонент, бо іншого способу зловити помилку рендеру в React
* немає: хуки цього не вміють.
*/
export class CanvasBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
// У консоль, а не в інтерфейс: стек потрібен тому, хто лагодить, а
// не тому, хто дивиться на схему мережі.
console.error('полотно мапи впало', error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-sm text-slate-300">Полотно не намалювалося</p>
<p className="max-w-md text-xs leading-relaxed text-slate-600">
Решта інтерфейсу працює можна перейти на іншу сторінку. Подробиці помилки в консолі
браузера.
</p>
<code className="max-w-md truncate rounded border border-slate-800 bg-slate-900 px-2 py-1 text-[11px] text-slate-500">
{this.state.error.message}
</code>
<Button kind="primary" onClick={() => this.setState({ error: null })}>
Спробувати ще раз
</Button>
</div>
)
}
}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Button, Confirm, ErrorNote, Field, Modal, Toggle, inputClass, plural } from './ui'
import { Button, Confirm, ErrorNote, Field, Modal, Toggle, inputClass } from './ui'
import type { ConfirmRequest } from './ui'
import { Picker } from './Picker'
import { TemplatePicker } from './TemplatePicker'
import { ChecksEditor } from './ChecksEditor'
import { BackupPolicyForm } from './BackupPolicyForm'
import type { CheckInput, DeviceInput } from '../api/client'
@ -259,18 +260,11 @@ export function DeviceForm({
Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як
опитувати хост окремо міняти інтервали тут не треба.
</p>
<Field label="Шаблони опитування">
<Picker
items={templates.map((t) => ({
id: t.id,
label: t.name,
hint: plural(t.item_count, 'метрика', 'метрики', 'метрик'),
}))}
<TemplatePicker
templates={templates}
value={templateIDs}
onChange={setTemplateIDs}
empty="Шаблонів ще немає"
/>
</Field>
{templateIDs.length === 0 && checks.length === 0 && (
<p className="rounded border border-amber-900/60 bg-amber-950/20 p-2.5 text-[11px] leading-relaxed text-amber-200">
Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший

View file

@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
import type { DeviceStatus } from '../types'
@ -12,9 +13,9 @@ export type DeviceNodeData = {
width?: number
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */
size?: string
/** Колір рамки. Перекриває статусний. */
/** Колір акценту. Перекриває статусний. */
color?: string
/** Форма вузла: card | pill | dot. */
/** Форма вузла: card | pill | dot | image | bare. */
shape?: string
/** Товщина рамки в пікселях. */
borderWidth?: number
@ -34,47 +35,36 @@ export type DeviceNodeData = {
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
// Кольори статусів. Червоний лишається лише за обривом — якщо ним
// підсвічувати ще й «невідомо», оператор перестане на нього реагувати.
/**
* Палітра станів.
*
* Червоний лишається лише за обривом: якщо ним підсвічувати ще й
* «невідомо», оператор перестане на нього реагувати.
*
* `glow` зовнішнє світіння, `rail` кольорова смуга зліва на картці.
* Смуга несе стан навіть тоді, коли людина перефарбувала рамку під свої
* майданчики: колір рамки рішення про схему, колір смуги факт про
* пристрій.
*/
const STATUS: Record<
string,
{ ring: string; dot: string; text: string; halo: string; raw: string }
{ raw: string; glow: string; text: string; pulse: boolean }
> = {
up: {
ring: 'border-emerald-500/70',
dot: 'bg-emerald-400',
text: 'text-emerald-300',
halo: '',
raw: '#34d399',
},
up: { raw: '#34d399', glow: '', text: 'text-emerald-300', pulse: false },
down: {
ring: 'border-red-500',
dot: 'bg-red-500',
text: 'text-red-300',
halo: 'shadow-[0_0_0_4px_rgba(239,68,68,0.18),0_0_22px_4px_rgba(239,68,68,0.35)]',
raw: '#ef4444',
raw: '#f43f5e',
glow: '0 0 0 1px rgba(244,63,94,.55), 0 0 24px 2px rgba(244,63,94,.32)',
text: 'text-rose-300',
pulse: true,
},
warning: {
ring: 'border-amber-500/80',
dot: 'bg-amber-400',
text: 'text-amber-300',
halo: 'shadow-[0_0_0_4px_rgba(245,158,11,0.16),0_0_18px_3px_rgba(245,158,11,0.28)]',
raw: '#f59e0b',
glow: '0 0 0 1px rgba(245,158,11,.5), 0 0 18px 1px rgba(245,158,11,.22)',
text: 'text-amber-300',
pulse: false,
},
maintenance: {
ring: 'border-sky-500/60',
dot: 'bg-sky-400',
text: 'text-sky-300',
halo: '',
raw: '#38bdf8',
},
unknown: {
ring: 'border-slate-600',
dot: 'bg-slate-500',
text: 'text-slate-400',
halo: '',
raw: '#64748b',
},
maintenance: { raw: '#38bdf8', glow: '', text: 'text-sky-300', pulse: false },
unknown: { raw: '#64748b', glow: '', text: 'text-slate-400', pulse: false },
}
const ICONS: Record<string, string> = {
@ -86,13 +76,19 @@ const ICONS: Record<string, string> = {
ups: '⚡',
cloud: '☁',
olt: '⌸',
onu: '◇',
camera: '◉',
printer: '⎙',
sensor: '⌁',
vm: '▧',
container: '▨',
other: '□',
}
const TEXT_SIZE: Record<string, string> = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
sm: 'text-[11px]',
md: 'text-[13px]',
lg: 'text-[15px]',
}
const DOT_SIZE: Record<string, number> = { sm: 12, md: 18, lg: 26 }
@ -104,39 +100,51 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
const textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md
const shape = data.shape ?? 'card'
const labelPos = data.labelPos ?? 'right'
const accent = data.color ?? s.raw
// Аварія й попередження світяться. Це не прикраса: на схемі з сотні
// вузлів рамка іншого кольору не помітна периферійним зором, а ореол
// видно навіть на віддаленому масштабі, коли підписи вже не читаються.
const halo = s.halo
const ring = selected ? 'ring-2 ring-sky-400 ring-offset-2 ring-offset-slate-950' : ''
// Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх
// тіні стану. Два різні кільця одне на одному читаються як брак, тому
// вони складаються в один box-shadow, а не в клас ring-*.
const shadow = [
selected ? '0 0 0 2px #38bdf8' : '',
s.glow,
'0 8px 24px -8px rgba(0,0,0,.7)',
]
.filter(Boolean)
.join(', ')
const handles = (
<>
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" />
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-slate-500" />
<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"
/>
</>
)
// Підпис малюється однаково для крапки, картинки й «без рамки»:
// міняється лише те, куди його поставити. Ті самі три форми з трьома
// копіями розмітки розійшлися б із першою ж правкою.
const label =
data.hideLabel || labelPos === 'none' ? null : (
<span className={`whitespace-nowrap font-medium text-slate-200 ${textSize}`}>
<span
className={`whitespace-nowrap font-medium tracking-tight text-slate-100 ${textSize}`}
style={{ textShadow: '0 1px 3px rgba(2,6,23,.9)' }}
>
{data.label}
</span>
)
const wrap = (visual: React.ReactNode) => {
const wrap = (visual: ReactNode) => {
const dir =
labelPos === 'bottom'
? 'flex-col'
: labelPos === 'top'
? 'flex-col-reverse'
: 'flex-row'
labelPos === 'bottom' ? 'flex-col' : labelPos === 'top' ? 'flex-col-reverse' : 'flex-row'
return (
<div className={`flex items-center gap-1.5 ${dir}`}>
<div className={`group flex items-center gap-2 ${dir}`}>
{handles}
{visual}
{label}
@ -151,56 +159,64 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
if (shape === 'dot') {
const d = data.iconSize ?? DOT_SIZE[size] ?? DOT_SIZE.md
return wrap(
<div
className={`shrink-0 rounded-full ${halo} ${ring}`}
<span className="relative flex shrink-0" style={{ width: d, height: d }}>
{s.pulse && (
<span
className="absolute inset-0 animate-ping rounded-full opacity-60"
style={{ background: accent }}
/>
)}
<span
className="relative h-full w-full rounded-full"
style={{
width: d,
height: d,
background: data.fill ?? data.color ?? s.raw,
border: `${data.borderWidth ?? 2}px solid ${data.color ?? 'rgba(15,23,42,0.9)'}`,
background: data.fill ?? accent,
border: `${data.borderWidth ?? 2}px solid rgba(2,6,23,.85)`,
boxShadow: shadow,
}}
title={`${data.label}${data.status}`}
/>,
/>
</span>,
)
}
// ---- картинка -----------------------------------------------------
//
// Стан показує тонка кольорова обвідка знизу, а не рамка навколо:
// рамка з'їдає саму картинку, заради якої її й ставили.
if (shape === 'image') {
const d = data.iconSize ?? 40
const d = data.iconSize ?? 44
return wrap(
<div className={`relative shrink-0 ${ring}`} style={{ width: d, height: d }}>
<span className="relative shrink-0" style={{ width: d, height: d }}>
{data.iconId ? (
<img
src={`/api/v1/icons/${data.iconId}`}
alt=""
draggable={false}
className={`h-full w-full object-contain ${halo}`}
className="h-full w-full object-contain"
style={{ filter: 'drop-shadow(0 4px 10px rgba(2,6,23,.8))' }}
/>
) : (
<span className="flex h-full w-full items-center justify-center text-2xl text-slate-500">
{icon}
</span>
)}
{/* Стан тонка обвідка знизу, а не рамка навколо: рамка
зʼїдає саму картинку, заради якої її й ставили. */}
<span
className="absolute -bottom-1 left-1/2 h-1 w-2/3 -translate-x-1/2 rounded-full"
style={{ background: s.raw }}
className="absolute -bottom-1.5 left-1/2 h-[3px] w-3/5 -translate-x-1/2 rounded-full"
style={{ background: accent, boxShadow: s.glow || undefined }}
/>
</div>,
{selected && (
<span className="absolute -inset-1.5 rounded-lg ring-2 ring-sky-400" />
)}
</span>,
)
}
// ---- без рамки ----------------------------------------------------
//
// Тільки крапка стану й значок. Для схем, де рамка навколо кожного
// вузла перетворює полотно на сітку прямокутників.
if (shape === 'bare') {
return wrap(
<span className={`flex shrink-0 items-center gap-1.5 ${ring}`}>
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot} ${halo}`} />
<span className="text-base leading-none text-slate-400">{icon}</span>
<span className="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>
{selected && <span className="absolute -inset-1 rounded ring-2 ring-sky-400" />}
</span>,
)
}
@ -209,17 +225,18 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
if (shape === 'pill') {
return (
<div
className={`flex items-center gap-2 rounded-full border-2 px-3 py-1 backdrop-blur
${s.ring} ${halo} ${ring}`}
className="group flex items-center gap-2 rounded-full px-3.5 py-1.5 backdrop-blur-sm"
style={{
background: data.fill ?? 'rgba(15,23,42,0.95)',
...(data.color ? { borderColor: data.color } : {}),
...(data.borderWidth ? { borderWidth: data.borderWidth } : {}),
background: data.fill ?? 'linear-gradient(180deg,#1e293b,#0f172a)',
border: `${data.borderWidth ?? 1}px solid ${hexA(accent, 0.45)}`,
boxShadow: shadow,
}}
>
{handles}
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<span className={`truncate font-medium text-slate-100 ${textSize}`}>{data.label}</span>
<StatusDot color={accent} pulse={s.pulse} />
<span className={`truncate font-medium tracking-tight text-slate-100 ${textSize}`}>
{data.label}
</span>
</div>
)
}
@ -227,35 +244,76 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
// ---- картка (типова) ----------------------------------------------
return (
<div
className={`rounded-lg border-2 px-3 py-2 shadow-lg backdrop-blur ${s.ring} ${halo} ${ring}`}
// Ширину задає людина в інспекторі; колір рамки — теж, і тоді він
// перекриває статусний. Це свідомо: на схемі, де кольором
// позначені майданчики, статус читають за крапкою й ореолом.
className="group relative overflow-hidden rounded-xl backdrop-blur-sm"
style={{
minWidth: data.width ?? 150,
background: data.fill ?? 'rgba(15,23,42,0.95)',
...(data.color ? { borderColor: data.color } : {}),
...(data.borderWidth ? { borderWidth: data.borderWidth } : {}),
minWidth: data.width ?? 168,
background: data.fill ?? 'linear-gradient(180deg,#1e293b 0%,#111c2e 100%)',
border: `${data.borderWidth ?? 1}px solid ${hexA(accent, 0.35)}`,
boxShadow: shadow,
}}
>
{handles}
{/* Кольорова смуга стану. Несе стан навіть тоді, коли рамку
перефарбували під майданчик: колір рамки рішення про схему,
колір смуги факт про пристрій. */}
<span className="absolute inset-y-0 left-0 w-1" style={{ background: accent }} />
<div className="py-2 pl-3.5 pr-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
<span className="text-base leading-none text-slate-400">{icon}</span>
<span className={`truncate font-medium text-slate-100 ${textSize}`}>{data.label}</span>
<span className={`truncate font-medium tracking-tight text-slate-100 ${textSize}`}>
{data.label}
</span>
<StatusDot color={accent} pulse={s.pulse} className="ml-auto" />
</div>
{/* Цифри показуємо лише коли вони є: порожній рядок « мс»
створює враження зламаного моніторингу. */}
{!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
<div className="mt-1 flex gap-3 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.loss !== undefined && data.loss > 0 && (
<span className="text-red-300">втрати {data.loss.toFixed(0)}%</span>
<span className="text-rose-300">втрати {data.loss.toFixed(0)}%</span>
)}
</div>
)}
</div>
</div>
)
}
function StatusDot({
color,
pulse,
className = '',
}: {
color: string
pulse: boolean
className?: string
}) {
return (
<span className={`relative flex h-2 w-2 shrink-0 ${className}`}>
{/* Пульсація лише за обривом: рух там, де все гаразд, це шум,
який навчає не дивитись на рух узагалі. */}
{pulse && (
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: color }}
/>
)}
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: color }}
/>
</span>
)
}
/** #rrggbb → rgba(). Потрібно для рамок, де колір задає людина. */
function hexA(hex: string, a: number): string {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
if (!m) return hex
const n = parseInt(m[1], 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`
}

View file

@ -253,10 +253,32 @@ export function MapCanvas({
minZoom={0.1}
maxZoom={2.5}
proOptions={{ hideAttribution: false }}
className="bg-slate-950"
className="netpulse-canvas"
>
<Background variant={BackgroundVariant.Dots} gap={state.grid?.size ?? 16} color="#1e293b" />
<Controls className="!bg-slate-900 !text-slate-200 [&>button]:!border-slate-700 [&>button]:!bg-slate-900" />
{/* Два шари сітки: дрібна для відчуття масштабу й велика для
орієнтації. Одна сітка або рябить, або не читається залежно
від кроку, а крок задає людина. */}
<Background
id="fine"
variant={BackgroundVariant.Dots}
gap={state.grid?.size ?? 16}
size={1}
color="#1e2a3d"
/>
<Background
id="coarse"
variant={BackgroundVariant.Lines}
gap={(state.grid?.size ?? 16) * 5}
lineWidth={0.5}
color="#141d2e"
/>
<Controls
showInteractive={false}
className="!overflow-hidden !rounded-lg !border !border-slate-700/70 !bg-slate-900/90
!shadow-xl !backdrop-blur
[&>button]:!border-slate-700/70 [&>button]:!bg-transparent
[&>button]:!text-slate-300 [&>button:hover]:!bg-slate-800"
/>
<MiniMap
pannable
zoomable

View file

@ -0,0 +1,123 @@
import { useMemo, useState } from 'react'
import { inputClass, plural } from './ui'
import type { Template } from '../types'
/**
* Вибір шаблонів картками.
*
* Пошуковий рядок із чипами добре працює для груп там важлива лише
* назва. Для шаблонів важливо ще й що всередині: скільки метрик, для
* якого виробника, що воно взагалі робить. Список чипів цього не
* показує, тож людина відмічає навмання й іде перевіряти на іншу
* сторінку.
*
* Тому картка: назва, опис, лічильник метрик і виразний стан «обрано».
*/
export function TemplatePicker({
templates,
value,
onChange,
disabled,
}: {
templates: Template[]
value: string[]
onChange: (next: string[]) => void
disabled?: boolean
}) {
const [query, setQuery] = useState('')
const list = useMemo(() => {
const q = query.trim().toLowerCase()
const matched = templates.filter(
(t) =>
!q ||
t.name.toLowerCase().includes(q) ||
t.key.toLowerCase().includes(q) ||
(t.vendor ?? '').toLowerCase().includes(q) ||
(t.description ?? '').toLowerCase().includes(q),
)
// Обрані — вгору. Інакше після вибору картка лишається десь у
// середині списку, і незрозуміло, чи вибір узагалі зарахувався.
return [...matched].sort((a, b) => {
const av = value.includes(a.id) ? 0 : 1
const bv = value.includes(b.id) ? 0 : 1
return av - bv || a.name.localeCompare(b.name, 'uk')
})
}, [templates, query, value])
const toggle = (id: string) =>
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id])
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<input
className={`${inputClass} min-w-0 flex-1`}
placeholder="пошук за назвою, виробником або описом…"
value={query}
disabled={disabled}
onChange={(e) => setQuery(e.target.value)}
/>
<span className="shrink-0 text-[11px] text-slate-500">
обрано {value.length} з {templates.length}
</span>
</div>
{list.length === 0 ? (
<p className="rounded border border-slate-800 py-6 text-center text-xs text-slate-600">
{templates.length === 0 ? 'Шаблонів ще немає' : 'Нічого не знайдено'}
</p>
) : (
<ul className="grid max-h-72 gap-2 overflow-y-auto pr-0.5 sm:grid-cols-2">
{list.map((t) => {
const on = value.includes(t.id)
return (
<li key={t.id}>
<button
type="button"
disabled={disabled}
onClick={() => toggle(t.id)}
aria-pressed={on}
className={`flex h-full w-full flex-col rounded-lg border p-2.5 text-left transition-colors
${
on
? 'border-sky-600 bg-sky-950/40'
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700 hover:bg-slate-800/50'
}`}
>
<span className="flex items-start gap-2">
{/* Своя галочка, а не <input>: у кнопці вкладений
чекбокс краде клік і дає два різні способи
зробити те саме. */}
<span
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px]
${on ? 'border-sky-500 bg-sky-500 text-slate-950' : 'border-slate-600'}`}
>
{on ? '✓' : ''}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-100">
{t.name}
</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
{plural(t.item_count, 'метрика', 'метрики', 'метрик')}
{t.vendor ? ` · ${t.vendor}` : ''}
{t.is_builtin ? ' · вбудований' : ''}
</span>
</span>
</span>
{t.description && (
<span className="mt-1.5 line-clamp-2 text-[11px] leading-snug text-slate-600">
{t.description}
</span>
)}
</button>
</li>
)
})}
</ul>
)}
</div>
)
}

View file

@ -78,6 +78,25 @@ export function TrafficEdge({
// каналі означала б рух, якого немає.
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
@ -99,20 +118,18 @@ export function TrafficEdge({
</circle>
)}
{d.showMetrics && (
{label && (
<EdgeLabelRenderer>
<div
className="pointer-events-none absolute rounded bg-slate-900/85 px-1.5 py-0.5
text-[10px] leading-tight text-slate-300 shadow"
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)` }}
>
<div className="whitespace-nowrap">
{d.sourcePort ?? '?'} {d.targetPort ?? '?'}
</div>
{d.capacityBps !== undefined && (
{ports && <div className="whitespace-nowrap font-medium">{ports}</div>}
{rate && (
<div className="whitespace-nowrap tabular-nums" style={{ color }}>
{util > 0 ? `${util < 0.01 ? '<0.01' : util.toFixed(2)}%` : '—'} з{' '}
{formatBps(d.capacityBps)}
{rate}
</div>
)}
</div>

View file

@ -26,3 +26,43 @@ body {
background: transparent !important;
color: #334155 !important;
}
/* ---------------------------------------------------------------------
Полотно мапи
---------------------------------------------------------------------
Радіальна підсвітка в центрі замість рівного чорного: вона дає
відчуття глибини й підказує, де «середина» схеми, коли вузли рознесені
далеко один від одного.
*/
.react-flow.netpulse-canvas {
background:
radial-gradient(1200px 700px at 50% 38%, #16233a 0%, #0b1220 62%, #070c16 100%);
}
/* Мініатюра й панель керування не мають виглядати як частина сторінки
вони плавають над полотном. */
.react-flow.netpulse-canvas .react-flow__minimap {
border-radius: 0.5rem;
border: 1px solid rgb(51 65 85 / 0.7);
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.8);
overflow: hidden;
}
/* Рамка виділення: та сама блакить, що й у решті інтерфейсу. */
.react-flow.netpulse-canvas .react-flow__selection {
background: rgb(56 189 248 / 0.08);
border: 1px solid rgb(56 189 248 / 0.6);
border-radius: 0.25rem;
}
/* Лінія, яку тягнуть від порту, має читатись на темному. */
.react-flow.netpulse-canvas .react-flow__connectionline path {
stroke: #38bdf8;
stroke-width: 2;
stroke-dasharray: 5 4;
}
.react-flow.netpulse-canvas .react-flow__attribution {
background: transparent;
color: rgb(100 116 139 / 0.5);
}

View file

@ -82,7 +82,7 @@ export function CredentialsPage() {
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{creds.map((c) => (
<Card key={c.id} className="flex flex-col p-4">
<Card key={c.id} className="flex min-w-0 flex-col p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-200">{c.name}</div>

View file

@ -3,6 +3,7 @@ import { api } from '../api/client'
import { session } from '../api/session'
import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas'
import { CanvasBoundary } from '../components/CanvasBoundary'
import { NodeInspector } from '../components/NodeInspector'
import { MapAddHosts } from '../components/MapAddHosts'
import { MapSettings } from '../components/MapSettings'
@ -70,9 +71,13 @@ export function MapPage() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-slate-800 px-3 py-2">
<div
className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-slate-800/80
bg-slate-900/60 px-3 py-2 backdrop-blur"
>
<select
className="min-w-0 flex-1 rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:flex-none"
className="min-w-0 flex-1 rounded-md border border-slate-700 bg-slate-800/80 px-2.5 py-1.5
text-sm text-slate-100 shadow-sm md:flex-none md:max-w-64"
value={mapID ?? ''}
onChange={(e) => setMapID(e.target.value || null)}
>
@ -84,6 +89,8 @@ export function MapPage() {
))}
</select>
<span className="mx-1 hidden h-5 w-px bg-slate-700/70 sm:block" />
{session.can('maps:write') && (
<Button
kind={editing ? 'primary' : 'default'}
@ -160,10 +167,26 @@ export function MapPage() {
</>
)}
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
<span className="hidden sm:inline tabular-nums">
{counts.up}/{counts.total} на звʼязку
{counts.down > 0 && <span className="ml-1 text-red-400">· {counts.down} впало</span>}
<div className="ml-auto flex items-center gap-2.5 text-xs text-slate-500">
<span className="hidden items-center gap-1.5 sm:flex">
<span
className="flex items-center gap-1.5 rounded-full border border-slate-700/70
bg-slate-800/60 px-2 py-0.5 tabular-nums"
title="На звʼязку"
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{counts.up}/{counts.total}
</span>
{counts.down > 0 && (
<span
className="flex items-center gap-1.5 rounded-full border border-rose-900/70
bg-rose-950/50 px-2 py-0.5 tabular-nums text-rose-300"
title="Недоступні"
>
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
{counts.down}
</span>
)}
</span>
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
{live.state && (
@ -180,6 +203,7 @@ export function MapPage() {
<div className="relative min-h-0 flex-1">
{live.state ? (
<CanvasBoundary>
<MapCanvas
state={live.state}
onPatch={live.patch}
@ -187,6 +211,7 @@ export function MapPage() {
onSelectionChange={setSelection}
readOnly={!editing}
/>
</CanvasBoundary>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}

View file

@ -112,9 +112,9 @@ export function TemplatesPage() {
<PageBody className="space-y-4">
<ErrorNote>{err}</ErrorNote>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{list.map((t) => (
<Card key={t.id} className="flex flex-col p-4">
<Card key={t.id} className="flex min-w-0 flex-col p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-200">{t.name}</div>
@ -131,12 +131,12 @@ export function TemplatesPage() {
<p className="mt-2 text-xs leading-relaxed text-slate-500">{t.description}</p>
)}
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-3 text-[11px] text-slate-600">
<span>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3 text-[11px] text-slate-600">
<span className="shrink-0">
{plural(t.item_count, 'метрика', 'метрики', 'метрик')} ·{' '}
{plural(t.device_count, 'хост', 'хости', 'хостів')}
</span>
<div className="flex gap-1.5">
<div className="ml-auto flex flex-wrap gap-1.5">
<Button
kind="ghost"
title="Забрати цей шаблон файлом"
@ -148,7 +148,23 @@ export function TemplatesPage() {
>
</Button>
<Button onClick={() => open(t.id)}>{t.is_builtin ? 'Переглянути' : 'Змінити'}</Button>
{canWrite && (
<Button
title="Зробити власну копію"
onClick={async () => {
try {
const r = await api.cloneTemplate(t.id)
reload()
setEditing(await api.getTemplate(r.id))
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}}
>
Копія
</Button>
)}
<Button onClick={() => open(t.id)}>{canWrite ? 'Змінити' : 'Переглянути'}</Button>
{canWrite && !t.is_builtin && (
<Button
kind="danger"
@ -191,7 +207,7 @@ export function TemplatesPage() {
{(editing || creating) && (
<TemplateForm
template={editing}
readOnly={!canWrite || (editing?.is_builtin ?? false)}
readOnly={!canWrite}
onClose={() => {
setEditing(null)
setCreating(false)
@ -278,6 +294,14 @@ function TemplateForm({
wide
>
<div className="space-y-3">
{template?.is_builtin && !readOnly && (
<p className="rounded border border-sky-900/60 bg-sky-950/25 p-2.5 text-[11px] leading-relaxed text-sky-200">
Це вбудований шаблон він спільний для всіх. Збереження створить вашу власну копію
з тим самим ключем; вона замінить оригінал у списку, а сам оригінал лишиться
недоторканим.
</p>
)}
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Назва">
<input