diff --git a/db/migrations/0021_map_access_icons.sql b/db/migrations/0021_map_access_icons.sql new file mode 100644 index 0000000..8124205 --- /dev/null +++ b/db/migrations/0021_map_access_icons.sql @@ -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; diff --git a/server/internal/httpapi/map_access.go b/server/internal/httpapi/map_access.go new file mode 100644 index 0000000..b868a67 --- /dev/null +++ b/server/internal/httpapi/map_access.go @@ -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) +} diff --git a/server/internal/httpapi/maps_write.go b/server/internal/httpapi/maps_write.go index b737d86..494d437 100644 --- a/server/internal/httpapi/maps_write.go +++ b/server/internal/httpapi/maps_write.go @@ -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) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 4280f9d..a48be9b 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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", "мапу не знайдено") diff --git a/server/internal/store/map_access.go b/server/internal/store/map_access.go new file mode 100644 index 0000000..921f6bb --- /dev/null +++ b/server/internal/store/map_access.go @@ -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 + }) +} diff --git a/server/internal/store/maps.go b/server/internal/store/maps.go index 12b1a38..d1390bd 100644 --- a/server/internal/store/maps.go +++ b/server/internal/store/maps.go @@ -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) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index df798f1..620ae90 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/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(`/api/v1/icons/${id}`, { method: 'DELETE' }), } diff --git a/web/src/components/DeviceNode.tsx b/web/src/components/DeviceNode.tsx index ab6616d..9bf06e4 100644 --- a/web/src/components/DeviceNode.tsx +++ b/web/src/components/DeviceNode.tsx @@ -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) { 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) { ) + // Підпис малюється однаково для крапки, картинки й «без рамки»: + // міняється лише те, куди його поставити. Ті самі три форми з трьома + // копіями розмітки розійшлися б із першою ж правкою. + const label = + data.hideLabel || labelPos === 'none' ? null : ( + + {data.label} + + ) + + const wrap = (visual: React.ReactNode) => { + const dir = + labelPos === 'bottom' + ? 'flex-col' + : labelPos === 'top' + ? 'flex-col-reverse' + : 'flex-row' + return ( +
+ {handles} + {visual} + {label} +
+ ) + } + // ---- крапка ------------------------------------------------------- // // Найщільніша форма: на великій схемі важливо бачити стан сотні // вузлів, а не читати сотню підписів. if (shape === 'dot') { - const d = DOT_SIZE[size] ?? DOT_SIZE.md - return ( -
- {handles} -
- {!data.hideLabel && ( - - {data.label} + const d = data.iconSize ?? DOT_SIZE[size] ?? DOT_SIZE.md + return wrap( +
, + ) + } + + // ---- картинка ----------------------------------------------------- + // + // Стан показує тонка кольорова обвідка знизу, а не рамка навколо: + // рамка з'їдає саму картинку, заради якої її й ставили. + if (shape === 'image') { + const d = data.iconSize ?? 40 + return wrap( +
+ {data.iconId ? ( + + ) : ( + + {icon} )} -
+ +
, + ) + } + + // ---- без рамки ---------------------------------------------------- + // + // Тільки крапка стану й значок. Для схем, де рамка навколо кожного + // вузла перетворює полотно на сітку прямокутників. + if (shape === 'bare') { + return wrap( + + + {icon} + , ) } diff --git a/web/src/components/MapAddHosts.tsx b/web/src/components/MapAddHosts.tsx new file mode 100644 index 0000000..cc5bdbe --- /dev/null +++ b/web/src/components/MapAddHosts.tsx @@ -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 + onClose: () => void +}) { + const [devices, setDevices] = useState([]) + const [groups, setGroups] = useState([]) + const [query, setQuery] = useState('') + const [groupID, setGroupID] = useState('') + const [picked, setPicked] = useState([]) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(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 ( + +
+
+ setQuery(e.target.value)} + /> + +
+ +
+ + Знайдено {plural(list.length, 'хост', 'хости', 'хостів')}, доступно до додавання{' '} + {selectable.length} + + + + + +
+ +
    + {list.map((d) => { + const already = onMap.has(d.id) + return ( +
  • + +
  • + ) + })} + {list.length === 0 && ( +
  • Нічого не знайдено
  • + )} +
