POST /api/v1/maps/{id}/undo повертає полотно до попереднього знімка.
Відкат оформлюється як нова ревізія, а не відмотування лічильника:
інакше клієнт зі старим номером тихо перезаписав би відкочене.
Ідентифікатори вузлів зберігаються, тож ребра прив'язуються назад самі.
В UI: малювання зв'язку від краю вузла, видалення по Delete, кнопка
відкату. Намальоване рукою ребро не прив'язується до topo.links —
лінія на полотні це подання, а не факт про мережу.
Виправлено флак у тестах: TestSchedulerRunsTaskAndFillsCredentials
перевіряв канал статусів знімком, хоча SUCCEEDED надсилається вже після
запису в sink. Під навантаженням падав раз на п'ять; тепер 0 з 8.
Перевірено наживо: намальовано зв'язок -> відкат -> видалено вузол ->
відкат повернув той самий id, ребро й живий стан лінка.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.3 KiB
Go
157 lines
5.3 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, tok *store.APIToken) {
|
||
if !tok.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(), tok.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, tok *store.APIToken) {
|
||
if !tok.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
if err := s.store.DeleteMap(r.Context(), tok.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, tok *store.APIToken) {
|
||
if !tok.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")
|
||
res, err := s.store.ApplyMapPatch(r.Context(), tok.TenantID, mapID, "", &patch)
|
||
if err != nil {
|
||
s.writeStoreError(w, "збереження мапи", err)
|
||
return
|
||
}
|
||
|
||
// Інші відкриті полотна мають дізнатись про зміну: у NOC над однією
|
||
// мапою часто працюють кілька людей, і мовчазне розходження станів
|
||
// закінчується взаємним затиранням.
|
||
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, res.Revision)
|
||
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
// handleBuildMap добудовує мапу з виявленої топології.
|
||
func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
|
||
if !tok.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
mapID := r.PathValue("id")
|
||
st, err := s.store.BuildFromTopology(r.Context(), tok.TenantID, mapID, "")
|
||
if err != nil {
|
||
s.writeStoreError(w, "автопобудова мапи", err)
|
||
return
|
||
}
|
||
|
||
if st.NodesAdded > 0 || st.EdgesAdded > 0 {
|
||
s.hub.BroadcastMapUpdate(tok.TenantID, mapID, st.Revision)
|
||
}
|
||
writeJSON(w, http.StatusOK, st)
|
||
}
|
||
|
||
// handleUndoMap відкочує полотно до попереднього знімка.
|
||
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, tok *store.APIToken) {
|
||
if !tok.Can("maps:write") {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:write")
|
||
return
|
||
}
|
||
|
||
mapID := r.PathValue("id")
|
||
res, err := s.store.UndoMap(r.Context(), tok.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(tok.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", "внутрішня помилка")
|
||
}
|
||
}
|