Netpulse_SasS/server/internal/store/sla_store.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

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 працює справжній.
2026-08-27 21:17:23 +03:00

600 lines
25 KiB
Go
Raw Permalink 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 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 строку немає
//
// Квартал — 9092 доби. Порахований по сирих вимірах, він СЬОГОДНІ
// правильний, а через два місяці той самий запит на той самий квартал
// дасть інше число: рядків під ним просто не буде, а «немає рядка» й
// «втрат не було» запит не розрізняє. Звіт мовчки поїде до 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
}