+ + {err} + +
+ + +
+
+
+ ) +} + +// Крок сітки для нових вузлів. 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, + })) +} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index 1945291..e280858 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -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( () => 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} diff --git a/web/src/components/MapSettings.tsx b/web/src/components/MapSettings.tsx new file mode 100644 index 0000000..4660ecf --- /dev/null +++ b/web/src/components/MapSettings.tsx @@ -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 ( + +
+
+ + +
+ + {tab === 'access' ? ( + + ) : ( + + )} +
+
+ ) +} + +function AccessTab({ mapID, onChanged }: { mapID: string; onChanged: () => void }) { + const [groups, setGroups] = useState([]) + const [perms, setPerms] = useState([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(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

Завантаження…

+ + return ( +
+

+ {perms.length === 0 + ? 'Зараз мапа доступна всім, хто має право дивитись мапи. Щойно ви дасте доступ хоч одній групі, решта її не побачить.' + : `Мапу бачать ${plural(perms.filter((p) => p.level !== 'deny').length, 'група', 'групи', 'груп')}. Заборона перекриває дозвіл.`} +

+ + {groups.length === 0 ? ( +

+ Груп доступу ще немає — заведіть їх на сторінці «Групи». +

+ ) : ( +
    + {groups.map((g) => ( +
  • + {g.name} + +
  • + ))} +
+ )} + + {err} + +
+ +
+
+ ) +} + +function IconsTab({ onChanged }: { onChanged: () => void }) { + const [icons, setIcons] = useState([]) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + const file = useRef(null) + + const reload = () => api.listIcons().then(setIcons).catch(() => {}) + useEffect(() => { + void reload() + }, []) + + return ( +
+

+ SVG, PNG, JPEG або WebP до 256 КБ. Іконка на полотні — це 32–64 пікселі, і більший файл + дає лише повільну мапу. +

+ +
+ {icons.map((i) => ( +
+ {i.name} + + {i.name} + + +
+ ))} + {icons.length === 0 && ( +

+ Власних іконок ще немає +

+ )} +
+ + {err} + + { + 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 = '' + } + }} + /> +
+ +
+
+ ) +} + +function readAsDataURL(f: File): Promise { + return new Promise((resolve, reject) => { + const r = new FileReader() + r.onload = () => resolve(String(r.result)) + r.onerror = () => reject(new Error('не вдалося прочитати файл')) + r.readAsDataURL(f) + }) +} diff --git a/web/src/components/NodeInspector.tsx b/web/src/components/NodeInspector.tsx index 5091c20..1448263 100644 --- a/web/src/components/NodeInspector.tsx +++ b/web/src/components/NodeInspector.tsx @@ -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 + onPatchMany?: (patch: Omit, comment: string) => Promise | 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 = {} + 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 (
+
+ + {iconID && ( + + )} +
+ + )} + +
+ + + + + setIconSize(e.target.value)} + /> + +
+
@@ -268,16 +399,6 @@ export function NodeInspector({
- {shape === 'dot' && ( - - )}
-
+
+ {selection.length > 1 && onPatchMany && ( + + )} diff --git a/web/src/pages/MapPage.tsx b/web/src/pages/MapPage.tsx index 93adb5c..097733e 100644 --- a/web/src/pages/MapPage.tsx +++ b/web/src/pages/MapPage.tsx @@ -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(null) const [creating, setCreating] = useState(false) const [confirm, setConfirm] = useState(null) + // Редагування вмикається кнопкою. + // + // Полотно, яке рухається від випадкового кліку, — найшвидший спосіб + // зіпсувати схему, на яку дивиться черговий. Тому типовий стан — + // перегляд, і навіть той, хто має право писати, спершу це підтверджує. + const [editMode, setEditMode] = useState(false) + const [selection, setSelection] = useState([]) + const [adding, setAdding] = useState(false) + const [settings, setSettings] = useState(false) + const [icons, setIcons] = useState([]) 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() { ))} - {canWrite && ( + {session.can('maps:write') && ( + + )} + + {editing && ( + <> + + + + )} + + {session.can('maps:write') && ( <>