Мапи: режим редагування, доступи груп, іконки, форми вузлів

Редагування вмикається кнопкою. Полотно, яке рухається від випадкового
кліку, — найшвидший спосіб зіпсувати схему, на яку дивиться черговий.

Доступи за групами користувачів (topo.map_permissions): read, write або
deny, deny перекриває решту. Мапа без жодного запису доступна всім, хто
має maps:read — інакше кожна нова мапа була б невидимою до окремого
налаштування. Перевірено: обмежена мапа зникає зі списку, прямий GET
дає 403, PATCH у read-only мапу теж 403.

Виділення рамкою лівою кнопкою (панорамує середня або права), Ctrl і
Shift додають до виділення, груповий драг пише один патч замість
десяти ревізій. Вигляд застосовується до всіх виділених одразу.

Додавання хостів на мапу з пошуком і фільтром за групою; ті, що вже на
полотні, показані сірим. Нові розкладаються сіткою праворуч від
наявних.

Власні іконки (topo.icons): SVG/PNG/JPEG/WebP до 256 КБ у БД, а не у
файловій системі — інакше інсталяція з кількох процесів вимагала б
спільного тому, а бекап продукту перестав би бути бекапом бази. SVG
віддається з CSP і nosniff: інакше завантажена іконка стає збереженою
XSS проти власного ж інтерфейсу.

Форми вузла: картка, пігулка, крапка, картинка, без рамки. Підпис
праворуч, знизу, зверху або відсутній. У форми «картинка» стан показує
обвідка знизу, а не рамка навколо — рамка зʼїдає саму картинку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 18:44:09 +03:00
parent 7fa8adc506
commit 5aba75ccc3
14 changed files with 1305 additions and 45 deletions

View file

