Netpulse_SasS/server/internal/httpapi/sla.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

296 lines
12 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 httpapi
import (
"errors"
"net/http"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// Звіти SLA: доступність за період і вивантаження.
//
// ПРО ПРАВА
//
// Дивитись — devices:read. Доступність своєї мережі бачить кожен, хто
// взагалі бачить моніторинг: ховати її немає від кого, а не побачити
// наближення до порога вчасно коштує грошей за договором. Те саме
// рішення, що й для сховища (0064) та дзеркала конфігів (0054).
//
// Заводити цілі й закривати періоди — settings:write. Ціль SLA — це
// зобов'язання ОРГАНІЗАЦІЇ перед клієнтом, того самого класу, що й
// строки зберігання: вона описує не один хост, а те, під чим
// підписались. Закриття періоду ще й незворотне за наслідками: після
// нього число стає фактом, який показують назовні.
//
// Окремих ключів sla:read / sla:write свідомо не заводимо. Кожен новий
// ключ треба роздати ролям, показати на екрані прав і пояснити — тобто
// взяти з людини плату за розрізнення, якого вона не просила. Завести
// їх пізніше можна; забрати роздане право назад — уже ні.
// Ключі прав, які питають обробники цього файла.
const (
slaReadPerm = "devices:read"
slaWritePerm = "settings:write"
)
// slaAnchor — мить усередині періоду, який просять.
//
// Клієнт шле дату (?date=2026-04-15), а не межі періоду. Це навмисно:
// межі кварталу — властивість цілі (її типу періоду й поясу), і давати
// клієнту рахувати їх самому означає завести другу реалізацію
// календаря, яка розійдеться з нашою на переході часу або на межі року.
// Порожнє поле означає «попередній період»: саме його питають на аудиті,
// і саме він єдиний, який уже можна закрити.
func slaAnchor(r *http.Request) (time.Time, bool, error) {
raw := r.URL.Query().Get("date")
if raw == "" {
return time.Time{}, false, nil
}
t, err := time.Parse("2006-01-02", raw)
if err != nil {
return time.Time{}, false, err
}
// Полудень, а не опівніч. Дата приходить без поясу, тобто читається
// як опівніч UTC; у поясі цілі це вже сусідня доба, а на межі місяця
// — сусідній період. Полудень лишається тією самою добою в будь-якому
// поясі Землі, тож звіт за 1 липня не виявиться звітом за червень.
return t.Add(12 * time.Hour), true, nil
}
// resolveSLAAnchor доводить якір до конкретної миті всередині періоду.
func (s *Server) resolveSLAAnchor(r *http.Request, t store.SLATarget) (time.Time, error) {
anchor, explicit, err := slaAnchor(r)
if err != nil {
return time.Time{}, err
}
if explicit {
return anchor, nil
}
loc, _ := t.Location()
prev, err := store.SLAPrevPeriod(t.PeriodKind, time.Now(), loc)
if err != nil {
return time.Time{}, err
}
return prev.From, nil
}
// handleListSLATargets — перелік цілей.
func (s *Server) handleListSLATargets(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, slaReadPerm) {
return
}
targets, err := s.store.SLATargets(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "цілі SLA", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"targets": targets,
// Правило «кому вільно міняти» живе на сервері в одному місці.
// Клієнт міг би вивести його зі свого переліку прав, але тоді
// воно жило б у двох і розійшлося б на першій же зміні.
"can_edit": p.Can(slaWritePerm),
// Скільки чекати після кінця періоду до закриття. Клієнт показує
// це поруч із кнопкою: інакше «закрити» неактивна без пояснення.
"settle_lag_sec": int(store.SLASettleLag.Seconds()),
})
}
// handleSaveSLATarget заводить або оновлює ціль.
func (s *Server) handleSaveSLATarget(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, slaWritePerm) {
return
}
var in store.SLATargetInput
if !decodeBody(w, r, &in) {
return
}
if id := r.PathValue("id"); id != "" {
in.ID = id
}
out, err := s.store.SaveSLATarget(r.Context(), p.TenantID, in)
if err != nil {
s.writeStoreError(w, "збереження цілі SLA", err)
return
}
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionSLATargetSave,
ObjectType: store.AuditObjectSLATarget,
ObjectID: out.ID,
Meta: map[string]any{
"name": out.Name,
"target_pct": out.TargetPct,
"period_kind": out.PeriodKind,
"tz": out.TZ,
"enabled": out.Enabled,
},
}); err != nil {
s.log.Error("аудит цілі SLA", "err", err)
}
writeJSON(w, http.StatusOK, out)
}
// handleDeleteSLATarget видаляє ціль разом із її закритими періодами.
func (s *Server) handleDeleteSLATarget(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, slaWritePerm) {
return
}
id := r.PathValue("id")
n, err := s.store.DeleteSLATarget(r.Context(), p.TenantID, id)
if err != nil {
s.writeStoreError(w, "видалення цілі SLA", err)
return
}
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionSLATargetDelete,
ObjectType: store.AuditObjectSLATarget,
ObjectID: id,
// Скільки закритих звітів зникло разом із ціллю. Саме це число
// шукатимуть у журналі, коли спитають «а де торішній звіт».
Meta: map[string]any{"periods_deleted": n},
}); err != nil {
s.log.Error("аудит видалення цілі SLA", "err", err)
}
writeJSON(w, http.StatusOK, map[string]any{"periods_deleted": n})
}
// handleSLAReport — звіт цілі за період.
func (s *Server) handleSLAReport(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, slaReadPerm) {
return
}
rep, err := s.slaReport(r, p)
if err != nil {
s.writeSLAError(w, err)
return
}
writeJSON(w, http.StatusOK, rep)
}
// handleSLAReportCSV — те саме вивантаженням.
//
// Окремий обробник, а не параметр формату в попередньому: відповідь тут
// не JSON, і плутати два типи вмісту в одному шляху означає, що клієнт
// має вгадувати, як її читати. Квиток завантаження (0038) тут не
// потрібен: звіт — це десятки кілобайтів, а не десятки мегабайтів, і
// вкладка забере його вмить.
func (s *Server) handleSLAReportCSV(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, slaReadPerm) {
return
}
rep, err := s.slaReport(r, p)
if err != nil {
s.writeSLAError(w, err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="`+store.SLAReportFileName(rep)+`"`)
// Звіт за закритий період незмінний, але кешувати його однаково не
// можна: попередній розрахунок за тим самим шляхом змінюється
// щогодини, а розрізняти їх кешу нема чим.
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if err := store.SLAReportCSV(w, rep); err != nil {
// Відповідь уже пішла — статус не змінити. Лишається журнал.
s.log.Error("вивантаження звіту SLA", "err", err)
}
}
// handleCloseSLAPeriod закриває період: рахує раз і зберігає як факт.
func (s *Server) handleCloseSLAPeriod(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, slaWritePerm) {
return
}
targets, err := s.store.SLATargets(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "цілі SLA", err)
return
}
t, ok := slaFind(targets, r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "not_found", "цілі не знайдено")
return
}
anchor, err := s.resolveSLAAnchor(r, t)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid", "дата має вигляд 2006-01-02")
return
}
// force — свідомий перерахунок уже закритого періоду. Окремим
// параметром, а не мовчазним переписуванням: без нього повторний
// виклик нічого не змінює, і фоновий такт може ходити сюди щогодини.
force := r.URL.Query().Get("force") == "1"
rep, err := s.store.CloseSLAPeriod(r.Context(), p.TenantID, t.ID, anchor, force)
if err != nil {
s.writeSLAError(w, err)
return
}
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
ActorUserID: p.UserID,
ActorTokenID: p.TokenID,
ActorIP: clientIP(r),
Action: store.AuditActionSLAPeriodClose,
ObjectType: store.AuditObjectSLATarget,
ObjectID: t.ID,
Meta: map[string]any{
"name": t.Name,
"period": rep.PeriodFrom + "/" + rep.PeriodTo,
"revision": rep.Revision,
"forced": force,
"uptime_pct": rep.Totals.UptimePct,
"hosts": len(rep.Rows),
},
}); err != nil {
s.log.Error("аудит закриття періоду SLA", "err", err)
}
writeJSON(w, http.StatusOK, rep)
}
func (s *Server) slaReport(r *http.Request, p *Principal) (store.SLAReport, error) {
targets, err := s.store.SLATargets(r.Context(), p.TenantID)
if err != nil {
return store.SLAReport{}, err
}
t, ok := slaFind(targets, r.PathValue("id"))
if !ok {
return store.SLAReport{}, store.ErrNotFound
}
anchor, err := s.resolveSLAAnchor(r, t)
if err != nil {
return store.SLAReport{}, store.ErrInvalid
}
return s.store.SLAReportFor(r.Context(), p.TenantID, t.ID, anchor)
}
func slaFind(targets []store.SLATarget, id string) (store.SLATarget, bool) {
for _, t := range targets {
if t.ID == id {
return t, true
}
}
return store.SLATarget{}, false
}
// writeSLAError відрізняє «ще рано» від решти.
//
// Окремий код, бо це не помилка клієнта й не поломка: період справді
// існує, просто згортки під ним ще рахуються. Клієнт показує цей текст
// біля кнопки замість загальної смужки помилки нагорі.
func (s *Server) writeSLAError(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrSLANotSettled) {
writeError(w, http.StatusConflict, "sla_not_settled", err.Error())
return
}
s.writeStoreError(w, "звіт SLA", err)
}