П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
538 lines
20 KiB
Go
538 lines
20 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Живий розрахунок і закриття періоду.
|
||
//
|
||
// ЧОМУ ЗАКРИТИЙ ПЕРІОД НЕ ПЕРЕРАХОВУЮТЬ
|
||
//
|
||
// Звіт про доступність — це документ, який показують назовні: клієнту за
|
||
// договором, аудитору на перевірці. У такого документа є одна властивість,
|
||
// без якої він не документ: два роздруки того самого кварталу, зроблені в
|
||
// різні дні, мають збігатися.
|
||
//
|
||
// Розрахунок «на льоту» цієї властивості не має й мати не може. Під ним
|
||
// лежать дані зі строком зберігання, вікна обслуговування, які хтось
|
||
// може дозаповнити заднім числом, перелік хостів, який змінюється щодня,
|
||
// і сама ціль, яку можна відредагувати. Кожна з цих речей рухається
|
||
// незалежно від нас, і жодна не питає дозволу.
|
||
//
|
||
// Тому щойно період скінчився й згортки під ним устоялись (SLASettleLag),
|
||
// він рахується РАЗ і лягає в core.sla_periods разом зі знімками умов:
|
||
// обіцяний відсоток, поріг покриття, пояс, ім'я хоста. Далі це вже не
|
||
// дані, а висновок, і читається він як висновок. Перерахувати можна —
|
||
// але лише свідомо, і кожен перерахунок видно в revision самого рядка.
|
||
|
||
// slaBackfillPeriods — скільки закритих періодів назад добирає фоновий
|
||
// такт.
|
||
//
|
||
// Три. Не «усі»: інсталяція, яку підняли після року простою, інакше
|
||
// порахувала б дванадцять місяців по даних, яких за той рік уже немає, і
|
||
// закрила б їх назавжди порожніми. Три періоди назад покривають звичайну
|
||
// причину пропуску — сервер стояв вихідні — і не дотягуються туди, де
|
||
// закривати вже нема чого.
|
||
const slaBackfillPeriods = 3
|
||
|
||
// slaDevice — хост і межі його життя.
|
||
type slaDevice struct {
|
||
ID string
|
||
Name string
|
||
CreatedAt time.Time
|
||
DeletedAt *time.Time
|
||
}
|
||
|
||
// slaSelectDevices — хости цілі, які ІСНУВАЛИ хоч частину періоду.
|
||
//
|
||
// М'яко видалені сюди входять навмисно. Хост, який прибрали з переліку
|
||
// 20 травня, у травні працював, і звіт за травень без нього показав би
|
||
// кращу картину, ніж була, — просто тому, що найгірший учасник зник зі
|
||
// списку разом із причиною, через яку його прибрали.
|
||
func (s *Store) slaSelectDevices(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
sel Selector, span SLASpan) ([]slaDevice, error) {
|
||
|
||
a := &args{}
|
||
pTenant := a.add(tenantID)
|
||
pFrom := a.add(span.From)
|
||
pTo := a.add(span.To)
|
||
where, err := s.selectorSQL(sel, a, "d.id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q := fmt.Sprintf(`
|
||
SELECT d.id::text, d.name, d.created_at, d.deleted_at
|
||
FROM inv.devices d
|
||
WHERE d.tenant_id = %s
|
||
AND d.created_at < %s
|
||
AND (d.deleted_at IS NULL OR d.deleted_at > %s)
|
||
%s
|
||
ORDER BY d.name
|
||
`, pTenant, pTo, pFrom, where)
|
||
|
||
rows, err := tx.Query(ctx, q, a.vals...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []slaDevice
|
||
for rows.Next() {
|
||
var d slaDevice
|
||
if err := rows.Scan(&d.ID, &d.Name, &d.CreatedAt, &d.DeletedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, d)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// slaBuckets — годинні відра всіх хостів за період, одним запитом.
|
||
//
|
||
// Один запит, а не по хосту: квартал на 500 хостів — це близько мільйона
|
||
// відер, і мільйон запитів по одному ряду перетворив би звіт на
|
||
// півгодинну операцію. Предикат tenant_id тут ОБОВ'ЯЗКОВИЙ і не
|
||
// дублюється RLS: 0011 не вмикає політик на гіпертаблицях і безперервних
|
||
// агрегатах, тобто другого рубежу для ts.icmp_1h не існує.
|
||
func (s *Store) slaBuckets(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
deviceIDs []string, span SLASpan) (map[string][]SLAHourBucket, error) {
|
||
|
||
out := map[string][]SLAHourBucket{}
|
||
if len(deviceIDs) == 0 {
|
||
return out, nil
|
||
}
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT device_id::text, bucket, samples, down_samples
|
||
FROM ts.icmp_1h
|
||
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[])
|
||
AND bucket >= $3 AND bucket < $4
|
||
ORDER BY device_id, bucket
|
||
`, tenantID, deviceIDs, span.From, span.To)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var id string
|
||
var b SLAHourBucket
|
||
if err := rows.Scan(&id, &b.Start, &b.Samples, &b.Down); err != nil {
|
||
return nil, err
|
||
}
|
||
out[id] = append(out[id], b)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// slaMaintenance — вікна обслуговування, розкладені по хостах.
|
||
//
|
||
// Селектор вікна розбирається тим самим selectorSQL, що й придушення
|
||
// алертів. Розійтись їм не можна: вікно, під яким алерт придушено, а SLA
|
||
// зіпсовано, — це найгірший із можливих станів, бо про нього дізнаються
|
||
// не тоді, коли він стався, а через квартал, з чужої претензії.
|
||
func (s *Store) slaMaintenance(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
devices []slaDevice, span SLASpan) (map[string][]SLASpan, bool, error) {
|
||
|
||
out := map[string][]SLASpan{}
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT COALESCE(selector::text, '{}'), lower(period), upper(period),
|
||
(rrule IS NOT NULL)
|
||
FROM alr.maintenance_windows
|
||
WHERE tenant_id = $1 AND exclude_from_sla
|
||
AND period && tstzrange($2, $3, '[)')
|
||
`, tenantID, span.From, span.To)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
type window struct {
|
||
sel Selector
|
||
empty bool
|
||
from, to time.Time
|
||
}
|
||
var windows []window
|
||
hasRRule := false
|
||
for rows.Next() {
|
||
var raw string
|
||
var from, to *time.Time
|
||
var rr bool
|
||
if err := rows.Scan(&raw, &from, &to, &rr); err != nil {
|
||
rows.Close()
|
||
return nil, false, err
|
||
}
|
||
var w window
|
||
if err := json.Unmarshal([]byte(raw), &w.sel); err != nil {
|
||
// Вікно з нерозбірливим селектором ігнорувати не можна: воно
|
||
// б мовчки перетворилось на простій. Трактуємо як «весь
|
||
// кабінет» — бік помилки, що не псує звіт клієнту.
|
||
w.sel = Selector{}
|
||
}
|
||
// Вікно без селектора накриває весь кабінет: так виглядає
|
||
// «вимикаємо все на 30 хвилин». Та сама умова, що й у
|
||
// LoadSuppression.
|
||
w.empty = len(w.sel.DeviceIDs) == 0 && len(w.sel.GroupIDs) == 0 &&
|
||
len(w.sel.SiteIDs) == 0 && len(w.sel.Tags) == 0 &&
|
||
len(w.sel.Kinds) == 0 && len(w.sel.Vendors) == 0 &&
|
||
len(w.sel.TemplateIDs) == 0
|
||
// Незамкнений діапазон ('[2026-01-01,)') означає «і далі»;
|
||
// обрізаємо періодом, а не відкидаємо вікно.
|
||
w.from, w.to = span.From, span.To
|
||
if from != nil && from.After(w.from) {
|
||
w.from = *from
|
||
}
|
||
if to != nil && to.Before(w.to) {
|
||
w.to = *to
|
||
}
|
||
if rr {
|
||
hasRRule = true
|
||
}
|
||
windows = append(windows, w)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return nil, false, err
|
||
}
|
||
if len(windows) == 0 {
|
||
return out, false, nil
|
||
}
|
||
|
||
// Порожній селектор накриває всіх — резолвити нічого.
|
||
inSet := map[string]bool{}
|
||
for _, d := range devices {
|
||
inSet[d.ID] = true
|
||
}
|
||
for _, w := range windows {
|
||
if w.empty {
|
||
for _, d := range devices {
|
||
out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to})
|
||
}
|
||
continue
|
||
}
|
||
ids, err := s.slaSelectDevices(ctx, tx, tenantID, w.sel, span)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
for _, d := range ids {
|
||
if !inSet[d.ID] {
|
||
continue
|
||
}
|
||
out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to})
|
||
}
|
||
}
|
||
return out, hasRRule, nil
|
||
}
|
||
|
||
// slaComputeRows — живий розрахунок цілі за період.
|
||
func (s *Store) slaComputeRows(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
t SLATarget, span SLASpan) ([]SLARow, []string, error) {
|
||
|
||
devices, err := s.slaSelectDevices(ctx, tx, tenantID, t.Selector, span)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
ids := make([]string, 0, len(devices))
|
||
for _, d := range devices {
|
||
ids = append(ids, d.ID)
|
||
}
|
||
buckets, err := s.slaBuckets(ctx, tx, tenantID, ids, span)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
maint, hasRRule, err := s.slaMaintenance(ctx, tx, tenantID, devices, span)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
var warn []string
|
||
if hasRRule {
|
||
warn = append(warn, SLAWarnRRule)
|
||
}
|
||
|
||
rows := make([]SLARow, 0, len(devices))
|
||
for _, d := range devices {
|
||
r := SLARow{DeviceID: d.ID, DeviceName: d.Name, Warnings: []string{}}
|
||
r.SLAOutcome = ComputeSLA(SLAInput{
|
||
From: span.From,
|
||
To: span.To,
|
||
CreatedAt: d.CreatedAt,
|
||
DeletedAt: d.DeletedAt,
|
||
Maintenance: maint[d.ID],
|
||
Buckets: buckets[d.ID],
|
||
TargetPct: t.TargetPct,
|
||
MinCoveragePct: t.MinCoveragePct,
|
||
})
|
||
if hasRRule && len(maint[d.ID]) > 0 {
|
||
r.Warnings = append(r.Warnings, SLAWarnRRule)
|
||
}
|
||
rows = append(rows, r)
|
||
}
|
||
return rows, warn, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Закриття
|
||
// ---------------------------------------------------------------------
|
||
|
||
// ErrSLANotSettled — період ще не можна закривати.
|
||
//
|
||
// Окрема помилка, а не загальний ErrInvalid: клієнт має показати не
|
||
// «некоректний запит», а причину — «зачекайте, згортки під періодом ще
|
||
// рахуються». Ці два тексти ведуть людину в різні боки.
|
||
var ErrSLANotSettled = fmt.Errorf("%w: період ще не завершився або згортки під ним не встоялись", ErrInvalid)
|
||
|
||
// CloseSLAPeriod рахує період один раз і зберігає як факт.
|
||
//
|
||
// Ідемпотентний: повторний виклик на вже закритому періоді нічого не
|
||
// переписує й повертає збережене. Саме через це фоновий такт може
|
||
// сміливо ходити по тих самих періодах щогодини.
|
||
//
|
||
// force переписує закрите — і це єдиний шлях це зробити. Він піднімає
|
||
// revision, тобто лишає слід у самому звіті: два роздруки того самого
|
||
// кварталу з різними числами інакше неможливо розрізнити.
|
||
func (s *Store) CloseSLAPeriod(ctx context.Context, tenantID, targetID string,
|
||
anchor time.Time, force bool) (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.slaCloseTx(ctx, tx, tenantID, t, anchor, force)
|
||
return err
|
||
})
|
||
return rep, err
|
||
}
|
||
|
||
func (s *Store) slaCloseTx(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
t SLATarget, anchor time.Time, force bool) (SLAReport, error) {
|
||
|
||
loc, tzOK := t.Location()
|
||
span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc)
|
||
if err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
// Раніше строку не закриваємо НІКОЛИ, навіть на вимогу.
|
||
//
|
||
// force дозволяє переписати вже пораховане, а не порахувати
|
||
// недорахованим. Різниця вирішальна: закритий на добу раніше
|
||
// квартал недорахував би останні години назавжди, і жодна кнопка
|
||
// цього вже не помітила б.
|
||
if time.Now().Before(span.To.Add(SLASettleLag)) {
|
||
return SLAReport{}, ErrSLANotSettled
|
||
}
|
||
|
||
stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc)
|
||
if err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
if stored.Closed && !force {
|
||
rep, err := s.slaReportTx(ctx, tx, tenantID, t, anchor)
|
||
return rep, err
|
||
}
|
||
|
||
rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span)
|
||
if err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
if !tzOK {
|
||
warn = slaAppendUnique(warn, SLAWarnUnknownTZ)
|
||
}
|
||
if len(t.BusinessHours) > 0 {
|
||
warn = slaAppendUnique(warn, SLAWarnBusinessHours)
|
||
}
|
||
horizon, err := s.slaHorizon(ctx, tx, tenantID)
|
||
if err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
if slaBeyondHorizon(horizon, span.From) {
|
||
// Найважливіше попередження з усіх: частина періоду не має даних
|
||
// не тому, що їх не збирали, а тому, що їх уже видалили. Воно
|
||
// вмерзає в рядок разом із числом — інакше через рік звіт
|
||
// виглядав би як звичайна недостатність покриття.
|
||
warn = slaAppendUnique(warn, SLAWarnBeyondHorizon)
|
||
}
|
||
|
||
revision := 1
|
||
if stored.Closed {
|
||
revision = stored.Revision + 1
|
||
// Дозвіл на правку закритого — рівно на цю транзакцію.
|
||
if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
}
|
||
|
||
from := span.From.In(loc).Format("2006-01-02")
|
||
to := span.To.In(loc).Format("2006-01-02")
|
||
|
||
// Повне перезаписування, а не UPSERT по хостах: за час між
|
||
// розрахунками хост міг випасти з селектора, і залишений рядок
|
||
// показував би в звіті когось, кого ціль уже не стосується.
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM core.sla_periods
|
||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||
AND period = daterange($3::date, $4::date)
|
||
`, tenantID, t.ID, from, to); err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
|
||
for _, r := range rows {
|
||
w := slaAppendUnique(append([]string(nil), r.Warnings...), warn...)
|
||
raw, _ := json.Marshal(w)
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO core.sla_periods
|
||
(tenant_id, sla_target_id, device_id, device_name, period,
|
||
uptime_pct, downtime_sec, maintenance_sec, up_sec, unknown_sec,
|
||
clock_sec, coverage_pct, incidents, breached,
|
||
target_pct, min_coverage_pct, tz, source, closed, revision,
|
||
warnings, computed_at)
|
||
VALUES ($1, $2, $3, $4, daterange($5::date, $6::date),
|
||
$7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||
$16, $17, $18, 'icmp_1h', true, $19, $20::jsonb, now())
|
||
`, tenantID, t.ID, r.DeviceID, r.DeviceName, from, to,
|
||
r.UptimePct, r.DownSec, r.MaintenanceSec, r.UpSec, r.UnknownSec,
|
||
r.ClockSec, r.CoveragePct, r.Incidents, r.Breached,
|
||
t.TargetPct, t.MinCoveragePct, t.TZ, revision, string(raw)); err != nil {
|
||
return SLAReport{}, err
|
||
}
|
||
}
|
||
|
||
now := time.Now()
|
||
return SLAReport{
|
||
Target: t,
|
||
From: span.From,
|
||
To: span.To,
|
||
PeriodFrom: from,
|
||
PeriodTo: to,
|
||
Closed: true,
|
||
Closable: true,
|
||
ComputedAt: &now,
|
||
Revision: revision,
|
||
Rows: rows,
|
||
Horizon: horizon,
|
||
Warnings: warn,
|
||
Totals: slaTotals(rows, t.TargetPct, t.MinCoveragePct),
|
||
}, nil
|
||
}
|
||
|
||
// SLAClosed — скільки періодів закрив прохід і за якими цілями.
|
||
type SLAClosed struct {
|
||
TenantID string
|
||
TargetID string
|
||
Name string
|
||
Period string
|
||
}
|
||
|
||
// CloseDueSLAPeriods закриває все, що вже можна закрити.
|
||
//
|
||
// Фоновий такт, а не «порахуємо, коли відкриють сторінку». Причина
|
||
// проста: періоду, який ніхто не відкрив вчасно, дані під ним не
|
||
// дочекаються. Квартал, уперше відкритий через півроку, закрився б
|
||
// назавжди по тому, що від нього лишилось, — і саме цей рядок відрізняє
|
||
// звіт, який можна показати, від звіту, який можна лише пояснити.
|
||
func (s *Store) CloseDueSLAPeriods(ctx context.Context) ([]SLAClosed, error) {
|
||
tenants, err := s.TenantIDs(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var done []SLAClosed
|
||
for _, tenantID := range tenants {
|
||
targets, err := s.slaEnabledTargets(ctx, tenantID)
|
||
if err != nil {
|
||
return done, err
|
||
}
|
||
for _, t := range targets {
|
||
loc, _ := t.Location()
|
||
// Від найстарішого до найсвіжішого: якщо на середині щось
|
||
// упаде, уже закрите лишиться закритим, а наступний тік
|
||
// продовжить з того ж місця.
|
||
for back := slaBackfillPeriods; back >= 1; back-- {
|
||
span, err := slaPeriodBack(t.PeriodKind, time.Now(), loc, back)
|
||
if err != nil {
|
||
break
|
||
}
|
||
if time.Now().Before(span.To.Add(SLASettleLag)) {
|
||
continue
|
||
}
|
||
closed, err := s.slaCloseIfMissing(ctx, tenantID, t, span)
|
||
if err != nil {
|
||
return done, err
|
||
}
|
||
if closed {
|
||
done = append(done, SLAClosed{
|
||
TenantID: tenantID, TargetID: t.ID, Name: t.Name,
|
||
Period: span.From.In(loc).Format("2006-01-02"),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return done, nil
|
||
}
|
||
|
||
// slaCloseIfMissing — окрема транзакція на кожен період.
|
||
//
|
||
// Не одна на все: закриття кварталу на 500 хостів — це 500 вставок, і
|
||
// тримати їх в одній транзакції з усіма іншими цілями означало б, що
|
||
// збій на останній скасовує роботу за всі попередні.
|
||
func (s *Store) slaCloseIfMissing(ctx context.Context, tenantID string, t SLATarget, span SLASpan) (bool, error) {
|
||
loc, _ := t.Location()
|
||
closed := false
|
||
err := s.InTenantTxBG(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var n int
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT count(*)::int FROM core.sla_periods
|
||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||
AND period = daterange($3::date, $4::date) AND closed
|
||
`, tenantID, t.ID, span.From.In(loc).Format("2006-01-02"),
|
||
span.To.In(loc).Format("2006-01-02")).Scan(&n); err != nil {
|
||
return err
|
||
}
|
||
if n > 0 {
|
||
return nil
|
||
}
|
||
// anchor — будь-яка мить усередині періоду; беремо початок.
|
||
if _, err := s.slaCloseTx(ctx, tx, tenantID, t, span.From, false); err != nil {
|
||
return err
|
||
}
|
||
closed = true
|
||
return nil
|
||
})
|
||
return closed, err
|
||
}
|
||
|
||
func (s *Store) slaEnabledTargets(ctx context.Context, tenantID string) ([]SLATarget, error) {
|
||
all, err := s.SLATargets(ctx, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := all[:0]
|
||
for _, t := range all {
|
||
if t.Enabled {
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// slaPeriodBack — межі періоду, що передує поточному на back кроків.
|
||
func slaPeriodBack(kind string, now time.Time, loc *time.Location, back int) (SLASpan, error) {
|
||
span, err := SLAPeriodBounds(kind, now, loc)
|
||
if err != nil {
|
||
return SLASpan{}, err
|
||
}
|
||
for i := 0; i < back; i++ {
|
||
// Секунда перед початком періоду завжди належить попередньому —
|
||
// незалежно від того, скільки в ньому діб. Віднімання фіксованої
|
||
// довжини тут дало б збій на лютому й на переході часу.
|
||
span, err = SLAPeriodBounds(kind, span.From.Add(-time.Second), loc)
|
||
if err != nil {
|
||
return SLASpan{}, err
|
||
}
|
||
}
|
||
return span, nil
|
||
}
|