П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.
Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.
0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.
0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.
ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
* healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
первинній ініціалізації Postgres слухає лише сокет — compose
вважав базу здоровою, migrate отримував connection refused. На
створеній базі цієї фази немає, тож вада чекала на першого клієнта;
* у білому переліку модулів API не було traps і filecfg — зонд із
приймачем трапів неможливо було зареєструвати взагалі.
ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
600 lines
25 KiB
Go
600 lines
25 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Цілі SLA і закриті періоди: усе, що ходить у базу.
|
||
//
|
||
// ЧОМУ ДЖЕРЕЛО — ts.icmp_1h, А НЕ СИРІ ВИМІРИ
|
||
//
|
||
// Це не питання швидкості, а питання того, чи буде звіт тим самим через
|
||
// два місяці. Строки з 0005, як їх бачить 0064:
|
||
//
|
||
// ts.icmp_samples 35 діб
|
||
// ts.icmp_5m 400 діб
|
||
// ts.icmp_1h строку немає
|
||
//
|
||
// Квартал — 90–92 доби. Порахований по сирих вимірах, він СЬОГОДНІ
|
||
// правильний, а через два місяці той самий запит на той самий квартал
|
||
// дасть інше число: рядків під ним просто не буде, а «немає рядка» й
|
||
// «втрат не було» запит не розрізняє. Звіт мовчки поїде до 100%.
|
||
//
|
||
// ts.icmp_5m покриває квартал, але 400 діб — це політика, яку 0064 дала
|
||
// міняти з веб-форми. ts.icmp_1h — єдиний рівень, якому продукт уже
|
||
// пообіцяв жити: 0005 не заводить йому політики зовсім, а
|
||
// retention_policy.go ставить нижню межу 30 діб зі словами «місячні
|
||
// звіти читають саме звідси».
|
||
//
|
||
// І головне — навіть цього замало. Тому закритий період не читає дані
|
||
// взагалі: він читає сам себе. Див. CloseSLAPeriod.
|
||
|
||
// SLATarget — ціль доступності.
|
||
type SLATarget struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Selector Selector `json:"selector"`
|
||
// TargetPct — обіцяний відсоток. 99.9 за квартал — це 2 год 12 хв
|
||
// допустимого простою.
|
||
TargetPct float64 `json:"target_pct"`
|
||
PeriodKind string `json:"period_kind"`
|
||
TZ string `json:"tz"`
|
||
MinCoveragePct float64 `json:"min_coverage_pct"`
|
||
Enabled bool `json:"enabled"`
|
||
// BusinessHours лежить у схемі з 0008 і НЕ реалізований. Поле
|
||
// віддається як є, щоб форма могла показати його заповненим і
|
||
// поруч — попередження: розрахунок його не звужує.
|
||
BusinessHours json.RawMessage `json:"business_hours,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// Location — пояс цілі, розібраний.
|
||
//
|
||
// Невідоме ім'я поясу не є приводом упасти: у контейнері без tzdata
|
||
// LoadLocation не знайде нічого взагалі, і тоді звіт має вийти в UTC із
|
||
// попередженням, а не не вийти зовсім.
|
||
func (t SLATarget) Location() (*time.Location, bool) {
|
||
if t.TZ == "" {
|
||
return time.UTC, true
|
||
}
|
||
loc, err := time.LoadLocation(t.TZ)
|
||
if err != nil {
|
||
return time.UTC, false
|
||
}
|
||
return loc, true
|
||
}
|
||
|
||
// Коди попереджень. Короткі ключі, бо лежать у jsonb закритого періоду й
|
||
// читаються через рік; людські формулювання — у slaWarningLabels.
|
||
const (
|
||
// Серед вікон обслуговування трапилось повторюване (rrule). RFC 5545
|
||
// у продукті не розгортає ніхто — ані придушення алертів, ані цей
|
||
// розрахунок. Враховано лише базове входження вікна.
|
||
SLAWarnRRule = "rrule_ignored"
|
||
// У цілі заповнені робочі години. Розрахунок їх не звужує.
|
||
SLAWarnBusinessHours = "business_hours_ignored"
|
||
// Період починається раніше, ніж сягає збережена історія годинних
|
||
// згорток. Частина періоду не має даних НЕ тому, що їх не збирали.
|
||
SLAWarnBeyondHorizon = "beyond_horizon"
|
||
// Пояс цілі не впізнано, рахували в UTC.
|
||
SLAWarnUnknownTZ = "unknown_tz"
|
||
// Хоста вже немає: рядок пережив повне видалення (0057). Ім'я
|
||
// показується зі знімка.
|
||
SLAWarnDevicePurged = "device_purged"
|
||
)
|
||
|
||
var slaWarningLabels = map[string]string{
|
||
SLAWarnRRule: "повторювані вікна обслуговування враховано лише першим входженням",
|
||
SLAWarnBusinessHours: "робочі години задано, але розрахунок їх не звужує",
|
||
SLAWarnBeyondHorizon: "початок періоду старший за збережену історію годинних згорток",
|
||
SLAWarnUnknownTZ: "часовий пояс цілі не впізнано, рахували в UTC",
|
||
SLAWarnDevicePurged: "хост видалено назавжди; показано ім'я на момент розрахунку",
|
||
}
|
||
|
||
// SLAWarningLabel — людське формулювання коду попередження.
|
||
func SLAWarningLabel(code string) string {
|
||
if s, ok := slaWarningLabels[code]; ok {
|
||
return s
|
||
}
|
||
return code
|
||
}
|
||
|
||
// SLARow — один хост за один період.
|
||
type SLARow struct {
|
||
DeviceID string `json:"device_id"`
|
||
DeviceName string `json:"device_name"`
|
||
SLAOutcome
|
||
Warnings []string `json:"warnings"`
|
||
}
|
||
|
||
// SLAHorizon — скільки історії РЕАЛЬНО є під цим звітом.
|
||
//
|
||
// Їде в кожній відповіді, а не лежить у довідці. Питання «чому за
|
||
// березень порожньо» має відповідь у тому самому вікні, де видно
|
||
// порожнечу, — інакше на нього відповідає підтримка, і щоразу заново.
|
||
type SLAHorizon struct {
|
||
// Relation — з чого рахували.
|
||
Relation string `json:"relation"`
|
||
// KeepDays — строк на це відношення; nil означає «не видаляється».
|
||
KeepDays *int `json:"keep_days"`
|
||
// Oldest — найстаріше годинне відро, яке зараз є в базі для цього
|
||
// кабінету. Це і є фактичний горизонт, на відміну від наміру.
|
||
Oldest *time.Time `json:"oldest_bucket,omitempty"`
|
||
}
|
||
|
||
// slaBeyondHorizon — чи починається період раніше, ніж сягають дані.
|
||
//
|
||
// Окрема функція, а не умова по місцю, рівно з однієї причини: це
|
||
// найважче помітна з усіх помилок звіту. Період, половина якого просто
|
||
// видалена за строком зберігання, виглядає точнісінько як період, у
|
||
// якому півмережі не опитувалось, — і обидва дають те саме низьке
|
||
// покриття. Різницю знає лише ця умова, і перевіряти її треба тестом, а
|
||
// не читанням.
|
||
func slaBeyondHorizon(h SLAHorizon, from time.Time) bool {
|
||
if h.Oldest == nil {
|
||
// Даних немає взагалі. Це не «за межею горизонту», а порожня
|
||
// база: горизонту, який можна перетнути, ще не існує.
|
||
return false
|
||
}
|
||
return h.Oldest.After(from)
|
||
}
|
||
|
||
// SLAReport — звіт цілі за один період.
|
||
type SLAReport struct {
|
||
Target SLATarget `json:"target"`
|
||
|
||
// Межі періоду. From/To — абсолютний час, PeriodFrom/PeriodTo —
|
||
// календарні дати в поясі цілі (те, що читає людина й що лежить у
|
||
// daterange).
|
||
From time.Time `json:"from"`
|
||
To time.Time `json:"to"`
|
||
PeriodFrom string `json:"period_from"`
|
||
PeriodTo string `json:"period_to"`
|
||
|
||
// Closed — рядки прочитано з core.sla_periods, а не пораховано.
|
||
// Саме це відрізняє звіт від прикидки, і саме це має бачити той,
|
||
// хто збирається його роздрукувати.
|
||
Closed bool `json:"closed"`
|
||
ComputedAt *time.Time `json:"computed_at,omitempty"`
|
||
Revision int `json:"revision,omitempty"`
|
||
|
||
// Closable — період уже скінчився й згортки під ним устоялись.
|
||
Closable bool `json:"closable"`
|
||
|
||
Rows []SLARow `json:"rows"`
|
||
Horizon SLAHorizon `json:"horizon"`
|
||
Warnings []string `json:"warnings"`
|
||
Totals SLAOutcome `json:"totals"`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Цілі
|
||
// ---------------------------------------------------------------------
|
||
|
||
// SLATargets — усі цілі кабінету.
|
||
func (s *Store) SLATargets(ctx context.Context, tenantID string) ([]SLATarget, error) {
|
||
var out []SLATarget
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, name, selector::text, target_pct, period_kind, tz,
|
||
min_coverage_pct, enabled,
|
||
COALESCE(business_hours::text, ''), created_at
|
||
FROM core.sla_targets
|
||
WHERE tenant_id = $1
|
||
ORDER BY name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var t SLATarget
|
||
var sel, bh string
|
||
if err := rows.Scan(&t.ID, &t.Name, &sel, &t.TargetPct, &t.PeriodKind,
|
||
&t.TZ, &t.MinCoveragePct, &t.Enabled, &bh, &t.CreatedAt); err != nil {
|
||
return err
|
||
}
|
||
// Зіпсований селектор не має ховати всю ціль: порожній
|
||
// селектор означає «весь кабінет», і це видно на екрані.
|
||
_ = json.Unmarshal([]byte(sel), &t.Selector)
|
||
if bh != "" && bh != "null" {
|
||
t.BusinessHours = json.RawMessage(bh)
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func (s *Store) slaTarget(ctx context.Context, tx pgx.Tx, tenantID, id string) (SLATarget, error) {
|
||
var t SLATarget
|
||
var sel, bh string
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT id::text, name, selector::text, target_pct, period_kind, tz,
|
||
min_coverage_pct, enabled, COALESCE(business_hours::text, ''), created_at
|
||
FROM core.sla_targets WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, id).Scan(&t.ID, &t.Name, &sel, &t.TargetPct, &t.PeriodKind, &t.TZ,
|
||
&t.MinCoveragePct, &t.Enabled, &bh, &t.CreatedAt)
|
||
if err == pgx.ErrNoRows {
|
||
return t, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return t, err
|
||
}
|
||
_ = json.Unmarshal([]byte(sel), &t.Selector)
|
||
if bh != "" && bh != "null" {
|
||
t.BusinessHours = json.RawMessage(bh)
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
// SLATargetInput — те, що приходить із форми.
|
||
type SLATargetInput struct {
|
||
ID string `json:"id,omitempty"`
|
||
Name string `json:"name"`
|
||
Selector Selector `json:"selector"`
|
||
TargetPct float64 `json:"target_pct"`
|
||
PeriodKind string `json:"period_kind"`
|
||
TZ string `json:"tz"`
|
||
MinCoveragePct float64 `json:"min_coverage_pct"`
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
|
||
// ValidateSLATarget перевіряє ціль до того, як вона щось порахує.
|
||
//
|
||
// Окрема функція, а не перевірки по місцю: рівно ті самі умови треба
|
||
// прикласти і в REST, і у фоновому такті, а розійшовшись, вони дали б
|
||
// ціль, яку форма приймає, а розрахунок не розуміє.
|
||
func ValidateSLATarget(in SLATargetInput) error {
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
return fmt.Errorf("%w: ціль без назви", ErrInvalid)
|
||
}
|
||
switch in.PeriodKind {
|
||
case SLAPeriodDaily, SLAPeriodWeekly, SLAPeriodMonthly, SLAPeriodQuarterly:
|
||
default:
|
||
return fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, in.PeriodKind)
|
||
}
|
||
// Нижня межа не нуль: ціль «0% доступності» не означає нічого, а
|
||
// набирається однією помилкою в порожньому полі.
|
||
if in.TargetPct <= 0 || in.TargetPct > 100 {
|
||
return fmt.Errorf("%w: ціль доступності має бути в межах (0; 100]", ErrInvalid)
|
||
}
|
||
if in.MinCoveragePct < 0 || in.MinCoveragePct > 100 {
|
||
return fmt.Errorf("%w: поріг покриття має бути в межах [0; 100]", ErrInvalid)
|
||
}
|
||
if in.TZ != "" {
|
||
if _, err := time.LoadLocation(in.TZ); err != nil {
|
||
return fmt.Errorf("%w: невідомий часовий пояс %q", ErrInvalid, in.TZ)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SaveSLATarget заводить або оновлює ціль.
|
||
//
|
||
// Пояс і тип періоду ЗМІНЮВАТИ можна, і це навмисно: помилку в них
|
||
// інакше довелось би виправляти видаленням цілі разом з усіма закритими
|
||
// періодами. Наслідок, який знімається знімками в самому періоді: старі
|
||
// закриті рядки лишаються порахованими за старим поясом і старою ціллю,
|
||
// і в них це записано.
|
||
func (s *Store) SaveSLATarget(ctx context.Context, tenantID string, in SLATargetInput) (SLATarget, error) {
|
||
if err := ValidateSLATarget(in); err != nil {
|
||
return SLATarget{}, err
|
||
}
|
||
sel, err := json.Marshal(in.Selector)
|
||
if err != nil {
|
||
return SLATarget{}, err
|
||
}
|
||
tz := in.TZ
|
||
if tz == "" {
|
||
tz = "UTC"
|
||
}
|
||
|
||
var out SLATarget
|
||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var id string
|
||
if in.ID != "" {
|
||
err := tx.QueryRow(ctx, `
|
||
UPDATE core.sla_targets
|
||
SET name = $3, selector = $4::jsonb, target_pct = $5,
|
||
period_kind = $6, tz = $7, min_coverage_pct = $8, enabled = $9
|
||
WHERE tenant_id = $1 AND id = $2
|
||
RETURNING id::text
|
||
`, tenantID, in.ID, in.Name, string(sel), in.TargetPct,
|
||
in.PeriodKind, tz, in.MinCoveragePct, in.Enabled).Scan(&id)
|
||
if err == pgx.ErrNoRows {
|
||
return ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.sla_targets
|
||
(tenant_id, name, selector, target_pct, period_kind, tz,
|
||
min_coverage_pct, enabled)
|
||
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8)
|
||
RETURNING id::text
|
||
`, tenantID, in.Name, string(sel), in.TargetPct, in.PeriodKind, tz,
|
||
in.MinCoveragePct, in.Enabled).Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
out, err = s.slaTarget(ctx, tx, tenantID, id)
|
||
return err
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// DeleteSLATarget видаляє ціль РАЗОМ із закритими періодами.
|
||
//
|
||
// Каскад тут лишається навмисно, на відміну від хоста: період без цілі
|
||
// не має сенсу — у ньому немає ані обіцяного відсотка, ані переліку
|
||
// хостів. А от кнопка мусить казати це вголос, тому кількість періодів,
|
||
// які зникнуть, повертається викликачеві до підтвердження.
|
||
func (s *Store) DeleteSLATarget(ctx context.Context, tenantID, id string) (int, error) {
|
||
var n int
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT count(*)::int FROM core.sla_periods
|
||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||
`, tenantID, id).Scan(&n); err != nil {
|
||
return err
|
||
}
|
||
// Тригер незмінності стоїть і на DELETE: без явного дозволу
|
||
// каскад від цілі не зніс би жодного закритого періоду, і
|
||
// видалення падало б із незрозумілою помилкою.
|
||
if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil {
|
||
return err
|
||
}
|
||
tag, err := tx.Exec(ctx, `
|
||
DELETE FROM core.sla_targets WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
return n, err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Звіт
|
||
// ---------------------------------------------------------------------
|
||
|
||
// SLAReportFor — звіт цілі за період, у який потрапляє anchor.
|
||
//
|
||
// Порядок дій тут і є все рішення задачі:
|
||
//
|
||
// 1. Якщо період ЗАКРИТО — читаємо збережені рядки й більше нічого не
|
||
// рахуємо. Дані під ними могли вже зникнути; висновок лишився.
|
||
// 2. Інакше рахуємо по ts.icmp_1h і віддаємо з позначкою «попередній».
|
||
func (s *Store) SLAReportFor(ctx context.Context, tenantID, targetID string, anchor time.Time) (SLAReport, error) {
|
||
var rep SLAReport
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
t, err := s.slaTarget(ctx, tx, tenantID, targetID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rep, err = s.slaReportTx(ctx, tx, tenantID, t, anchor)
|
||
return err
|
||
})
|
||
return rep, err
|
||
}
|
||
|
||
func (s *Store) slaReportTx(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
t SLATarget, anchor time.Time) (SLAReport, error) {
|
||
|
||
loc, tzOK := t.Location()
|
||
span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc)
|
||
if err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
|
||
rep := SLAReport{
|
||
Target: t,
|
||
From: span.From,
|
||
To: span.To,
|
||
PeriodFrom: span.From.In(loc).Format("2006-01-02"),
|
||
PeriodTo: span.To.In(loc).Format("2006-01-02"),
|
||
Closable: !time.Now().Before(span.To.Add(SLASettleLag)),
|
||
}
|
||
if !tzOK {
|
||
rep.Warnings = append(rep.Warnings, SLAWarnUnknownTZ)
|
||
}
|
||
if len(t.BusinessHours) > 0 {
|
||
rep.Warnings = append(rep.Warnings, SLAWarnBusinessHours)
|
||
}
|
||
if rep.Horizon, err = s.slaHorizon(ctx, tx, tenantID); err != nil {
|
||
return rep, err
|
||
}
|
||
if slaBeyondHorizon(rep.Horizon, span.From) {
|
||
rep.Warnings = append(rep.Warnings, SLAWarnBeyondHorizon)
|
||
}
|
||
|
||
// 1. Уже закритий період читається, а не рахується.
|
||
stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc)
|
||
if err != nil {
|
||
return rep, err
|
||
}
|
||
if len(stored.Rows) > 0 && stored.Closed {
|
||
rep.Closed = true
|
||
rep.Rows = stored.Rows
|
||
rep.ComputedAt = stored.ComputedAt
|
||
rep.Revision = stored.Revision
|
||
// Знімки з рядка перекривають поточну ціль: звіт має показувати
|
||
// умови, за якими його виносили, а не сьогоднішні.
|
||
if stored.TargetPct > 0 {
|
||
rep.Target.TargetPct = stored.TargetPct
|
||
}
|
||
if stored.TZ != "" {
|
||
rep.Target.TZ = stored.TZ
|
||
}
|
||
// Підсумок теж рахується проти ЗНІМКІВ, а не проти сьогоднішньої
|
||
// цілі: інакше піднятий сьогодні поріг покриття заднім числом
|
||
// зробив би торішній вердикт «невідомим».
|
||
rep.Totals = slaTotals(rep.Rows, stored.TargetPct, stored.MinCoverage)
|
||
return rep, nil
|
||
}
|
||
|
||
// 2. Живий розрахунок.
|
||
rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span)
|
||
if err != nil {
|
||
return rep, err
|
||
}
|
||
rep.Rows = rows
|
||
rep.Warnings = slaAppendUnique(rep.Warnings, warn...)
|
||
rep.Totals = slaTotals(rows, t.TargetPct, t.MinCoveragePct)
|
||
return rep, nil
|
||
}
|
||
|
||
// slaHorizon — фактичний горизонт даних під звітом.
|
||
func (s *Store) slaHorizon(ctx context.Context, tx pgx.Tx, tenantID string) (SLAHorizon, error) {
|
||
h := SLAHorizon{Relation: "ts.icmp_1h"}
|
||
// Строк читаємо з core.retention_settings, а не з політики
|
||
// TimescaleDB: саме він показує НАМІР людини, і саме його вона
|
||
// щойно могла змінити. NULL тут — «не видаляється», і це поточний
|
||
// стан продукту.
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT keep_days FROM core.retention_settings WHERE relation = 'ts.icmp_1h'
|
||
`).Scan(&h.KeepDays); err != nil && err != pgx.ErrNoRows {
|
||
return h, err
|
||
}
|
||
// А це — факт: найстаріше відро, яке справді лежить. Розходження
|
||
// між ним і строком означає, що дані молодші за політику (нова
|
||
// інсталяція) — і саме воно пояснює порожній звіт за минулий рік.
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT min(bucket) FROM ts.icmp_1h WHERE tenant_id = $1
|
||
`, tenantID).Scan(&h.Oldest); err != nil && err != pgx.ErrNoRows {
|
||
return h, err
|
||
}
|
||
return h, nil
|
||
}
|
||
|
||
type slaStored struct {
|
||
Closed bool
|
||
Rows []SLARow
|
||
ComputedAt *time.Time
|
||
Revision int
|
||
// Знімки умов, за яких виносився вердикт. Читаються з рядка, а не з
|
||
// цілі: ціль живе далі й могла змінитись, а торішній «виконано» не
|
||
// має ставати «порушено» без жодної події в мережі.
|
||
TargetPct float64
|
||
MinCoverage float64
|
||
TZ string
|
||
}
|
||
|
||
func (s *Store) slaStoredRows(ctx context.Context, tx pgx.Tx, tenantID, targetID string,
|
||
span SLASpan, loc *time.Location) (slaStored, error) {
|
||
|
||
var out slaStored
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT COALESCE(p.device_id::text, ''), p.device_name,
|
||
p.clock_sec, p.maintenance_sec, p.up_sec, p.downtime_sec, p.unknown_sec,
|
||
p.uptime_pct, p.coverage_pct, p.incidents, p.breached,
|
||
p.warnings::text, p.closed, p.revision, p.computed_at,
|
||
COALESCE(p.target_pct, 0), COALESCE(p.min_coverage_pct, 0), p.tz,
|
||
(d.id IS NULL) AS purged
|
||
FROM core.sla_periods p
|
||
LEFT JOIN inv.devices d ON d.id = p.device_id
|
||
WHERE p.tenant_id = $1 AND p.sla_target_id = $2
|
||
AND p.period = daterange($3::date, $4::date)
|
||
ORDER BY p.device_name
|
||
`, tenantID, targetID, span.From.In(loc).Format("2006-01-02"), span.To.In(loc).Format("2006-01-02"))
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var r SLARow
|
||
var warn string
|
||
var closed bool
|
||
var rev int
|
||
var at time.Time
|
||
var tgt, cov float64
|
||
var tz string
|
||
var purged bool
|
||
if err := rows.Scan(&r.DeviceID, &r.DeviceName,
|
||
&r.ClockSec, &r.MaintenanceSec, &r.UpSec, &r.DownSec, &r.UnknownSec,
|
||
&r.UptimePct, &r.CoveragePct, &r.Incidents, &r.Breached,
|
||
&warn, &closed, &rev, &at, &tgt, &cov, &tz, &purged); err != nil {
|
||
return out, err
|
||
}
|
||
_ = json.Unmarshal([]byte(warn), &r.Warnings)
|
||
if purged && r.DeviceID != "" {
|
||
r.Warnings = slaAppendUnique(r.Warnings, SLAWarnDevicePurged)
|
||
}
|
||
// Недостатність відновлюється зі знімка порога, а не з поточної
|
||
// цілі: інакше піднятий сьогодні поріг заднім числом зробив би
|
||
// торішній вердикт «невідомим».
|
||
r.Insufficient = r.ClockSec-r.MaintenanceSec == 0 ||
|
||
r.UpSec+r.DownSec == 0 || r.CoveragePct < cov
|
||
out.Rows = append(out.Rows, r)
|
||
out.Closed = closed
|
||
out.Revision = rev
|
||
out.ComputedAt = &at
|
||
out.TargetPct = tgt
|
||
out.MinCoverage = cov
|
||
out.TZ = tz
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// slaTotals — підсумок по цілі.
|
||
//
|
||
// Підсумкова доступність рахується з СУМИ секунд, а не як середнє
|
||
// відсотків по хостах. Різниця не косметична: середнє відсотків дає
|
||
// однакову вагу хосту, що прожив у періоді добу, і хосту, що прожив
|
||
// квартал, — тобто новий хост, заведений 30 числа й одразу впалий,
|
||
// зіпсував би місяць усій групі.
|
||
func slaTotals(rows []SLARow, targetPct, minCoverage float64) SLAOutcome {
|
||
var t SLAOutcome
|
||
for _, r := range rows {
|
||
t.ClockSec += r.ClockSec
|
||
t.MaintenanceSec += r.MaintenanceSec
|
||
t.UpSec += r.UpSec
|
||
t.DownSec += r.DownSec
|
||
t.UnknownSec += r.UnknownSec
|
||
t.Incidents += r.Incidents
|
||
}
|
||
measured := t.UpSec + t.DownSec
|
||
billable := t.ClockSec - t.MaintenanceSec
|
||
if measured > 0 {
|
||
t.UptimePct = slaRound3(100 * float64(t.UpSec) / float64(measured))
|
||
}
|
||
if billable > 0 {
|
||
t.CoveragePct = slaRound3(100 * float64(measured) / float64(billable))
|
||
}
|
||
t.Insufficient = billable == 0 || measured == 0 || t.CoveragePct < minCoverage
|
||
if !t.Insufficient {
|
||
t.Breached = t.UptimePct < targetPct
|
||
}
|
||
return t
|
||
}
|
||
|
||
func slaAppendUnique(dst []string, add ...string) []string {
|
||
for _, a := range add {
|
||
found := false
|
||
for _, d := range dst {
|
||
if d == a {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
dst = append(dst, a)
|
||
}
|
||
}
|
||
return dst
|
||
}
|