П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
296 lines
12 KiB
Go
296 lines
12 KiB
Go
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)
|
||
}
|