Вигляд мапи, редаговані вбудовані шаблони, мобільні картки
Мапа: підпис ребра більше не друкує «? → ?» і «— з 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("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))
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
||||
|
|
|
|||
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 { 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, 'метрика', 'метрики', 'метрик'),
|
||||
}))}
|
||||
value={templateIDs}
|
||||
onChange={setTemplateIDs}
|
||||
empty="Шаблонів ще немає"
|
||||
/>
|
||||
</Field>
|
||||
<TemplatePicker
|
||||
templates={templates}
|
||||
value={templateIDs}
|
||||
onChange={setTemplateIDs}
|
||||
/>
|
||||
{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">
|
||||
Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
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)'}`,
|
||||
}}
|
||||
title={`${data.label} — ${data.status}`}
|
||||
/>,
|
||||
<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={{
|
||||
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}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{/* Кольорова смуга стану. Несе стан навіть тоді, коли рамку
|
||||
перефарбували під майданчик: колір рамки — рішення про схему,
|
||||
колір смуги — факт про пристрій. */}
|
||||
<span className="absolute inset-y-0 left-0 w-1" style={{ background: accent }} />
|
||||
|
||||
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
||||
створює враження зламаного моніторингу. */}
|
||||
{!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && (
|
||||
<div className="mt-1 flex gap-3 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>
|
||||
)}
|
||||
<div className="py-2 pl-3.5 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base leading-none text-slate-400">{icon}</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-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>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
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
|
||||
|
|
|
|||
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 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,13 +203,15 @@ export function MapPage() {
|
|||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{live.state ? (
|
||||
<MapCanvas
|
||||
<CanvasBoundary>
|
||||
<MapCanvas
|
||||
state={live.state}
|
||||
onPatch={live.patch}
|
||||
onSelect={setSelected}
|
||||
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">
|
||||
{live.loading ? 'Завантаження полотна…' : 'Мапу не вибрано'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue