From edc6209c984fa59210d76f35cf0b2b5aaa4e2cf6 Mon Sep 17 00:00:00 2001
From: byrsapty
Date: Mon, 24 Aug 2026 23:53:58 +0300
Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B8=D0=B3=D0=BB=D1=8F=D0=B4=20=D0=BC?=
=?UTF-8?q?=D0=B0=D0=BF=D0=B8,=20=D1=80=D0=B5=D0=B4=D0=B0=D0=B3=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B0=D0=BD=D1=96=20=D0=B2=D0=B1=D1=83=D0=B4=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B0=D0=BD=D1=96=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=B8?=
=?UTF-8?q?,=20=D0=BC=D0=BE=D0=B1=D1=96=D0=BB=D1=8C=D0=BD=D1=96=20=D0=BA?=
=?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BA=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Мапа: підпис ребра більше не друкує «? → ?» і «— з 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
---
HISTORY.md | 86 ++++++++
server/internal/httpapi/server.go | 1 +
server/internal/httpapi/templates.go | 34 +++-
server/internal/store/templates.go | 107 +++++++++-
web/src/api/client.ts | 3 +
web/src/components/CanvasBoundary.tsx | 53 +++++
web/src/components/DeviceForm.tsx | 20 +-
web/src/components/DeviceNode.tsx | 278 ++++++++++++++++----------
web/src/components/MapCanvas.tsx | 28 ++-
web/src/components/TemplatePicker.tsx | 123 ++++++++++++
web/src/components/TrafficEdge.tsx | 35 +++-
web/src/index.css | 40 ++++
web/src/pages/CredentialsPage.tsx | 2 +-
web/src/pages/MapPage.tsx | 43 +++-
web/src/pages/TemplatesPage.tsx | 38 +++-
15 files changed, 730 insertions(+), 161 deletions(-)
create mode 100644 web/src/components/CanvasBoundary.tsx
create mode 100644 web/src/components/TemplatePicker.tsx
diff --git a/HISTORY.md b/HISTORY.md
index 6b30987..946a30b 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -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 одного разу вже поклав увесь застосунок:
+мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку.
+Причину виправлено, але наслідок треба було обмежити — тепер полотно
+падає саме, а навігація й решта інтерфейсу лишаються живими.
+
diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go
index 1fa4ddd..32f4bf7 100644
--- a/server/internal/httpapi/server.go
+++ b/server/internal/httpapi/server.go
@@ -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))
diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go
index f52e6ca..75f3a87 100644
--- a/server/internal/httpapi/templates.go
+++ b/server/internal/httpapi/templates.go
@@ -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})
+}
+
diff --git a/server/internal/store/templates.go b/server/internal/store/templates.go
index 84e892f..7820dd4 100644
--- a/server/internal/store/templates.go
+++ b/server/internal/store/templates.go
@@ -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
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 812060c..d667783 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -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(`/api/v1/templates/${id}`, { method: 'DELETE' }),
diff --git a/web/src/components/CanvasBoundary.tsx b/web/src/components/CanvasBoundary.tsx
new file mode 100644
index 0000000..b56b0f7
--- /dev/null
+++ b/web/src/components/CanvasBoundary.tsx
@@ -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 (
+
+
Полотно не намалювалося
+
+ Решта інтерфейсу працює — можна перейти на іншу сторінку. Подробиці помилки в консолі
+ браузера.
+
+
+ {this.state.error.message}
+
+
+
+ )
+ }
+}
diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx
index 750d8f8..a619f35 100644
--- a/web/src/components/DeviceForm.tsx
+++ b/web/src/components/DeviceForm.tsx
@@ -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({
Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як
опитувати хост — окремо міняти інтервали тут не треба.
+ {/* Кольорова смуга стану. Несе стан навіть тоді, коли рамку
+ перефарбували під майданчик: колір рамки — рішення про схему,
+ колір смуги — факт про пристрій. */}
+
- {/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
- створює враження зламаного моніторингу. */}
- {!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
-