Netpulse_SasS/server/internal/httpapi/metrics.go
byrsapty cd8d4c62ed Історія метрик, шаблони будь-яких перевірок, маршрути правил
Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.

Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.

Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.

Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.

Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.

Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.

Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.

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

150 lines
4.2 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 (
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
func (s *Server) handleListSeries(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
}
list, err := s.store.ListSeries(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "метрики хоста", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"series": list})
}
// handleQueryMetrics — точки для графіка.
//
// Вікно задається або парою from/to, або відносним `range` (`6h`, `7d`).
// Відносне зручніше й чесніше для типового випадку «останні шість
// годин»: абсолютні межі, пораховані в браузері, розповзаються з
// серверним часом рівно настільки, наскільки розходяться годинники.
func (s *Server) handleQueryMetrics(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
}
q := r.URL.Query()
var ids []int64
for _, raw := range strings.Split(q.Get("series"), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_series", "ідентифікатор ряду має бути числом")
return
}
ids = append(ids, id)
}
if len(ids) == 0 {
writeError(w, http.StatusBadRequest, "bad_series", "потрібен хоча б один ряд")
return
}
if len(ids) > 20 {
writeError(w, http.StatusBadRequest, "too_many",
"понад 20 ліній на одному графіку не читаються")
return
}
mq := store.MetricQuery{DeviceID: deviceID, SeriesIDs: ids}
if v := q.Get("range"); v != "" {
d, err := parseRange(v)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_range", err.Error())
return
}
mq.To = time.Now()
mq.From = mq.To.Add(-d)
} else {
if v := q.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_from", "from має бути в форматі RFC3339")
return
}
mq.From = t
}
if v := q.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_to", "to має бути в форматі RFC3339")
return
}
mq.To = t
}
}
if v := q.Get("points"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
mq.Points = n
}
}
res, err := s.store.QueryMetrics(r.Context(), p.TenantID, mq)
if err != nil {
s.writeStoreError(w, "історія метрик", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// parseRange розбирає відносне вікно: 30m, 6h, 7d.
//
// Свій розбір, а не time.ParseDuration, бо той не знає «d», а доба —
// найчастіше вживане вікно після годин. Стеля в рік: далі роллапи
// однаково зачищає retention.
func parseRange(v string) (time.Duration, error) {
if len(v) < 2 {
return 0, errBadRange
}
n, err := strconv.Atoi(v[:len(v)-1])
if err != nil || n <= 0 {
return 0, errBadRange
}
var unit time.Duration
switch v[len(v)-1] {
case 'm':
unit = time.Minute
case 'h':
unit = time.Hour
case 'd':
unit = 24 * time.Hour
default:
return 0, errBadRange
}
d := time.Duration(n) * unit
if d > 365*24*time.Hour {
return 0, errBadRange
}
return d, nil
}
var errBadRange = errRange{}
type errRange struct{}
func (errRange) Error() string {
return "вікно задається як 30m, 6h або 7d, максимум 365d"
}