Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
708 lines
30 KiB
Go
708 lines
30 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Картка хоста: усе, що система про нього знає, розкладене по полицях.
|
||
//
|
||
// Головне питання, на яке відповідає цей файл, — «звідки взялась ця
|
||
// метрика». Досі відповіді не існувало ніде: перелік рядів
|
||
// (`ListSeries`) показував ЩО зібрано, перелік перевірок
|
||
// (`ListDeviceChecks`) показував лише РУЧНІ, а шаблонні — ті, що й
|
||
// збирають майже все, — не показував узагалі. Між двома половинами
|
||
// зяяла діра, і саме в неї провалювались чотириста рядів, які
|
||
// збираються без жодного видимого чека.
|
||
|
||
// MetricOrigin — походження одного ряду метрик.
|
||
//
|
||
// Kind відповідає на питання «хто це завів і де це міняти»:
|
||
//
|
||
// template — чек прийшов із шаблону; правити треба в шаблоні
|
||
// auto — чек завела сама система (порти, розпізнавання)
|
||
// manual — чек завела людина в картці хоста
|
||
// none — чека немає; ряд лишився від колишнього збору
|
||
//
|
||
// Останній випадок не теоретичний: на стенді 466 рядів `if.*` живуть
|
||
// без жодного snmp.if-чека й не оновлюються з учора. Без цього поля
|
||
// вони виглядають як звичайні метрики зі старим значенням — тобто як
|
||
// збій збору, а не як історія.
|
||
type MetricOrigin struct {
|
||
Kind string `json:"kind"`
|
||
CheckID string `json:"check_id,omitempty"`
|
||
CheckType string `json:"check_type,omitempty"`
|
||
CheckName string `json:"check_name,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
IntervalSec int `json:"interval_sec,omitempty"`
|
||
TemplateID string `json:"template_id,omitempty"`
|
||
TemplateName string `json:"template_name,omitempty"`
|
||
ItemName string `json:"item_name,omitempty"`
|
||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||
LastError string `json:"last_error,omitempty"`
|
||
}
|
||
|
||
// LatestMetric — рядок розділу «Останні дані».
|
||
type LatestMetric struct {
|
||
SeriesID int64 `json:"series_id"`
|
||
MetricKey string `json:"metric_key"`
|
||
Unit string `json:"unit,omitempty"`
|
||
PluginKey string `json:"plugin_key,omitempty"`
|
||
Labels map[string]string `json:"labels,omitempty"`
|
||
InterfaceID string `json:"interface_id,omitempty"`
|
||
InterfaceName string `json:"interface_name,omitempty"`
|
||
LastValue *float64 `json:"last_value,omitempty"`
|
||
LastAt *time.Time `json:"last_at,omitempty"`
|
||
// Stale рахує сервер, а не браузер: поріг залежить від інтервалу
|
||
// чека, а інтервал знає лише той бік, який щойно зіставив ряд із
|
||
// чеком. Порахований у браузері, він розійшовся б із цим
|
||
// зіставленням при першій же зміні правила.
|
||
Stale bool `json:"stale"`
|
||
Origin MetricOrigin `json:"origin"`
|
||
}
|
||
|
||
// DeviceCheck — перевірка хоста разом із тим, хто її завів.
|
||
//
|
||
// Окремо від store.Check навмисно: той тип обслуговує РЕДАКТОР ручних
|
||
// перевірок, і додати до нього шаблонні означало б показати в редакторі
|
||
// те, що з нього не редагується. Тут — перелік на читання, і в нього
|
||
// входить усе, що справді опитує хост.
|
||
type DeviceCheck struct {
|
||
ID string `json:"id"`
|
||
CheckType string `json:"check_type"`
|
||
TypeName string `json:"type_name,omitempty"`
|
||
Params json.RawMessage `json:"params"`
|
||
IntervalSec int `json:"interval_sec"`
|
||
TimeoutMs int `json:"timeout_ms"`
|
||
Retries int `json:"retries"`
|
||
Enabled bool `json:"enabled"`
|
||
AutoManaged bool `json:"auto_managed"`
|
||
Origin string `json:"origin"`
|
||
TemplateID string `json:"template_id,omitempty"`
|
||
TemplateName string `json:"template_name,omitempty"`
|
||
ItemKey string `json:"template_item_key,omitempty"`
|
||
InterfaceID string `json:"interface_id,omitempty"`
|
||
InterfaceName string `json:"interface_name,omitempty"`
|
||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||
LastError string `json:"last_error,omitempty"`
|
||
// Метрики, які цей ТИП уміє віддавати (довідник core.check_types).
|
||
Metrics []string `json:"metrics"`
|
||
// Скільки рядів система віднесла саме до цієї перевірки. Нуль у
|
||
// ввімкненого чека — найцікавіший рядок у переліку: опитування йде,
|
||
// а даних немає.
|
||
SeriesCount int `json:"series_count"`
|
||
}
|
||
|
||
// DeviceLatest — відповідь розділу «Останні дані».
|
||
type DeviceLatest struct {
|
||
Metrics []LatestMetric `json:"metrics"`
|
||
Checks []DeviceCheck `json:"checks"`
|
||
// Ряди, для яких чека не знайшлось. Дублює лічильник у Metrics,
|
||
// але потрібен окремо: саме він відповідає на питання «чому в
|
||
// хоста без шаблонів усе одно є метрики».
|
||
Orphans int `json:"orphans"`
|
||
}
|
||
|
||
// staleFloor — мінімальний поріг «не оновлюється».
|
||
//
|
||
// Навіть у хвилинного чека між тактами буває пропуск: зонд перечитує
|
||
// чергу, пристрій відповідає з другої спроби. Позначати таке червоним
|
||
// означало б привчити не дивитись на позначку взагалі.
|
||
const staleFloor = 5 * time.Minute
|
||
|
||
// staleNoCheck — поріг для ряду, за яким не стоїть жодна перевірка.
|
||
//
|
||
// Такий ряд не оновиться вже ніколи, але кричати про це в першу ж
|
||
// хвилину після зняття шаблону — зайве: людина щойно це й зробила.
|
||
const staleNoCheck = 15 * time.Minute
|
||
|
||
// DeviceLatestData — усі метрики хоста з останнім значенням і походженням.
|
||
//
|
||
// Два запити, а не N+1: один по рядах (з LATERAL на останню точку) і
|
||
// один по перевірках. Зіставлення робиться в Go — перевірок у хоста
|
||
// одиниці, а виражати пріоритет зіставлення в SQL означало б написати
|
||
// CASE на чотири гілки й потім не мати змоги його перевірити тестом.
|
||
//
|
||
// Про LATERAL замість DISTINCT ON: заміряно на стенді, хост зі 200
|
||
// рядами, 63 тис. вибірок. DISTINCT ON читає гіпертаблицю ЦІЛКОМ
|
||
// (Seq Scan по всіх чанках) — 20.1 мс, і зростає разом з історією.
|
||
// LATERAL робить один індексний доторк на ряд у кожному чанку —
|
||
// 0.78 мс, і зростає разом із кількістю рядів, тобто з тим, що
|
||
// показуємо. Через рік retention різниця буде не в 25 разів.
|
||
func (s *Store) DeviceLatestData(ctx context.Context, tenantID, deviceID string) (*DeviceLatest, error) {
|
||
out := &DeviceLatest{Metrics: []LatestMetric{}, Checks: []DeviceCheck{}}
|
||
|
||
checks, err := s.DeviceChecksAll(ctx, tenantID, deviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out.Checks = checks
|
||
|
||
// Назви елементів шаблонів: metric_key → людська назва. Саме вона
|
||
// відповідає на «що це число означає» — ключ `sys.uptime_sec` це
|
||
// пояснює, а `1.3.6.1.2.1.1.3.0` вже ні.
|
||
itemNames, err := s.deviceTemplateItemNames(ctx, tenantID, deviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT s.id, s.metric_key, COALESCE(s.unit,''), COALESCE(s.plugin_key::text,''),
|
||
s.labels::text,
|
||
COALESCE(s.interface_id::text,''), COALESCE(i.name,''),
|
||
l.value, l.ts
|
||
FROM ts.series s
|
||
LEFT JOIN inv.interfaces i ON i.id = s.interface_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT m.value, m.ts FROM ts.samples m
|
||
WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1
|
||
) l ON true
|
||
WHERE s.tenant_id = $1 AND s.device_id = $2
|
||
ORDER BY s.metric_key, i.if_index NULLS FIRST, i.name
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var m LatestMetric
|
||
var labels string
|
||
if err := rows.Scan(&m.SeriesID, &m.MetricKey, &m.Unit, &m.PluginKey,
|
||
&labels, &m.InterfaceID, &m.InterfaceName,
|
||
&m.LastValue, &m.LastAt); err != nil {
|
||
return err
|
||
}
|
||
m.Labels = decodeLabels(labels)
|
||
out.Metrics = append(out.Metrics, m)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
now := time.Now()
|
||
seriesPerCheck := map[string]int{}
|
||
for i := range out.Metrics {
|
||
m := &out.Metrics[i]
|
||
m.Origin = matchOrigin(m, checks, itemNames)
|
||
if m.Origin.CheckID != "" {
|
||
seriesPerCheck[m.Origin.CheckID]++
|
||
} else {
|
||
out.Orphans++
|
||
}
|
||
m.Stale = isStale(m, now)
|
||
}
|
||
for i := range out.Checks {
|
||
out.Checks[i].SeriesCount = seriesPerCheck[out.Checks[i].ID]
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// isStale — чи перестав ряд оновлюватись.
|
||
func isStale(m *LatestMetric, now time.Time) bool {
|
||
if m.LastAt == nil {
|
||
return true
|
||
}
|
||
limit := staleNoCheck
|
||
if m.Origin.IntervalSec > 0 && m.Origin.Enabled {
|
||
// Три такти: один пропуск — це життя, три поспіль — це збій.
|
||
if d := 3 * time.Duration(m.Origin.IntervalSec) * time.Second; d > staleFloor {
|
||
limit = d
|
||
} else {
|
||
limit = staleFloor
|
||
}
|
||
}
|
||
return now.Sub(*m.LastAt) > limit
|
||
}
|
||
|
||
// checkParams — те з params, що дозволяє впізнати «свої» ряди.
|
||
type checkParams struct {
|
||
// snmp.get: людина (або шаблон) прямо назвала ключ метрики.
|
||
OIDs []struct {
|
||
MetricKey string `json:"metric_key"`
|
||
} `json:"oids"`
|
||
// snmp.if: перелік портів складає автовиявлення.
|
||
Interfaces []struct {
|
||
InterfaceID string `json:"interface_id"`
|
||
} `json:"interfaces"`
|
||
}
|
||
|
||
// matchOrigin зіставляє ряд із перевіркою, яка його наповнює.
|
||
//
|
||
// Прив'язки ряду до чека в схемі немає: агент реєструє серію по
|
||
// (пристрій, ключ метрики, мітки), а не по чеку, і правильно робить —
|
||
// одна серія переживає перескладання пачки OID. Тож зіставлення —
|
||
// висновок, і робиться воно за спаданням надійності:
|
||
//
|
||
// 1. ключ метрики прямо названий у params чека (snmp.get) — точно він;
|
||
// 2. порт ряду є в переліку портів чека (snmp.if) — точно він;
|
||
// 3. ключ метрики стоїть у довіднику типу (core.check_types.metrics);
|
||
// 4. збігається лише плагін — це вже здогад, але кращий за «невідомо».
|
||
//
|
||
// Порядок важливіший за повноту: помилитись тут означає показати
|
||
// людині не той шаблон, у який іти щось міняти.
|
||
func matchOrigin(m *LatestMetric, checks []DeviceCheck, itemNames map[string]string) MetricOrigin {
|
||
best := -1
|
||
bestPri := 99
|
||
|
||
for i := range checks {
|
||
c := &checks[i]
|
||
pri := 99
|
||
|
||
var p checkParams
|
||
if len(c.Params) > 0 {
|
||
_ = json.Unmarshal(c.Params, &p)
|
||
}
|
||
|
||
for _, o := range p.OIDs {
|
||
if o.MetricKey != "" && o.MetricKey == m.MetricKey {
|
||
pri = 1
|
||
break
|
||
}
|
||
}
|
||
if pri > 2 && m.InterfaceID != "" {
|
||
for _, iface := range p.Interfaces {
|
||
if iface.InterfaceID == m.InterfaceID {
|
||
pri = 2
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if pri > 3 {
|
||
for _, mk := range c.Metrics {
|
||
if mk == m.MetricKey {
|
||
pri = 3
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if pri > 4 && m.PluginKey != "" && pluginOf(c.CheckType) == m.PluginKey {
|
||
pri = 4
|
||
}
|
||
|
||
if pri < bestPri {
|
||
best, bestPri = i, pri
|
||
}
|
||
}
|
||
|
||
if best < 0 || bestPri == 99 {
|
||
return MetricOrigin{Kind: "none", ItemName: itemNames[m.MetricKey]}
|
||
}
|
||
|
||
c := &checks[best]
|
||
return MetricOrigin{
|
||
Kind: c.Origin,
|
||
CheckID: c.ID,
|
||
CheckType: c.CheckType,
|
||
CheckName: c.TypeName,
|
||
Enabled: c.Enabled,
|
||
IntervalSec: c.IntervalSec,
|
||
TemplateID: c.TemplateID,
|
||
TemplateName: c.TemplateName,
|
||
ItemName: itemNames[m.MetricKey],
|
||
LastRunAt: c.LastRunAt,
|
||
LastError: c.LastError,
|
||
}
|
||
}
|
||
|
||
// pluginOf — «icmp.ping» → «icmp». Обмеження check_types_prefix_matches_plugin
|
||
// гарантує, що ключ типу завжди починається з ключа плагіна.
|
||
func pluginOf(checkType string) string {
|
||
if i := strings.IndexByte(checkType, '.'); i > 0 {
|
||
return checkType[:i]
|
||
}
|
||
return checkType
|
||
}
|
||
|
||
// DeviceChecksAll — УСІ перевірки хоста, включно зі шаблонними.
|
||
//
|
||
// ListDeviceChecks навмисно віддає лише ручні: він годує редактор, а
|
||
// шаблонний чек звідти не редагується. Але людині, яка питає «що взагалі
|
||
// опитує цей хост», потрібні саме всі — інакше на екрані стоїть «одна
|
||
// перевірка», а метрик приїжджає шість.
|
||
func (s *Store) DeviceChecksAll(ctx context.Context, tenantID, deviceID string) ([]DeviceCheck, error) {
|
||
out := []DeviceCheck{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT c.id::text, c.check_type, COALESCE(ct.name,''), c.params::text,
|
||
c.interval_sec, c.timeout_ms, c.retries, c.enabled,
|
||
COALESCE(ct.auto_managed,false),
|
||
COALESCE(c.template_id::text,''), COALESCE(t.name,''),
|
||
COALESCE(c.template_item_key,''),
|
||
COALESCE(c.interface_id::text,''), COALESCE(i.name,''),
|
||
c.last_run_at, c.next_run_at, COALESCE(c.last_error,''),
|
||
COALESCE(ct.metrics::text,'[]')
|
||
FROM core.checks c
|
||
LEFT JOIN core.check_types ct ON ct.key = c.check_type
|
||
LEFT JOIN tpl.templates t ON t.id = c.template_id
|
||
LEFT JOIN inv.interfaces i ON i.id = c.interface_id
|
||
WHERE c.tenant_id = $1 AND c.device_id = $2
|
||
ORDER BY c.check_type, t.name
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c DeviceCheck
|
||
var params, metrics string
|
||
if err := rows.Scan(&c.ID, &c.CheckType, &c.TypeName, ¶ms,
|
||
&c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled,
|
||
&c.AutoManaged, &c.TemplateID, &c.TemplateName, &c.ItemKey,
|
||
&c.InterfaceID, &c.InterfaceName,
|
||
&c.LastRunAt, &c.NextRunAt, &c.LastError, &metrics); err != nil {
|
||
return err
|
||
}
|
||
c.Params = json.RawMessage(params)
|
||
c.Metrics = []string{}
|
||
_ = json.Unmarshal([]byte(metrics), &c.Metrics)
|
||
switch {
|
||
case c.TemplateID != "":
|
||
c.Origin = "template"
|
||
case c.AutoManaged:
|
||
c.Origin = "auto"
|
||
default:
|
||
c.Origin = "manual"
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// deviceTemplateItemNames — назви елементів шаблонів, прив'язаних до хоста.
|
||
//
|
||
// Ключ — metric_key, бо саме він є в ряді. Збіг ключа в двох шаблонах
|
||
// означає одну й ту саму метрику, названу двічі; беремо першу за
|
||
// алфавітом шаблону, щоб підпис не стрибав між перезавантаженнями.
|
||
func (s *Store) deviceTemplateItemNames(ctx context.Context, tenantID, deviceID string) (map[string]string, error) {
|
||
out := map[string]string{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT COALESCE(it.metric_key, ''), COALESCE(it.name, '')
|
||
FROM tpl.device_templates dt
|
||
JOIN tpl.templates t ON t.id = dt.template_id
|
||
JOIN tpl.items it ON it.template_id = dt.template_id
|
||
WHERE dt.tenant_id = $1 AND dt.device_id = $2
|
||
ORDER BY t.name, it.key
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var key, name string
|
||
if err := rows.Scan(&key, &name); err != nil {
|
||
return err
|
||
}
|
||
// Порожній ключ трапляється: елемент icmp-шаблону описує
|
||
// перевірку, а не окрему метрику (їх у неї три). Такий
|
||
// запис зіставляти нема з чим, і в мапі він лише зайняв би
|
||
// ключ "" — тобто підписав би собою перший-ліпший ряд без
|
||
// ключа, якби такий колись з'явився.
|
||
//
|
||
// COALESCE у запиті обов'язковий: у tpl.items колонка
|
||
// NULLABLE, і в живих даних там саме NULL, а не порожній
|
||
// рядок. Без нього сканування падає, і сторінка хоста
|
||
// віддає «внутрішню помилку» замість усіх своїх даних —
|
||
// через один рядок довідника, який до цих даних не має
|
||
// стосунку.
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if _, ok := out[key]; !ok {
|
||
out[key] = name
|
||
}
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// DeviceInterface — порт із автовиявлення разом з останнім трафіком.
|
||
type DeviceInterface struct {
|
||
ID string `json:"id"`
|
||
IfIndex *int64 `json:"if_index,omitempty"`
|
||
Name string `json:"name"`
|
||
Alias string `json:"alias,omitempty"`
|
||
Type string `json:"type,omitempty"`
|
||
MAC string `json:"mac,omitempty"`
|
||
MTU *int `json:"mtu,omitempty"`
|
||
SpeedBps *int64 `json:"speed_bps,omitempty"`
|
||
Duplex string `json:"duplex,omitempty"`
|
||
AdminStatus string `json:"admin_status"`
|
||
OperStatus string `json:"oper_status"`
|
||
IsUplink bool `json:"is_uplink"`
|
||
Monitored bool `json:"monitored"`
|
||
LastChange *time.Time `json:"last_change_at,omitempty"`
|
||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||
// Останні лічильники — те, заради чого на порт і дивляться.
|
||
CountersAt *time.Time `json:"counters_at,omitempty"`
|
||
InBps *float64 `json:"in_bps,omitempty"`
|
||
OutBps *float64 `json:"out_bps,omitempty"`
|
||
UtilIn *float64 `json:"util_in_pct,omitempty"`
|
||
UtilOut *float64 `json:"util_out_pct,omitempty"`
|
||
InErrors *int64 `json:"in_errors,omitempty"`
|
||
OutErrors *int64 `json:"out_errors,omitempty"`
|
||
// Скільки рядів метрик прив'язано саме до цього порту: звідси
|
||
// людина переходить у «Останні дані», а не гадає, чи щось збирається.
|
||
SeriesCount int `json:"series_count"`
|
||
}
|
||
|
||
// DeviceInterfaces — порти хоста з останніми лічильниками.
|
||
//
|
||
// Один запит на весь перелік: у JUN.QFX сто портів, і окремий запит на
|
||
// кожен перетворив би відкриття вкладки на сотню походів у базу. Заміряно
|
||
// на стенді: 0.90 мс на сто портів.
|
||
func (s *Store) DeviceInterfaces(ctx context.Context, tenantID, deviceID string) ([]DeviceInterface, error) {
|
||
out := []DeviceInterface{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT i.id::text, i.if_index, i.name, COALESCE(i.alias,''),
|
||
COALESCE(i.type,''), COALESCE(i.mac::text,''), i.mtu,
|
||
i.speed_bps, COALESCE(i.duplex,''),
|
||
i.admin_status::text, i.oper_status::text,
|
||
i.is_uplink, i.monitored, i.last_change_at, i.updated_at,
|
||
f.ts, f.in_bps, f.out_bps, f.util_in_pct, f.util_out_pct,
|
||
f.in_errors, f.out_errors,
|
||
COALESCE(sc.n, 0)
|
||
FROM inv.interfaces i
|
||
LEFT JOIN LATERAL (
|
||
-- util_* лежать як real; приводимо явно, щоб не залежати
|
||
-- від того, чи вміє драйвер класти float4 у float64.
|
||
SELECT c.ts, c.in_bps, c.out_bps,
|
||
c.util_in_pct::double precision AS util_in_pct,
|
||
c.util_out_pct::double precision AS util_out_pct,
|
||
c.in_errors, c.out_errors
|
||
FROM ts.if_counters c
|
||
WHERE c.interface_id = i.id ORDER BY c.ts DESC LIMIT 1
|
||
) f ON true
|
||
LEFT JOIN (
|
||
SELECT interface_id, count(*) AS n FROM ts.series
|
||
WHERE tenant_id = $1 AND device_id = $2 AND interface_id IS NOT NULL
|
||
GROUP BY interface_id
|
||
) sc ON sc.interface_id = i.id
|
||
WHERE i.tenant_id = $1 AND i.device_id = $2
|
||
ORDER BY i.if_index NULLS LAST, i.name
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var d DeviceInterface
|
||
if err := rows.Scan(&d.ID, &d.IfIndex, &d.Name, &d.Alias, &d.Type,
|
||
&d.MAC, &d.MTU, &d.SpeedBps, &d.Duplex,
|
||
&d.AdminStatus, &d.OperStatus, &d.IsUplink, &d.Monitored,
|
||
&d.LastChange, &d.UpdatedAt,
|
||
&d.CountersAt, &d.InBps, &d.OutBps, &d.UtilIn, &d.UtilOut,
|
||
&d.InErrors, &d.OutErrors, &d.SeriesCount); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, d)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// DeviceCounts — скільки чого система про хост зберігає.
|
||
//
|
||
// Це зміст картки, а не статистика: кожне число — це підпис під
|
||
// вкладкою, у яку по ньому й переходять. Порожня вкладка, про яку не
|
||
// сказано заздалегідь, читається як зламана.
|
||
type DeviceCounts struct {
|
||
Checks int `json:"checks"`
|
||
ChecksTemplate int `json:"checks_template"`
|
||
ChecksManual int `json:"checks_manual"`
|
||
ChecksAuto int `json:"checks_auto"`
|
||
Series int `json:"series"`
|
||
Interfaces int `json:"interfaces"`
|
||
InterfacesUp int `json:"interfaces_up"`
|
||
Configs int `json:"configs"`
|
||
Alerts int `json:"alerts"`
|
||
}
|
||
|
||
// TemplateRef — шаблон, прив'язаний до хоста.
|
||
type TemplateRef struct {
|
||
ID string `json:"id"`
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Items int `json:"items"`
|
||
}
|
||
|
||
// CredentialRef — доступ, прив'язаний до хоста.
|
||
type CredentialRef struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Proto string `json:"proto"`
|
||
}
|
||
|
||
// DeviceOverview — вкладка «Огляд»: паспорт хоста й зміст решти вкладок.
|
||
type DeviceOverview struct {
|
||
// Розпізнане по SNMP. Разом із помилкою й часом: «модель порожня»
|
||
// і «розпізнавання впало годину тому» — різні діагнози, і лікуються
|
||
// вони по-різному.
|
||
SysObjectID string `json:"sys_object_id,omitempty"`
|
||
SysDescr string `json:"sys_descr,omitempty"`
|
||
SystemName string `json:"system_name,omitempty"`
|
||
FQDN string `json:"fqdn,omitempty"`
|
||
Serial string `json:"serial_number,omitempty"`
|
||
ChassisID string `json:"chassis_id,omitempty"`
|
||
BaseMAC string `json:"base_mac,omitempty"`
|
||
Source string `json:"source,omitempty"`
|
||
IdentifiedAt *time.Time `json:"identified_at,omitempty"`
|
||
IdentifyRequestedAt *time.Time `json:"identify_requested_at,omitempty"`
|
||
HardwareChangedAt *time.Time `json:"hardware_changed_at,omitempty"`
|
||
IdentifyError string `json:"identify_error,omitempty"`
|
||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||
Notes string `json:"notes,omitempty"`
|
||
|
||
AgentID string `json:"agent_id,omitempty"`
|
||
AgentName string `json:"agent_name,omitempty"`
|
||
AgentStatus string `json:"agent_status,omitempty"`
|
||
|
||
Templates []TemplateRef `json:"templates"`
|
||
Credentials []CredentialRef `json:"credentials"`
|
||
Counts DeviceCounts `json:"counts"`
|
||
|
||
LastConfigAt *time.Time `json:"last_config_at,omitempty"`
|
||
LastJobStatus string `json:"last_job_status,omitempty"`
|
||
LastJobAt *time.Time `json:"last_job_at,omitempty"`
|
||
LastJobError string `json:"last_job_error,omitempty"`
|
||
// Остання успішна відповідь пінга — окремо від last_seen_at, бо
|
||
// той оновлює будь-який успішний чек.
|
||
LastPingAt *time.Time `json:"last_ping_at,omitempty"`
|
||
}
|
||
|
||
// DeviceOverviewData — паспорт хоста одним запитом плюс два дрібні переліки.
|
||
//
|
||
// Скалярні підзапити, а не окремі походи: усі вони — count по індексу
|
||
// device_id, і планувальник рахує їх у тому самому проході.
|
||
func (s *Store) DeviceOverviewData(ctx context.Context, tenantID, deviceID string) (*DeviceOverview, error) {
|
||
o := &DeviceOverview{Templates: []TemplateRef{}, Credentials: []CredentialRef{}}
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''),
|
||
COALESCE(d.system_name,''), COALESCE(d.fqdn,''),
|
||
COALESCE(d.serial_number,''), COALESCE(d.chassis_id,''),
|
||
COALESCE(d.base_mac::text,''), COALESCE(d.source,''),
|
||
d.identified_at, d.identify_requested_at, d.hardware_changed_at,
|
||
COALESCE(d.identify_error,''), d.status_changed_at,
|
||
COALESCE(d.notes,''),
|
||
COALESCE(d.agent_id::text,''), COALESCE(ag.name,''),
|
||
COALESCE(ag.status::text,''),
|
||
(SELECT count(*) FROM core.checks c WHERE c.device_id = d.id),
|
||
(SELECT count(*) FROM core.checks c
|
||
WHERE c.device_id = d.id AND c.template_id IS NOT NULL),
|
||
(SELECT count(*) FROM core.checks c
|
||
JOIN core.check_types ct ON ct.key = c.check_type
|
||
WHERE c.device_id = d.id AND c.template_id IS NULL AND ct.auto_managed),
|
||
(SELECT count(*) FROM ts.series s WHERE s.device_id = d.id),
|
||
(SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id),
|
||
(SELECT count(*) FROM inv.interfaces i
|
||
WHERE i.device_id = d.id AND i.oper_status = 'up'),
|
||
(SELECT count(*) FROM ncm.configs cf WHERE cf.device_id = d.id),
|
||
(SELECT max(cf.collected_at) FROM ncm.configs cf WHERE cf.device_id = d.id),
|
||
(SELECT count(*) FROM alr.alerts a
|
||
WHERE a.device_id = d.id AND a.state IN ('firing','acknowledged','suppressed')),
|
||
(SELECT max(ic.ts) FROM ts.icmp_samples ic
|
||
WHERE ic.device_id = d.id AND ic.reachable)
|
||
FROM inv.devices d
|
||
LEFT JOIN core.agents ag ON ag.id = d.agent_id
|
||
WHERE d.tenant_id = $1 AND d.id = $2 AND d.deleted_at IS NULL
|
||
`, tenantID, deviceID).Scan(
|
||
&o.SysObjectID, &o.SysDescr, &o.SystemName, &o.FQDN,
|
||
&o.Serial, &o.ChassisID, &o.BaseMAC, &o.Source,
|
||
&o.IdentifiedAt, &o.IdentifyRequestedAt, &o.HardwareChangedAt,
|
||
&o.IdentifyError, &o.StatusChangedAt, &o.Notes,
|
||
&o.AgentID, &o.AgentName, &o.AgentStatus,
|
||
&o.Counts.Checks, &o.Counts.ChecksTemplate, &o.Counts.ChecksAuto,
|
||
&o.Counts.Series, &o.Counts.Interfaces, &o.Counts.InterfacesUp,
|
||
&o.Counts.Configs, &o.LastConfigAt, &o.Counts.Alerts, &o.LastPingAt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
o.Counts.ChecksManual = o.Counts.Checks - o.Counts.ChecksTemplate - o.Counts.ChecksAuto
|
||
|
||
trows, err := tx.Query(ctx, `
|
||
SELECT t.id::text, t.key::text, t.name,
|
||
(SELECT count(*) FROM tpl.items it WHERE it.template_id = t.id)
|
||
FROM tpl.device_templates dt
|
||
JOIN tpl.templates t ON t.id = dt.template_id
|
||
WHERE dt.tenant_id = $1 AND dt.device_id = $2
|
||
ORDER BY t.name
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer trows.Close()
|
||
for trows.Next() {
|
||
var t TemplateRef
|
||
if err := trows.Scan(&t.ID, &t.Key, &t.Name, &t.Items); err != nil {
|
||
return err
|
||
}
|
||
o.Templates = append(o.Templates, t)
|
||
}
|
||
if err := trows.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
crows, err := tx.Query(ctx, `
|
||
SELECT c.id::text, c.name, c.proto::text
|
||
FROM inv.device_credentials dc
|
||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||
WHERE c.tenant_id = $1 AND dc.device_id = $2
|
||
ORDER BY c.proto, c.name
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer crows.Close()
|
||
for crows.Next() {
|
||
var c CredentialRef
|
||
if err := crows.Scan(&c.ID, &c.Name, &c.Proto); err != nil {
|
||
return err
|
||
}
|
||
o.Credentials = append(o.Credentials, c)
|
||
}
|
||
if err := crows.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Останнє завдання збору конфігу — окремо від самих версій:
|
||
// найцікавіший стан це «версій три, а останній збір упав».
|
||
var status, jobErr string
|
||
var at *time.Time
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT status::text, COALESCE(error,''), COALESCE(finished_at, created_at)
|
||
FROM ncm.jobs
|
||
WHERE tenant_id = $1 AND device_id = $2
|
||
ORDER BY created_at DESC LIMIT 1
|
||
`, tenantID, deviceID).Scan(&status, &jobErr, &at)
|
||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
return err
|
||
}
|
||
if err == nil {
|
||
o.LastJobStatus, o.LastJobError, o.LastJobAt = status, jobErr, at
|
||
}
|
||
return nil
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return o, nil
|
||
}
|