Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
614 lines
28 KiB
Go
614 lines
28 KiB
Go
package store
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// Висновок із вимірів.
|
||
//
|
||
// Головне рішення сторінки «Черги»: оператор не має сам здогадуватись,
|
||
// що з десятка чисел означає біду. Числа однакові за формою й різні за
|
||
// сенсом — сто завдань у черзі бекапів це нормальний вечір, а сто
|
||
// невідправлених подій це зупинена публікація. Знання, яке з них яке,
|
||
// живе тут, а не в голові людини о третій ночі.
|
||
//
|
||
// Тому кожна черга віддає готовий рядок-висновок, а числа йдуть під ним
|
||
// як підтвердження. І порядок карток — за тяжкістю, а не за абеткою:
|
||
// перше, що бачить людина, — місце, де болить.
|
||
|
||
// Рівні. Навмисно п'ять, а не три:
|
||
//
|
||
// crit — дані втрачаються або обробка стоїть;
|
||
// warn — черга росте чи спізнюється, але нічого не втрачено;
|
||
// ok — встигає;
|
||
// idle — роботи немає (порожня черга ≠ справна черга, але й не біда);
|
||
// unknown — величину ніхто не міряє, і мовчати про це не можна.
|
||
const (
|
||
LevelCrit = "crit"
|
||
LevelWarn = "warn"
|
||
LevelOK = "ok"
|
||
LevelIdle = "idle"
|
||
LevelUnknown = "unknown"
|
||
)
|
||
|
||
// Що саме буває з даними в цій черзі, коли вона не встигає. Різниця
|
||
// принципова: затримка минеться сама, втрата — ні.
|
||
const (
|
||
LossDrop = "loss" // переповнення викидає дані назавжди
|
||
LossDelay = "delay" // дані дочекаються, питання лише в часі
|
||
LossUnknown = "unknown" // втрати можливі, але їх ніхто не рахує
|
||
)
|
||
|
||
// QueueMetric — одне число під висновком.
|
||
//
|
||
// Значення вже відформатоване рядком: одиниці («7 хв», «12/год»)
|
||
// належать сенсу величини, а не місцю показу, і роздавати їх по всіх
|
||
// сторінках-споживачах означало б розійтись у формулюваннях.
|
||
type QueueMetric struct {
|
||
Label string `json:"label"`
|
||
Value string `json:"value"`
|
||
Bad bool `json:"bad,omitempty"`
|
||
}
|
||
|
||
// QueueCard — одна черга на сторінці.
|
||
type QueueCard struct {
|
||
Key string `json:"key"`
|
||
Title string `json:"title"`
|
||
Source string `json:"source"`
|
||
Level string `json:"level"`
|
||
LossKind string `json:"loss_kind"`
|
||
// Verdict — той самий один рядок, заради якого все й робиться.
|
||
Verdict string `json:"verdict"`
|
||
Advice string `json:"advice,omitempty"`
|
||
Metrics []QueueMetric `json:"metrics"`
|
||
Where []string `json:"where,omitempty"`
|
||
}
|
||
|
||
// QueueReport — те, що бачить сторінка.
|
||
type QueueReport struct {
|
||
At string `json:"at"`
|
||
WindowSec int `json:"window_sec"`
|
||
Level string `json:"level"`
|
||
Headline string `json:"headline"`
|
||
Cards []QueueCard `json:"cards"`
|
||
// BlindSpots — величини, яких система не міряє. Порожнє місце на
|
||
// сторінці про втрати читається як «втрат немає», і це найгірша з
|
||
// можливих помилок такої сторінки.
|
||
BlindSpots []string `json:"blind_spots"`
|
||
}
|
||
|
||
// QueueDeltas — приріст накопичувальних лічильників від попереднього
|
||
// знімка. Лічильник «викинуто семплів» рахується з моменту старту
|
||
// зонда: ненульовий він може бути з учорашньої аварії, і без приросту
|
||
// сторінка вічно кричала б про давно полагоджене.
|
||
type QueueDeltas struct {
|
||
// Сховище: id зонда → скільки викинуто від попереднього знімка.
|
||
AgentDropped map[string]int64
|
||
PoolEmpty int64
|
||
// ObservedSec — скільки часу минуло між знімками. Нуль означає, що
|
||
// порівнювати ще нема з чим (процес щойно піднявся).
|
||
ObservedSec int
|
||
}
|
||
|
||
// BuildQueueReport перетворює виміри на висновок.
|
||
func BuildQueueReport(f *QueueFacts, d QueueDeltas) *QueueReport {
|
||
r := &QueueReport{
|
||
At: f.At.Format("2006-01-02T15:04:05Z07:00"),
|
||
WindowSec: f.WindowSec,
|
||
}
|
||
|
||
r.Cards = append(r.Cards,
|
||
jobsCard(f),
|
||
commandsCard(f),
|
||
agentLinkCard(f),
|
||
agentBufferCard(f, d),
|
||
schedulerCard(f),
|
||
outboxCard(f),
|
||
notifyCard(f),
|
||
poolCard(f, d),
|
||
)
|
||
|
||
// Порядок за тяжкістю, і всередині рівня — як були. Стабільність
|
||
// має значення: картки, що стрибають місцями на кожному оновленні,
|
||
// змушують щоразу читати всі.
|
||
rank := map[string]int{LevelCrit: 0, LevelWarn: 1, LevelUnknown: 2, LevelOK: 3, LevelIdle: 4}
|
||
sort.SliceStable(r.Cards, func(i, j int) bool {
|
||
return rank[r.Cards[i].Level] < rank[r.Cards[j].Level]
|
||
})
|
||
|
||
var crit, warn []string
|
||
for _, c := range r.Cards {
|
||
switch c.Level {
|
||
case LevelCrit:
|
||
crit = append(crit, c.Title)
|
||
case LevelWarn:
|
||
warn = append(warn, c.Title)
|
||
}
|
||
}
|
||
switch {
|
||
case len(crit) > 0:
|
||
r.Level = LevelCrit
|
||
r.Headline = "Не встигає й втрачає: " + strings.Join(crit, ", ")
|
||
case len(warn) > 0:
|
||
r.Level = LevelWarn
|
||
r.Headline = "Встигає, але з натяжкою: " + strings.Join(warn, ", ")
|
||
default:
|
||
r.Level = LevelOK
|
||
r.Headline = "Усе встигає"
|
||
}
|
||
|
||
r.BlindSpots = blindSpots(f)
|
||
return r
|
||
}
|
||
|
||
func jobsCard(f *QueueFacts) QueueCard {
|
||
j := f.Jobs
|
||
c := QueueCard{
|
||
Key: "ncm.jobs",
|
||
Title: "Збір конфігів",
|
||
Source: "ncm.jobs",
|
||
LossKind: LossDelay,
|
||
Metrics: []QueueMetric{
|
||
{Label: "у черзі", Value: fmt.Sprint(j.Queued)},
|
||
{Label: "виконується", Value: fmt.Sprint(j.Running)},
|
||
{Label: "найстаріше в черзі", Value: humanSec(j.OldestQueuedSec), Bad: j.OldestQueuedSec > 120},
|
||
{Label: "надійшло за годину", Value: fmt.Sprint(j.Arrived)},
|
||
{Label: "оброблено за годину", Value: fmt.Sprint(j.Finished)},
|
||
{Label: "з них невдало", Value: fmt.Sprint(j.Failed), Bad: j.Failed > 0},
|
||
{Label: "втрачено (зонд не відповів)", Value: fmt.Sprint(j.Reaped), Bad: j.Reaped > 0},
|
||
},
|
||
}
|
||
for _, b := range j.ByAgent {
|
||
c.Where = append(c.Where, fmt.Sprintf("%s — %d у черзі, найстаріше %s",
|
||
b.AgentName, b.Queued, humanSec(b.OldestSec)))
|
||
}
|
||
|
||
switch {
|
||
case j.Reaped > 0:
|
||
c.Level, c.LossKind = LevelCrit, LossDrop
|
||
c.Verdict = fmt.Sprintf(
|
||
"За годину %s закрито як «зонд не відповів» — стільки бекапів не зроблено.",
|
||
plural(j.Reaped, "завдання", "завдання", "завдань"))
|
||
c.Advice = "Завдання висіло в роботі понад десять хвилин, і прибиральник закрив його. Дивіться зв'язок із зондом і доступ до самого пристрою."
|
||
case j.OldestQueuedSec > 600:
|
||
c.Level = LevelCrit
|
||
c.Verdict = fmt.Sprintf("Найстаріше завдання чекає %s — черга не рухається.",
|
||
humanSec(j.OldestQueuedSec))
|
||
c.Advice = "Диспетчер роздає завдання раз на п'ять секунд і лише зондам на зв'язку. Такий вік означає, що потрібного зонда немає в мережі."
|
||
case j.Stuck > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("%s у роботі понад десять хвилин — прибиральник от-от закриє %s.",
|
||
plural(j.Stuck, "завдання", "завдання", "завдань"),
|
||
oneOf(j.Stuck, "його як невдале", "їх як невдалі"))
|
||
c.Advice = "Це ще не втрата, але за дві хвилини стане нею."
|
||
case j.OldestQueuedSec > 120:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Черга спізнюється: найстарішому завданню %s.", humanSec(j.OldestQueuedSec))
|
||
case j.Queued > 10 && j.Arrived > j.Finished:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Черга росте: за годину надійшло %d, оброблено %d.", j.Arrived, j.Finished)
|
||
c.Advice = "Сама глибина ні про що не каже — важливо, що надходить швидше, ніж встигає обробитись."
|
||
case j.Queued == 0 && j.Running == 0 && j.Arrived == 0:
|
||
c.Level = LevelIdle
|
||
c.Verdict = "Збору конфігів зараз немає."
|
||
default:
|
||
c.Level = LevelOK
|
||
c.Verdict = fmt.Sprintf("Встигає: за годину надійшло %d, оброблено %d, у черзі %d.",
|
||
j.Arrived, j.Finished, j.Queued)
|
||
}
|
||
return c
|
||
}
|
||
|
||
func commandsCard(f *QueueFacts) QueueCard {
|
||
m := f.Commands
|
||
c := QueueCard{
|
||
Key: "ncm.command_targets",
|
||
Title: "Масове виконання команд",
|
||
Source: "ncm.command_targets",
|
||
LossKind: LossDelay,
|
||
Metrics: []QueueMetric{
|
||
{Label: "активних прогонів", Value: fmt.Sprint(m.ActiveRuns)},
|
||
{Label: "чекають слота", Value: fmt.Sprint(m.Pending)},
|
||
{Label: "віддано зонду", Value: fmt.Sprint(m.Queued)},
|
||
{Label: "виконується", Value: fmt.Sprint(m.Running)},
|
||
{Label: "стеля паралельності", Value: fmt.Sprint(m.Concurrency)},
|
||
{Label: "завершено за годину", Value: fmt.Sprint(m.Finished)},
|
||
{Label: "втрачено (зонд не відповів)", Value: fmt.Sprint(m.TimedOut), Bad: m.TimedOut > 0},
|
||
},
|
||
}
|
||
|
||
switch {
|
||
case m.TimedOut > 0:
|
||
c.Level, c.LossKind = LevelCrit, LossDrop
|
||
c.Verdict = fmt.Sprintf("За годину %s закрито як «зонд не відповів» — команда %s не виконалась.",
|
||
plural(m.TimedOut, "хост", "хости", "хостів"),
|
||
oneOf(m.TimedOut, "на ньому", "на них"))
|
||
c.Advice = "Другої спроби прогін не робить: перелік хостів людина підтвердила один раз."
|
||
case m.OldestQueuedSec > 300:
|
||
c.Level = LevelCrit
|
||
c.Verdict = fmt.Sprintf("Хост чекає відправки %s — диспетчер не доносить завдання до зонда.",
|
||
humanSec(m.OldestQueuedSec))
|
||
case m.OldestQueuedSec > 120:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Відправка сповільнилась: найстаріше завдання віддано %s тому.",
|
||
humanSec(m.OldestQueuedSec))
|
||
case m.ActiveRuns == 0:
|
||
c.Level = LevelIdle
|
||
c.Verdict = "Прогонів немає."
|
||
default:
|
||
c.Level = LevelOK
|
||
c.Verdict = fmt.Sprintf("%s у роботі, %s %s свого слота.",
|
||
plural(m.ActiveRuns, "прогін", "прогони", "прогонів"),
|
||
plural(m.Pending, "хост", "хости", "хостів"),
|
||
oneOf(m.Pending, "чекає", "чекають"))
|
||
c.Advice = "Черга «чекають слота» — це не затор, а сам механізм: більше ніж стеля паралельності одночасних сесій до заліза прогін не відкриває."
|
||
}
|
||
return c
|
||
}
|
||
|
||
// agentLinkCard — зв'язок із зондами.
|
||
//
|
||
// Окремо від буферів навмисно: зонд офлайн — це не «черга росте», це
|
||
// «нічого не збирається взагалі», і рахувати глибину буфера в нього
|
||
// зараз безглуздо.
|
||
func agentLinkCard(f *QueueFacts) QueueCard {
|
||
c := QueueCard{
|
||
Key: "agents.link",
|
||
Title: "Зв'язок із зондами",
|
||
Source: "core.agents",
|
||
LossKind: LossDrop,
|
||
}
|
||
|
||
var offline, stale int
|
||
var lostDevices int
|
||
for _, a := range f.Agents {
|
||
switch {
|
||
case a.Status != "online":
|
||
offline++
|
||
lostDevices += a.Devices
|
||
c.Where = append(c.Where, fmt.Sprintf("%s — %s, хостів: %d, heartbeat %s тому",
|
||
a.Name, a.Status, a.Devices, humanSec(a.HeartbeatSec)))
|
||
case a.HeartbeatSec > 120:
|
||
stale++
|
||
c.Where = append(c.Where, fmt.Sprintf("%s — мовчить %s", a.Name, humanSec(a.HeartbeatSec)))
|
||
}
|
||
}
|
||
c.Metrics = []QueueMetric{
|
||
{Label: "зондів усього", Value: fmt.Sprint(len(f.Agents))},
|
||
{Label: "не на зв'язку", Value: fmt.Sprint(offline), Bad: offline > 0},
|
||
{Label: "хостів за ними", Value: fmt.Sprint(lostDevices), Bad: lostDevices > 0},
|
||
}
|
||
|
||
switch {
|
||
case len(f.Agents) == 0:
|
||
c.Level, c.LossKind = LevelIdle, LossDelay
|
||
c.Verdict = "Зондів ще немає."
|
||
case offline > 0 && lostDevices > 0:
|
||
c.Level = LevelCrit
|
||
c.Verdict = fmt.Sprintf("%s не на зв'язку — з %s зараз не збирається нічого.",
|
||
plural(offline, "зонд", "зонди", "зондів"),
|
||
plural(lostDevices, "хоста", "хостів", "хостів"))
|
||
case offline > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("%s не на зв'язку, але хостів за ними немає.",
|
||
plural(offline, "зонд", "зонди", "зондів"))
|
||
case stale > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("%s %s понад дві хвилини.",
|
||
plural(stale, "зонд", "зонди", "зондів"),
|
||
oneOf(stale, "рахується живим, але мовчить", "рахуються живими, але мовчать"))
|
||
default:
|
||
c.Level, c.LossKind = LevelOK, LossDelay
|
||
c.Verdict = "Єдиний зонд на зв'язку."
|
||
if len(f.Agents) > 1 {
|
||
c.Verdict = fmt.Sprintf("Усі %d на зв'язку.", len(f.Agents))
|
||
}
|
||
}
|
||
return c
|
||
}
|
||
|
||
// agentBufferCard — буфер телеметрії на зондах.
|
||
//
|
||
// Єдине місце в системі, де переповнення викидає дані мовчки й
|
||
// назавжди, тому саме тут висновок робиться не з глибини, а з приросту
|
||
// лічильника викинутого.
|
||
func agentBufferCard(f *QueueFacts, d QueueDeltas) QueueCard {
|
||
c := QueueCard{
|
||
Key: "agents.buffer",
|
||
Title: "Буфер телеметрії зондів",
|
||
Source: "core.agents.health · ts.agent_health",
|
||
LossKind: LossDrop,
|
||
}
|
||
|
||
var depth, growing int
|
||
var totalDropped, freshDropped int64
|
||
var reporting int
|
||
for _, a := range f.Agents {
|
||
if a.QueueDepth == nil && a.Dropped == nil {
|
||
continue
|
||
}
|
||
reporting++
|
||
if a.QueueDepth != nil {
|
||
depth += *a.QueueDepth
|
||
}
|
||
if a.Dropped != nil {
|
||
totalDropped += *a.Dropped
|
||
}
|
||
if inc := d.AgentDropped[a.ID]; inc > 0 {
|
||
freshDropped += inc
|
||
c.Where = append(c.Where, fmt.Sprintf("%s — викинув %s за останні %s",
|
||
a.Name, plural(int(inc), "запис", "записи", "записів"), humanSec(d.ObservedSec)))
|
||
continue
|
||
}
|
||
if a.DepthFirst != nil && a.DepthLast != nil &&
|
||
*a.DepthLast > 1000 && *a.DepthLast > *a.DepthFirst*2 {
|
||
growing++
|
||
c.Where = append(c.Where, fmt.Sprintf("%s — буфер зріс із %d до %d за півгодини",
|
||
a.Name, *a.DepthFirst, *a.DepthLast))
|
||
}
|
||
}
|
||
|
||
c.Metrics = []QueueMetric{
|
||
{Label: "у буферах зараз", Value: fmt.Sprint(depth)},
|
||
{Label: "викинуто від старту зондів", Value: fmt.Sprint(totalDropped), Bad: totalDropped > 0},
|
||
{Label: "викинуто щойно", Value: fmt.Sprint(freshDropped), Bad: freshDropped > 0},
|
||
}
|
||
|
||
switch {
|
||
case reporting == 0:
|
||
c.Level, c.LossKind = LevelUnknown, LossUnknown
|
||
c.Verdict = "Жоден зонд ще не доповів про свій буфер."
|
||
case freshDropped > 0:
|
||
c.Level = LevelCrit
|
||
c.Verdict = fmt.Sprintf("Дані втрачаються просто зараз: %s за останні %s.",
|
||
plural(int(freshDropped), "запис", "записи", "записів"), humanSec(d.ObservedSec))
|
||
c.Advice = "Буфер зонда обмежений і при переповненні викидає найстаріше. Причина майже завжди одна: зонд не встигає віддати телеметрію серверу."
|
||
case growing > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Буфер росте на %s — до втрат ще не дійшло.", plural(growing, "зонді", "зондах", "зондах"))
|
||
case totalDropped > 0 && d.ObservedSec > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Втрати були (%s від старту зондів), але зараз не ростуть.",
|
||
plural(int(totalDropped), "запис", "записи", "записів"))
|
||
c.Advice = "Лічильник накопичувальний і обнуляється лише перезапуском зонда."
|
||
case totalDropped > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Від старту зондів викинуто %s; чи ростуть втрати зараз, буде видно з наступного оновлення.",
|
||
plural(int(totalDropped), "запис", "записи", "записів"))
|
||
default:
|
||
c.Level = LevelOK
|
||
c.Verdict = "Нічого не викинуто."
|
||
}
|
||
return c
|
||
}
|
||
|
||
// schedulerCard — планувальник зондів очима сервера.
|
||
func schedulerCard(f *QueueFacts) QueueCard {
|
||
ch := f.Checks
|
||
c := QueueCard{
|
||
Key: "agents.scheduler",
|
||
Title: "Планувальник опитувань",
|
||
Source: "core.checks",
|
||
LossKind: LossDrop,
|
||
Metrics: []QueueMetric{
|
||
{Label: "увімкнених чеків", Value: fmt.Sprint(ch.Enabled)},
|
||
{Label: "пропустили такт", Value: fmt.Sprint(ch.Skipped), Bad: ch.Skipped > 0},
|
||
{Label: "мовчать понад 3 інтервали", Value: fmt.Sprint(ch.Stale), Bad: ch.Stale > 0},
|
||
},
|
||
Where: ch.StaleSample,
|
||
}
|
||
|
||
switch {
|
||
case ch.Enabled == 0:
|
||
c.Level, c.LossKind = LevelIdle, LossDelay
|
||
c.Verdict = "Увімкнених перевірок немає."
|
||
case ch.Skipped > 0:
|
||
// Один пропущений такт із сімнадцяти чеків — не аварія. Це
|
||
// повільний пристрій або хвилинний сплеск, і воно лікується
|
||
// саме. Аварія — коли пропуски стають системними.
|
||
//
|
||
// Поріг у частці, а не в числі: на двох чеках один пропуск —
|
||
// це половина моніторингу, на двох тисячах — шум. Червоне,
|
||
// яке горить від шуму, вчить не дивитись на сторінку взагалі,
|
||
// і тоді вона не спрацює тоді, коли справді знадобиться.
|
||
c.Level = LevelWarn
|
||
if ch.Skipped*10 >= ch.Enabled || ch.Skipped >= 10 {
|
||
c.Level = LevelCrit
|
||
}
|
||
c.Verdict = fmt.Sprintf("%s %s свій такт — %s.",
|
||
plural(ch.Skipped, "перевірка", "перевірки", "перевірок"),
|
||
oneOf(ch.Skipped, "пропустила", "пропустили"),
|
||
oneOf(ch.Skipped, "це вимірювання не зроблене й не буде",
|
||
"ці вимірювання не зроблені й не будуть"))
|
||
c.Advice = "Причини дві: попередній запуск не завершився до наступного інтервалу або задача не дочекалась вільного слота паралельності. Обидві лікуються більшим інтервалом або меншою кількістю чеків на зонд."
|
||
case ch.Stale > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Від %s немає звіту довше трьох інтервалів.",
|
||
plural(ch.Stale, "перевірки", "перевірок", "перевірок"))
|
||
c.Advice = "Якщо зонд при цьому на зв'язку, дивіться на сам пристрій: недоступний хост дає ту саму картину."
|
||
default:
|
||
c.Level = LevelOK
|
||
c.Verdict = "Єдина перевірка звітує вчасно."
|
||
if ch.Enabled > 1 {
|
||
c.Verdict = fmt.Sprintf("Усі %s звітують вчасно.",
|
||
plural(ch.Enabled, "перевірка", "перевірки", "перевірок"))
|
||
}
|
||
}
|
||
return c
|
||
}
|
||
|
||
func outboxCard(f *QueueFacts) QueueCard {
|
||
o := f.Outbox
|
||
depth := fmt.Sprint(o.Unpublished)
|
||
if o.Capped {
|
||
depth = "понад " + depth
|
||
}
|
||
c := QueueCard{
|
||
Key: "core.event_outbox",
|
||
Title: "Події для інтерфейсу",
|
||
Source: "core.event_outbox",
|
||
LossKind: LossDelay,
|
||
Metrics: []QueueMetric{
|
||
{Label: "не опубліковано", Value: depth, Bad: o.Unpublished > 100 || o.Capped},
|
||
{Label: "найстаріша чекає", Value: humanSec(o.OldestSec), Bad: o.OldestSec > 30},
|
||
{Label: "з'явилось за годину", Value: fmt.Sprint(o.Created)},
|
||
},
|
||
}
|
||
|
||
switch {
|
||
case o.Capped || o.OldestSec > 300:
|
||
c.Level = LevelCrit
|
||
c.Verdict = "Публікація подій стоїть — усі відкриті вкладки показують застарілий стан."
|
||
c.Advice = "Події не губляться: підписники читають їх за id і доженуть. Але доти мапа й список алертів брешуть."
|
||
case o.OldestSec > 30 || o.Unpublished > 500:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("Публікація відстає: найстаріша подія чекає %s.", humanSec(o.OldestSec))
|
||
case o.Created == 0 && o.Unpublished == 0:
|
||
c.Level = LevelIdle
|
||
c.Verdict = "Подій за годину не було."
|
||
default:
|
||
c.Level = LevelOK
|
||
c.Verdict = fmt.Sprintf("Публікація встигає: %s за годину, у черзі %d.",
|
||
plural(o.Created, "подія", "події", "подій"), o.Unpublished)
|
||
}
|
||
return c
|
||
}
|
||
|
||
func notifyCard(f *QueueFacts) QueueCard {
|
||
n := f.Notify
|
||
c := QueueCard{
|
||
Key: "alr.notifications",
|
||
Title: "Доставка сповіщень",
|
||
Source: "alr.notifications",
|
||
LossKind: LossDrop,
|
||
Metrics: []QueueMetric{
|
||
{Label: "доставлено за годину", Value: fmt.Sprint(n.Sent)},
|
||
{Label: "не доставлено", Value: fmt.Sprint(n.Failed), Bad: n.Failed > 0},
|
||
{Label: "придушено лімітом", Value: fmt.Sprint(n.Throttled), Bad: n.Throttled > 0},
|
||
},
|
||
}
|
||
|
||
switch {
|
||
case n.Failed > 0:
|
||
c.Level = LevelCrit
|
||
c.Verdict = fmt.Sprintf("За годину %s не %s до жодного отримувача.",
|
||
plural(n.Failed, "сповіщення", "сповіщення", "сповіщень"),
|
||
oneOf(n.Failed, "дійшло", "дійшли"))
|
||
c.Advice = "Алерт, про який ніхто не дізнався, дорівнює алерту, якого не було."
|
||
case n.Throttled > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("%s придушено лімітом каналу.",
|
||
plural(n.Throttled, "сповіщення", "сповіщення", "сповіщень"))
|
||
case n.Sent == 0:
|
||
c.Level, c.LossKind = LevelIdle, LossDelay
|
||
c.Verdict = "Сповіщень за годину не було."
|
||
default:
|
||
c.Level, c.LossKind = LevelOK, LossDelay
|
||
c.Verdict = fmt.Sprintf("Доставлено %s, без відмов.",
|
||
plural(n.Sent, "сповіщення", "сповіщення", "сповіщень"))
|
||
}
|
||
return c
|
||
}
|
||
|
||
func poolCard(f *QueueFacts, d QueueDeltas) QueueCard {
|
||
p := f.Pool
|
||
c := QueueCard{
|
||
Key: "db.pool",
|
||
Title: "Пул з'єднань до бази (API)",
|
||
Source: "pgxpool",
|
||
LossKind: LossDelay,
|
||
Metrics: []QueueMetric{
|
||
{Label: "зайнято", Value: fmt.Sprintf("%d з %d", p.Acquired, p.Max),
|
||
Bad: p.Max > 0 && p.Acquired >= p.Max},
|
||
{Label: "відкрито", Value: fmt.Sprint(p.Total)},
|
||
{Label: "чекали вільного", Value: fmt.Sprint(p.EmptyAcquires), Bad: p.EmptyAcquires > 0},
|
||
{Label: "чекали щойно", Value: fmt.Sprint(d.PoolEmpty), Bad: d.PoolEmpty > 0},
|
||
},
|
||
}
|
||
|
||
switch {
|
||
case p.Max > 0 && p.Acquired >= p.Max:
|
||
c.Level = LevelCrit
|
||
c.Verdict = fmt.Sprintf("Пул вичерпано: зайнято всі %d з'єднань — запити стоять у черзі за ними.", p.Max)
|
||
case d.PoolEmpty > 0:
|
||
c.Level = LevelWarn
|
||
c.Verdict = fmt.Sprintf("За останні %s %s %s вільного з'єднання.",
|
||
humanSec(d.ObservedSec),
|
||
plural(int(d.PoolEmpty), "запит", "запити", "запитів"),
|
||
oneOf(int(d.PoolEmpty), "чекав", "чекали"))
|
||
default:
|
||
c.Level = LevelOK
|
||
c.Verdict = fmt.Sprintf("Вільно: зайнято %d з %d.", p.Acquired, p.Max)
|
||
}
|
||
return c
|
||
}
|
||
|
||
// blindSpots — чесний перелік того, чого сторінка не знає.
|
||
func blindSpots(f *QueueFacts) []string {
|
||
out := []string{
|
||
"Скільки подій syslog зонд викинув через переповнення своєї черги: зонд рахує це й шле в кожній пачці журналу, але сервер лише пише в лог і нікуди не зберігає.",
|
||
"Пул з'єднань колектора: це інший процес у іншому контейнері, і звідси його не видно. Тут показано пул того процесу, що віддає цю сторінку.",
|
||
"Розбіжність плану зонда з базою живе в пам'яті колектора; у базі її немає.",
|
||
}
|
||
|
||
// Про чергу планувальника — лише поки її справді немає. Зонд шле її
|
||
// в кожному heartbeat, тож щойно колектор перезапуститься з новою
|
||
// збіркою, рядок зникне сам.
|
||
missing := true
|
||
for _, a := range f.Agents {
|
||
if a.TasksQueued != nil {
|
||
missing = false
|
||
break
|
||
}
|
||
}
|
||
if missing && len(f.Agents) > 0 {
|
||
out = append(out, "Довжина черги задач у планувальнику зонда: зонд шле її в heartbeat, але в зведенні core.agents.health її ще немає — з'явиться після оновлення колектора.")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// humanSec — вік у зрозумілих одиницях. Від'ємне означає «невідомо».
|
||
func humanSec(sec int) string {
|
||
switch {
|
||
case sec < 0:
|
||
return "—"
|
||
case sec < 60:
|
||
return fmt.Sprintf("%d с", sec)
|
||
case sec < 3600:
|
||
return fmt.Sprintf("%d хв", sec/60)
|
||
case sec < 86400:
|
||
return fmt.Sprintf("%d год", sec/3600)
|
||
default:
|
||
return fmt.Sprintf("%d дн", sec/86400)
|
||
}
|
||
}
|
||
|
||
// plural — українська множина для одного слова у висновку.
|
||
func plural(n int, one, few, many string) string {
|
||
mod100 := n % 100
|
||
if mod100 >= 11 && mod100 <= 14 {
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|
||
switch n % 10 {
|
||
case 1:
|
||
return fmt.Sprintf("%d %s", n, one)
|
||
case 2, 3, 4:
|
||
return fmt.Sprintf("%d %s", n, few)
|
||
default:
|
||
return fmt.Sprintf("%d %s", n, many)
|
||
}
|
||
}
|
||
|
||
// oneOf обирає форму за числом: 1, 21, 31 — однина, решта — множина.
|
||
//
|
||
// Потрібне окремо від plural: там змінюється іменник, тут — дієслово
|
||
// або займенник поруч із ним, а вони не збігаються («1 перевірка
|
||
// пропустила», «2 перевірки пропустили»).
|
||
func oneOf(n int, one, many string) string {
|
||
if n%10 == 1 && n%100 != 11 {
|
||
return one
|
||
}
|
||
return many
|
||
}
|