Метрики збиралися в 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>
150 lines
4.2 KiB
Go
150 lines
4.2 KiB
Go
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"
|
||
}
|