@ -0,0 +1,93 @@
-- =====================================================================
-- NetPulse :: 0021_map_access_icons.sql
-- Доступ до мап за групами користувачів і власні іконки вузлів.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Хто бачить мапу
-- ---------------------------------------------------------------------
-- Та сама модель, що для хостів (core.group_permissions): роль каже, що
-- людина взагалі вміє робити з мапами, група — з якими саме.
--
-- Мапа без жодного запису доступна всім, хто має maps:read. Це свідомо
-- інакше, ніж у Zabbix: у типовій інсталяції мапа одна на всіх, і
-- вимагати налаштувати доступ до неї означало б зробити типовий випадок
-- найдовшим.
CREATE TABLE topo.map_permissions (
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
level core.access_level NOT NULL DEFAULT 'read',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (map_id, group_id)
);
CREATE INDEX map_permissions_group_idx ON topo.map_permissions (group_id);
ALTER TABLE topo.map_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE topo.map_permissions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON topo.map_permissions
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
GRANT SELECT, INSERT, UPDATE, DELETE ON topo.map_permissions
TO netpulse_app, netpulse_worker;
-- Рівень доступу людини до конкретної мапи.
--
-- Правила ті самі, що для хостів: deny перекриває все, потім write,
-- потім read. Відсутність записів для мапи означає «доступна всім» —
-- інакше кожна нова мапа була б невидимою до окремого налаштування.
CREATE OR REPLACE FUNCTION topo.map_access_level(p_user uuid, p_map uuid)
RETURNS core.access_level
LANGUAGE sql STABLE AS $$
SELECT CASE
WHEN NOT EXISTS (SELECT 1 FROM topo.map_permissions WHERE map_id = p_map)
THEN 'write'::core.access_level
WHEN EXISTS (
SELECT 1 FROM topo.map_permissions mp
JOIN core.user_group_members gm ON gm.group_id = mp.group_id
WHERE mp.map_id = p_map AND gm.user_id = p_user AND mp.level = 'deny'
) THEN 'deny'::core.access_level
WHEN EXISTS (
SELECT 1 FROM topo.map_permissions mp
JOIN core.user_group_members gm ON gm.group_id = mp.group_id
WHERE mp.map_id = p_map AND gm.user_id = p_user AND mp.level = 'write'
) THEN 'write'::core.access_level
WHEN EXISTS (
SELECT 1 FROM topo.map_permissions mp
JOIN core.user_group_members gm ON gm.group_id = mp.group_id
WHERE mp.map_id = p_map AND gm.user_id = p_user AND mp.level = 'read'
) THEN 'read'::core.access_level
ELSE 'deny'::core.access_level
END
$$;
-- ---------------------------------------------------------------------
-- Власні іконки
-- ---------------------------------------------------------------------
-- Картинка зберігається в БД, а не у файловій системі: інсталяція з
-- кількох процесів інакше вимагала б спільного тому, а бекап продукту
-- перестав би бути бекапом бази.
--
-- SVG і PNG до 256 КБ. Більше на мапі не потрібно: іконка вузла — це
-- 3264 пікселі, і мегабайтний PNG там означає лише повільне полотно.
CREATE TABLE topo.icons (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
mime text NOT NULL CHECK (mime IN ('image/svg+xml','image/png','image/jpeg','image/webp')),
bytes bytea NOT NULL CHECK (octet_length(bytes) <= 262144),
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
ALTER TABLE topo.icons ENABLE ROW LEVEL SECURITY;
ALTER TABLE topo.icons FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON topo.icons
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
GRANT SELECT, INSERT, UPDATE, DELETE ON topo.icons TO netpulse_app, netpulse_worker;

View file

@ -0,0 +1,176 @@
package httpapi
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
)
// maxIconBytes — стеля розміру іконки.
//
// Іконка вузла — це 3264 пікселі на екрані. Мегабайтний PNG там не дає
// нічого, крім повільного полотна на мапі з сотнею вузлів.
const maxIconBytes = 256 * 1024
var validIconMimes = map[string]bool{
"image/svg+xml": true, "image/png": true,
"image/jpeg": true, "image/webp": true,
}
func (s *Server) handleListMapPermissions(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "maps:read") {
return
}
list, err := s.store.ListMapPermissions(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "доступи до мапи", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": list})
}
func (s *Server) handleSetMapPermissions(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "maps:write") {
return
}
var in struct {
Permissions []store.MapPermission `json:"permissions"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for _, perm := range in.Permissions {
if perm.Level != "read" && perm.Level != "write" && perm.Level != "deny" {
writeError(w, http.StatusBadRequest, "bad_level",
"рівень доступу: read, write або deny")
return
}
}
if err := s.store.SetMapPermissions(r.Context(), p.TenantID, r.PathValue("id"), in.Permissions); err != nil {
s.writeStoreError(w, "доступи до мапи", err)
return
}
list, err := s.store.ListMapPermissions(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "доступи до мапи", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": list})
}
// ---------------------------------------------------------------------
// Іконки
// ---------------------------------------------------------------------
func (s *Server) handleListIcons(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "maps:read") {
return
}
list, err := s.store.ListIcons(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "іконки", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"icons": list})
}
// handleGetIcon віддає саму картинку.
//
// Окремим запитом, а не в JSON поруч зі списком: браузер кешує
// зображення за URL, і мапа з сотнею вузлів на десяти іконках зробить
// десять запитів один раз, а не потягне base64 при кожному оновленні
// списку.
func (s *Server) handleGetIcon(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "maps:read") {
return
}
data, mime, err := s.store.IconBytes(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "іконка", err)
return
}
w.Header().Set("Content-Type", mime)
// Вміст іконки незмінний: id новий на кожне завантаження.
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
// SVG виконує скрипти, якщо його відкрити напряму. Заголовок робить
// із нього просто картинку — інакше завантажена іконка стає
// збереженою XSS проти власного ж інтерфейсу.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(data)
}
func (s *Server) handleCreateIcon(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "maps:write") {
return
}
var in struct {
Name string `json:"name"`
Mime string `json:"mime"`
// Картинка приходить base64: JSON бінарні дані не носить, а
// заводити multipart заради одного поля означало б два різні
// способи говорити з тим самим API.
Data string `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва іконки")
return
}
if !validIconMimes[in.Mime] {
writeError(w, http.StatusBadRequest, "bad_mime", "підтримуються SVG, PNG, JPEG і WebP")
return
}
// Data URI приходить як «data:image/png;base64,….» — відрізаємо
// префікс, бо саме в такому вигляді його віддає FileReader.
if i := strings.Index(in.Data, ","); i >= 0 && strings.HasPrefix(in.Data, "data:") {
in.Data = in.Data[i+1:]
}
data, err := base64.StdEncoding.DecodeString(in.Data)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_data", "картинка не читається як base64")
return
}
if len(data) == 0 {
writeError(w, http.StatusBadRequest, "bad_data", "порожня картинка")
return
}
if len(data) > maxIconBytes {
writeError(w, http.StatusRequestEntityTooLarge, "too_big",
"іконка більша за 256 КБ — на мапі це лише повільне полотно")
return
}
id, err := s.store.CreateIcon(r.Context(), p.TenantID, p.UserID, in.Name, in.Mime, data)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "іконка з такою назвою вже є")
return
}
s.writeStoreError(w, "збереження іконки", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleDeleteIcon(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "maps:write") {
return
}
if err := s.store.DeleteIcon(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "видалення іконки", err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -61,6 +61,15 @@ func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, p *Princ
}
mapID := r.PathValue("id")
// Право «редагувати мапи» і право «редагувати ЦЮ мапу» — різні речі:
// друге дають групи доступу.
if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil &&
level != "write" {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цієї мапи")
return
}
res, err := s.store.ApplyMapPatch(r.Context(), p.TenantID, mapID, "", &patch)
if err != nil {
s.writeStoreError(w, "збереження мапи", err)

View file

@ -93,6 +93,12 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap))
mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap))
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
mux.Handle("GET /api/v1/maps/{id}/permissions", s.authenticated(s.handleListMapPermissions))
mux.Handle("PUT /api/v1/maps/{id}/permissions", s.authenticated(s.handleSetMapPermissions))
mux.Handle("GET /api/v1/icons", s.authenticated(s.handleListIcons))
mux.Handle("POST /api/v1/icons", s.authenticated(s.handleCreateIcon))
mux.Handle("GET /api/v1/icons/{id}", s.authenticated(s.handleGetIcon))
mux.Handle("DELETE /api/v1/icons/{id}", s.authenticated(s.handleDeleteIcon))
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice))
mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice))
@ -266,7 +272,7 @@ func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, p *Princ
return
}
maps, err := s.store.ListMaps(r.Context(), p.TenantID)
maps, err := s.store.ListMaps(r.Context(), p.TenantID, p.UserID)
if err != nil {
s.log.Error("перелік мап", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
@ -284,6 +290,14 @@ func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, p *Princip
return
}
// Групи доступу можуть закрити конкретну мапу навіть тому, хто має
// maps:read: право читати мапи взагалі й право читати цю — різні речі.
if level, aerr := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, r.PathValue("id")); aerr == nil &&
level == "deny" {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи")
return
}
state, err := s.store.GetMapState(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "мапу не знайдено")

View file

