Netpulse_SasS/server/internal/httpapi/checks.go
byrsapty 7fa8adc506 Дашборди, Esc/клік повз вікно, редагування каналів і протоколів
Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду.
Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список
алертів, сітка хостів, текст), автооновлення з інтервалом дашборда,
режим редагування. Права окремі від maps:* — дашборд збирає дані з
усього тенанта.

Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо
фокус може стояти де завгодно; закриття за mousedown, а не click, щоб
виділення тексту, доведене за межі вікна, не втрачало набране.

Канали сповіщень редагуються (раніше лише створювались і видалялись).
Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет
зашифрований під видом старого протоколу.

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

567 lines
19 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/cronx"
"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{}
}
// Скільки хостів користується кожним доступом — щоб попередження
// перед видаленням казало щось конкретне, а не «Ви впевнені?».
usage, err := s.store.CredentialUsage(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік доступів", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": creds, "usage": usage})
}
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)
}
// ---------------------------------------------------------------------
// Розклад бекапу
// ---------------------------------------------------------------------
func (s *Server) handleGetBackupPolicy(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
}
pol, err := s.store.GetBackupPolicy(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "розклад бекапу", err)
return
}
// Політики немає — це не помилка, а «ще не налаштовано».
writeJSON(w, http.StatusOK, map[string]any{"policy": pol})
}
func (s *Server) handleSetBackupPolicy(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
}
var in struct {
ProfileID string `json:"profile_id"`
CredentialID string `json:"credential_id"`
Enabled *bool `json:"enabled"`
Cron string `json:"cron"`
OnSyslog *bool `json:"on_syslog"`
// true — повернути хост під глобальний розклад.
FollowsDefault *bool `json:"follows_default"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Cron) == "" {
in.Cron = "0 3 * * *"
}
// Розклад перевіряємо тут, а не в планувальнику: інакше людина
// дізнається про друкарську помилку через добу мовчання, а не
// одразу при збереженні.
if _, err := cronx.Parse(in.Cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
err := s.store.SetBackupPolicy(r.Context(), p.TenantID, store.BackupPolicy{
DeviceID: deviceID,
ProfileID: in.ProfileID,
CredentialID: in.CredentialID,
Enabled: in.Enabled == nil || *in.Enabled,
Cron: in.Cron,
OnSyslog: in.OnSyslog != nil && *in.OnSyslog,
// Збереження форми хоста типово означає «в мене свій розклад».
// Інакше найближча зміна глобального мовчки затерла б те, що
// людина щойно налаштувала руками.
FollowsDefault: in.FollowsDefault != nil && *in.FollowsDefault,
})
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "збереження розкладу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListProfiles — довідник профілів для форми розкладу.
func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
list, err := s.store.ListProfiles(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік профілів", err)
return
}
if list == nil {
list = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": list})
}
// ---------------------------------------------------------------------
// Глобальний розклад бекапів
// ---------------------------------------------------------------------
func (s *Server) handleGetBackupDefaults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
d, err := s.store.GetBackupDefaults(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleSetBackupDefaults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
var in struct {
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
ProfileID string `json:"profile_id"`
CredentialID string `json:"credential_id"`
// Підпорядкувати глобальному розкладу навіть ті хости, які
// налаштували окремо. Руйнівно, тому окремим прапорцем.
ApplyToAll bool `json:"apply_to_all"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Cron) == "" {
in.Cron = "0 3 * * *"
}
if _, err := cronx.Parse(in.Cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
if err := s.store.SetBackupDefaults(r.Context(), p.TenantID, store.BackupDefaults{
Enabled: in.Enabled,
Cron: in.Cron,
ProfileID: in.ProfileID,
CredentialID: in.CredentialID,
}, in.ApplyToAll); err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
d, err := s.store.GetBackupDefaults(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "глобальний розклад", err)
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleUpdateCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
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 in.Secret != "" && s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — зберегти пароль ніяк")
return
}
if in.Proto != "" && !validCredProtos[in.Proto] {
writeError(w, http.StatusBadRequest, "bad_proto", "невідомий протокол доступу")
return
}
err := s.store.UpdateCredential(r.Context(), p.TenantID, r.PathValue("id"),
strings.TrimSpace(in.Name), in.Proto, in.Username, in.Port, in.Secret,
jsonOr(in.Options, "{}"), s.keyring)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено")
return
}
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "доступ із такою назвою вже є")
return
}
s.writeStoreError(w, "оновлення доступу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteCredential(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
err := s.store.DeleteCredential(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення доступу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}