Редагування вмикається кнопкою. Полотно, яке рухається від випадкового кліку, — найшвидший спосіб зіпсувати схему, на яку дивиться черговий. Доступи за групами користувачів (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>
166 lines
5.6 KiB
Go
166 lines
5.6 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Обмеження тіла запиту. Полотно на 1000 вузлів у патчі — це ~1 МБ;
|
||
// 8 МБ лишає запас і водночас не дає одним запитом з'їсти пам'ять.
|
||
const maxPatchBody = 8 << 20
|
||
|
||
func (s *Server) handleCreateMap(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !p.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Slug string `json:"slug"`
|
||
Kind string `json:"kind"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
m, err := s.store.CreateMap(r.Context(), p.TenantID, in.Name, in.Slug, in.Kind)
|
||
if err != nil {
|
||
s.writeStoreError(w, "створення мапи", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, m)
|
||
}
|
||
|
||
func (s *Server) handleDeleteMap(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !p.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
if err := s.store.DeleteMap(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
||
s.writeStoreError(w, "видалення мапи", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// handlePatchMap — редактор полотна.
|
||
func (s *Server) handlePatchMap(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !p.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
var patch store.MapPatch
|
||
if !decodeBody(w, r, &patch) {
|
||
return
|
||
}
|
||
|
||
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)
|
||
return
|
||
}
|
||
|
||
// Інші відкриті полотна мають дізнатись про зміну: у NOC над однією
|
||
// мапою часто працюють кілька людей, і мовчазне розходження станів
|
||
// закінчується взаємним затиранням.
|
||
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
|
||
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
// handleBuildMap добудовує мапу з виявленої топології.
|
||
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !p.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
mapID := r.PathValue("id")
|
||
st, err := s.store.BuildFromTopology(r.Context(), p.TenantID, mapID, "")
|
||
if err != nil {
|
||
s.writeStoreError(w, "автопобудова мапи", err)
|
||
return
|
||
}
|
||
|
||
if st.NodesAdded > 0 || st.EdgesAdded > 0 {
|
||
s.hub.BroadcastMapUpdate(p.TenantID, mapID, st.Revision)
|
||
}
|
||
writeJSON(w, http.StatusOK, st)
|
||
}
|
||
|
||
// handleUndoMap відкочує полотно до попереднього знімка.
|
||
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !p.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
mapID := r.PathValue("id")
|
||
res, err := s.store.UndoMap(r.Context(), p.TenantID, mapID, "")
|
||
if errors.Is(err, store.ErrNothingToUndo) {
|
||
writeError(w, http.StatusConflict, "nothing_to_undo", "історії змін ще немає")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.writeStoreError(w, "відкат мапи", err)
|
||
return
|
||
}
|
||
|
||
s.hub.BroadcastMapUpdate(p.TenantID, mapID, res.Revision)
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
|
||
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPatchBody))
|
||
// Невідоме поле — майже завжди друкарська помилка в клієнті.
|
||
// Мовчки її проковтнути означає, що правка «збереглась», але
|
||
// не застосувалась.
|
||
dec.DisallowUnknownFields()
|
||
|
||
if err := dec.Decode(v); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_body", "не вдалося розібрати тіло: "+err.Error())
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) {
|
||
switch {
|
||
case errors.Is(err, store.ErrNotFound):
|
||
writeError(w, http.StatusNotFound, "not_found", "не знайдено")
|
||
|
||
case errors.Is(err, store.ErrConflict):
|
||
// 409 із поточною ревізією дозволяє клієнту перечитати мапу
|
||
// й повторити правку, а не втратити її.
|
||
writeError(w, http.StatusConflict, "revision_conflict", err.Error())
|
||
|
||
case errors.Is(err, store.ErrPlanLimit):
|
||
// 402 — саме те, що сталося: потрібен інший тариф.
|
||
writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error())
|
||
|
||
case errors.Is(err, store.ErrInvalid):
|
||
writeError(w, http.StatusBadRequest, "invalid", err.Error())
|
||
|
||
default:
|
||
s.log.Error(what, "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
}
|
||
}
|