@ -0,0 +1,180 @@
package store
import (
"context"
"github.com/jackc/pgx/v5"
)
// MapPermission — рівень доступу групи до мапи.
type MapPermission struct {
GroupID string `json:"group_id"`
GroupName string `json:"group_name,omitempty"`
Level string `json:"level"`
}
// MapAccess каже, що людина може робити з мапою.
//
// Порожній набір дозволів на мапі означає «доступна всім, хто має
// maps:read»: у типовій інсталяції мапа одна на всіх, і вимагати
// налаштувати доступ до неї означало б зробити типовий випадок
// найдовшим.
func (s *Store) MapAccess(ctx context.Context, tenantID, userID, mapID string) (string, error) {
var level string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`SELECT topo.map_access_level($1, $2)::text`, userID, mapID).Scan(&level)
})
return level, err
}
// ListMapPermissions — хто має доступ до мапи.
func (s *Store) ListMapPermissions(ctx context.Context, tenantID, mapID string) ([]MapPermission, error) {
out := []MapPermission{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT mp.group_id::text, g.name, mp.level::text
FROM topo.map_permissions mp
JOIN core.user_groups g ON g.id = mp.group_id
WHERE mp.map_id = $1
ORDER BY g.name
`, mapID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var p MapPermission
if err := rows.Scan(&p.GroupID, &p.GroupName, &p.Level); err != nil {
return err
}
out = append(out, p)
}
return rows.Err()
})
return out, err
}
// SetMapPermissions замінює набір доступів мапи цілком.
//
// Порожній список повертає мапу в стан «доступна всім». Це видно у
// формі прямим текстом — мовчазне перетворення обмеженої мапи на
// загальнодоступну було б найгіршим способом втратити межі.
func (s *Store) SetMapPermissions(ctx context.Context, tenantID, mapID string, perms []MapPermission) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM topo.maps
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, mapID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
if _, err := tx.Exec(ctx,
`DELETE FROM topo.map_permissions WHERE map_id = $1`, mapID); err != nil {
return err
}
for _, p := range perms {
if p.Level == "" {
p.Level = "read"
}
if _, err := tx.Exec(ctx, `
INSERT INTO topo.map_permissions (map_id, group_id, tenant_id, level)
VALUES ($1, $2, $3, $4::core.access_level)
ON CONFLICT (map_id, group_id) DO UPDATE SET level = EXCLUDED.level
`, mapID, p.GroupID, tenantID, p.Level); err != nil {
return err
}
}
return nil
})
}
// ---------------------------------------------------------------------
// Іконки
// ---------------------------------------------------------------------
// Icon — власна картинка для вузлів мапи.
type Icon struct {
ID string `json:"id"`
Name string `json:"name"`
Mime string `json:"mime"`
Size int `json:"size_bytes"`
}
// ListIcons — довідник іконок без самих байтів.
func (s *Store) ListIcons(ctx context.Context, tenantID string) ([]Icon, error) {
out := []Icon{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, name, mime, octet_length(bytes)
FROM topo.icons WHERE tenant_id = $1 ORDER BY name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var i Icon
if err := rows.Scan(&i.ID, &i.Name, &i.Mime, &i.Size); err != nil {
return err
}
out = append(out, i)
}
return rows.Err()
})
return out, err
}
// IconBytes віддає саму картинку.
func (s *Store) IconBytes(ctx context.Context, tenantID, id string) ([]byte, string, error) {
var b []byte
var mime string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT bytes, mime FROM topo.icons WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&b, &mime)
})
if err != nil {
if isNoRows(err) {
return nil, "", ErrNotFound
}
return nil, "", err
}
return b, mime, nil
}
// CreateIcon зберігає картинку.
func (s *Store) CreateIcon(ctx context.Context, tenantID, userID, name, mime string, data []byte) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO topo.icons (tenant_id, name, mime, bytes, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id::text
`, tenantID, name, mime, data, nullUUID(userID)).Scan(&id)
})
return id, err
}
// DeleteIcon прибирає картинку.
//
// Вузли, що на неї посилались, лишаються — просто повертаються до
// значка за типом. Каскадом чистити style у JSON довелося б обходом
// усіх мап, а користі з цього рівно стільки ж.
func (s *Store) DeleteIcon(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`DELETE FROM topo.icons WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}

View file

@ -26,6 +26,10 @@ type MapSummary struct {
EdgeCount int `json:"edge_count"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updated_at"`
// Що людина може робити саме з цією мапою: read або write.
// Рахується з груп доступу; мапа без жодного запису — write для всіх,
// хто взагалі має maps:write.
Access string `json:"access"`
}
type MapState struct {
@ -110,7 +114,13 @@ type MapEdge struct {
var ErrNotFound = errors.New("не знайдено")
// ListMaps віддає перелік мап тенанта з лічильниками.
func (s *Store) ListMaps(ctx context.Context, tenantID string) ([]MapSummary, error) {
// ListMaps віддає лише ті мапи, які людині видно, і каже, що з ними
// можна робити.
//
// Фільтр у самому запиті, а не після вибірки: відсіювати вже прочитане
// означало б тягнути з БД чужі рядки й покладатися на те, що жоден із
// них не проскочить у відповідь.
func (s *Store) ListMaps(ctx context.Context, tenantID, userID string) ([]MapSummary, error) {
var out []MapSummary
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
@ -118,11 +128,13 @@ func (s *Store) ListMaps(ctx context.Context, tenantID string) ([]MapSummary, er
SELECT m.id::text, m.name, m.slug, m.kind::text,
COALESCE(m.site_id::text, ''), m.is_default, m.revision, m.updated_at,
(SELECT count(*) FROM topo.map_nodes n WHERE n.map_id = m.id),
(SELECT count(*) FROM topo.map_edges e WHERE e.map_id = m.id)
(SELECT count(*) FROM topo.map_edges e WHERE e.map_id = m.id),
topo.map_access_level($2, m.id)::text
FROM topo.maps m
WHERE m.tenant_id = $1 AND m.deleted_at IS NULL
AND topo.map_access_level($2, m.id) <> 'deny'
ORDER BY m.is_default DESC, m.name
`, tenantID)
`, tenantID, userID)
if err != nil {
return err
}
@ -131,7 +143,8 @@ func (s *Store) ListMaps(ctx context.Context, tenantID string) ([]MapSummary, er
for rows.Next() {
var m MapSummary
if err := rows.Scan(&m.ID, &m.Name, &m.Slug, &m.Kind, &m.SiteID,
&m.IsDefault, &m.Revision, &m.UpdatedAt, &m.NodeCount, &m.EdgeCount); err != nil {
&m.IsDefault, &m.Revision, &m.UpdatedAt, &m.NodeCount, &m.EdgeCount,
&m.Access); err != nil {
return err
}
out = append(out, m)

View file

@ -28,6 +28,8 @@ import type {
UndoResult,
BackupDefaults,
Dashboard,
Icon,
MapPermission,
MetricResult,
SeriesInfo,
Template,
@ -609,4 +611,24 @@ export const api = {
setDefaultDashboard: (id: string) =>
request<void>(`/api/v1/dashboards/${id}/default`, { method: 'POST' }),
// --- доступи до мап та іконки ---
listMapPermissions: (mapID: string) =>
request<{ permissions: MapPermission[] }>(`/api/v1/maps/${mapID}/permissions`).then(
(r) => r.permissions ?? [],
),
setMapPermissions: (mapID: string, permissions: MapPermission[]) =>
request<{ permissions: MapPermission[] }>(`/api/v1/maps/${mapID}/permissions`, {
method: 'PUT',
body: JSON.stringify({ permissions }),
}).then((r) => r.permissions ?? []),
listIcons: () => request<{ icons: Icon[] }>('/api/v1/icons').then((r) => r.icons ?? []),
createIcon: (i: { name: string; mime: string; data: string }) =>
request<{ id: string }>('/api/v1/icons', { method: 'POST', body: JSON.stringify(i) }),
deleteIcon: (id: string) => request<void>(`/api/v1/icons/${id}`, { method: 'DELETE' }),
}

View file

@ -20,8 +20,14 @@ export type DeviceNodeData = {
borderWidth?: number
/** Колір заливки. */
fill?: string
/** Показувати підпис поруч із крапкою (для shape=dot). */
/** Приховати підпис зовсім. */
hideLabel?: boolean
/** Де підпис: right | bottom | top. */
labelPos?: string
/** Власна картинка з бібліотеки тенанта. */
iconId?: string
/** Розмір картинки або крапки в пікселях. */
iconSize?: number
/** Показувати цифри пінга. */
hideMetrics?: boolean
}
@ -97,6 +103,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
const size = data.size ?? 'md'
const textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md
const shape = data.shape ?? 'card'
const labelPos = data.labelPos ?? 'right'
// Аварія й попередження світяться. Це не прикраса: на схемі з сотні
// вузлів рамка іншого кольору не помітна периферійним зором, а ореол
@ -111,31 +118,90 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
</>
)
// Підпис малюється однаково для крапки, картинки й «без рамки»:
// міняється лише те, куди його поставити. Ті самі три форми з трьома
// копіями розмітки розійшлися б із першою ж правкою.
const label =
data.hideLabel || labelPos === 'none' ? null : (
<span className={`whitespace-nowrap font-medium text-slate-200 ${textSize}`}>
{data.label}
</span>
)
const wrap = (visual: React.ReactNode) => {
const dir =
labelPos === 'bottom'
? 'flex-col'
: labelPos === 'top'
? 'flex-col-reverse'
: 'flex-row'
return (
<div className={`flex items-center gap-1.5 ${dir}`}>
{handles}
{visual}
{label}
</div>
)
}
// ---- крапка -------------------------------------------------------
//
// Найщільніша форма: на великій схемі важливо бачити стан сотні
// вузлів, а не читати сотню підписів.
if (shape === 'dot') {
const d = DOT_SIZE[size] ?? DOT_SIZE.md
return (
<div className="flex items-center gap-1.5">
{handles}
<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}`}
/>
{!data.hideLabel && (
<span className={`whitespace-nowrap font-medium text-slate-200 ${textSize}`}>
{data.label}
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}`}
/>,
)
}
// ---- картинка -----------------------------------------------------
//
// Стан показує тонка кольорова обвідка знизу, а не рамка навколо:
// рамка з'їдає саму картинку, заради якої її й ставили.
if (shape === 'image') {
const d = data.iconSize ?? 40
return wrap(
<div className={`relative shrink-0 ${ring}`} 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}`}
/>
) : (
<span className="flex h-full w-full items-center justify-center text-2xl text-slate-500">
{icon}
</span>
)}
</div>
<span
className="absolute -bottom-1 left-1/2 h-1 w-2/3 -translate-x-1/2 rounded-full"
style={{ background: s.raw }}
/>
</div>,
)
}
// ---- без рамки ----------------------------------------------------
//
// Тільки крапка стану й значок. Для схем, де рамка навколо кожного
// вузла перетворює полотно на сітку прямокутників.
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>,
)
}

View file

@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Modal, inputClass, plural } from './ui'
import type { DeviceGroup, DeviceSummary, MapState, NodeInput } from '../types'
/**
* Додавання хостів на мапу.
*
* Ті, що вже на полотні, показані сірим і не вибираються: додати той
* самий пристрій двічі не можна (унікальний індекс у БД), і краще
* сказати це до спроби.
*
* Нові вузли розкладаються сіткою праворуч від наявних. Класти їх усі
* в одну точку означало б віддати людині купу з двадцяти карток, які
* доведеться розтягувати вручну.
*/
export function MapAddHosts({
state,
onAdd,
onClose,
}: {
state: MapState
onAdd: (nodes: NodeInput[]) => Promise<void>
onClose: () => void
}) {
const [devices, setDevices] = useState<DeviceSummary[]>([])
const [groups, setGroups] = useState<DeviceGroup[]>([])
const [query, setQuery] = useState('')
const [groupID, setGroupID] = useState('')
const [picked, setPicked] = useState<string[]>([])
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
Promise.all([
api.listDevices().catch(() => [] as DeviceSummary[]),
api.listDeviceGroups().catch(() => [] as DeviceGroup[]),
]).then(([d, g]) => {
setDevices(d)
setGroups(g)
})
}, [])
const onMap = useMemo(
() => new Set(state.nodes.map((n) => n.device_id).filter(Boolean) as string[]),
[state.nodes],
)
const list = useMemo(() => {
const q = query.trim().toLowerCase()
return devices.filter((d) => {
if (groupID && !(d.group_ids ?? []).includes(groupID)) return false
if (!q) return true
return (
d.name.toLowerCase().includes(q) ||
(d.address ?? '').toLowerCase().includes(q) ||
(d.vendor ?? '').toLowerCase().includes(q)
)
})
}, [devices, query, groupID])
const selectable = list.filter((d) => !onMap.has(d.id))
return (
<Modal title="Додати хости на мапу" onClose={onClose} wide>
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
<input
className={inputClass}
autoFocus
placeholder="пошук за назвою, адресою або виробником…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<select
className={inputClass}
value={groupID}
onChange={(e) => setGroupID(e.target.value)}
>
<option value="">усі групи</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span>
Знайдено {plural(list.length, 'хост', 'хости', 'хостів')}, доступно до додавання{' '}
{selectable.length}
</span>
<span className="ml-auto flex gap-1.5">
<Button onClick={() => setPicked(selectable.map((d) => d.id))}>Усі</Button>
<Button onClick={() => setPicked([])}>Зняти</Button>
</span>
</div>
<ul className="max-h-72 divide-y divide-slate-800/70 overflow-y-auto rounded border border-slate-800">
{list.map((d) => {
const already = onMap.has(d.id)
return (
<li key={d.id}>
<label
className={`flex items-center gap-2 px-3 py-2 text-sm ${
already ? 'opacity-45' : 'cursor-pointer hover:bg-slate-800/40'
}`}
>
<input
type="checkbox"
disabled={already}
checked={picked.includes(d.id)}
onChange={() =>
setPicked((prev) =>
prev.includes(d.id) ? prev.filter((x) => x !== d.id) : [...prev, d.id],
)
}
/>
<span className="truncate text-slate-200">{d.name}</span>
<span className="truncate text-xs text-slate-600">{d.address}</span>
{already && <span className="ml-auto text-[11px] text-slate-600">уже на мапі</span>}
</label>
</li>
)
})}
{list.length === 0 && (
<li className="px-3 py-6 text-center text-xs text-slate-600">Нічого не знайдено</li>
)}
</ul>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || picked.length === 0}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onAdd(layout(picked, state))
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Додаємо…' : `Додати ${picked.length}`}
</Button>
</div>
</div>
</Modal>
)
}
// Крок сітки для нових вузлів. 200×110 — трохи більше за типову картку,
// щоб підписи не злипалися.
const STEP_X = 200
const STEP_Y = 110
const PER_ROW = 5
function layout(deviceIDs: string[], state: MapState): NodeInput[] {
// Праворуч від найправішого наявного вузла: так нові не накривають
// те, що людина вже розклала руками.
const startX = state.nodes.length > 0 ? Math.max(...state.nodes.map((n) => n.x)) + STEP_X : 0
const startY = state.nodes.length > 0 ? Math.min(...state.nodes.map((n) => n.y)) : 0
return deviceIDs.map((id, i) => ({
client_id: `n-${id}`,
kind: 'device',
device_id: id,
x: startX + (i % PER_ROW) * STEP_X,
y: startY + Math.floor(i / PER_ROW) * STEP_Y,
}))
}

View file

@ -11,6 +11,7 @@ import {
type Edge,
type Node,
type NodeChange,
SelectionMode,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
@ -25,11 +26,19 @@ interface Props {
state: MapState
onPatch: (p: MapPatch, comment?: string) => void
onSelect: (nodeID: string | null) => void
/** Виділені вузли — для групових дій в інспекторі. */
onSelectionChange?: (nodeIDs: string[]) => void
/** Глядач без maps:write бачить полотно, але не змінює його. */
readOnly?: boolean
}
export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props) {
export function MapCanvas({
state,
onPatch,
onSelect,
onSelectionChange,
readOnly = false,
}: Props) {
const rfNodes = useMemo<DeviceNodeType[]>(
() =>
state.nodes.map((n) => ({
@ -51,6 +60,9 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props)
borderWidth: n.style?.borderWidth as number | undefined,
fill: n.style?.fill as string | undefined,
hideLabel: n.style?.hideLabel as boolean | undefined,
labelPos: n.style?.labelPos as string | undefined,
iconId: n.style?.iconId as string | undefined,
iconSize: n.style?.iconSize as number | undefined,
hideMetrics: n.style?.hideMetrics as boolean | undefined,
},
})),
@ -108,6 +120,23 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props)
[onNodesChange, onSelect],
)
// Груповий драг: React Flow викликає onNodeDragStop на кожному
// виділеному вузлі окремо, тож збираємо їх в один патч — інакше
// перетягування десяти вузлів дало б десять ревізій в історії.
const handleSelectionDragStop = useCallback(
(_: unknown, dragged: Node[]) => {
const upsert = dragged
.map((n) => ({ id: n.id, x: Math.round(n.position.x), y: Math.round(n.position.y) }))
.filter((n) => {
const before = state.nodes.find((x) => x.id === n.id)
return !before || Math.round(before.x) !== n.x || Math.round(before.y) !== n.y
})
if (upsert.length === 0) return
onPatch({ nodes: { upsert } }, `перетягування ${upsert.length} вузлів`)
},
[onPatch, state.nodes],
)
// Малювання зв'язку мишею. Ребро зберігається одразу: намальована,
// але не збережена лінія — це обіцянка, якої полотно не тримає.
//
@ -178,9 +207,27 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props)
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={handleDragStop}
onSelectionDragStop={handleSelectionDragStop}
onSelectionChange={
onSelectionChange ? ({ nodes: sn }) => onSelectionChange(sn.map((n) => n.id)) : undefined
}
onConnect={readOnly ? undefined : handleConnect}
onDelete={readOnly ? undefined : handleDelete}
nodesConnectable={!readOnly}
// У режимі редагування ліва кнопка тягне рамку виділення, а
// панорамує середня або права. У режимі перегляду навпаки: там
// нічого не виділяють, і ліва кнопка має возити полотно.
//
// Partial, а не Full: рамка бере й ті вузли, які зачепила краєм.
// Вимагати накрити вузол цілком означає промахуватись повз
// крайні вузли рівно тоді, коли виділяють «усе в цьому кутку».
selectionOnDrag={!readOnly}
selectionMode={SelectionMode.Partial}
selectionKeyCode={null}
multiSelectionKeyCode={readOnly ? null : ['Control', 'Meta', 'Shift']}
panOnDrag={readOnly ? true : [1, 2]}
nodesDraggable={!readOnly}
elementsSelectable
deleteKeyCode={readOnly ? null : ['Delete', 'Backspace']}
connectionRadius={30}
defaultViewport={state.viewport}

View file

@ -0,0 +1,234 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Modal, inputClass, plural } from './ui'
import type { Icon, MapPermission, MapSummary, UserGroup } from '../types'
const LEVELS: { key: string; label: string; hint: string }[] = [
{ key: 'read', label: 'перегляд', hint: 'бачить мапу, не змінює' },
{ key: 'write', label: 'редагування', hint: 'бачить і змінює' },
{ key: 'deny', label: 'заборонено', hint: 'не бачить зовсім' },
]
/**
* Налаштування мапи: доступи груп і бібліотека іконок.
*
* Доступи й іконки в одному вікні свідомо: і те, і те налаштовують раз
* на мапу й рідко, і розкидати їх по різних місцях означало б змусити
* шукати.
*/
export function MapSettings({
map,
onClose,
onChanged,
}: {
map: MapSummary
onClose: () => void
onChanged: () => void
}) {
const [tab, setTab] = useState<'access' | 'icons'>('access')
return (
<Modal title={`Налаштування: ${map.name}`} onClose={onClose} wide>
<div className="space-y-3">
<div className="flex gap-1 border-b border-slate-800 pb-2">
<Button kind={tab === 'access' ? 'primary' : 'ghost'} onClick={() => setTab('access')}>
Доступи
</Button>
<Button kind={tab === 'icons' ? 'primary' : 'ghost'} onClick={() => setTab('icons')}>
Іконки
</Button>
</div>
{tab === 'access' ? (
<AccessTab mapID={map.id} onChanged={onChanged} />
) : (
<IconsTab onChanged={onChanged} />
)}
</div>
</Modal>
)
}
function AccessTab({ mapID, onChanged }: { mapID: string; onChanged: () => void }) {
const [groups, setGroups] = useState<UserGroup[]>([])
const [perms, setPerms] = useState<MapPermission[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
Promise.all([
api.listUserGroups().catch(() => [] as UserGroup[]),
api.listMapPermissions(mapID).catch(() => [] as MapPermission[]),
])
.then(([g, p]) => {
setGroups(g)
setPerms(p)
})
.finally(() => setLoading(false))
}, [mapID])
const levelOf = (id: string) => perms.find((p) => p.group_id === id)?.level ?? ''
function setLevel(id: string, level: string) {
setPerms((prev) => {
const rest = prev.filter((p) => p.group_id !== id)
return level ? [...rest, { group_id: id, level }] : rest
})
}
if (loading) return <p className="py-6 text-center text-xs text-slate-600">Завантаження</p>
return (
<div className="space-y-3">
<p className="rounded border border-slate-800 bg-slate-900/60 p-2.5 text-[11px] leading-relaxed text-slate-500">
{perms.length === 0
? 'Зараз мапа доступна всім, хто має право дивитись мапи. Щойно ви дасте доступ хоч одній групі, решта її не побачить.'
: `Мапу бачать ${plural(perms.filter((p) => p.level !== 'deny').length, 'група', 'групи', 'груп')}. Заборона перекриває дозвіл.`}
</p>
{groups.length === 0 ? (
<p className="py-6 text-center text-xs leading-relaxed text-slate-600">
Груп доступу ще немає заведіть їх на сторінці «Групи».
</p>
) : (
<ul className="divide-y divide-slate-800/70 rounded border border-slate-800">
{groups.map((g) => (
<li key={g.id} className="flex items-center gap-2 px-3 py-2">
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">{g.name}</span>
<select
className={`${inputClass} w-40 py-1`}
value={levelOf(g.id)}
onChange={(e) => setLevel(g.id, e.target.value)}
>
<option value=""> не задано </option>
{LEVELS.map((l) => (
<option key={l.key} value={l.key}>
{l.label}
</option>
))}
</select>
</li>
))}
</ul>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end pt-1">
<Button
kind="primary"
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.setMapPermissions(mapID, perms)
onChanged()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти доступи'}
</Button>
</div>
</div>
)
}
function IconsTab({ onChanged }: { onChanged: () => void }) {
const [icons, setIcons] = useState<Icon[]>([])
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const file = useRef<HTMLInputElement>(null)
const reload = () => api.listIcons().then(setIcons).catch(() => {})
useEffect(() => {
void reload()
}, [])
return (
<div className="space-y-3">
<p className="text-[11px] leading-relaxed text-slate-500">
SVG, PNG, JPEG або WebP до 256 КБ. Іконка на полотні це 3264 пікселі, і більший файл
дає лише повільну мапу.
</p>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{icons.map((i) => (
<div
key={i.id}
className="flex flex-col items-center gap-1 rounded border border-slate-800 bg-slate-900/60 p-2"
>
<img
src={`/api/v1/icons/${i.id}`}
alt={i.name}
className="h-10 w-10 object-contain"
/>
<span className="w-full truncate text-center text-[10px] text-slate-400">
{i.name}
</span>
<button
className="text-[10px] text-slate-600 hover:text-red-400"
onClick={async () => {
await api.deleteIcon(i.id)
await reload()
onChanged()
}}
>
видалити
</button>
</div>
))}
{icons.length === 0 && (
<p className="col-span-full py-4 text-center text-xs text-slate-600">
Власних іконок ще немає
</p>
)}
</div>
<ErrorNote>{err}</ErrorNote>
<input
ref={file}
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp"
className="hidden"
onChange={async (e) => {
const f = e.target.files?.[0]
if (!f) return
setBusy(true)
setErr(null)
try {
const data = await readAsDataURL(f)
await api.createIcon({ name: f.name.replace(/\.[^.]+$/, ''), mime: f.type, data })
await reload()
onChanged()
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2))
} finally {
setBusy(false)
e.target.value = ''
}
}}
/>
<div className="flex justify-end">
<Button kind="primary" disabled={busy} onClick={() => file.current?.click()}>
{busy ? 'Завантаження…' : 'Завантажити іконку'}
</Button>
</div>
</div>
)
}
function readAsDataURL(f: File): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(String(r.result))
r.onerror = () => reject(new Error('не вдалося прочитати файл'))
r.readAsDataURL(f)
})
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Button, Field, inputClass } from './ui'
import type { MapNode, NodeInput } from '../types'
import type { Icon, MapNode, NodeInput } from '../types'
const ICON_CHOICES = [
{ key: '', label: 'за типом' },
@ -27,6 +27,16 @@ const SHAPE_CHOICES = [
{ key: 'card', label: 'картка' },
{ key: 'pill', label: 'пігулка' },
{ key: 'dot', label: 'крапка' },
{ key: 'image', label: 'картинка' },
{ key: 'bare', label: 'без рамки' },
]
// Де стоїть підпис відносно вузла.
const LABEL_POS = [
{ key: 'right', label: 'праворуч' },
{ key: 'bottom', label: 'знизу' },
{ key: 'top', label: 'зверху' },
{ key: 'none', label: 'без підпису' },
]
/**
@ -41,13 +51,21 @@ const SHAPE_CHOICES = [
export function NodeInspector({
node,
readOnly,
icons = [],
selection = [],
onPatch,
onPatchMany,
onDelete,
onClose,
}: {
node: MapNode
readOnly: boolean
/** Бібліотека власних іконок тенанта. */
icons?: Icon[]
/** Виділені вузли — форма вміє застосувати вигляд до всіх одразу. */
selection?: string[]
onPatch: (patch: NodeInput, comment: string) => Promise<void> | void
onPatchMany?: (patch: Omit<NodeInput, 'id'>, comment: string) => Promise<void> | void
onDelete: () => void
onClose: () => void
}) {
@ -60,6 +78,11 @@ export function NodeInspector({
const [border, setBorder] = useState(
node.style?.borderWidth ? String(node.style.borderWidth) : '',
)
const [iconID, setIconID] = useState((node.style?.iconId as string) ?? '')
const [iconSize, setIconSize] = useState(
node.style?.iconSize ? String(node.style.iconSize) : '',
)
const [labelPos, setLabelPos] = useState((node.style?.labelPos as string) ?? 'right')
const [hideLabel, setHideLabel] = useState((node.style?.hideLabel as boolean) ?? false)
const [hideMetrics, setHideMetrics] = useState((node.style?.hideMetrics as boolean) ?? false)
const [width, setWidth] = useState(node.width ? String(Math.round(node.width)) : '')
@ -76,6 +99,9 @@ export function NodeInspector({
setShape((node.style?.shape as string) ?? 'card')
setFill((node.style?.fill as string) ?? '')
setBorder(node.style?.borderWidth ? String(node.style.borderWidth) : '')
setIconID((node.style?.iconId as string) ?? '')
setIconSize(node.style?.iconSize ? String(node.style.iconSize) : '')
setLabelPos((node.style?.labelPos as string) ?? 'right')
setHideLabel((node.style?.hideLabel as boolean) ?? false)
setHideMetrics((node.style?.hideMetrics as boolean) ?? false)
setWidth(node.width ? String(Math.round(node.width)) : '')
@ -90,6 +116,9 @@ export function NodeInspector({
shape !== ((node.style?.shape as string) ?? 'card') ||
fill !== ((node.style?.fill as string) ?? '') ||
border !== (node.style?.borderWidth ? String(node.style.borderWidth) : '') ||
iconID !== ((node.style?.iconId as string) ?? '') ||
iconSize !== (node.style?.iconSize ? String(node.style.iconSize) : '') ||
labelPos !== ((node.style?.labelPos as string) ?? 'right') ||
hideLabel !== ((node.style?.hideLabel as boolean) ?? false) ||
hideMetrics !== ((node.style?.hideMetrics as boolean) ?? false) ||
width !== (node.width ? String(Math.round(node.width)) : '') ||
@ -114,8 +143,17 @@ export function NodeInspector({
const bw = Number(border)
if (border.trim() !== '' && Number.isFinite(bw) && bw > 0) style.borderWidth = bw
else delete style.borderWidth
if (hideLabel) style.hideLabel = true
// hideLabel виводиться з позиції підпису: два способи сказати
// те саме рано чи пізно розійдуться.
if (labelPos === 'none') style.hideLabel = true
else delete style.hideLabel
if (iconID) style.iconId = iconID
else delete style.iconId
const isz = Number(iconSize)
if (iconSize.trim() !== '' && Number.isFinite(isz) && isz > 0) style.iconSize = isz
else delete style.iconSize
if (labelPos && labelPos !== 'right') style.labelPos = labelPos
else delete style.labelPos
if (hideMetrics) style.hideMetrics = true
else delete style.hideMetrics
@ -129,6 +167,36 @@ export function NodeInspector({
}
}
// Той самий вигляд — на всі виділені вузли.
//
// Підпис і ширина сюди не йдуть: підпис у кожного свій, а однакова
// ширина для вузлів із різними іменами робить схему гіршою, не
// кращою. Стиль — навпаки, саме те, що хочуть застосувати гуртом.
async function applyToSelection() {
if (!onPatchMany) return
setBusy(true)
try {
const style: Record<string, unknown> = {}
if (icon) style.icon = icon
if (size && size !== 'md') style.size = size
if (color) style.color = color
if (shape && shape !== 'card') style.shape = shape
if (fill) style.fill = fill
const bw = Number(border)
if (border.trim() !== '' && Number.isFinite(bw) && bw > 0) style.borderWidth = bw
if (labelPos === 'none') style.hideLabel = true
if (hideMetrics) style.hideMetrics = true
if (iconID) style.iconId = iconID
const isz = Number(iconSize)
if (iconSize.trim() !== '' && Number.isFinite(isz) && isz > 0) style.iconSize = isz
if (labelPos && labelPos !== 'right') style.labelPos = labelPos
await onPatchMany({ style }, `вигляд ${selection.length} вузлів`)
} finally {
setBusy(false)
}
}
return (
<div
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2.5 text-xs
@ -216,6 +284,69 @@ export function NodeInspector({
</Field>
</div>
{shape === 'image' && (
<Field
label="Картинка"
hint={
icons.length === 0
? 'Власних іконок немає — завантажте їх у ⚙ налаштуваннях мапи'
: undefined
}
>
<div className="flex items-center gap-2">
<select
className={`${inputClass} py-1.5`}
value={iconID}
disabled={readOnly}
onChange={(e) => setIconID(e.target.value)}
>
<option value=""> оберіть </option>
{icons.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
{iconID && (
<img
src={`/api/v1/icons/${iconID}`}
alt=""
className="h-8 w-8 shrink-0 object-contain"
/>
)}
</div>
</Field>
)}
<div className="grid grid-cols-2 gap-2">
<Field label="Підпис де">
<select
className={`${inputClass} py-1.5`}
value={labelPos}
disabled={readOnly}
onChange={(e) => setLabelPos(e.target.value)}
>
{LABEL_POS.map((l) => (
<option key={l.key} value={l.key}>
{l.label}
</option>
))}
</select>
</Field>
<Field label={shape === 'image' ? 'Розмір картинки, px' : 'Розмір значка, px'}>
<input
className={`${inputClass} py-1.5`}
type="number"
min="8"
max="256"
placeholder="40"
value={iconSize}
disabled={readOnly}
onChange={(e) => setIconSize(e.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Заливка">
<div className="flex items-center gap-1.5">
@ -268,16 +399,6 @@ export function NodeInspector({
</div>
<div className="space-y-1">
{shape === 'dot' && (
<label className="flex items-center gap-2 text-slate-400">
<input
type="checkbox"
checked={hideLabel}
onChange={(e) => setHideLabel(e.target.checked)}
/>
Без підпису лише крапка
</label>
)}
<label className="flex items-center gap-2 text-slate-400">
<input
type="checkbox"
@ -296,10 +417,19 @@ export function NodeInspector({
</label>
</div>
<div className="flex gap-1.5 pt-0.5">
<div className="flex flex-wrap gap-1.5 pt-0.5">
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}>
{busy ? 'Збереження…' : 'Застосувати'}
</Button>
{selection.length > 1 && onPatchMany && (
<Button
disabled={busy}
title="Той самий вигляд на всі виділені вузли"
onClick={() => void applyToSelection()}
>
До всіх ({selection.length})
</Button>
)}
<Button kind="danger" onClick={onDelete} title="Прибрати вузол із полотна">
Видалити
</Button>

View file

@ -4,8 +4,11 @@ import { session } from '../api/session'
import { useLiveMap } from '../hooks/useLiveMap'
import { MapCanvas } from '../components/MapCanvas'
import { NodeInspector } from '../components/NodeInspector'
import { MapAddHosts } from '../components/MapAddHosts'
import { MapSettings } from '../components/MapSettings'
import { Button, Confirm, ErrorNote, Field, Modal, inputClass } from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { Icon } from '../types'
import type { MapSummary } from '../types'
export function MapPage() {
@ -15,9 +18,23 @@ export function MapPage() {
const [listError, setListError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
// Редагування вмикається кнопкою.
//
// Полотно, яке рухається від випадкового кліку, — найшвидший спосіб
// зіпсувати схему, на яку дивиться черговий. Тому типовий стан —
// перегляд, і навіть той, хто має право писати, спершу це підтверджує.
const [editMode, setEditMode] = useState(false)
const [selection, setSelection] = useState<string[]>([])
const [adding, setAdding] = useState(false)
const [settings, setSettings] = useState(false)
const [icons, setIcons] = useState<Icon[]>([])
const live = useLiveMap(mapID)
const canWrite = session.can('maps:write')
const currentMap = maps.find((m) => m.id === mapID) ?? null
// Право «редагувати мапи» і право «редагувати ЦЮ мапу» — різні речі:
// друге дають групи доступу.
const canWrite = session.can('maps:write') && (currentMap?.access ?? 'write') === 'write'
const editing = canWrite && editMode
const loadMaps = useCallback(async (selectID?: string) => {
try {
@ -34,6 +51,12 @@ export function MapPage() {
void loadMaps()
}, [loadMaps])
const loadIcons = useCallback(() => {
api.listIcons().then(setIcons).catch(() => {})
}, [])
useEffect(loadIcons, [loadIcons])
const counts = useMemo(() => {
const nodes = live.state?.nodes ?? []
return {
@ -61,13 +84,39 @@ export function MapPage() {
))}
</select>
{canWrite && (
{session.can('maps:write') && (
<Button
kind={editing ? 'primary' : 'default'}
disabled={!canWrite}
title={
canWrite
? 'Увімкнути редагування полотна'
: 'Ця мапа доступна лише для перегляду'
}
onClick={() => setEditMode((v) => !v)}
>
{editing ? '✓ Редагування' : '✎ Редагувати'}
</Button>
)}
{editing && (
<>
<Button kind="primary" onClick={() => setAdding(true)} title="Додати хости на мапу">
+ Хости
</Button>
<Button onClick={() => setSettings(true)} title="Налаштування мапи й доступи">
</Button>
</>
)}
{session.can('maps:write') && (
<>
<Button onClick={() => setCreating(true)} title="Створити порожню мапу">
+ Мапа
</Button>
<Button
disabled={!mapID}
disabled={!mapID || !editing}
onClick={() => void live.build()}
title="Додати на мапу пристрої та зв'язки, знайдені автовиявленням"
>
@ -75,7 +124,7 @@ export function MapPage() {
<span className="lg:hidden">Добудувати</span>
</Button>
<Button
disabled={!live.canUndo}
disabled={!live.canUndo || !editing}
onClick={() => void live.undo()}
title="Відкотити останню зміну полотна"
>
@ -83,7 +132,7 @@ export function MapPage() {
</Button>
<Button
kind="danger"
disabled={!mapID}
disabled={!mapID || !editing}
title="Видалити цю мапу"
onClick={() => {
const m = maps.find((x) => x.id === mapID)
@ -135,7 +184,8 @@ export function MapPage() {
state={live.state}
onPatch={live.patch}
onSelect={setSelected}
readOnly={!canWrite}
onSelectionChange={setSelection}
readOnly={!editing}
/>
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-600">
@ -147,7 +197,15 @@ export function MapPage() {
{node && (
<NodeInspector
node={node}
readOnly={!canWrite}
readOnly={!editing}
icons={icons}
selection={selection}
onPatchMany={(patch, comment) =>
live.patch(
{ nodes: { upsert: selection.map((id) => ({ ...patch, id })) } },
comment,
)
}
onClose={() => setSelected(null)}
onPatch={(patch, comment) => live.patch({ nodes: { upsert: [patch] } }, comment)}
onDelete={() => {
@ -168,6 +226,27 @@ export function MapPage() {
)}
{adding && live.state && (
<MapAddHosts
state={live.state}
onClose={() => setAdding(false)}
onAdd={async (nodes) => {
await live.patch({ nodes: { upsert: nodes } }, `додано ${nodes.length} хостів`)
}}
/>
)}
{settings && currentMap && (
<MapSettings
map={currentMap}
onClose={() => setSettings(false)}
onChanged={() => {
loadIcons()
void loadMaps(mapID ?? undefined)
}}
/>
)}
<Confirm request={confirm} onClose={() => setConfirm(null)} />
</div>
)

View file

@ -85,6 +85,8 @@ export interface MapSummary {
edge_count: number
revision: number
updated_at: string
/** Що людина може робити саме з цією мапою: read або write. */
access?: string
}
export interface MapBackground {
@ -576,3 +578,19 @@ export type Dashboard = {
widget_count: number
}
// --- мапи: доступи та іконки ---
export type MapPermission = {
group_id: string
group_name?: string
/** read | write | deny */
level: string
}
export type Icon = {
id: string
name: string
mime: string
size_bytes: number
}