Мапи: режим редагування, доступи груп, іконки, форми вузлів
Редагування вмикається кнопкою. Полотно, яке рухається від випадкового кліку, — найшвидший спосіб зіпсувати схему, на яку дивиться черговий. Доступи за групами користувачів (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:
parent
7fa8adc506
commit
5aba75ccc3
14 changed files with 1305 additions and 45 deletions
93
db/migrations/0021_map_access_icons.sql
Normal file
93
db/migrations/0021_map_access_icons.sql
Normal 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 КБ. Більше на мапі не потрібно: іконка вузла — це
|
||||
-- 32–64 пікселі, і мегабайтний 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;
|
||||
176
server/internal/httpapi/map_access.go
Normal file
176
server/internal/httpapi/map_access.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// maxIconBytes — стеля розміру іконки.
|
||||
//
|
||||
// Іконка вузла — це 32–64 пікселі на екрані. Мегабайтний 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", "мапу не знайдено")
|
||||
|
|
|
|||
180
server/internal/store/map_access.go
Normal file
180
server/internal/store/map_access.go
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
179
web/src/components/MapAddHosts.tsx
Normal file
179
web/src/components/MapAddHosts.tsx
Normal 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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
234
web/src/components/MapSettings.tsx
Normal file
234
web/src/components/MapSettings.tsx
Normal 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 КБ. Іконка на полотні — це 32–64 пікселі, і більший файл
|
||||
дає лише повільну мапу.
|
||||
</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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue