diff --git a/HISTORY.md b/HISTORY.md index 6b30987..946a30b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1831,3 +1831,89 @@ gateway «Час відгуку» (line) → дві лінії, вісь 0 Тепер набір віддається назовні лише коли справді змінився — порівняння за склеєним рядком ідентифікаторів. +--- + +## Вигляд мапи + +Схема виглядала як сітка сірих прямокутників на чорному. Причини були +конкретні, і кожна лікується окремо. + +**«? → ?» на кожній лінії.** Так виглядав підпис ребра, коли +автовиявлення ще не зіставило порти. Читалося це як зламаний інтерфейс, +хоча означало лише «поки невідомо». Тепер невідомий порт не друкується +взагалі — порожнеча чесніша, вона нічого не обіцяє. Так само зі +швидкістю: «— з 10.0 Гбіт/с» зникає, поки трафік не виміряний. + +**Вузол став карткою з ієрархією.** Градієнт замість пласкої заливки, +кольорова смуга стану зліва, значок і підпис в одному рядку, крапка +стану праворуч. Смуга несе стан навіть тоді, коли рамку перефарбували +під майданчик: колір рамки — рішення про схему, колір смуги — факт про +пристрій. + +**Пульсація лише за обривом.** Рух там, де все гаразд, навчає не +дивитись на рух узагалі. + +**Порти показуються на наведення.** Вісім завжди видимих кружечків на +кожному вузлі перетворюють схему на россип точок. + +**Фон — радіальна підсвітка й дві сітки.** Дрібна дає відчуття масштабу, +велика — орієнтацію; одна або рябить, або не читається, залежно від +кроку, а крок задає людина. + +**Специфічність довелося підняти.** Стилі React Flow приходять з .tsx і +потрапляють у документ пізніше за index.css, тож одного класу було +замало — правила стали `.react-flow.netpulse-canvas`. + +--- + +## Вбудовані шаблони редагуються + +Раніше відповіддю було «вбудований не редагується — зробіть копію». Це +межа нашої моделі даних, а не відповідь на питання людини, яка хоче +підправити OID під своє залізо. + +Тепер правка вбудованого шаблону створює **власну копію тенанта з тим +самим ключем**, а список показує її замість оригіналу. Оригінал лишається +недоторканим для решти тенантів. Форма попереджає про це прямим текстом +до збереження. + +Плюс окрема кнопка «Копія» — для випадку, коли потрібні обидва варіанти: +«як було» і «як хочу». Ключ отримує суфікс, бо копія з тим самим ключем +сховала б оригінал. + +**Ключі елементів і графіків виводяться на сервері.** Прямий виклик API +без `key` давав 500 від перевірки домену `core.slug`. Ключ потрібен базі, +але жодного рішення не несе — вимагати його від клієнта означало +відповідати помилкою там, де можна просто зберегти. + +--- + +## Вибір шаблонів картками + +Пошуковий рядок із чипами добре працює для груп — там важлива лише +назва. Для шаблонів важливо ще й що всередині: скільки метрик, для якого +виробника, що воно робить. Список чипів цього не показує, тож людина +відмічала навмання й ішла перевіряти на іншу сторінку. + +Тепер картка з назвою, описом і лічильником метрик. Обрані спливають +угору — інакше після вибору картка лишається десь у середині списку, і +незрозуміло, чи вибір зарахувався. + +--- + +## Мобільна сітка карток + +На телефоні картка вилазила за екран і обрізалась. Причина не в +брейкпойнтах: елемент грід-сітки типово має `min-width: auto` й не +стискається нижче ширини вмісту. `min-w-0` на картці — і 410 px стали +351 px рівно за шириною сітки. + +--- + +## Межа помилок навколо полотна + +Цикл рендеру в React Flow одного разу вже поклав увесь застосунок: +мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку. +Причину виправлено, але наслідок треба було обмежити — тепер полотно +падає саме, а навігація й решта інтерфейсу лишаються живими. + diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 1fa4ddd..32f4bf7 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -128,6 +128,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate)) mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate)) mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate)) + mux.Handle("POST /api/v1/templates/{id}/clone", s.authenticated(s.handleCloneTemplate)) mux.Handle("PUT /api/v1/templates/{id}/graphs", s.authenticated(s.handleSaveTemplateGraphs)) mux.Handle("GET /api/v1/devices/{id}/graphs", s.authenticated(s.handleDeviceGraphs)) mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates)) diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go index f52e6ca..75f3a87 100644 --- a/server/internal/httpapi/templates.go +++ b/server/internal/httpapi/templates.go @@ -121,8 +121,22 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P if i.Enabled != nil { enabled = *i.Enabled } + // Ключ елемента виводимо самі, якщо його не прислали: він + // потрібен базі, але жодного рішення не несе, і вимагати його + // від клієнта означало б 500 замість збереження. + key := strings.TrimSpace(i.Key) + if key == "" { + key = slugify(metric) + } + if key == "" { + key = slugify(strings.TrimSpace(i.Name)) + } + if key == "" { + key = fmt.Sprintf("item-%d", len(t.Items)+1) + } + t.Items = append(t.Items, store.TemplateItem{ - Key: strings.TrimSpace(i.Key), + Key: key, Name: strings.TrimSpace(i.Name), CheckType: checkType, OID: oid, @@ -302,6 +316,12 @@ func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request writeError(w, http.StatusBadRequest, "bad_request", "графік без назви") return } + if g.Key == "" { + g.Key = slugify(g.Name) + } + if g.Key == "" { + g.Key = fmt.Sprintf("graph-%d", i+1) + } if g.Kind == "" { g.Kind = "line" } @@ -347,3 +367,15 @@ func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *P writeJSON(w, http.StatusOK, map[string]any{"graphs": list}) } +func (s *Server) handleCloneTemplate(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + id, err := s.store.CloneTemplate(r.Context(), p.TenantID, r.PathValue("id")) + if err != nil { + s.writeStoreError(w, "клонування шаблону", err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"id": id}) +} + diff --git a/server/internal/store/templates.go b/server/internal/store/templates.go index 84e892f..7820dd4 100644 --- a/server/internal/store/templates.go +++ b/server/internal/store/templates.go @@ -67,14 +67,24 @@ func groupsByOID(checkType string) bool { func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) { var out []Template err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Власна копія ховає вбудований оригінал. + // + // Так виглядає «редагування вбудованого»: правка не змінює + // спільний для всіх тенантів рядок, а створює власний із тим + // самим ключем. Показувати обидва означало б питати людину, який + // із двох однакових шаблонів вона щойно змінила. rows, err := tx.Query(ctx, ` SELECT t.id::text, t.key, t.name, COALESCE(t.description,''), COALESCE(t.vendor,''), t.is_builtin, (SELECT count(*) FROM tpl.items i WHERE i.template_id = t.id), (SELECT count(*) FROM tpl.device_templates dt WHERE dt.template_id = t.id) FROM tpl.templates t + WHERE t.tenant_id IS NOT NULL + OR NOT EXISTS ( + SELECT 1 FROM tpl.templates o + WHERE o.tenant_id = $1 AND o.key = t.key) ORDER BY t.is_builtin DESC, t.name - `) + `, tenantID) if err != nil { return err } @@ -171,17 +181,30 @@ func (s *Store) SaveTemplate(ctx context.Context, tenantID string, t Template) ( err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { if t.ID != "" { var builtin bool + var owner *string if err := tx.QueryRow(ctx, ` - SELECT is_builtin FROM tpl.templates WHERE id = $1 - `, t.ID).Scan(&builtin); err != nil { + SELECT is_builtin, tenant_id::text FROM tpl.templates WHERE id = $1 + `, t.ID).Scan(&builtin, &owner); err != nil { if isNoRows(err) { return ErrNotFound } return err } - if builtin { - return ErrBuiltin + + // Правка вбудованого не змінює спільний рядок, а створює + // власну копію тенанта з тим самим ключем. Оригінал лишається + // недоторканим для решти тенантів, а список показує копію + // замість нього. + // + // Це чесніше за «вбудований не редагується»: людина хоче + // підправити OID під своє залізо, а не дізнатися про межі + // нашої моделі даних. + if owner == nil { + t.ID = "" } + } + + if t.ID != "" { if err := tx.QueryRow(ctx, ` UPDATE tpl.templates SET key = $2, name = $3, description = NULLIF($4,''), @@ -708,15 +731,18 @@ func templateGraphs(ctx context.Context, tx pgx.Tx, templateID string) ([]Templa // SaveTemplateGraphs замінює графіки шаблону цілком. func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { - var builtin bool + var owner *string if err := tx.QueryRow(ctx, - `SELECT is_builtin FROM tpl.templates WHERE id = $1`, templateID).Scan(&builtin); err != nil { + `SELECT tenant_id::text FROM tpl.templates WHERE id = $1`, templateID).Scan(&owner); err != nil { if isNoRows(err) { return ErrNotFound } return err } - if builtin { + // Графіки вбудованого шаблону писати нікуди: сам шаблон при + // збереженні вже перетворився на власну копію, і клієнт присилає + // її id. Якщо все ж прийшов вбудований — це помилка виклику. + if owner == nil { return ErrBuiltin } if _, err := tx.Exec(ctx, `DELETE FROM tpl.graphs WHERE template_id = $1`, templateID); err != nil { @@ -824,3 +850,68 @@ func (s *Store) DeviceGraphs(ctx context.Context, tenantID, deviceID string) ([] }) return out, err } + +// CloneTemplate робить власну копію шаблону. +// +// Ключ отримує суфікс: копія з тим самим ключем ховала б оригінал, а +// клонують саме тоді, коли потрібні обидва — «як було» і «як хочу». +func (s *Store) CloneTemplate(ctx context.Context, tenantID, id string) (string, error) { + var newID string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var t Template + if err := tx.QueryRow(ctx, ` + SELECT key, name, COALESCE(description,''), COALESCE(vendor,'') + FROM tpl.templates WHERE id = $1 + `, id).Scan(&t.Key, &t.Name, &t.Description, &t.Vendor); err != nil { + if isNoRows(err) { + return ErrNotFound + } + return err + } + + // Вільний ключ шукаємо перебором, а не випадковим суфіксом: + // «snmp-mikrotik-2» читається, «snmp-mikrotik-a7f3» — ні. + base := t.Key + key := base + "-copy" + for i := 2; ; i++ { + var taken bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM tpl.templates + WHERE key = $1 AND tenant_id = $2) + `, key, tenantID).Scan(&taken); err != nil { + return err + } + if !taken { + break + } + key = fmt.Sprintf("%s-copy-%d", base, i) + } + + if err := tx.QueryRow(ctx, ` + INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin) + VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false) + RETURNING id::text + `, tenantID, key, t.Name+" (копія)", t.Description, t.Vendor).Scan(&newID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO tpl.items + (template_id, key, name, check_type, oid, metric_key, unit, + scale, interval_sec, enabled, params) + SELECT $1, key, name, check_type, oid, metric_key, unit, + scale, interval_sec, enabled, params + FROM tpl.items WHERE template_id = $2 + `, newID, id); err != nil { + return err + } + + _, err := tx.Exec(ctx, ` + INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position) + SELECT $1, key, name, kind, metric_keys, options, position + FROM tpl.graphs WHERE template_id = $2 + `, newID, id) + return err + }) + return newID, err +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 812060c..d667783 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -546,6 +546,9 @@ export const api = { body: JSON.stringify(t), }), + cloneTemplate: (id: string) => + request<{ id: string }>(`/api/v1/templates/${id}/clone`, { method: 'POST' }), + deleteTemplate: (id: string) => request(`/api/v1/templates/${id}`, { method: 'DELETE' }), diff --git a/web/src/components/CanvasBoundary.tsx b/web/src/components/CanvasBoundary.tsx new file mode 100644 index 0000000..b56b0f7 --- /dev/null +++ b/web/src/components/CanvasBoundary.tsx @@ -0,0 +1,53 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { Button } from './ui' + +/** + * Межа помилок навколо полотна мапи. + * + * Причина конкретна: збій усередині React Flow одного разу вже поклав + * увесь застосунок — обробник виділення зациклив оновлення, цикл зʼїв + * головний потік, і мертвими стали ВСІ сторінки, не лише мапа. Людині + * лишалося перезавантажувати вкладку. + * + * Межа не лікує причину — вона обмежує наслідки: полотно падає саме, а + * навігація й решта інтерфейсу лишаються живими. + * + * Класовий компонент, бо іншого способу зловити помилку рендеру в React + * немає: хуки цього не вміють. + */ +export class CanvasBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state: { error: Error | null } = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // У консоль, а не в інтерфейс: стек потрібен тому, хто лагодить, а + // не тому, хто дивиться на схему мережі. + console.error('полотно мапи впало', error, info.componentStack) + } + + render() { + if (!this.state.error) return this.props.children + + return ( +
+

