Вигляд мапи, редаговані вбудовані шаблони, мобільні картки
Мапа: підпис ребра більше не друкує «? → ?» і «— з 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:
parent
387980d655
commit
edc6209c98
15 changed files with 730 additions and 161 deletions
86
HISTORY.md
86
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 одного разу вже поклав увесь застосунок:
|
||||||
|
мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку.
|
||||||
|
Причину виправлено, але наслідок треба було обмежити — тепер полотно
|
||||||
|
падає саме, а навігація й решта інтерфейсу лишаються живими.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate))
|
mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate))
|
||||||
mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate))
|
mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate))
|
||||||
mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
|
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("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}/graphs", s.authenticated(s.handleDeviceGraphs))
|
||||||
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
|
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
|
||||||
|
|
|
||||||
|
|
@ -121,8 +121,22 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P
|
||||||
if i.Enabled != nil {
|
if i.Enabled != nil {
|
||||||
enabled = *i.Enabled
|
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{
|
t.Items = append(t.Items, store.TemplateItem{
|
||||||
Key: strings.TrimSpace(i.Key),
|
Key: key,
|
||||||
Name: strings.TrimSpace(i.Name),
|
Name: strings.TrimSpace(i.Name),
|
||||||
CheckType: checkType,
|
CheckType: checkType,
|
||||||
OID: oid,
|
OID: oid,
|
||||||
|
|
@ -302,6 +316,12 @@ func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request
|
||||||
writeError(w, http.StatusBadRequest, "bad_request", "графік без назви")
|
writeError(w, http.StatusBadRequest, "bad_request", "графік без назви")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if g.Key == "" {
|
||||||
|
g.Key = slugify(g.Name)
|
||||||
|
}
|
||||||
|
if g.Key == "" {
|
||||||
|
g.Key = fmt.Sprintf("graph-%d", i+1)
|
||||||
|
}
|
||||||
if g.Kind == "" {
|
if g.Kind == "" {
|
||||||
g.Kind = "line"
|
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})
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,24 @@ func groupsByOID(checkType string) bool {
|
||||||
func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) {
|
func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) {
|
||||||
var out []Template
|
var out []Template
|
||||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
// Власна копія ховає вбудований оригінал.
|
||||||
|
//
|
||||||
|
// Так виглядає «редагування вбудованого»: правка не змінює
|
||||||
|
// спільний для всіх тенантів рядок, а створює власний із тим
|
||||||
|
// самим ключем. Показувати обидва означало б питати людину, який
|
||||||
|
// із двох однакових шаблонів вона щойно змінила.
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
SELECT t.id::text, t.key, t.name, COALESCE(t.description,''),
|
SELECT t.id::text, t.key, t.name, COALESCE(t.description,''),
|
||||||
COALESCE(t.vendor,''), t.is_builtin,
|
COALESCE(t.vendor,''), t.is_builtin,
|
||||||
(SELECT count(*) FROM tpl.items i WHERE i.template_id = t.id),
|
(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)
|
(SELECT count(*) FROM tpl.device_templates dt WHERE dt.template_id = t.id)
|
||||||
FROM tpl.templates t
|
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
|
ORDER BY t.is_builtin DESC, t.name
|
||||||
`)
|
`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
if t.ID != "" {
|
if t.ID != "" {
|
||||||
var builtin bool
|
var builtin bool
|
||||||
|
var owner *string
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
SELECT is_builtin FROM tpl.templates WHERE id = $1
|
SELECT is_builtin, tenant_id::text FROM tpl.templates WHERE id = $1
|
||||||
`, t.ID).Scan(&builtin); err != nil {
|
`, t.ID).Scan(&builtin, &owner); err != nil {
|
||||||
if isNoRows(err) {
|
if isNoRows(err) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if builtin {
|
|
||||||
return ErrBuiltin
|
// Правка вбудованого не змінює спільний рядок, а створює
|
||||||
|
// власну копію тенанта з тим самим ключем. Оригінал лишається
|
||||||
|
// недоторканим для решти тенантів, а список показує копію
|
||||||
|
// замість нього.
|
||||||
|
//
|
||||||
|
// Це чесніше за «вбудований не редагується»: людина хоче
|
||||||
|
// підправити OID під своє залізо, а не дізнатися про межі
|
||||||
|
// нашої моделі даних.
|
||||||
|
if owner == nil {
|
||||||
|
t.ID = ""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.ID != "" {
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
UPDATE tpl.templates
|
UPDATE tpl.templates
|
||||||
SET key = $2, name = $3, description = NULLIF($4,''),
|
SET key = $2, name = $3, description = NULLIF($4,''),
|
||||||
|
|
@ -708,15 +731,18 @@ func templateGraphs(ctx context.Context, tx pgx.Tx, templateID string) ([]Templa
|
||||||
// SaveTemplateGraphs замінює графіки шаблону цілком.
|
// SaveTemplateGraphs замінює графіки шаблону цілком.
|
||||||
func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error {
|
func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error {
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
var builtin bool
|
var owner *string
|
||||||
if err := tx.QueryRow(ctx,
|
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) {
|
if isNoRows(err) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if builtin {
|
// Графіки вбудованого шаблону писати нікуди: сам шаблон при
|
||||||
|
// збереженні вже перетворився на власну копію, і клієнт присилає
|
||||||
|
// її id. Якщо все ж прийшов вбудований — це помилка виклику.
|
||||||
|
if owner == nil {
|
||||||
return ErrBuiltin
|
return ErrBuiltin
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `DELETE FROM tpl.graphs WHERE template_id = $1`, templateID); err != nil {
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -546,6 +546,9 @@ export const api = {
|
||||||
body: JSON.stringify(t),
|
body: JSON.stringify(t),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
cloneTemplate: (id: string) =>
|
||||||
|
request<{ id: string }>(`/api/v1/templates/${id}/clone`, { method: 'POST' }),
|
||||||
|
|
||||||
deleteTemplate: (id: string) =>
|
deleteTemplate: (id: string) =>
|
||||||
request<void>(`/api/v1/templates/${id}`, { method: 'DELETE' }),
|
request<void>(`/api/v1/templates/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
|
|
||||||
53
web/src/components/CanvasBoundary.tsx
Normal file
53
web/src/components/CanvasBoundary.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api } from '../api/client'
|
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 type { ConfirmRequest } from './ui'
|
||||||
import { Picker } from './Picker'
|
import { Picker } from './Picker'
|
||||||
|
import { TemplatePicker } from './TemplatePicker'
|
||||||
import { ChecksEditor } from './ChecksEditor'
|
import { ChecksEditor } from './ChecksEditor'
|
||||||
import { BackupPolicyForm } from './BackupPolicyForm'
|
import { BackupPolicyForm } from './BackupPolicyForm'
|
||||||
import type { CheckInput, DeviceInput } from '../api/client'
|
import type { CheckInput, DeviceInput } from '../api/client'
|
||||||
|
|
@ -259,18 +260,11 @@ export function DeviceForm({
|
||||||
Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як
|
Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як
|
||||||
опитувати хост — окремо міняти інтервали тут не треба.
|
опитувати хост — окремо міняти інтервали тут не треба.
|
||||||
</p>
|
</p>
|
||||||
<Field label="Шаблони опитування">
|
<TemplatePicker
|
||||||
<Picker
|
templates={templates}
|
||||||
items={templates.map((t) => ({
|
value={templateIDs}
|
||||||
id: t.id,
|
onChange={setTemplateIDs}
|
||||||
label: t.name,
|
/>
|
||||||
hint: plural(t.item_count, 'метрика', 'метрики', 'метрик'),
|
|
||||||
}))}
|
|
||||||
value={templateIDs}
|
|
||||||
onChange={setTemplateIDs}
|
|
||||||
empty="Шаблонів ще немає"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{templateIDs.length === 0 && checks.length === 0 && (
|
{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">
|
<p className="rounded border border-amber-900/60 bg-amber-950/20 p-2.5 text-[11px] leading-relaxed text-amber-200">
|
||||||
Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший
|
Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
|
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'
|
||||||
import type { DeviceStatus } from '../types'
|
import type { DeviceStatus } from '../types'
|
||||||
|
|
||||||
|
|
@ -12,9 +13,9 @@ export type DeviceNodeData = {
|
||||||
width?: number
|
width?: number
|
||||||
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */
|
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */
|
||||||
size?: string
|
size?: string
|
||||||
/** Колір рамки. Перекриває статусний. */
|
/** Колір акценту. Перекриває статусний. */
|
||||||
color?: string
|
color?: string
|
||||||
/** Форма вузла: card | pill | dot. */
|
/** Форма вузла: card | pill | dot | image | bare. */
|
||||||
shape?: string
|
shape?: string
|
||||||
/** Товщина рамки в пікселях. */
|
/** Товщина рамки в пікселях. */
|
||||||
borderWidth?: number
|
borderWidth?: number
|
||||||
|
|
@ -34,47 +35,36 @@ export type DeviceNodeData = {
|
||||||
|
|
||||||
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
|
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
|
||||||
|
|
||||||
// Кольори статусів. Червоний лишається лише за обривом — якщо ним
|
/**
|
||||||
// підсвічувати ще й «невідомо», оператор перестане на нього реагувати.
|
* Палітра станів.
|
||||||
|
*
|
||||||
|
* Червоний лишається лише за обривом: якщо ним підсвічувати ще й
|
||||||
|
* «невідомо», оператор перестане на нього реагувати.
|
||||||
|
*
|
||||||
|
* `glow` — зовнішнє світіння, `rail` — кольорова смуга зліва на картці.
|
||||||
|
* Смуга несе стан навіть тоді, коли людина перефарбувала рамку під свої
|
||||||
|
* майданчики: колір рамки — рішення про схему, колір смуги — факт про
|
||||||
|
* пристрій.
|
||||||
|
*/
|
||||||
const STATUS: Record<
|
const STATUS: Record<
|
||||||
string,
|
string,
|
||||||
{ ring: string; dot: string; text: string; halo: string; raw: string }
|
{ raw: string; glow: string; text: string; pulse: boolean }
|
||||||
> = {
|
> = {
|
||||||
up: {
|
up: { raw: '#34d399', glow: '', text: 'text-emerald-300', pulse: false },
|
||||||
ring: 'border-emerald-500/70',
|
|
||||||
dot: 'bg-emerald-400',
|
|
||||||
text: 'text-emerald-300',
|
|
||||||
halo: '',
|
|
||||||
raw: '#34d399',
|
|
||||||
},
|
|
||||||
down: {
|
down: {
|
||||||
ring: 'border-red-500',
|
raw: '#f43f5e',
|
||||||
dot: 'bg-red-500',
|
glow: '0 0 0 1px rgba(244,63,94,.55), 0 0 24px 2px rgba(244,63,94,.32)',
|
||||||
text: 'text-red-300',
|
text: 'text-rose-300',
|
||||||
halo: 'shadow-[0_0_0_4px_rgba(239,68,68,0.18),0_0_22px_4px_rgba(239,68,68,0.35)]',
|
pulse: true,
|
||||||
raw: '#ef4444',
|
|
||||||
},
|
},
|
||||||
warning: {
|
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',
|
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: {
|
maintenance: { raw: '#38bdf8', glow: '', text: 'text-sky-300', pulse: false },
|
||||||
ring: 'border-sky-500/60',
|
unknown: { raw: '#64748b', glow: '', text: 'text-slate-400', pulse: false },
|
||||||
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',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS: Record<string, string> = {
|
const ICONS: Record<string, string> = {
|
||||||
|
|
@ -86,13 +76,19 @@ const ICONS: Record<string, string> = {
|
||||||
ups: '⚡',
|
ups: '⚡',
|
||||||
cloud: '☁',
|
cloud: '☁',
|
||||||
olt: '⌸',
|
olt: '⌸',
|
||||||
|
onu: '◇',
|
||||||
|
camera: '◉',
|
||||||
|
printer: '⎙',
|
||||||
|
sensor: '⌁',
|
||||||
|
vm: '▧',
|
||||||
|
container: '▨',
|
||||||
other: '□',
|
other: '□',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEXT_SIZE: Record<string, string> = {
|
const TEXT_SIZE: Record<string, string> = {
|
||||||
sm: 'text-xs',
|
sm: 'text-[11px]',
|
||||||
md: 'text-sm',
|
md: 'text-[13px]',
|
||||||
lg: 'text-base',
|
lg: 'text-[15px]',
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOT_SIZE: Record<string, number> = { sm: 12, md: 18, lg: 26 }
|
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 textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md
|
||||||
const shape = data.shape ?? 'card'
|
const shape = data.shape ?? 'card'
|
||||||
const labelPos = data.labelPos ?? 'right'
|
const labelPos = data.labelPos ?? 'right'
|
||||||
|
const accent = data.color ?? s.raw
|
||||||
|
|
||||||
// Аварія й попередження світяться. Це не прикраса: на схемі з сотні
|
// Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх
|
||||||
// вузлів рамка іншого кольору не помітна периферійним зором, а ореол
|
// тіні стану. Два різні кільця одне на одному читаються як брак, тому
|
||||||
// видно навіть на віддаленому масштабі, коли підписи вже не читаються.
|
// вони складаються в один box-shadow, а не в клас ring-*.
|
||||||
const halo = s.halo
|
const shadow = [
|
||||||
const ring = selected ? 'ring-2 ring-sky-400 ring-offset-2 ring-offset-slate-950' : ''
|
selected ? '0 0 0 2px #38bdf8' : '',
|
||||||
|
s.glow,
|
||||||
|
'0 8px 24px -8px rgba(0,0,0,.7)',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
const handles = (
|
const handles = (
|
||||||
<>
|
<>
|
||||||
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" />
|
<Handle
|
||||||
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-slate-500" />
|
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 =
|
const label =
|
||||||
data.hideLabel || labelPos === 'none' ? null : (
|
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}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
const wrap = (visual: React.ReactNode) => {
|
const wrap = (visual: ReactNode) => {
|
||||||
const dir =
|
const dir =
|
||||||
labelPos === 'bottom'
|
labelPos === 'bottom' ? 'flex-col' : labelPos === 'top' ? 'flex-col-reverse' : 'flex-row'
|
||||||
? 'flex-col'
|
|
||||||
: labelPos === 'top'
|
|
||||||
? 'flex-col-reverse'
|
|
||||||
: 'flex-row'
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-1.5 ${dir}`}>
|
<div className={`group flex items-center gap-2 ${dir}`}>
|
||||||
{handles}
|
{handles}
|
||||||
{visual}
|
{visual}
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -151,56 +159,64 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
||||||
if (shape === 'dot') {
|
if (shape === 'dot') {
|
||||||
const d = data.iconSize ?? DOT_SIZE[size] ?? DOT_SIZE.md
|
const d = data.iconSize ?? DOT_SIZE[size] ?? DOT_SIZE.md
|
||||||
return wrap(
|
return wrap(
|
||||||
<div
|
<span className="relative flex shrink-0" style={{ width: d, height: d }}>
|
||||||
className={`shrink-0 rounded-full ${halo} ${ring}`}
|
{s.pulse && (
|
||||||
style={{
|
<span
|
||||||
width: d,
|
className="absolute inset-0 animate-ping rounded-full opacity-60"
|
||||||
height: d,
|
style={{ background: accent }}
|
||||||
background: data.fill ?? data.color ?? s.raw,
|
/>
|
||||||
border: `${data.borderWidth ?? 2}px solid ${data.color ?? 'rgba(15,23,42,0.9)'}`,
|
)}
|
||||||
}}
|
<span
|
||||||
title={`${data.label} — ${data.status}`}
|
className="relative h-full w-full rounded-full"
|
||||||
/>,
|
style={{
|
||||||
|
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') {
|
if (shape === 'image') {
|
||||||
const d = data.iconSize ?? 40
|
const d = data.iconSize ?? 44
|
||||||
return wrap(
|
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 ? (
|
{data.iconId ? (
|
||||||
<img
|
<img
|
||||||
src={`/api/v1/icons/${data.iconId}`}
|
src={`/api/v1/icons/${data.iconId}`}
|
||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
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">
|
<span className="flex h-full w-full items-center justify-center text-2xl text-slate-500">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Стан — тонка обвідка знизу, а не рамка навколо: рамка
|
||||||
|
зʼїдає саму картинку, заради якої її й ставили. */}
|
||||||
<span
|
<span
|
||||||
className="absolute -bottom-1 left-1/2 h-1 w-2/3 -translate-x-1/2 rounded-full"
|
className="absolute -bottom-1.5 left-1/2 h-[3px] w-3/5 -translate-x-1/2 rounded-full"
|
||||||
style={{ background: s.raw }}
|
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') {
|
if (shape === 'bare') {
|
||||||
return wrap(
|
return wrap(
|
||||||
<span className={`flex shrink-0 items-center gap-1.5 ${ring}`}>
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot} ${halo}`} />
|
<StatusDot color={accent} pulse={s.pulse} />
|
||||||
<span className="text-base leading-none text-slate-400">{icon}</span>
|
<span className="text-lg leading-none text-slate-300">{icon}</span>
|
||||||
|
{selected && <span className="absolute -inset-1 rounded ring-2 ring-sky-400" />}
|
||||||
</span>,
|
</span>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -209,17 +225,18 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
||||||
if (shape === 'pill') {
|
if (shape === 'pill') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-full border-2 px-3 py-1 backdrop-blur
|
className="group flex items-center gap-2 rounded-full px-3.5 py-1.5 backdrop-blur-sm"
|
||||||
${s.ring} ${halo} ${ring}`}
|
|
||||||
style={{
|
style={{
|
||||||
background: data.fill ?? 'rgba(15,23,42,0.95)',
|
background: data.fill ?? 'linear-gradient(180deg,#1e293b,#0f172a)',
|
||||||
...(data.color ? { borderColor: data.color } : {}),
|
border: `${data.borderWidth ?? 1}px solid ${hexA(accent, 0.45)}`,
|
||||||
...(data.borderWidth ? { borderWidth: data.borderWidth } : {}),
|
boxShadow: shadow,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{handles}
|
{handles}
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
<StatusDot color={accent} pulse={s.pulse} />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -227,35 +244,76 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
||||||
// ---- картка (типова) ----------------------------------------------
|
// ---- картка (типова) ----------------------------------------------
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
minWidth: data.width ?? 150,
|
minWidth: data.width ?? 168,
|
||||||
background: data.fill ?? 'rgba(15,23,42,0.95)',
|
background: data.fill ?? 'linear-gradient(180deg,#1e293b 0%,#111c2e 100%)',
|
||||||
...(data.color ? { borderColor: data.color } : {}),
|
border: `${data.borderWidth ?? 1}px solid ${hexA(accent, 0.35)}`,
|
||||||
...(data.borderWidth ? { borderWidth: data.borderWidth } : {}),
|
boxShadow: shadow,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{handles}
|
{handles}
|
||||||
|
|
||||||
<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="absolute inset-y-0 left-0 w-1" style={{ background: accent }} />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
<div className="py-2 pl-3.5 pr-3">
|
||||||
створює враження зламаного моніторингу. */}
|
<div className="flex items-center gap-2">
|
||||||
{!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
|
<span className="text-base leading-none text-slate-400">{icon}</span>
|
||||||
<div className="mt-1 flex gap-3 text-[11px] tabular-nums">
|
<span className={`truncate font-medium tracking-tight text-slate-100 ${textSize}`}>
|
||||||
{data.rtt !== undefined && <span className={s.text}>{data.rtt.toFixed(2)} мс</span>}
|
{data.label}
|
||||||
{data.loss !== undefined && data.loss > 0 && (
|
</span>
|
||||||
<span className="text-red-300">втрати {data.loss.toFixed(0)}%</span>
|
<StatusDot color={accent} pulse={s.pulse} className="ml-auto" />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
||||||
|
створює враження зламаного моніторингу. */}
|
||||||
|
{!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
|
||||||
|
<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-rose-300">втрати {data.loss.toFixed(0)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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})`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,10 +253,32 @@ export function MapCanvas({
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
maxZoom={2.5}
|
maxZoom={2.5}
|
||||||
proOptions={{ hideAttribution: false }}
|
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
|
<MiniMap
|
||||||
pannable
|
pannable
|
||||||
zoomable
|
zoomable
|
||||||
|
|
|
||||||
123
web/src/components/TemplatePicker.tsx
Normal file
123
web/src/components/TemplatePicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,25 @@ export function TrafficEdge({
|
||||||
// каналі означала б рух, якого немає.
|
// каналі означала б рух, якого немає.
|
||||||
const moving = d.animated && d.linkStatus !== 'down' && util > 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge
|
<BaseEdge
|
||||||
|
|
@ -99,20 +118,18 @@ export function TrafficEdge({
|
||||||
</circle>
|
</circle>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{d.showMetrics && (
|
{label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute rounded bg-slate-900/85 px-1.5 py-0.5
|
className="pointer-events-none absolute rounded-md border border-slate-700/60
|
||||||
text-[10px] leading-tight text-slate-300 shadow"
|
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)` }}
|
style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
|
||||||
>
|
>
|
||||||
<div className="whitespace-nowrap">
|
{ports && <div className="whitespace-nowrap font-medium">{ports}</div>}
|
||||||
{d.sourcePort ?? '?'} → {d.targetPort ?? '?'}
|
{rate && (
|
||||||
</div>
|
|
||||||
{d.capacityBps !== undefined && (
|
|
||||||
<div className="whitespace-nowrap tabular-nums" style={{ color }}>
|
<div className="whitespace-nowrap tabular-nums" style={{ color }}>
|
||||||
{util > 0 ? `${util < 0.01 ? '<0.01' : util.toFixed(2)}%` : '—'} з{' '}
|
{rate}
|
||||||
{formatBps(d.capacityBps)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,43 @@ body {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
color: #334155 !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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export function CredentialsPage() {
|
||||||
|
|
||||||
<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">
|
||||||
{creds.map((c) => (
|
{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="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium text-slate-200">{c.name}</div>
|
<div className="truncate text-sm font-medium text-slate-200">{c.name}</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { api } from '../api/client'
|
||||||
import { session } from '../api/session'
|
import { session } from '../api/session'
|
||||||
import { useLiveMap } from '../hooks/useLiveMap'
|
import { useLiveMap } from '../hooks/useLiveMap'
|
||||||
import { MapCanvas } from '../components/MapCanvas'
|
import { MapCanvas } from '../components/MapCanvas'
|
||||||
|
import { CanvasBoundary } from '../components/CanvasBoundary'
|
||||||
import { NodeInspector } from '../components/NodeInspector'
|
import { NodeInspector } from '../components/NodeInspector'
|
||||||
import { MapAddHosts } from '../components/MapAddHosts'
|
import { MapAddHosts } from '../components/MapAddHosts'
|
||||||
import { MapSettings } from '../components/MapSettings'
|
import { MapSettings } from '../components/MapSettings'
|
||||||
|
|
@ -70,9 +71,13 @@ export function MapPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<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
|
<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 ?? ''}
|
value={mapID ?? ''}
|
||||||
onChange={(e) => setMapID(e.target.value || null)}
|
onChange={(e) => setMapID(e.target.value || null)}
|
||||||
>
|
>
|
||||||
|
|
@ -84,6 +89,8 @@ export function MapPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<span className="mx-1 hidden h-5 w-px bg-slate-700/70 sm:block" />
|
||||||
|
|
||||||
{session.can('maps:write') && (
|
{session.can('maps:write') && (
|
||||||
<Button
|
<Button
|
||||||
kind={editing ? 'primary' : 'default'}
|
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">
|
<div className="ml-auto flex items-center gap-2.5 text-xs text-slate-500">
|
||||||
<span className="hidden sm:inline tabular-nums">
|
<span className="hidden items-center gap-1.5 sm:flex">
|
||||||
{counts.up}/{counts.total} на звʼязку
|
<span
|
||||||
{counts.down > 0 && <span className="ml-1 text-red-400">· {counts.down} впало</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>
|
</span>
|
||||||
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
|
<ConnectionBadge state={live.connection} lastEventAt={live.lastEventAt} />
|
||||||
{live.state && (
|
{live.state && (
|
||||||
|
|
@ -180,13 +203,15 @@ export function MapPage() {
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
{live.state ? (
|
{live.state ? (
|
||||||
<MapCanvas
|
<CanvasBoundary>
|
||||||
|
<MapCanvas
|
||||||
state={live.state}
|
state={live.state}
|
||||||
onPatch={live.patch}
|
onPatch={live.patch}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={setSelection}
|
||||||
readOnly={!editing}
|
readOnly={!editing}
|
||||||
/>
|
/>
|
||||||
|
</CanvasBoundary>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
|
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
|
||||||
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
|
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,9 @@ export function TemplatesPage() {
|
||||||
<PageBody className="space-y-4">
|
<PageBody className="space-y-4">
|
||||||
<ErrorNote>{err}</ErrorNote>
|
<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) => (
|
{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="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium text-slate-200">{t.name}</div>
|
<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>
|
<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">
|
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3 text-[11px] text-slate-600">
|
||||||
<span>
|
<span className="shrink-0">
|
||||||
{plural(t.item_count, 'метрика', 'метрики', 'метрик')} ·{' '}
|
{plural(t.item_count, 'метрика', 'метрики', 'метрик')} ·{' '}
|
||||||
{plural(t.device_count, 'хост', 'хости', 'хостів')}
|
{plural(t.device_count, 'хост', 'хости', 'хостів')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="ml-auto flex flex-wrap gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
kind="ghost"
|
kind="ghost"
|
||||||
title="Забрати цей шаблон файлом"
|
title="Забрати цей шаблон файлом"
|
||||||
|
|
@ -148,7 +148,23 @@ export function TemplatesPage() {
|
||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
</Button>
|
</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 && (
|
{canWrite && !t.is_builtin && (
|
||||||
<Button
|
<Button
|
||||||
kind="danger"
|
kind="danger"
|
||||||
|
|
@ -191,7 +207,7 @@ export function TemplatesPage() {
|
||||||
{(editing || creating) && (
|
{(editing || creating) && (
|
||||||
<TemplateForm
|
<TemplateForm
|
||||||
template={editing}
|
template={editing}
|
||||||
readOnly={!canWrite || (editing?.is_builtin ?? false)}
|
readOnly={!canWrite}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
|
|
@ -278,6 +294,14 @@ function TemplateForm({
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Field label="Назва">
|
<Field label="Назва">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue