Netpulse_SasS/server/internal/httpapi/checks.go
byrsapty cdf5f1fb54 Візуальний diff конфігів
Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
lines_added завжди 0 — порівняння ніхто не рахував.

difftext: власне порядкове порівняння з ділянками й контекстом.
Бібліотека принесла б підтримку слів, символів і кольорів у терміналі —
десяток речей, які тут не знадобляться.

- спільний початок і кінець відкидаються до основного алгоритму: у
  конфігах змінюється кілька рядків із тисячі, і квадратична таблиця
  будувалася б там, де досить порівняти десяток
- понад чотири мільйони клітинок дають truncated і грубу заміну блоку:
  точність там нічого не дає, а чесна позначка краща за правдоподібний,
  але вигаданий diff
- сусідні зміни зливаються в одну ділянку, інакше контекст дублюється

Результат кешується в ncm.diffs — diff двох версій незмінний назавжди.
Підсумок +N/−M заразом дозаписується у version, щоб список історії не
розшифровував два тіла на кожен рядок.

UI: сторінка «Конфіги» — хости зліва, історія й diff справа. Порівняння
з попередньою версією відкривається одразу: питання завжди одне — що
змінилось цього разу.

Перевірено наживо: друга версія стенду дала @@ −8,3 +8,4 @@ з одним
доданим рядком і контекстом, лічильник +1 −0 дозаписався, повний текст
на 292 байти читається.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:30:30 +03:00

