Netpulse_SasS/server/internal/store/sla_csv.go
byrsapty 6de3565bbe
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m56s
Відповідність: редагування правил, звіт і пʼять знахідок рецензії
Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.

РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.

Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.

ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:

1. Перше збереження будь-якого вбудованого правила стирало результати.
   Селектор порівнювався в базі, але порівнювались різні представлення
   одного значення: міграція кладе {}, Go марширує сім ключів із null.
   Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
   файл відкриває аудитор. Одне місце екранування на всі три звіти:
   дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
   рахувались як чинні. Три заслони: фільтр у списку, прибирання при
   прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
   інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
   число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
   ручка робила протилежне до задуманого в мить найвищої довіри.
2026-08-28 13:40:40 +03:00

254 lines
9.7 KiB
Go
Raw 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 (
"encoding/csv"
"fmt"
"io"
"strconv"
"strings"
"time"
)
// Вивантаження звіту SLA.
//
// ЧОМУ CSV ЗБИРАЄ СЕРВЕР, А НЕ БРАУЗЕР
//
// Спокуса зібрати його в TypeScript велика: рядки вже на екрані,
// лишається склеїти. Але саме цей файл кладуть у папку до договору, і
// саме його потім звіряють із тим, що показує сторінка. Дві реалізації
// одного округлення розійдуться на третьому знаку — і розбіжність
// побачить не той, хто її зробив, а клієнт.
//
// ЧОМУ КРАПКА, А НЕ КОМА, У ДРОБАХ
//
// Український Excel читає крапку як текст, і 99.912 стає рядком.
// Спокусливо писати кому — і тоді той самий файл ламається в усьому
// іншому: у csv кома вже є роздільником полів, у Python, jq, Grafana й
// будь-якому імпортері кома в числі — це помилка розбору.
//
// Тому крапка, а різницю бере на себе перший рядок файла: заголовок
// sep=; змушує Excel читати роздільником крапку з комою, і тоді числа з
// крапкою вже нікуди не з'їжджають. Це не наша вигадка, а домовленість
// самого Excel; решта світу цей рядок пропускає як коментар.
// SLAReportCSV пише звіт у потік.
//
// Один рядок на хост плюс підсумковий. Підсумок саме в тому ж файлі, а
// не окремим: людина, яка відкрила вивантаження, шукає в ньому головне
// число, і змушувати її додавати стовпчик руками означає віддати їй
// шанс помилитись у тому, заради чого файл і робився.
func SLAReportCSV(w io.Writer, rep SLAReport) error {
// BOM: без нього український Excel читає UTF-8 як cp1251, і всі
// назви хостів перетворюються на кракозябри. Решта інструментів BOM
// пропускає.
if _, err := io.WriteString(w, "\ufeff"); err != nil {
return err
}
if _, err := io.WriteString(w, "sep=;\r\n"); err != nil {
return err
}
raw := csv.NewWriter(w)
raw.Comma = ';'
// CRLF — те, чого чекає Excel. Для решти світу різниці немає.
raw.UseCRLF = true
// Через safeCSV: назва цілі й назви хостів приходять від людини, а
// клітинка, що починається з `=`, для Excel не текст, а формула.
// Пояснення повністю — у csv_safe.go; тут навмисно немає власного
// екранування, бо третій його примірник розійшовся б із двома
// іншими.
cw := newSafeCSV(raw)
// Шапка з умовами звіту. Вона й є те, що робить файл документом:
// без неї «99.912» не відповідає на питання «за що і проти чого».
head := [][]string{
{"Ціль", rep.Target.Name},
{"Період", rep.PeriodFrom + " — " + rep.PeriodTo},
{"Тип періоду", slaPeriodLabel(rep.Target.PeriodKind)},
{"Часовий пояс", rep.Target.TZ},
{"Ціль доступності, %", slaNum(rep.Target.TargetPct)},
{"Поріг покриття, %", slaNum(rep.Target.MinCoveragePct)},
{"Джерело даних", rep.Horizon.Relation},
{"Стан звіту", slaStateLabel(rep)},
}
if rep.ComputedAt != nil {
head = append(head, []string{"Розраховано", rep.ComputedAt.Format(time.RFC3339)})
}
if rep.Revision > 1 {
head = append(head, []string{"Перерахунків", strconv.Itoa(rep.Revision)})
}
// Строк зберігання джерела — у шапці, а не в примітці. Це та
// властивість, через яку звіт узагалі може змінитись, і читач має
// побачити її поруч із числами.
head = append(head, []string{"Строк зберігання джерела", slaKeepLabel(rep.Horizon.KeepDays)})
for _, code := range rep.Warnings {
head = append(head, []string{"Застереження", SLAWarningLabel(code)})
}
for _, row := range head {
if err := cw.Write(row); err != nil {
return err
}
}
if err := cw.Write(nil); err != nil {
return err
}
if err := cw.Write([]string{
"Хост",
"Доступність, %",
"Покриття, %",
"Вердикт",
"Простій, с",
"Доступно, с",
"Немає даних, с",
"Обслуговування, с",
"Тривалість періоду, с",
"Смуг недоступності",
"Застереження",
}); err != nil {
return err
}
for _, r := range rep.Rows {
if err := cw.Write(slaCSVRow(r.DeviceName, r.SLAOutcome, r.Warnings)); err != nil {
return err
}
}
if err := cw.Write(slaCSVRow("РАЗОМ", rep.Totals, nil)); err != nil {
return err
}
cw.Flush()
return cw.Error()
}
func slaCSVRow(name string, o SLAOutcome, warnings []string) []string {
// Доступність порожня, а не «0», коли її не виміряли. Нуль у цій
// клітинці читається як «лежало весь період» — рівно навпаки до
// того, що сталось насправді.
uptime := ""
if !o.Insufficient {
uptime = slaNum(o.UptimePct)
}
labels := make([]string, 0, len(warnings))
for _, c := range warnings {
labels = append(labels, SLAWarningLabel(c))
}
return []string{
name,
uptime,
slaNum(o.CoveragePct),
slaVerdict(o),
strconv.FormatInt(o.DownSec, 10),
strconv.FormatInt(o.UpSec, 10),
strconv.FormatInt(o.UnknownSec, 10),
strconv.FormatInt(o.MaintenanceSec, 10),
strconv.FormatInt(o.ClockSec, 10),
strconv.Itoa(o.Incidents),
strings.Join(labels, "; "),
}
}
// slaVerdict — три стани, а не два.
//
// «Недостатньо даних» мусить бути окремим словом. Звести його до
// «виконано» означає збрехати, до «порушено» — звинуватити мережу в
// тому, що зламався моніторинг.
func slaVerdict(o SLAOutcome) string {
switch {
case o.Insufficient:
return "недостатньо даних"
case o.Breached:
return "порушено"
default:
return "виконано"
}
}
// SLAVerdict — той самий висновок для інших форматів.
func SLAVerdict(o SLAOutcome) string { return slaVerdict(o) }
func slaStateLabel(rep SLAReport) string {
if rep.Closed {
return "закритий період (перерахунку не підлягає)"
}
if rep.Closable {
return "попередній розрахунок; період можна закрити"
}
return "попередній розрахунок; період ще триває"
}
func slaKeepLabel(days *int) string {
if days == nil {
return "без строку"
}
return strconv.Itoa(*days) + " діб"
}
func slaPeriodLabel(kind string) string {
switch kind {
case SLAPeriodDaily:
return "доба"
case SLAPeriodWeekly:
return "тиждень"
case SLAPeriodMonthly:
return "місяць"
case SLAPeriodQuarterly:
return "квартал"
}
return kind
}
// SLAPeriodLabel — назва типу періоду для інтерфейсу й журналу.
func SLAPeriodLabel(kind string) string { return slaPeriodLabel(kind) }
// slaNum — число з трьома знаками й крапкою, без хвостових нулів.
//
// Без хвостових нулів навмисно: «100» і «100.000» в одному стовпчику
// читаються як різні за точністю виміри, хоч це те саме число.
func slaNum(v float64) string {
s := strconv.FormatFloat(v, 'f', 3, 64)
s = strings.TrimRight(s, "0")
s = strings.TrimSuffix(s, ".")
if s == "" || s == "-" {
return "0"
}
return s
}
// SLAReportFileName — ім'я файла вивантаження.
//
// Назва цілі в імені навмисно: у папці «Договори» лежатиме десяток таких
// файлів, і «sla.csv» серед них не означає нічого.
func SLAReportFileName(rep SLAReport) string {
name := slaSafeName(rep.Target.Name)
return fmt.Sprintf("sla-%s-%s.csv", name, rep.PeriodFrom)
}
func slaSafeName(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune(r)
default:
// Кирилиця й пробіли зводяться до дефіса: ім'я файла їде в
// заголовок Content-Disposition, а він у частині проксі й
// старих браузерів не переживає ані не-ASCII, ані пробілу.
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-")
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
if out == "" {
return "report"
}
if len(out) > 40 {
out = strings.Trim(out[:40], "-")
}
return out
}