Netpulse_SasS/server/internal/httpapi/checks.go
byrsapty 387980d655 Картка хоста на вкладках, графіки в шаблонах, пошукові списки
Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів,
Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть
у шаблонах, і два джерела правди про одне число розійшлися б саме тоді,
коли треба швидко зрозуміти, чому хост опитується не так, як написано.
Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.

Групи, шаблони й доступи обираються пошуком із чипами: список галочок
працює на пʼяти позиціях і перестає працювати на пʼятдесяти.

Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі
осей, перелік ключів метрик замість посилань на елементи — графік має
право показувати й те, що прийшло з іншого шаблону. Графік без жодного
знайденого ряду не показується: порожня рамка з підписом — це обіцянка
даних, яких немає.

Знайдено живим прогоном і виправлено: обробник виділення на полотні
створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав
головний потік, і мертвими ставали всі сторінки, не лише мапа.

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

585 lines
20 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)
}
func (s *Server) handleGetDeviceCredentials(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
}
ids, err := s.store.DeviceCredentialIDs(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "доступи хоста", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"credential_ids": ids})
}