342 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
)
func (s *Server) handleListCheckTypes(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
types, err := s.store.ListCheckTypes(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "типи перевірок", err)
return
}
if types == nil {
types = []store.CheckType{}
}
writeJSON(w, http.StatusOK, map[string]any{"check_types": types})
}
func (s *Server) handleListDeviceChecks(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
checks, err := s.store.ListDeviceChecks(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "перевірки хоста", err)
return
}
if checks == nil {
checks = []store.Check{}
}
writeJSON(w, http.StatusOK, map[string]any{"checks": checks})
}
// handleSetDeviceChecks приймає повний набір перевірок хоста.
func (s *Server) handleSetDeviceChecks(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
var in struct {
Checks []struct {
ID string `json:"id"`
CheckType string `json:"check_type"`
Params json.RawMessage `json:"params"`
IntervalSec int `json:"interval_sec"`
TimeoutMs int `json:"timeout_ms"`
Retries int `json:"retries"`
Enabled *bool `json:"enabled"`
} `json:"checks"`
}
if !decodeBody(w, r, &in) {
return
}
// Типи звіряємо з довідником: невідомий ключ інакше дійшов би до
// зонда й помер там як задача неіснуючому модулю — за кілометри від
// того місця, де його ввели.
known, err := s.store.ListCheckTypes(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "типи перевірок", err)
return
}
ok := map[string]bool{}
for _, t := range known {
ok[t.Key] = true
}
out := make([]store.CheckInput, 0, len(in.Checks))
for _, c := range in.Checks {
if !ok[c.CheckType] {
writeError(w, http.StatusBadRequest, "bad_check_type",
"невідомий тип перевірки: "+c.CheckType)
return
}
out = append(out, store.CheckInput{
ID: c.ID,
CheckType: c.CheckType,
Params: jsonOr(c.Params, "{}"),
IntervalSec: c.IntervalSec,
TimeoutMs: c.TimeoutMs,
Retries: c.Retries,
Enabled: c.Enabled == nil || *c.Enabled,
})
}
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, out); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
s.writeStoreError(w, "збереження перевірок", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Доступи до обладнання
// ---------------------------------------------------------------------
func (s *Server) handleListCredentials(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
creds, err := s.store.ListCredentials(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік доступів", err)
return
}
if creds == nil {
creds = []store.Credential{}
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": creds})
}
var validCredProtos = map[string]bool{
"ssh": true, "telnet": true, "snmp_v2c": true, "snmp_v3": true,
"http": true, "https": true, "api": true, "modbus": true,
}
func (s *Server) handleCreateCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — зберегти доступ ніяк")
return
}
var in struct {
Name string `json:"name"`
Proto string `json:"proto"`
Username string `json:"username"`
Port int `json:"port"`
Secret string `json:"secret"`
Options json.RawMessage `json:"options"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва доступу")
return
}
if !validCredProtos[in.Proto] {
writeError(w, http.StatusBadRequest, "bad_proto", "невідомий протокол доступу")
return
}
id, err := s.store.CreateCredential(r.Context(), p.TenantID, strings.TrimSpace(in.Name),
in.Proto, in.Username, in.Port, in.Secret, jsonOr(in.Options, "{}"), s.keyring)
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})
}
// ---------------------------------------------------------------------
// Збір конфігу
// ---------------------------------------------------------------------
// handleCollectConfig ставить збір у чергу.
//
// Саме в чергу, а не одразу зонду: живу сесію тримає інший процес
// (AgentService), і черга в БД — єдиний чесний спосіб передати між ними
// завдання так, щоб воно пережило перезапуск будь-якого з них.
func (s *Server) handleCollectConfig(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
jobID, err := s.store.EnqueueConfigJob(r.Context(), p.TenantID, deviceID, "manual", p.UserID)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "постановка збору конфігу", err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}
// handleListConfigJobs — історія збору для хоста.
func (s *Server) handleListConfigJobs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
jobs, err := s.store.ListConfigJobs(r.Context(), p.TenantID, deviceID, 20)
if err != nil {
s.writeStoreError(w, "історія збору", err)
return
}
if jobs == nil {
jobs = []store.ConfigJobRow{}
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
}
// ---------------------------------------------------------------------
// Версії конфігу
// ---------------------------------------------------------------------
func (s *Server) handleListConfigs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
list, err := s.store.ListDeviceConfigs(r.Context(), p.TenantID, deviceID, 50)
if err != nil {
s.writeStoreError(w, "історія конфігів", err)
return
}
if list == nil {
list = []store.ConfigVersion{}
}
writeJSON(w, http.StatusOK, map[string]any{"configs": list})
}
// handleGetConfig віддає текст версії.
//
// Конфіг у БД зашифрований, тому без ключа процес чесно відмовляє, а не
// повертає порожній рядок: мовчазна порожнеча виглядала б як «пристрій
// віддав порожній конфіг».
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
body, meta, err := s.store.ConfigBody(r.Context(), p.TenantID, r.PathValue("id"), s.keyring)
if errors.Is(err, store.ErrConfigNotFound) {
writeError(w, http.StatusNotFound, "not_found", "версію не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "читання конфігу", err)
return
}
if !p.Scope().CanRead(meta.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
writeJSON(w, http.StatusOK, map[string]any{"config": meta, "body": body})
}
// handleDiffConfigs порівнює дві версії.
//
// Без параметра from береться попередня версія: саме це питання ставлять
// у 90% випадків — «що змінилось цього разу».
func (s *Server) handleDiffConfigs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
toID := r.PathValue("id")
_, meta, err := s.store.ConfigBody(r.Context(), p.TenantID, toID, s.keyring)
if errors.Is(err, store.ErrConfigNotFound) {
writeError(w, http.StatusNotFound, "not_found", "версію не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "читання конфігу", err)
return
}
if !p.Scope().CanRead(meta.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
fromID := r.URL.Query().Get("from")
if fromID == "" {
fromID = meta.PrevConfigID
}
if fromID == "" {
// Перша версія: порівнювати нема з чим, і це не помилка.
writeJSON(w, http.StatusOK, map[string]any{
"first": true, "hunks": []any{}, "lines_added": 0, "lines_removed": 0,
})
return
}
res, err := s.store.DiffConfigs(r.Context(), p.TenantID, fromID, toID, s.keyring)
if errors.Is(err, store.ErrConfigNotFound) {
writeError(w, http.StatusNotFound, "not_found", "версію для порівняння не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "порівняння конфігів", err)
return
}
writeJSON(w, http.StatusOK, res)
}