Полотно не намалювалося

+

+ Решта інтерфейсу працює — можна перейти на іншу сторінку. Подробиці помилки в консолі + браузера. +

+ + {this.state.error.message} + + +
+ ) + } +} diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx index 750d8f8..a619f35 100644 --- a/web/src/components/DeviceForm.tsx +++ b/web/src/components/DeviceForm.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' import { api } from '../api/client' -import { Button, Confirm, ErrorNote, Field, Modal, Toggle, inputClass, plural } from './ui' +import { Button, Confirm, ErrorNote, Field, Modal, Toggle, inputClass } from './ui' import type { ConfirmRequest } from './ui' import { Picker } from './Picker' +import { TemplatePicker } from './TemplatePicker' import { ChecksEditor } from './ChecksEditor' import { BackupPolicyForm } from './BackupPolicyForm' import type { CheckInput, DeviceInput } from '../api/client' @@ -259,18 +260,11 @@ export function DeviceForm({ Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як опитувати хост — окремо міняти інтервали тут не треба.

- - ({ - id: t.id, - label: t.name, - hint: plural(t.item_count, 'метрика', 'метрики', 'метрик'), - }))} - value={templateIDs} - onChange={setTemplateIDs} - empty="Шаблонів ще немає" - /> - + {templateIDs.length === 0 && checks.length === 0 && (

Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший diff --git a/web/src/components/DeviceNode.tsx b/web/src/components/DeviceNode.tsx index 9bf06e4..1424d1f 100644 --- a/web/src/components/DeviceNode.tsx +++ b/web/src/components/DeviceNode.tsx @@ -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 -// Кольори статусів. Червоний лишається лише за обривом — якщо ним -// підсвічувати ще й «невідомо», оператор перестане на нього реагувати. +/** + * Палітра станів. + * + * Червоний лишається лише за обривом: якщо ним підсвічувати ще й + * «невідомо», оператор перестане на нього реагувати. + * + * `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 = { @@ -86,13 +76,19 @@ const ICONS: Record = { ups: '⚡', cloud: '☁', olt: '⌸', + onu: '◇', + camera: '◉', + printer: '⎙', + sensor: '⌁', + vm: '▧', + container: '▨', other: '□', } const TEXT_SIZE: Record = { - sm: 'text-xs', - md: 'text-sm', - lg: 'text-base', + sm: 'text-[11px]', + md: 'text-[13px]', + lg: 'text-[15px]', } const DOT_SIZE: Record = { sm: 12, md: 18, lg: 26 } @@ -104,39 +100,51 @@ export function DeviceNode({ data, selected }: NodeProps) { 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 = ( <> - - + + ) - // Підпис малюється однаково для крапки, картинки й «без рамки»: - // міняється лише те, куди його поставити. Ті самі три форми з трьома - // копіями розмітки розійшлися б із першою ж правкою. const label = data.hideLabel || labelPos === 'none' ? null : ( - + {data.label} ) - 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 ( -

+
{handles} {visual} {label} @@ -151,56 +159,64 @@ export function DeviceNode({ data, selected }: NodeProps) { if (shape === 'dot') { const d = data.iconSize ?? DOT_SIZE[size] ?? DOT_SIZE.md return wrap( -
, + + {s.pulse && ( + + )} + + , ) } // ---- картинка ----------------------------------------------------- - // - // Стан показує тонка кольорова обвідка знизу, а не рамка навколо: - // рамка з'їдає саму картинку, заради якої її й ставили. if (shape === 'image') { - const d = data.iconSize ?? 40 + const d = data.iconSize ?? 44 return wrap( -
+ {data.iconId ? ( ) : ( {icon} )} + {/* Стан — тонка обвідка знизу, а не рамка навколо: рамка + зʼїдає саму картинку, заради якої її й ставили. */} -
, + {selected && ( + + )} + , ) } // ---- без рамки ---------------------------------------------------- - // - // Тільки крапка стану й значок. Для схем, де рамка навколо кожного - // вузла перетворює полотно на сітку прямокутників. if (shape === 'bare') { return wrap( - - - {icon} + + + {icon} + {selected && } , ) } @@ -209,17 +225,18 @@ export function DeviceNode({ data, selected }: NodeProps) { if (shape === 'pill') { return (
{handles} - - {data.label} + + + {data.label} +
) } @@ -227,35 +244,76 @@ export function DeviceNode({ data, selected }: NodeProps) { // ---- картка (типова) ---------------------------------------------- return (
{handles} -
- - {icon} - {data.label} -
+ {/* Кольорова смуга стану. Несе стан навіть тоді, коли рамку + перефарбували під майданчик: колір рамки — рішення про схему, + колір смуги — факт про пристрій. */} + - {/* Цифри показуємо лише коли вони є: порожній рядок «— мс» - створює враження зламаного моніторингу. */} - {!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && ( -
- {data.rtt !== undefined && {data.rtt.toFixed(2)} мс} - {data.loss !== undefined && data.loss > 0 && ( - втрати {data.loss.toFixed(0)}% - )} +
+
+ {icon} + + {data.label} + +
- )} + + {/* Цифри показуємо лише коли вони є: порожній рядок «— мс» + створює враження зламаного моніторингу. */} + {!data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) && ( +
+ {data.rtt !== undefined && {data.rtt.toFixed(2)} мс} + {data.loss !== undefined && data.loss > 0 && ( + втрати {data.loss.toFixed(0)}% + )} +
+ )} +
) } + +function StatusDot({ + color, + pulse, + className = '', +}: { + color: string + pulse: boolean + className?: string +}) { + return ( + + {/* Пульсація лише за обривом: рух там, де все гаразд, — це шум, + який навчає не дивитись на рух узагалі. */} + {pulse && ( + + )} + + + ) +} + +/** #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})` +} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index 25bc4a6..87ce08c 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -253,10 +253,32 @@ export function MapCanvas({ minZoom={0.1} maxZoom={2.5} proOptions={{ hideAttribution: false }} - className="bg-slate-950" + className="netpulse-canvas" > - - + {/* Два шари сітки: дрібна для відчуття масштабу й велика для + орієнтації. Одна сітка або рябить, або не читається — залежно + від кроку, а крок задає людина. */} + + + 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 ( +
+
+ setQuery(e.target.value)} + /> + + обрано {value.length} з {templates.length} + +
+ + {list.length === 0 ? ( +

+ {templates.length === 0 ? 'Шаблонів ще немає' : 'Нічого не знайдено'} +

+ ) : ( +
    + {list.map((t) => { + const on = value.includes(t.id) + return ( +
  • + +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/web/src/components/TrafficEdge.tsx b/web/src/components/TrafficEdge.tsx index 6ff3837..06eb683 100644 --- a/web/src/components/TrafficEdge.tsx +++ b/web/src/components/TrafficEdge.tsx @@ -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 ( <> )} - {d.showMetrics && ( + {label && (
-
- {d.sourcePort ?? '?'} → {d.targetPort ?? '?'} -
- {d.capacityBps !== undefined && ( + {ports &&
{ports}
} + {rate && (
- {util > 0 ? `${util < 0.01 ? '<0.01' : util.toFixed(2)}%` : '—'} з{' '} - {formatBps(d.capacityBps)} + {rate}
)}
diff --git a/web/src/index.css b/web/src/index.css index 97600d4..b1be2b0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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); +} diff --git a/web/src/pages/CredentialsPage.tsx b/web/src/pages/CredentialsPage.tsx index 6458dd7..28438a3 100644 --- a/web/src/pages/CredentialsPage.tsx +++ b/web/src/pages/CredentialsPage.tsx @@ -82,7 +82,7 @@ export function CredentialsPage() {
{creds.map((c) => ( - +
{c.name}
diff --git a/web/src/pages/MapPage.tsx b/web/src/pages/MapPage.tsx index 097733e..a4f5b36 100644 --- a/web/src/pages/MapPage.tsx +++ b/web/src/pages/MapPage.tsx @@ -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 (
-
+
+ + {session.can('maps:write') && ( - + {canWrite && ( + + )} + {canWrite && !t.is_builtin && (