Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
1409 lines
65 KiB
Go
1409 lines
65 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// Детальний розбір однієї черги — те, що стоїть за числом на картці.
|
||
//
|
||
// Картка каже «1 перевірка пропустила свій такт» і на цьому зупиняється.
|
||
// Далі людина має піти шукати руками, і саме на цьому кроці сторінка
|
||
// перестає допомагати. Тут — відповідь на «де саме»: перелік того, що
|
||
// зламалось, з причиною в кожному рядку.
|
||
//
|
||
// Три рішення, які визначили всю форму цього файлу.
|
||
//
|
||
// ПЕРШЕ: групування за спільною причиною. Двісті перевірок, що
|
||
// пропустили такт на одному зонді, — це одна проблема, а не двісті
|
||
// рядків. Тому перелік — це групи (причина + місце), а рядки всередині
|
||
// групи лише показують, кого саме зачепило. Кількість у заголовку групи
|
||
// точна завжди, навіть коли рядків показано десять із двохсот: інакше
|
||
// стеля переліку мовчки применшувала б масштаб.
|
||
//
|
||
// ДРУГЕ: ціна. Деталі не їдуть разом зі знімком — вони окремим запитом і
|
||
// лише тоді, коли їх попросили. Всередині: точні кількості беруться
|
||
// агрегатом (рядки не їдуть по дроту зовсім), а самі рядки — вибіркою зі
|
||
// стелею. Дороге звіряння з телеметрією робиться ЛИШЕ для тих кількох
|
||
// десятків рядків, що потрапили у вибірку, і одним запитом на всі.
|
||
//
|
||
// ТРЕТЄ: рядок має пояснювати, а не лише називати. «Леніна.21 ·
|
||
// icmp.ping» — це те саме «десь щось не так», лише конкретніше. Тому в
|
||
// рядку стоїть і інтервал, і таймаут, і коли востаннє був звіт від
|
||
// зонда, і — головне — коли востаннє прийшло справжнє вимірювання. Це
|
||
// різні речі: перевірка, що щотакту доповідає «пропустив», має свіжий
|
||
// звіт і мертві дані, і без другого числа вона виглядає майже здоровою.
|
||
|
||
// Стелі переліку.
|
||
//
|
||
// Двісті рядків — це вже не перелік, а звалище; після них відповідь на
|
||
// «де саме» дає не рядок, а заголовок групи. Десять рядків на групу —
|
||
// рівно стільки, щоб побачити, чи всередині групи однорідно.
|
||
const (
|
||
detailMaxRows = 200
|
||
detailRowsPerGroup = 10
|
||
detailMaxGroups = 12
|
||
)
|
||
|
||
// QueueDetailField — одне названо́ значення в рядку.
|
||
type QueueDetailField struct {
|
||
Label string `json:"label"`
|
||
Value string `json:"value"`
|
||
Bad bool `json:"bad,omitempty"`
|
||
}
|
||
|
||
// QueueDetailLink — куди йти з рядка.
|
||
type QueueDetailLink struct {
|
||
Label string `json:"label"`
|
||
To string `json:"to"`
|
||
}
|
||
|
||
// QueueDetailRow — один зачеплений об'єкт.
|
||
type QueueDetailRow struct {
|
||
Title string `json:"title"`
|
||
Subtitle string `json:"subtitle,omitempty"`
|
||
Level string `json:"level,omitempty"`
|
||
Fields []QueueDetailField `json:"fields,omitempty"`
|
||
// Note — повний текст помилки. У полях він не поміщається, а
|
||
// обрізаний текст помилки — найгірше з можливого: він виглядає як
|
||
// повний і мовчки ховає найцікавіше.
|
||
Note string `json:"note,omitempty"`
|
||
Links []QueueDetailLink `json:"links,omitempty"`
|
||
}
|
||
|
||
// QueueDetailGroup — одна спільна причина.
|
||
type QueueDetailGroup struct {
|
||
Key string `json:"key"`
|
||
Title string `json:"title"`
|
||
// Cause — чому це ОДНА проблема, а не N. Без цього рядка групування
|
||
// виглядає як довільне сортування.
|
||
Cause string `json:"cause,omitempty"`
|
||
Advice string `json:"advice,omitempty"`
|
||
Level string `json:"level"`
|
||
Count int `json:"count"`
|
||
Rows []QueueDetailRow `json:"rows"`
|
||
More int `json:"more,omitempty"`
|
||
}
|
||
|
||
// QueueDetail — усе, що стоїть за карткою.
|
||
type QueueDetail struct {
|
||
Key string `json:"key"`
|
||
Title string `json:"title"`
|
||
Intro string `json:"intro"`
|
||
Groups []QueueDetailGroup `json:"groups"`
|
||
Total int `json:"total"`
|
||
// Capped — точну кількість не рахували: підрахунок уперся в стелю.
|
||
Capped bool `json:"capped,omitempty"`
|
||
// Note — межі самого переліку. Порожнє місце тут читалось би як
|
||
// «більше нічого немає», і це була б неправда.
|
||
Note string `json:"note,omitempty"`
|
||
// Empty — окремо від порожнього переліку: «нічого не зламалось» і
|
||
// «не вміємо перелічити» — різні відповіді.
|
||
Empty string `json:"empty,omitempty"`
|
||
}
|
||
|
||
// QueueDetailKeys — ключі, для яких перелік існує. Збігаються з ключами
|
||
// карток: людина натискає на картку, а не на назву таблиці.
|
||
func QueueDetailKeys() []string {
|
||
return []string{
|
||
"agents.scheduler", "ncm.jobs", "agents.link", "agents.buffer",
|
||
"ncm.command_targets", "alr.notifications", "core.event_outbox", "db.pool",
|
||
}
|
||
}
|
||
|
||
// QueueDetailFor збирає перелік для однієї картки.
|
||
//
|
||
// f і d — той самий знімок, з якого зроблено картку. Перезнімати їх тут
|
||
// означало б показати перелік, який суперечить числу над ним: за ті
|
||
// кілька секунд, поки людина вела мишу до картки, зонд встиг прислати
|
||
// новий heartbeat.
|
||
func (s *Store) QueueDetailFor(ctx context.Context, tenantID, key string,
|
||
window time.Duration, f *QueueFacts, d QueueDeltas) (*QueueDetail, error) {
|
||
|
||
if window <= 0 {
|
||
window = time.Hour
|
||
}
|
||
since := time.Now().Add(-window)
|
||
det := &QueueDetail{Key: key}
|
||
|
||
switch key {
|
||
case "agents.buffer":
|
||
buildBufferDetail(det, f, d)
|
||
return det, nil
|
||
case "db.pool":
|
||
buildPoolDetail(det, f, d)
|
||
return det, nil
|
||
}
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
switch key {
|
||
case "agents.scheduler":
|
||
return schedulerDetail(ctx, tx, tenantID, det)
|
||
case "ncm.jobs":
|
||
return jobsDetail(ctx, tx, tenantID, since, det)
|
||
case "agents.link":
|
||
return agentLinkDetail(ctx, tx, tenantID, det)
|
||
case "ncm.command_targets":
|
||
return commandsDetail(ctx, tx, tenantID, since, det)
|
||
case "alr.notifications":
|
||
return notifyDetail(ctx, tx, tenantID, since, det)
|
||
case "core.event_outbox":
|
||
return outboxDetail(ctx, tx, tenantID, det)
|
||
}
|
||
return fmt.Errorf("невідома черга %q", key)
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
finishDetail(det)
|
||
return det, nil
|
||
}
|
||
|
||
// finishDetail упорядковує групи й рахує підсумок.
|
||
//
|
||
// Порядок — за тяжкістю, і лише всередині рівня за розміром. Найбільша
|
||
// група не обов'язково найгірша: сто відкладених завдань нікого не
|
||
// розбудять, а одне втрачене вимірювання вже не повернеться.
|
||
func finishDetail(det *QueueDetail) {
|
||
rank := map[string]int{LevelCrit: 0, LevelWarn: 1, LevelUnknown: 2, LevelOK: 3, LevelIdle: 4}
|
||
// Ключ як останній критерій — не для краси. Групи збираються по мапі,
|
||
// а порядок обходу мапи в Go випадковий: без цього рівні за тяжкістю
|
||
// й розміром групи стрибали б місцями на кожному оновленні, і читати
|
||
// перелік довелось би щоразу заново.
|
||
sort.Slice(det.Groups, func(i, j int) bool {
|
||
a, b := det.Groups[i], det.Groups[j]
|
||
if rank[a.Level] != rank[b.Level] {
|
||
return rank[a.Level] < rank[b.Level]
|
||
}
|
||
if a.Count != b.Count {
|
||
return a.Count > b.Count
|
||
}
|
||
return a.Key < b.Key
|
||
})
|
||
if len(det.Groups) > detailMaxGroups {
|
||
det.Groups = det.Groups[:detailMaxGroups]
|
||
det.Capped = true
|
||
}
|
||
// Порожній зріз, а не nil: у JSON nil стає null, і сторінка
|
||
// зобов'язана була б розрізняти «груп немає» і «поля немає». Два
|
||
// різні написання одного й того самого — це рівно те місце, де
|
||
// одного дня забудуть перевірити друге.
|
||
if det.Groups == nil {
|
||
det.Groups = []QueueDetailGroup{}
|
||
}
|
||
det.Total = 0
|
||
for i := range det.Groups {
|
||
g := &det.Groups[i]
|
||
if g.Rows == nil {
|
||
g.Rows = []QueueDetailRow{}
|
||
}
|
||
if len(g.Rows) > detailRowsPerGroup {
|
||
g.Rows = g.Rows[:detailRowsPerGroup]
|
||
}
|
||
if g.Count < len(g.Rows) {
|
||
g.Count = len(g.Rows)
|
||
}
|
||
g.More = g.Count - len(g.Rows)
|
||
det.Total += g.Count
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Планувальник опитувань
|
||
// ---------------------------------------------------------------------
|
||
|
||
// schedulerReasons — таксономія причин і порядок їх старшинства.
|
||
//
|
||
// Перевірка може підпадати одразу під кілька умов: та, що щотакту
|
||
// пропускається, заразом і мовчить. Показати її двічі означало б удвічі
|
||
// завищити масштаб, а показати як «мовчить» — назвати наслідок замість
|
||
// причини. Тому причина рівно одна, і виграє найконкретніша.
|
||
type schedulerReason struct {
|
||
key string
|
||
title string
|
||
cause string
|
||
advice string
|
||
level string
|
||
}
|
||
|
||
var schedulerReasons = []schedulerReason{
|
||
{
|
||
key: "still_running",
|
||
title: "попередній запуск не завершився до наступного такту",
|
||
cause: "Планувальник зонда прийшов запускати перевірку й побачив, що попередній запуск ще йде. Такт пропущено — це вимірювання не зроблене й не буде. Спільне в усіх рядках групи одне: перевірка триває довше за власний інтервал.",
|
||
advice: "Звіряйте в рядку інтервал із таймаутом. Якщо таймаут не менший за інтервал, пропуски триватимуть вічно: перевірка фізично не встигає між тактами. Друге місце, куди дивитись, — колонка «вимірювання»: свіжий звіт від зонда при мертвих даних означає, що застряг саме цей запуск, а не пристрій.",
|
||
level: LevelCrit,
|
||
},
|
||
{
|
||
key: "concurrency_limit",
|
||
title: "не дочекалась вільного слота на зонді",
|
||
cause: "Зонд тримає обмежену кількість одночасних перевірок, і черга задач не встигла дійти до цієї до наступного такту. Це не про пристрій — це про те, що зонду роздали більше роботи, ніж він тримає одночасно.",
|
||
advice: "Лікується не тут: або більший інтервал у цих перевірок, або менше хостів на цьому зонді. Дивіться, чи всі рядки групи з одного зонда — якщо так, вузьке місце саме він.",
|
||
level: LevelCrit,
|
||
},
|
||
{
|
||
key: "never",
|
||
title: "жодного запуску не було",
|
||
cause: "Перевірку заведено, але план її ще жодного разу не взяв. Зонд або не отримав план, або не має відповідного модуля.",
|
||
advice: "Перевірте, чи хост закріплений за зондом і чи ввімкнено на зонді потрібний модуль.",
|
||
level: LevelWarn,
|
||
},
|
||
{
|
||
key: "stale",
|
||
title: "звіту немає довше трьох інтервалів",
|
||
cause: "Зонд не доповідав про цю перевірку довше трьох її інтервалів. Три, а не один: одиничний пропуск буває від сплеску, три поспіль — це вже не сплеск.",
|
||
advice: "Якщо зонд при цьому на зв'язку, дивіться на сам пристрій: недоступний хост дає рівно таку саму картину.",
|
||
level: LevelWarn,
|
||
},
|
||
{
|
||
key: "error",
|
||
title: "виконується, але щоразу з помилкою",
|
||
cause: "Такт не пропущено — перевірка справді відпрацювала, але завершилась помилкою. Дані з неї не приходять так само, як і від пропущеного такту, тільки причина зовсім інша.",
|
||
advice: "Текст помилки в рядку й є відповіддю: облікові дані, недосяжний порт, невірні параметри.",
|
||
level: LevelWarn,
|
||
},
|
||
}
|
||
|
||
// schedulerReasonSQL — та сама таксономія мовою бази.
|
||
//
|
||
// Один вираз на два запити (кількості й рядки) навмисно: дві копії
|
||
// умови розійдуться на першій же правці, і тоді заголовок групи
|
||
// рахуватиме не те, що показано під ним.
|
||
const schedulerReasonSQL = `
|
||
CASE
|
||
WHEN c.last_error LIKE 'still_running%' THEN 'still_running'
|
||
WHEN c.last_error LIKE 'concurrency_limit%' THEN 'concurrency_limit'
|
||
WHEN c.last_run_at IS NULL THEN 'never'
|
||
WHEN c.last_run_at < now() - make_interval(secs => c.interval_sec * 3) THEN 'stale'
|
||
WHEN c.last_error IS NOT NULL AND c.last_error <> '' THEN 'error'
|
||
ELSE 'ok'
|
||
END`
|
||
|
||
// schedulerWhereSQL — той самий відбір, що й у картці: увімкнені чеки
|
||
// живих хостів. Чеки видаленого хоста план не бере, і рахувати їх як
|
||
// затор означало б тримати на сторінці вічну червону картку.
|
||
const schedulerWhereSQL = `
|
||
FROM core.checks c
|
||
JOIN inv.devices d ON d.id = c.device_id AND d.deleted_at IS NULL
|
||
LEFT JOIN core.agents a ON a.id = d.agent_id
|
||
WHERE c.tenant_id = $1 AND c.enabled`
|
||
|
||
func schedulerDetail(ctx context.Context, tx pgx.Tx, tenantID string, det *QueueDetail) error {
|
||
det.Title = "Планувальник опитувань"
|
||
det.Intro = "Увімкнені перевірки живих хостів, у яких щось не так із тактом. Групи — за причиною й зондом: та сама причина на тому самому зонді — це одна проблема, скільки б перевірок вона не зачепила."
|
||
det.Empty = "Усі ввімкнені перевірки звітують вчасно й без помилок."
|
||
det.Note = "Колонка «вимірювання» звіряється з телеметрією лише для показаних рядків — це окремий запит, і робити його для всього парку на кожне відкриття було б дорожче за саму сторінку."
|
||
|
||
// Кількості — агрегатом. Двісті перевірок в одній групі мають дати
|
||
// число «200», а не двісті рядків по дроту й count(len(rows)) з
|
||
// обрізаної вибірки.
|
||
type gkey struct{ reason, agent string }
|
||
counts := map[gkey]int{}
|
||
agentStatus := map[string]string{}
|
||
|
||
crows, err := tx.Query(ctx, `
|
||
SELECT reason, agent_name, agent_status, count(*)::int
|
||
FROM (
|
||
SELECT `+schedulerReasonSQL+` AS reason,
|
||
COALESCE(a.name, 'без зонда') AS agent_name,
|
||
COALESCE(a.status::text, '') AS agent_status
|
||
`+schedulerWhereSQL+`
|
||
) x
|
||
WHERE reason <> 'ok'
|
||
GROUP BY 1, 2, 3
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for crows.Next() {
|
||
var reason, agent, status string
|
||
var n int
|
||
if err := crows.Scan(&reason, &agent, &status, &n); err != nil {
|
||
crows.Close()
|
||
return err
|
||
}
|
||
counts[gkey{reason, agent}] = n
|
||
agentStatus[agent] = status
|
||
}
|
||
crows.Close()
|
||
if err := crows.Err(); err != nil {
|
||
return err
|
||
}
|
||
if len(counts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Рядки — вибіркою зі стелею, у порядку старшинства причини. Порядок
|
||
// має значення саме через стелю: якщо вона спрацює, зрізаними мають
|
||
// виявитись найменш цікаві рядки, а не випадкові.
|
||
type schedRow struct {
|
||
reason string
|
||
agent string
|
||
agentStatus string
|
||
deviceID string
|
||
device string
|
||
address string
|
||
devStatus string
|
||
checkType string
|
||
plugin string
|
||
intervalSec int
|
||
timeoutMs int
|
||
lastRunSec int
|
||
lastErr string
|
||
}
|
||
var rows []schedRow
|
||
devices := map[string]bool{}
|
||
|
||
rrows, err := tx.Query(ctx, `
|
||
SELECT reason, agent_name, agent_status, device_id, device_name, address,
|
||
device_status, check_type, plugin_key, interval_sec, timeout_ms,
|
||
last_run_sec, last_error
|
||
FROM (
|
||
SELECT `+schedulerReasonSQL+` AS reason,
|
||
COALESCE(a.name, 'без зонда') AS agent_name,
|
||
COALESCE(a.status::text, '') AS agent_status,
|
||
d.id::text AS device_id,
|
||
d.name AS device_name,
|
||
COALESCE(host(d.address), '') AS address,
|
||
d.status::text AS device_status,
|
||
c.check_type,
|
||
COALESCE((SELECT ct.plugin_key::text FROM core.check_types ct
|
||
WHERE ct.key = c.check_type), '') AS plugin_key,
|
||
c.interval_sec,
|
||
c.timeout_ms,
|
||
COALESCE(extract(epoch FROM now() - c.last_run_at), -1)::int AS last_run_sec,
|
||
COALESCE(c.last_error, '') AS last_error,
|
||
CASE
|
||
WHEN c.last_error LIKE 'still_running%' THEN 0
|
||
WHEN c.last_error LIKE 'concurrency_limit%' THEN 1
|
||
WHEN c.last_run_at IS NULL THEN 2
|
||
ELSE 3
|
||
END AS pri
|
||
`+schedulerWhereSQL+`
|
||
) x
|
||
WHERE reason <> 'ok'
|
||
ORDER BY pri, last_run_sec DESC
|
||
LIMIT $2
|
||
`, tenantID, detailMaxRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for rrows.Next() {
|
||
var r schedRow
|
||
if err := rrows.Scan(&r.reason, &r.agent, &r.agentStatus, &r.deviceID, &r.device,
|
||
&r.address, &r.devStatus, &r.checkType, &r.plugin, &r.intervalSec,
|
||
&r.timeoutMs, &r.lastRunSec, &r.lastErr); err != nil {
|
||
rrows.Close()
|
||
return err
|
||
}
|
||
rows = append(rows, r)
|
||
devices[r.deviceID] = true
|
||
}
|
||
rrows.Close()
|
||
if err := rrows.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Головне число цієї сторінки — вік ОСТАННЬОГО ВИМІРЮВАННЯ, а не
|
||
// останнього звіту.
|
||
//
|
||
// Перевірка, що щотакту доповідає «пропустив», має свіжий last_run_at
|
||
// і мертву телеметрію: у базі вона виглядає майже здоровою, а хост
|
||
// при цьому може стояти «up» за іншим чеком. Саме така пара на стенді
|
||
// й ховалась за рядком «1 перевірка пропустила такт».
|
||
//
|
||
// Один запит на всі показані рядки, і лише по їхніх хостах: звіряти
|
||
// весь парк на кожне відкриття було б дорожче за все інше разом.
|
||
lastSample := map[string]time.Time{}
|
||
if len(devices) > 0 {
|
||
ids := make([]string, 0, len(devices))
|
||
for id := range devices {
|
||
ids = append(ids, id)
|
||
}
|
||
srows, err := tx.Query(ctx, `
|
||
SELECT se.device_id::text, se.plugin_key::text, max(x.ts)
|
||
FROM ts.series se
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.ts FROM ts.samples s
|
||
WHERE s.series_id = se.id
|
||
ORDER BY s.ts DESC LIMIT 1
|
||
) x ON true
|
||
WHERE se.tenant_id = $1
|
||
AND se.device_id = ANY($2::uuid[])
|
||
AND se.plugin_key IS NOT NULL
|
||
GROUP BY 1, 2
|
||
`, tenantID, ids)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for srows.Next() {
|
||
var dev, plugin string
|
||
var ts *time.Time
|
||
if err := srows.Scan(&dev, &plugin, &ts); err != nil {
|
||
srows.Close()
|
||
return err
|
||
}
|
||
if ts != nil {
|
||
lastSample[dev+"/"+plugin] = *ts
|
||
}
|
||
}
|
||
srows.Close()
|
||
if err := srows.Err(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
byGroup := map[gkey]*QueueDetailGroup{}
|
||
for _, r := range rows {
|
||
k := gkey{r.reason, r.agent}
|
||
g := byGroup[k]
|
||
if g == nil {
|
||
meta := schedulerReasonMeta(r.reason)
|
||
place := r.agent
|
||
if r.agentStatus != "" && r.agentStatus != "online" {
|
||
place += " (" + r.agentStatus + ")"
|
||
}
|
||
g = &QueueDetailGroup{
|
||
Key: r.reason + "/" + r.agent,
|
||
Title: place + " — " + meta.title,
|
||
Cause: meta.cause,
|
||
Advice: meta.advice,
|
||
Level: meta.level,
|
||
Count: counts[k],
|
||
}
|
||
byGroup[k] = g
|
||
}
|
||
if len(g.Rows) >= detailRowsPerGroup {
|
||
continue
|
||
}
|
||
|
||
// Вік вимірювання рахується по плагіну, а не по хосту: у
|
||
// Леніна.21 snmp жива, а icmp мертва, і максимум по хосту показав
|
||
// би «щойно» рівно там, де все й зламалось.
|
||
sampleSec := -1
|
||
if ts, ok := lastSample[r.deviceID+"/"+r.plugin]; ok {
|
||
sampleSec = int(time.Since(ts).Seconds())
|
||
}
|
||
dataDead := sampleSec < 0 || sampleSec > r.intervalSec*3
|
||
|
||
sub := r.address
|
||
if sub == "" {
|
||
sub = "без адреси"
|
||
}
|
||
sub += " · зонд " + r.agent
|
||
if r.agentStatus != "" {
|
||
sub += " (" + r.agentStatus + ")"
|
||
}
|
||
|
||
row := QueueDetailRow{
|
||
Title: r.device + " · " + r.checkType,
|
||
Subtitle: sub,
|
||
Level: LevelWarn,
|
||
Fields: []QueueDetailField{
|
||
{Label: "інтервал", Value: humanSec(r.intervalSec)},
|
||
{Label: "таймаут", Value: fmt.Sprintf("%d мс", r.timeoutMs),
|
||
// Таймаут, не менший за інтервал, — це не збіг
|
||
// обставин, а гарантія вічних пропусків.
|
||
Bad: r.timeoutMs >= r.intervalSec*1000},
|
||
{Label: "звіт від зонда", Value: humanSec(r.lastRunSec),
|
||
Bad: r.lastRunSec < 0 || r.lastRunSec > r.intervalSec*3},
|
||
{Label: "вимірювання", Value: sampleAge(sampleSec), Bad: dataDead},
|
||
{Label: "стан хоста", Value: r.devStatus},
|
||
},
|
||
Note: r.lastErr,
|
||
Links: deviceLinks(),
|
||
}
|
||
if dataDead {
|
||
row.Level = LevelCrit
|
||
}
|
||
g.Rows = append(g.Rows, row)
|
||
}
|
||
|
||
// Групи, у яких стеля рядків не лишила жодного прикладу, все одно
|
||
// мають бути видні: їхня кількість — це факт, а відсутність прикладу
|
||
// — наслідок нашої стелі, а не відсутності проблеми.
|
||
for k, n := range counts {
|
||
if byGroup[k] == nil {
|
||
meta := schedulerReasonMeta(k.reason)
|
||
byGroup[k] = &QueueDetailGroup{
|
||
Key: k.reason + "/" + k.agent,
|
||
Title: k.agent + statusSuffix(nonOnline(agentStatus[k.agent])) + " — " + meta.title,
|
||
Cause: meta.cause, Advice: meta.advice,
|
||
Level: meta.level, Count: n,
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, g := range byGroup {
|
||
det.Groups = append(det.Groups, *g)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// schedulerReasonMeta — опис причини за її кодом.
|
||
func schedulerReasonMeta(reason string) schedulerReason {
|
||
for _, m := range schedulerReasons {
|
||
if m.key == reason {
|
||
return m
|
||
}
|
||
}
|
||
return schedulerReason{key: reason, title: reason, level: LevelWarn}
|
||
}
|
||
|
||
// nonOnline — статус зонда, лише якщо він відрізняється від нормального.
|
||
// Дописувати «(online)» до кожного заголовка означало б привчити око
|
||
// пропускати саме те місце, де колись стоятиме «offline».
|
||
func nonOnline(status string) string {
|
||
if status == "online" {
|
||
return ""
|
||
}
|
||
return status
|
||
}
|
||
|
||
// sampleAge окремо від humanSec: «вимірювань не було жодного» і
|
||
// «вимірювання було невідомо коли» — різні відповіді, і прочерк на місці
|
||
// першої з них читається як друга.
|
||
func sampleAge(sec int) string {
|
||
if sec < 0 {
|
||
return "жодного"
|
||
}
|
||
return humanSec(sec) + " тому"
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Збір конфігів
|
||
// ---------------------------------------------------------------------
|
||
|
||
// normErrSQL зводить різні тексти однієї причини до спільного ключа.
|
||
//
|
||
// «SSH-рукостискання з 10.1.0.254:22» і «SSH-рукостискання з 10.1.0.7:22»
|
||
// — це одна проблема на двох хостах. Без нормалізації кожен рядок стає
|
||
// власною групою, і групування перетворюється на сортування.
|
||
//
|
||
// Змінного в текстах рівно три види: адреси, числа й лапки з командою.
|
||
// Прибираємо саме їх, решту лишаємо як є — заголовок групи має читатись
|
||
// людиною, а не бути хешем.
|
||
func normErrSQL(col string) string {
|
||
return `left(regexp_replace(regexp_replace(regexp_replace(regexp_replace(` + col + `,
|
||
'"[^"]*"', '«…»', 'g'),
|
||
'\d{1,3}(\.\d{1,3}){3}(:\d+)?', 'адреса', 'g'),
|
||
'\d+', 'N', 'g'),
|
||
'\s+', ' ', 'g'), 110)`
|
||
}
|
||
|
||
func jobsDetail(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
since time.Time, det *QueueDetail) error {
|
||
|
||
det.Title = "Збір конфігів"
|
||
det.Intro = "Що зараз у черзі й що не зібралось за останню годину. Невдалі завдання згруповано за текстом помилки з прибраними адресами й числами: та сама помилка на п'яти хостах — одна причина, а не п'ять."
|
||
det.Empty = "У черзі порожньо, і за годину нічого не впало."
|
||
det.Note = "Успішні завдання тут не перелічуються — питання сторінки не «що робилось», а «що не встигло»."
|
||
|
||
// Те, що чекає просто зараз, — за частковим індексом
|
||
// ncm_jobs_pending_idx: читається хвіст, а не вся історія збору.
|
||
qrows, err := tx.Query(ctx, `
|
||
SELECT j.status::text,
|
||
COALESCE(a.name, 'без зонда') AS agent_name,
|
||
COALESCE(a.status::text, '') AS agent_status,
|
||
d.name,
|
||
COALESCE(host(d.address), ''),
|
||
j.trigger::text,
|
||
COALESCE(extract(epoch FROM now() - j.created_at), 0)::int,
|
||
COALESCE(extract(epoch FROM now() - j.started_at), -1)::int
|
||
FROM ncm.jobs j
|
||
JOIN inv.devices d ON d.id = j.device_id
|
||
LEFT JOIN core.agents a ON a.id = j.agent_id
|
||
WHERE j.tenant_id = $1 AND j.status IN ('queued','running')
|
||
ORDER BY j.created_at
|
||
LIMIT $2
|
||
`, tenantID, detailMaxRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
waiting := map[string]*QueueDetailGroup{}
|
||
for qrows.Next() {
|
||
var status, agent, agentStatus, device, addr, trigger string
|
||
var ageSec, runSec int
|
||
if err := qrows.Scan(&status, &agent, &agentStatus, &device, &addr,
|
||
&trigger, &ageSec, &runSec); err != nil {
|
||
qrows.Close()
|
||
return err
|
||
}
|
||
|
||
// Три різні стани, які легко зліпити в один «чекає»: завдання, що
|
||
// стоїть у черзі; те, що вже виконується; і те, що виконується
|
||
// так довго, що прибиральник от-от закриє його як невдале. Останнє
|
||
// — майбутня втрата бекапу, і ховати його серед «у роботі» не можна.
|
||
kind, title, cause, advice, level := "queued", agent+" — чекають роздачі", "", "", LevelWarn
|
||
switch {
|
||
case status == "running" && runSec >= 600:
|
||
kind, level = "reaping", LevelCrit
|
||
title = agent + " — у роботі понад десять хвилин"
|
||
cause = "Власний таймаут завдання — п'ять хвилин. Усе, що прожило вдвічі довше, вже не завершиться: прибиральник закриє його як «зонд не відповів», і бекапу не буде."
|
||
advice = "Це ще не втрата, але стане нею найближчим тіком прибиральника. Дивіться зв'язок із зондом і доступ до самого пристрою."
|
||
case status == "running":
|
||
kind, level = "running", LevelOK
|
||
title = agent + " — виконуються зараз"
|
||
cause = "Нормальна робота: зонд узяв завдання й тримає сесію до пристрою."
|
||
default:
|
||
cause = "Диспетчер роздає завдання раз на п'ять секунд і лише зондам на зв'язку. Якщо група не порожніє, зонда в мережі немає."
|
||
if agentStatus != "" && agentStatus != "online" {
|
||
level = LevelCrit
|
||
advice = "Зонд не на зв'язку — черга не зрушить, доки він не повернеться."
|
||
}
|
||
}
|
||
|
||
key := kind + "/" + agent
|
||
g := waiting[key]
|
||
if g == nil {
|
||
g = &QueueDetailGroup{Key: key, Title: title, Cause: cause, Advice: advice, Level: level}
|
||
waiting[key] = g
|
||
}
|
||
g.Count++
|
||
if len(g.Rows) < detailRowsPerGroup {
|
||
g.Rows = append(g.Rows, QueueDetailRow{
|
||
Title: device,
|
||
Subtitle: nonEmpty(addr, "без адреси") + " · зонд " + agent + statusSuffix(agentStatus),
|
||
Level: level,
|
||
Fields: []QueueDetailField{
|
||
{Label: "стан", Value: status},
|
||
{Label: "чому запущено", Value: trigger},
|
||
{Label: "у черзі", Value: humanSec(ageSec), Bad: ageSec > 120},
|
||
{Label: "у роботі", Value: sampleAgeOrDash(runSec), Bad: runSec >= 600},
|
||
},
|
||
Links: append(deviceLinks(), QueueDetailLink{Label: "Конфіги", To: "/configs"}),
|
||
})
|
||
}
|
||
}
|
||
qrows.Close()
|
||
if err := qrows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, g := range waiting {
|
||
det.Groups = append(det.Groups, *g)
|
||
}
|
||
|
||
// Те, що вже не зібралось. Прибиральника відрізняємо за тривалістю, а
|
||
// не за текстом: текст живе в ReapStuckJobs і зміниться при першому ж
|
||
// редагуванні повідомлення, а лічильник втрат мовчки стане нулем.
|
||
frows, err := tx.Query(ctx, `
|
||
SELECT `+normErrSQL(`COALESCE(j.error, 'без тексту помилки')`)+` AS norm,
|
||
bool_or(j.started_at IS NOT NULL
|
||
AND j.finished_at - j.started_at >= interval '10 minutes') AS reaped,
|
||
count(*)::int,
|
||
(array_agg(d.name ORDER BY j.finished_at DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(host(d.address), '') ORDER BY j.finished_at DESC))[1:($3)::int],
|
||
(array_agg(j.trigger::text ORDER BY j.finished_at DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(j.error, '') ORDER BY j.finished_at DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(extract(epoch FROM now() - j.finished_at), 0)::int
|
||
ORDER BY j.finished_at DESC))[1:($3)::int],
|
||
-- Прибиральник закриває завдання, не заповнюючи duration_ms:
|
||
-- він не знає, скільки воно виконувалось, бо не він його
|
||
-- запускав. Нуль на цьому місці читався б як «впало
|
||
-- миттєво» — рівно протилежне до правди про застрягле
|
||
-- завдання. Тому за відсутності поля беремо сам проміжок.
|
||
(array_agg(COALESCE(j.duration_ms,
|
||
(extract(epoch FROM j.finished_at - j.started_at) * 1000)::int,
|
||
0) ORDER BY j.finished_at DESC))[1:($3)::int]
|
||
FROM ncm.jobs j
|
||
JOIN inv.devices d ON d.id = j.device_id
|
||
WHERE j.tenant_id = $1 AND j.status = 'failed' AND j.finished_at > $2
|
||
GROUP BY 1
|
||
ORDER BY 3 DESC
|
||
LIMIT $4
|
||
`, tenantID, since, detailRowsPerGroup, detailMaxGroups)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer frows.Close()
|
||
for frows.Next() {
|
||
var norm string
|
||
var reaped bool
|
||
var n int
|
||
var names, addrs, triggers, errs []string
|
||
var ages, durations []int32
|
||
if err := frows.Scan(&norm, &reaped, &n, &names, &addrs, &triggers,
|
||
&errs, &ages, &durations); err != nil {
|
||
return err
|
||
}
|
||
g := QueueDetailGroup{
|
||
Key: "failed/" + norm,
|
||
Title: "не зібралось: " + norm,
|
||
Cause: "Одна причина на " + plural(n, "хост", "хости", "хостів") + ". Текст помилки зведено до спільного вигляду — адреси й числа прибрано, решта як у самій помилці.",
|
||
Level: LevelWarn,
|
||
Count: n,
|
||
}
|
||
if reaped {
|
||
g.Level = LevelCrit
|
||
g.Advice = "Принаймні одне завдання групи закрив прибиральник, а не сам збір: воно пробуло в роботі понад десять хвилин. Це бекап, якого не сталося."
|
||
}
|
||
for i := range names {
|
||
row := QueueDetailRow{
|
||
Title: names[i],
|
||
Subtitle: nonEmpty(addrs[i], "без адреси"),
|
||
Level: g.Level,
|
||
Fields: []QueueDetailField{
|
||
{Label: "чому запущено", Value: triggers[i]},
|
||
{Label: "коли впало", Value: humanSec(int(ages[i])) + " тому"},
|
||
{Label: "тривало", Value: humanSec(int(durations[i] / 1000)),
|
||
Bad: durations[i] >= 600_000},
|
||
},
|
||
Note: errs[i],
|
||
Links: append(deviceLinks(), QueueDetailLink{Label: "Конфіги", To: "/configs"}),
|
||
}
|
||
g.Rows = append(g.Rows, row)
|
||
}
|
||
det.Groups = append(det.Groups, g)
|
||
}
|
||
return frows.Err()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Зв'язок із зондами
|
||
// ---------------------------------------------------------------------
|
||
|
||
func agentLinkDetail(ctx context.Context, tx pgx.Tx, tenantID string, det *QueueDetail) error {
|
||
det.Title = "Зв'язок із зондами"
|
||
det.Intro = "Зонди, з якими щось не так, і — головне — скільки хостів і перевірок лишилось за кожним без опитування. Один офлайн зонд із сотнею хостів гірший за три офлайн зонди без жодного."
|
||
det.Empty = "Усі зонди на зв'язку й доповідають вчасно."
|
||
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT a.name, a.status::text, COALESCE(a.version, ''),
|
||
COALESCE(a.hostname, ''), COALESCE(host(a.public_ip), ''),
|
||
COALESCE(extract(epoch FROM now() - a.last_heartbeat_at), -1)::int,
|
||
(SELECT count(*)::int FROM inv.devices d
|
||
WHERE d.agent_id = a.id AND d.deleted_at IS NULL),
|
||
(SELECT count(*)::int FROM core.checks c
|
||
JOIN inv.devices d ON d.id = c.device_id
|
||
WHERE d.agent_id = a.id AND d.deleted_at IS NULL AND c.enabled),
|
||
(SELECT count(*)::int FROM ncm.jobs j
|
||
WHERE j.agent_id = a.id AND j.status IN ('queued','running'))
|
||
FROM core.agents a
|
||
WHERE a.tenant_id = $1
|
||
AND (a.status <> 'online'
|
||
OR a.last_heartbeat_at IS NULL
|
||
OR a.last_heartbeat_at < now() - interval '120 seconds')
|
||
ORDER BY a.name
|
||
LIMIT $2
|
||
`, tenantID, detailMaxRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
groups := map[string]*QueueDetailGroup{}
|
||
for rows.Next() {
|
||
var name, status, version, hostname, ip string
|
||
var hbSec, devices, checks, jobs int
|
||
if err := rows.Scan(&name, &status, &version, &hostname, &ip,
|
||
&hbSec, &devices, &checks, &jobs); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Розділення тут не за статусом у базі, а за наслідком. «Зонд
|
||
// офлайн» і «зонд рахується живим, але мовчить» — різні аварії:
|
||
// у першій сервер знає, що збору немає, у другій він цього ще не
|
||
// зрозумів і всі решта сторінок показують останні відомі числа
|
||
// як поточні.
|
||
key, title, cause, advice, level := "offline", "не на зв'язку", "", "", LevelWarn
|
||
if status == "online" {
|
||
key, title = "silent", "рахується живим, але мовчить"
|
||
cause = "Сервер вважає зонд онлайн, бо статус ще не переписано, але heartbeat не приходив довше двох хвилин. Найнебезпечніший стан: усі інші сторінки показують останні відомі числа так, ніби вони поточні."
|
||
advice = "Дивіться мережу між зондом і сервером. Якщо зонд живий, а heartbeat не доходить, дані з нього теж не доходять."
|
||
level = LevelCrit
|
||
} else {
|
||
cause = "Зонд не на зв'язку — з його хостів зараз не збирається нічого й не збереться, доки він не повернеться."
|
||
advice = "Завдання на збір конфігів для його хостів диспетчер не роздає взагалі: вони чекатимуть у черзі."
|
||
if devices > 0 {
|
||
level = LevelCrit
|
||
}
|
||
}
|
||
|
||
g := groups[key]
|
||
if g == nil {
|
||
g = &QueueDetailGroup{Key: key, Title: title, Cause: cause, Advice: advice, Level: level}
|
||
groups[key] = g
|
||
}
|
||
if level == LevelCrit {
|
||
g.Level = LevelCrit
|
||
}
|
||
g.Count++
|
||
|
||
sub := hostname
|
||
if ip != "" {
|
||
sub = nonEmpty(sub, "") + " · " + ip
|
||
}
|
||
if len(g.Rows) < detailRowsPerGroup {
|
||
g.Rows = append(g.Rows, QueueDetailRow{
|
||
Title: name,
|
||
Subtitle: strings.TrimPrefix(nonEmpty(sub, "адреса невідома"), " · "),
|
||
Level: level,
|
||
Fields: []QueueDetailField{
|
||
{Label: "стан", Value: status, Bad: status != "online"},
|
||
{Label: "heartbeat", Value: sampleAge(hbSec), Bad: hbSec < 0 || hbSec > 120},
|
||
{Label: "хостів за ним", Value: fmt.Sprint(devices), Bad: devices > 0},
|
||
{Label: "перевірок стоїть", Value: fmt.Sprint(checks), Bad: checks > 0},
|
||
{Label: "завдань чекає", Value: fmt.Sprint(jobs), Bad: jobs > 0},
|
||
{Label: "версія", Value: nonEmpty(version, "—")},
|
||
},
|
||
Links: []QueueDetailLink{{Label: "Зонди", To: "/agents"}},
|
||
})
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, g := range groups {
|
||
det.Groups = append(det.Groups, *g)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Буфер телеметрії зондів
|
||
// ---------------------------------------------------------------------
|
||
|
||
// buildBufferDetail нічого не запитує: усе потрібне вже є в знімку.
|
||
//
|
||
// Приріст викинутого рахує спостерігач у пам'яті процесу, і другого
|
||
// джерела для нього не існує — у базі лежить лише накопичувальний
|
||
// лічильник від старту зонда.
|
||
func buildBufferDetail(det *QueueDetail, f *QueueFacts, d QueueDeltas) {
|
||
det.Title = "Буфер телеметрії зондів"
|
||
det.Intro = "Єдине місце в системі, де переповнення викидає дані мовчки й назавжди. Тому групи тут — не за зондами, а за тим, що з даними: викидаються просто зараз, ось-ось почнуть, чи все минуло."
|
||
det.Empty = "Жоден зонд не доповідає про втрати."
|
||
det.Note = "Приріст рахується від точки відліку в пам'яті цього процесу. Після його перезапуску спостереження чесно починається заново — «щойно викинуто» покаже нуль, доки не набереться нова точка."
|
||
|
||
now := &QueueDetailGroup{
|
||
Key: "dropping", Title: "викидають дані просто зараз", Level: LevelCrit,
|
||
Cause: "Лічильник викинутих вибірок виріс від попереднього знімка. Буфер зонда обмежений і при переповненні викидає найстаріше — ці вимірювання не повернуться ніколи й не «доїдуть пізніше».",
|
||
Advice: "Причина майже завжди одна: зонд не встигає віддати телеметрію серверу. Дивіться канал між зондом і сервером, а не сам зонд.",
|
||
}
|
||
growing := &QueueDetailGroup{
|
||
Key: "growing", Title: "буфер росте, але втрат ще немає", Level: LevelWarn,
|
||
Cause: "Глибина буфера за півгодини зросла більш ніж удвічі й перевищила тисячу. Даних ще не втрачено, але напрямок один.",
|
||
Advice: "Це вікно, у якому проблему ще можна полагодити без втрат.",
|
||
}
|
||
past := &QueueDetailGroup{
|
||
Key: "past", Title: "втрати були раніше, зараз не ростуть", Level: LevelWarn,
|
||
Cause: "Лічильник ненульовий, але від попереднього знімка не змінився. Він накопичувальний від старту зонда, тож ненульовим лишиться й після того, як усе полагодили.",
|
||
Advice: "Обнуляється лише перезапуском зонда. Дивіться не на це число, а на «викинуто щойно».",
|
||
}
|
||
quiet := &QueueDetailGroup{
|
||
Key: "quiet", Title: "звітують про буфер, втрат немає", Level: LevelOK,
|
||
Cause: "Зонд доповідає глибину буфера, і вона не росте.",
|
||
}
|
||
silent := &QueueDetailGroup{
|
||
Key: "silent", Title: "про буфер не доповідають", Level: LevelUnknown,
|
||
Cause: "Зонд не прислав жодного з полів про буфер. Це не означає «втрат немає» — це означає, що втрат ніхто не рахує.",
|
||
Advice: "Порожнє місце на сторінці про втрати читається як нуль. Тому ці зонди названо окремо.",
|
||
}
|
||
|
||
for _, a := range f.Agents {
|
||
fields := []QueueDetailField{
|
||
{Label: "стан", Value: a.Status, Bad: a.Status != "online"},
|
||
{Label: "heartbeat", Value: sampleAge(a.HeartbeatSec), Bad: a.HeartbeatSec < 0 || a.HeartbeatSec > 120},
|
||
{Label: "хостів", Value: fmt.Sprint(a.Devices)},
|
||
{Label: "у буфері зараз", Value: intOrDash(a.QueueDepth)},
|
||
{Label: "пік за півгодини", Value: intOrDash(a.DepthPeak)},
|
||
{Label: "було півгодини тому", Value: intOrDash(a.DepthFirst)},
|
||
{Label: "викинуто від старту", Value: int64OrDash(a.Dropped),
|
||
Bad: a.Dropped != nil && *a.Dropped > 0},
|
||
{Label: "задач у роботі", Value: intOrDash(a.TasksRunning)},
|
||
{Label: "задач у черзі", Value: intOrDash(a.TasksQueued)},
|
||
}
|
||
row := QueueDetailRow{
|
||
Title: a.Name,
|
||
Subtitle: fmt.Sprintf("точок телеметрії за півгодини: %d", a.DepthPoints),
|
||
Fields: fields,
|
||
Links: []QueueDetailLink{{Label: "Зонди", To: "/agents"}},
|
||
}
|
||
|
||
inc := d.AgentDropped[a.ID]
|
||
switch {
|
||
case a.QueueDepth == nil && a.Dropped == nil:
|
||
row.Level = LevelUnknown
|
||
addRow(silent, row)
|
||
case inc > 0:
|
||
row.Level = LevelCrit
|
||
row.Fields = append([]QueueDetailField{{
|
||
Label: "викинуто за " + humanSec(d.ObservedSec),
|
||
Value: fmt.Sprint(inc), Bad: true,
|
||
}}, row.Fields...)
|
||
addRow(now, row)
|
||
case a.DepthFirst != nil && a.DepthLast != nil &&
|
||
*a.DepthLast > 1000 && *a.DepthLast > *a.DepthFirst*2:
|
||
row.Level = LevelWarn
|
||
addRow(growing, row)
|
||
case a.Dropped != nil && *a.Dropped > 0:
|
||
row.Level = LevelWarn
|
||
addRow(past, row)
|
||
default:
|
||
row.Level = LevelOK
|
||
addRow(quiet, row)
|
||
}
|
||
}
|
||
|
||
for _, g := range []*QueueDetailGroup{now, growing, past, silent, quiet} {
|
||
if g.Count > 0 {
|
||
det.Groups = append(det.Groups, *g)
|
||
}
|
||
}
|
||
finishDetail(det)
|
||
}
|
||
|
||
// buildPoolDetail чесно каже, що перелічувати нічого.
|
||
//
|
||
// Порожній перелік без пояснення читається як «проблем немає». Тут
|
||
// проблема може бути, але переліку не існує в природі: pgxpool рахує
|
||
// з'єднання, а не тих, хто їх бере.
|
||
func buildPoolDetail(det *QueueDetail, f *QueueFacts, d QueueDeltas) {
|
||
p := f.Pool
|
||
det.Title = "Пул з'єднань до бази (API)"
|
||
det.Intro = "Переліку тут немає, і це не збіг обставин: пул веде облік з'єднань, а не тих, хто їх бере. Рядок «ось цей запит тримає з'єднання» скласти нема з чого — такої інформації в pgxpool просто не існує. Нижче — усе, що взагалі можна сказати про цю чергу."
|
||
det.Empty = "Про цю чергу відомо лише те, що показано вище."
|
||
det.Note = "І це пул лише того процесу, що віддає цю сторінку. У колектора власний пул у власному контейнері, і звідси його не видно взагалі — порожньо тут не означає, що з колектором усе гаразд."
|
||
det.Groups = []QueueDetailGroup{{
|
||
Key: "pool",
|
||
Title: "стан пулу цього процесу",
|
||
Cause: "Усе, що взагалі можна сказати про цю чергу.",
|
||
Level: LevelOK,
|
||
Count: 1,
|
||
Rows: []QueueDetailRow{{
|
||
Title: "pgxpool процесу API",
|
||
Fields: []QueueDetailField{
|
||
{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: "чекали за " + humanSec(d.ObservedSec), Value: fmt.Sprint(d.PoolEmpty),
|
||
Bad: d.PoolEmpty > 0},
|
||
{Label: "скасовано в черзі", Value: fmt.Sprint(p.CanceledAcquires),
|
||
Bad: p.CanceledAcquires > 0},
|
||
{Label: "сумарне очікування", Value: fmt.Sprintf("%d мс", p.AcquireWaitMs)},
|
||
},
|
||
}},
|
||
}}
|
||
if p.Max > 0 && p.Acquired >= p.Max {
|
||
det.Groups[0].Level = LevelCrit
|
||
det.Groups[0].Rows[0].Level = LevelCrit
|
||
}
|
||
det.Total = 1
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Масове виконання команд
|
||
// ---------------------------------------------------------------------
|
||
|
||
func commandsDetail(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
since time.Time, det *QueueDetail) error {
|
||
|
||
det.Title = "Масове виконання команд"
|
||
det.Intro = "Хости прогонів: хто ще чекає й на кому команда не виконалась. Групи — за прогоном, бо прогін і є одним рішенням людини: перелік хостів вона підтвердила один раз, і другої спроби не буде."
|
||
det.Empty = "Активних прогонів немає, і за годину нічого не зірвалось."
|
||
|
||
// Хвіст за command_targets_pending_idx: прогін на п'ятсот хостів
|
||
// лишає по собі п'ятсот рядків історії, і рахувати їх щоразу — рівно
|
||
// та помилка, від якої ця сторінка мала б стерегти.
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT r.id::text,
|
||
COALESCE(r.commands->>0, r.commands::text) AS first_cmd,
|
||
r.concurrency, r.total,
|
||
t.status::text,
|
||
d.name, COALESCE(host(d.address), ''),
|
||
COALESCE(a.name, 'без зонда'), COALESCE(a.status::text, ''),
|
||
COALESCE(extract(epoch FROM now() - t.created_at), 0)::int,
|
||
COALESCE(extract(epoch FROM now() - t.started_at), -1)::int,
|
||
COALESCE(t.error, '')
|
||
FROM ncm.command_targets t
|
||
JOIN ncm.command_runs r ON r.id = t.run_id
|
||
JOIN inv.devices d ON d.id = t.device_id
|
||
LEFT JOIN core.agents a ON a.id = t.agent_id
|
||
WHERE t.tenant_id = $1
|
||
AND (t.status IN ('pending','queued','running')
|
||
OR (t.status = 'failed' AND t.finished_at > $2))
|
||
ORDER BY t.created_at
|
||
LIMIT $3
|
||
`, tenantID, since, detailMaxRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
groups := map[string]*QueueDetailGroup{}
|
||
var order []string
|
||
for rows.Next() {
|
||
var runID, cmd, status, device, addr, agent, agentStatus, errText string
|
||
var concurrency, total, ageSec, runSec int
|
||
if err := rows.Scan(&runID, &cmd, &concurrency, &total, &status, &device,
|
||
&addr, &agent, &agentStatus, &ageSec, &runSec, &errText); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Усередині прогону стани не рівноцінні: «чекає слота» — це сам
|
||
// механізм паралельності, а не затор, тоді як «зонд не відповів»
|
||
// означає, що команда на цьому хості не виконалась і не виконається.
|
||
bucket, level := "pending", LevelOK
|
||
switch {
|
||
case status == "failed" && strings.HasPrefix(errText, "зонд не відповів"):
|
||
bucket, level = "timeout", LevelCrit
|
||
case status == "failed":
|
||
bucket, level = "failed", LevelWarn
|
||
case status == "queued" && ageSec > 120:
|
||
bucket, level = "stuck", LevelWarn
|
||
case status == "queued":
|
||
bucket = "queued"
|
||
case status == "running":
|
||
bucket = "running"
|
||
}
|
||
|
||
key := bucket + "/" + runID
|
||
g := groups[key]
|
||
if g == nil {
|
||
g = &QueueDetailGroup{Key: key, Level: level}
|
||
g.Title = commandBucketTitle(bucket) + " · " + shortCmd(cmd)
|
||
g.Cause = commandBucketCause(bucket, concurrency, total)
|
||
g.Advice = commandBucketAdvice(bucket)
|
||
groups[key] = g
|
||
order = append(order, key)
|
||
}
|
||
g.Count++
|
||
if len(g.Rows) < detailRowsPerGroup {
|
||
g.Rows = append(g.Rows, QueueDetailRow{
|
||
Title: device,
|
||
Subtitle: nonEmpty(addr, "без адреси") + " · зонд " + agent + statusSuffix(agentStatus),
|
||
Level: level,
|
||
Fields: []QueueDetailField{
|
||
{Label: "стан", Value: status},
|
||
{Label: "у прогоні", Value: humanSec(ageSec), Bad: bucket == "stuck"},
|
||
{Label: "виконується", Value: sampleAgeOrDash(runSec)},
|
||
{Label: "хостів у прогоні", Value: fmt.Sprint(total)},
|
||
{Label: "стеля паралельності", Value: fmt.Sprint(concurrency)},
|
||
},
|
||
Note: errText,
|
||
Links: append(deviceLinks(), QueueDetailLink{Label: "Команди", To: "/commands"}),
|
||
})
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, k := range order {
|
||
det.Groups = append(det.Groups, *groups[k])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func commandBucketTitle(b string) string {
|
||
switch b {
|
||
case "timeout":
|
||
return "зонд не відповів — команда не виконалась"
|
||
case "failed":
|
||
return "команда впала"
|
||
case "stuck":
|
||
return "віддано зонду, але не рушило"
|
||
case "running":
|
||
return "виконуються зараз"
|
||
case "queued":
|
||
return "віддано зонду"
|
||
default:
|
||
return "чекають вільного слота"
|
||
}
|
||
}
|
||
|
||
func commandBucketCause(b string, concurrency, total int) string {
|
||
switch b {
|
||
case "timeout":
|
||
return "Прибиральник закрив хост як «зонд не відповів». Другої спроби прогін не робить: перелік хостів людина підтвердила один раз, і повторити його може теж лише людина."
|
||
case "failed":
|
||
return "Сесія до пристрою відкрилась, але команда завершилась помилкою. Текст у рядку й є причиною."
|
||
case "stuck":
|
||
return "Хост уже віддано диспетчеру, і він мав поїхати до зонда за секунди. Такий вік означає, що завдання не доходить до зонда."
|
||
case "running":
|
||
return "Нормальна робота: зонд тримає сесію до пристрою."
|
||
case "queued":
|
||
return "Диспетчер уже віддав хост зонду й чекає на результат."
|
||
default:
|
||
return fmt.Sprintf(
|
||
"Це не затор, а сам механізм: більше ніж %d одночасних сесій до заліза прогін не відкриває, тож решта з %d хостів чекає своєї черги.",
|
||
concurrency, total)
|
||
}
|
||
}
|
||
|
||
func commandBucketAdvice(b string) string {
|
||
switch b {
|
||
case "timeout":
|
||
return "Щоб повторити, зберіть новий прогін на цих хостах."
|
||
case "stuck":
|
||
return "Дивіться зв'язок із зондом: диспетчер роздає завдання лише тим, хто на зв'язку."
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func shortCmd(cmd string) string {
|
||
cmd = strings.TrimSpace(cmd)
|
||
if len(cmd) > 60 {
|
||
return cmd[:60] + "…"
|
||
}
|
||
return nonEmpty(cmd, "без команди")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Доставка сповіщень
|
||
// ---------------------------------------------------------------------
|
||
|
||
func notifyDetail(ctx context.Context, tx pgx.Tx, tenantID string,
|
||
since time.Time, det *QueueDetail) error {
|
||
|
||
det.Title = "Доставка сповіщень"
|
||
det.Intro = "Сповіщення, які не дійшли або були придушені лімітом. Групи — за каналом і причиною: один зламаний канал дає стільки недоставлених, скільки було алертів, і це одна проблема."
|
||
det.Empty = "За годину всі сповіщення доставлено без відмов."
|
||
det.Note = "Успішні доставки тут не перелічуються. Придушені лімітом — перелічуються: це не помилка, але людина про подію все одно не дізналась."
|
||
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT n.status::text,
|
||
COALESCE(c.name, 'канал видалено') AS channel_name,
|
||
COALESCE(c.kind::text, '') AS channel_kind,
|
||
`+normErrSQL(`COALESCE(NULLIF(n.error, ''), 'без тексту помилки')`)+` AS norm,
|
||
count(*)::int,
|
||
(array_agg(COALESCE(al.title, 'алерт видалено') ORDER BY n.ts DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(d.name, '') ORDER BY n.ts DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(al.severity::text, '') ORDER BY n.ts DESC))[1:($3)::int],
|
||
(array_agg(n.attempt::int ORDER BY n.ts DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(n.error, '') ORDER BY n.ts DESC))[1:($3)::int],
|
||
(array_agg(COALESCE(extract(epoch FROM now() - n.ts), 0)::int ORDER BY n.ts DESC))[1:($3)::int]
|
||
FROM alr.notifications n
|
||
LEFT JOIN alr.channels c ON c.id = n.channel_id
|
||
LEFT JOIN alr.alerts al ON al.id = n.alert_id
|
||
LEFT JOIN inv.devices d ON d.id = al.device_id
|
||
WHERE n.tenant_id = $1 AND n.ts > $2 AND n.status IN ('failed','throttled')
|
||
GROUP BY 1, 2, 3, 4
|
||
ORDER BY 5 DESC
|
||
LIMIT $4
|
||
`, tenantID, since, detailRowsPerGroup, detailMaxGroups)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var status, channel, kind, norm string
|
||
var n int
|
||
var titles, devices, severities, errs []string
|
||
var attempts, ages []int32
|
||
if err := rows.Scan(&status, &channel, &kind, &norm, &n,
|
||
&titles, &devices, &severities, &attempts, &errs, &ages); err != nil {
|
||
return err
|
||
}
|
||
|
||
g := QueueDetailGroup{
|
||
Key: status + "/" + channel + "/" + norm,
|
||
Count: n,
|
||
Level: LevelWarn,
|
||
}
|
||
if status == "failed" {
|
||
g.Level = LevelCrit
|
||
g.Title = channel + statusSuffix(kind) + " — не доставлено: " + norm
|
||
g.Cause = "Канал відмовив на всіх спробах. Алерт, про який ніхто не дізнався, дорівнює алерту, якого не було — і саме тому це червоне, а не жовте."
|
||
g.Advice = "Причина одна на всю групу: це той самий канал і та сама помилка. Полагодьте канал — зникнуть усі рядки одразу."
|
||
} else {
|
||
g.Title = channel + statusSuffix(kind) + " — придушено лімітом"
|
||
g.Cause = "Канал має ліміт частоти, і ці сповіщення в нього не влізли. Помилки тут немає, але людина про подію все одно не дізналась."
|
||
g.Advice = "Якщо придушень багато — або правило шумить, або ліміт каналу занизький для цього потоку алертів."
|
||
}
|
||
|
||
for i := range titles {
|
||
sub := devices[i]
|
||
if severities[i] != "" {
|
||
sub = nonEmpty(sub, "без хоста") + " · " + severities[i]
|
||
}
|
||
g.Rows = append(g.Rows, QueueDetailRow{
|
||
Title: titles[i],
|
||
Subtitle: strings.TrimPrefix(sub, " · "),
|
||
Level: g.Level,
|
||
Fields: []QueueDetailField{
|
||
{Label: "коли", Value: humanSec(int(ages[i])) + " тому"},
|
||
{Label: "спроба", Value: fmt.Sprint(attempts[i]), Bad: attempts[i] > 1},
|
||
{Label: "канал", Value: channel},
|
||
},
|
||
Note: errs[i],
|
||
Links: []QueueDetailLink{{Label: "Алерти", To: "/alerts"}, {Label: "Канали", To: "/channels"}},
|
||
})
|
||
}
|
||
det.Groups = append(det.Groups, g)
|
||
}
|
||
return rows.Err()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Події для інтерфейсу
|
||
// ---------------------------------------------------------------------
|
||
|
||
func outboxDetail(ctx context.Context, tx pgx.Tx, tenantID string, det *QueueDetail) error {
|
||
det.Title = "Події для інтерфейсу"
|
||
det.Intro = "Події, які ще не пішли у відкриті вкладки. Групи — за темою: застрягла публікація тримає всі теми одразу, а от одна тема, що росте на тлі решти, означає зовсім інше."
|
||
det.Empty = "Усі події опубліковано."
|
||
det.Note = "Події не губляться: підписники читають їх за id і доженуть. Доти мапа й перелік алертів показують застарілий стан — це затримка, а не втрата."
|
||
|
||
// Стеля навмисна, як і в самому знімку. Точне число потрібне, лише
|
||
// поки воно мале; за кількадесят тисяч відповідь однаково одна —
|
||
// «публікація стоїть».
|
||
const maxCount = 5000
|
||
crows, err := tx.Query(ctx, `
|
||
SELECT topic, count(*)::int,
|
||
COALESCE(extract(epoch FROM now() - min(created_at)), 0)::int
|
||
FROM (
|
||
SELECT topic, created_at FROM core.event_outbox
|
||
WHERE tenant_id = $1 AND published_at IS NULL
|
||
ORDER BY id
|
||
LIMIT $2
|
||
) x
|
||
GROUP BY topic
|
||
ORDER BY 2 DESC
|
||
`, tenantID, maxCount+1)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
counts := map[string]int{}
|
||
oldest := map[string]int{}
|
||
total := 0
|
||
for crows.Next() {
|
||
var topic string
|
||
var n, age int
|
||
if err := crows.Scan(&topic, &n, &age); err != nil {
|
||
crows.Close()
|
||
return err
|
||
}
|
||
counts[topic], oldest[topic] = n, age
|
||
total += n
|
||
}
|
||
crows.Close()
|
||
if err := crows.Err(); err != nil {
|
||
return err
|
||
}
|
||
if total > maxCount {
|
||
det.Capped = true
|
||
det.Note = "Підрахунок уперся в стелю у 5000 подій — справжнє число більше. За такої глибини відповідь однаково одна: публікація стоїть. " + det.Note
|
||
}
|
||
if len(counts) == 0 {
|
||
return nil
|
||
}
|
||
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id, topic,
|
||
COALESCE(extract(epoch FROM now() - created_at), 0)::int
|
||
FROM core.event_outbox
|
||
WHERE tenant_id = $1 AND published_at IS NULL
|
||
ORDER BY id
|
||
LIMIT $2
|
||
`, tenantID, detailMaxRows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
groups := map[string]*QueueDetailGroup{}
|
||
for rows.Next() {
|
||
var id int64
|
||
var topic string
|
||
var age int
|
||
if err := rows.Scan(&id, &topic, &age); err != nil {
|
||
return err
|
||
}
|
||
g := groups[topic]
|
||
if g == nil {
|
||
level := LevelWarn
|
||
if oldest[topic] > 300 {
|
||
level = LevelCrit
|
||
}
|
||
g = &QueueDetailGroup{
|
||
Key: "topic/" + topic,
|
||
Title: topic,
|
||
Count: counts[topic],
|
||
Level: level,
|
||
Cause: "Тема, у якій події не опубліковано. Найстаріша чекає " + humanSec(oldest[topic]) + ".",
|
||
Advice: "Якщо чекають усі теми — стоїть сама публікація. Якщо одна — дивіться того, хто її породжує.",
|
||
}
|
||
groups[topic] = g
|
||
}
|
||
if len(g.Rows) < detailRowsPerGroup {
|
||
g.Rows = append(g.Rows, QueueDetailRow{
|
||
Title: fmt.Sprintf("подія #%d", id),
|
||
Subtitle: topic,
|
||
Level: g.Level,
|
||
Fields: []QueueDetailField{
|
||
{Label: "чекає", Value: humanSec(age), Bad: age > 30},
|
||
},
|
||
})
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, g := range groups {
|
||
det.Groups = append(det.Groups, *g)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Дрібниці
|
||
// ---------------------------------------------------------------------
|
||
|
||
// deviceLinks — куди йти з рядка про хост.
|
||
//
|
||
// Посилання ведуть у розділ, а не на конкретний хост: жодна зі сторінок
|
||
// продукту поки не читає адресний рядок, і посилання, яке обіцяє хост, а
|
||
// відкриває повний перелік, гірше за посилання, що обіцяє розділ.
|
||
func deviceLinks() []QueueDetailLink {
|
||
return []QueueDetailLink{{Label: "Хости", To: "/devices"}}
|
||
}
|
||
|
||
func addRow(g *QueueDetailGroup, row QueueDetailRow) {
|
||
g.Count++
|
||
if len(g.Rows) < detailRowsPerGroup {
|
||
g.Rows = append(g.Rows, row)
|
||
}
|
||
}
|
||
|
||
func nonEmpty(s, fallback string) string {
|
||
if strings.TrimSpace(s) == "" {
|
||
return fallback
|
||
}
|
||
return s
|
||
}
|
||
|
||
func statusSuffix(s string) string {
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
return " (" + s + ")"
|
||
}
|
||
|
||
func sampleAgeOrDash(sec int) string {
|
||
if sec < 0 {
|
||
return "—"
|
||
}
|
||
return humanSec(sec)
|
||
}
|
||
|
||
func intOrDash(v *int) string {
|
||
if v == nil {
|
||
return "не доповідає"
|
||
}
|
||
return fmt.Sprint(*v)
|
||
}
|
||
|
||
func int64OrDash(v *int64) string {
|
||
if v == nil {
|
||
return "не доповідає"
|
||
}
|
||
return fmt.Sprint(*v)
|
||
}
|