Netpulse_SasS/server/internal/httpapi/serverlog.go
byrsapty 04e1242c52
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m10s
CI / server (push) Successful in 1m41s
CI / agent (push) Successful in 59s
Журнали файлами, тонший штрих мінікарти
ФАЙЛИ В /var/log/netpulse. rsyslog читає journald і розкладає по файлах
на службу. Драйвер docker лишається journald, а не syslog: syslog
віддав би рядки назовні й нічого не лишив докеру, тож `docker logs`
замовк би назавжди — і мовчання виглядало б як «служба нічого не пише».
Тепер працюють усі три шляхи: docker logs, journalctl, файли.

Дорогою три власні помилки, кожна виглядала як «rsyslog не працює»:

  1. Умова матчила $!CONTAINER_NAME — метадані журналу. journalctl їх
     бачить, а до правил rsyslog вони доходять не завжди. Каталог
     створювався й лишався порожнім.
  2. Тег «netpulse/{{.Name}}» здавався охайнішим, але rsyslog обриває
     programname на скісній: для ВСІХ контейнерів вона ставала просто
     «netpulse». Тег тепер — саме ім'я контейнера, воно й так має
     префікс проєкту.
  3. Шаблон `%msg:::sp-if-no-1st-sp,drop-last-lf%` давав ПОРОЖНІЙ
     текст: файли були, рядки були, слів не було. Журнал виглядав
     робочим і не містив нічого — найгірший різновид поломки.

Конфігурації в deploy/, щоб їхали клієнтам, а не лишались разовим
налаштуванням одного сервера.

МІНІКАРТА. Штрих рядка був 2 px при 3 px на рядок — просвіт в один
піксель. На дробовому масштабі екрана (1.25, 1.5 — тобто на більшості
ноутбуків) він губився при округленні, і рядки злипались у суцільну
пляму: мінікарта показувала не форму конфігу, а сірий прямокутник.

Тепер штрих 1 px, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
2026-08-27 22:55:29 +03:00

247 lines
12 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 httpapi
import (
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/logbuf"
)
// Журнал самого сервера: на що лається NetPulse, коли лається він, а не
// мережа.
//
// ПРО ПРАВА
//
// settings:write, і нового права не заводиться.
//
// Звірено з наявним переліком (core.permissions, міграції 0010 і 0053).
// Придатних кандидатів там рівно два. audit:read — «читати журнал
// аудиту», і воно НЕ підходить: аудит відповідає на питання «хто що
// зробив», його дають внутрішньому контролеру, і давати разом із ним
// тексти помилок SQL і внутрішні адреси означало б розширити те право
// мовчки. settings:write — «Змінювати налаштування організації», і воно
// є лише у власника й адміна (0064 спирається на це прямо). Той, хто
// може змінити строки зберігання даних і викинути піврічну історію,
// уже бачить усе, що можна побачити; додати йому системний журнал —
// не розширення доступу, а виправлення того, що він досі не міг
// подивитись, не маючи ssh.
//
// Право на ЗАПИС для сторінки, яка лише читає, виглядає дивно рівно
// доти, доки не спитати, що воно насправді означає в цьому продукті:
// «це власник інсталяції». Заводити читальний двійник settings:read
// заради одної сторінки — це нове право в переліку ролей, нова галочка
// в редакторі ролей і нова міграція; коштує це дорожче, ніж дає.
//
// ЧОГО ТУТ НЕМАЄ Й НЕ БУДЕ
//
// Ані запису в базу, ані фільтра по кабінету. Журнал процесу — це
// журнал ІНСТАЛЯЦІЇ, а не кабінету: один такт прибиральника обслуговує
// всі кабінети одразу, і розділити його рядки між ними неможливо. Тому
// сторінка доступна власникові кабінету, але показує спільне — і саме
// тому нею не можна користуватись на спільному хостингу кількох
// клієнтів. На інсталяції в клієнта (а це і є коробковий продукт)
// кабінет один, і питання не виникає.
const (
// serverLogPerm — див. розбір прав вище.
serverLogPerm = "settings:write"
// Скільки рядків віддавати за раз.
//
// Фільтр накладається на сервері ДО стелі, і це не оптимізація, а
// сенс: «останні 500 рядків, серед яких три помилки» і «останні 500
// помилок» — різні відповіді, а людина, яка обрала рівень «error»,
// питає друге.
serverLogLimit = 500
serverLogMaxLimit = 5000
// Скільки чекати на сусідній процес. Три секунди: більше — і
// сторінка підвисає рівно тоді, коли по неї прийшли; менше — і
// завантажений колектор не встигав би відповісти.
serverLogTimeout = 3 * time.Second
)
// serverLogSource — стан одного джерела для сторінки.
//
// Помилка живе тут, а не в загальному полі відповіді, з тієї самої
// причини, з якої в StorageAlertState є Blind: «джерело мовчить» і
// «джерело недоступне» на екрані виглядають однаково, а означають
// протилежне. Без цього поля порожній колектор читався б як «у
// колекторі все тихо».
type serverLogSource struct {
Key string `json:"key"`
Label string `json:"label"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Records int `json:"records"`
Bytes int `json:"bytes"`
MaxBytes int `json:"max_bytes"`
Dropped uint64 `json:"dropped"`
Since time.Time `json:"since,omitempty"`
}
// WithServerLog вмикає сторінку журналу сервера.
//
// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі
// шифрування чи ліцензія: тести й частина інсталяцій кільця не мають, і
// сторінка тоді має чесно відповісти 503, а не впасти.
//
// collectorURL порожній — робочий стан: журнал колектора просто не
// показується, і сторінка каже, чому саме.
func (s *Server) WithServerLog(ring *logbuf.Ring, collectorURL, token string) *Server {
s.logRing = ring
s.collectorLogURL = strings.TrimSpace(collectorURL)
s.collectorLogToken = token
return s
}
// handleServerLog віддає злитий журнал обох процесів, найсвіжіші зверху.
func (s *Server) handleServerLog(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, serverLogPerm) {
return
}
if s.logRing == nil {
writeError(w, http.StatusServiceUnavailable, "no_log_buffer",
"кільцевий буфер журналу не увімкнено в цій збірці")
return
}
minLevel, levelOK := parseLogLevel(r.URL.Query().Get("level"))
if !levelOK {
writeError(w, http.StatusBadRequest, "bad_level", "рівень має бути error, warn або info")
return
}
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
limit := serverLogLimit
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
limit = min(v, serverLogMaxLimit)
}
snaps := []logbuf.Snapshot{s.logRing.Snapshot("api")}
sources := []serverLogSource{describeSource("api", "API", snaps[0], nil)}
// Колектор питається завжди, коли адреса є: кешувати тут нема чого,
// сторінку відкриває одна людина й дивиться на неї хвилину, а
// застарілий на п'ять секунд журнал у момент аварії — це рівно ті
// п'ять секунд, яких бракує.
if s.collectorLogURL != "" {
snap, err := logbuf.Fetch(r.Context(), s.collectorLogURL, s.collectorLogToken, serverLogTimeout)
sources = append(sources, describeSource("collector", "Колектор", snap, err))
if err == nil {
snaps = append(snaps, snap)
} else {
// У власний журнал це не пишеться навмисно: сторінку
// оновлюють раз на кілька секунд, і недоступний сусід за
// хвилину залив би кільце сотнею однакових рядків — тобто
// витіснив би саме те, по що людина прийшла.
s.log.Debug("журнал колектора недоступний", "url", s.collectorLogURL, "err", err)
}
} else {
sources = append(sources, serverLogSource{
Key: "collector", Label: "Колектор",
Error: "адресу колектора не налаштовано (-collector-log або NETPULSE_COLLECTOR_LOG_URL)",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"records": mergeLog(snaps, minLevel, query, limit),
"sources": sources,
"limit": limit,
})
}
// describeSource зводить знімок і помилку в один опис для сторінки.
func describeSource(key, label string, snap logbuf.Snapshot, err error) serverLogSource {
if err != nil {
return serverLogSource{Key: key, Label: label, Error: err.Error()}
}
return serverLogSource{
Key: key, Label: label, OK: true,
Records: len(snap.Records),
Bytes: snap.Bytes,
MaxBytes: snap.MaxBytes,
Dropped: snap.Dropped,
Since: snap.Since,
}
}
// parseLogLevel читає фільтр рівня як НИЖНЮ МЕЖУ.
//
// Саме межу, а не точний збіг: людина, яка обрала «warn», питає «що
// пішло не так», і ховати від неї error було б буквальним виконанням
// букви запиту проти його змісту.
//
// Порожнє значення — «усе», і сюди ж потрапляє debug: він вмикається
// прапорцем процесу, а не сторінкою, і якщо вже ввімкнений — має бути
// видним.
func parseLogLevel(s string) (slog.Level, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "all", "debug":
return slog.LevelDebug, true
case "info":
return slog.LevelInfo, true
case "warn", "warning":
return slog.LevelWarn, true
case "error":
return slog.LevelError, true
}
return 0, false
}
// mergeLog зливає знімки процесів в один перелік, найсвіжіші зверху.
//
// Порядок — за часом, а не за джерелом: людина шукає, ЩО сталося о
// третій ночі, а не що робив кожен процес окремо. Номер запису
// розв'язує збіги в межах одного джерела: два рядки в одну наносекунду
// цілком трапляються, і без цього вони переставлялися б місцями на
// кожному оновленні сторінки — рух, який виглядає як нові події.
func mergeLog(snaps []logbuf.Snapshot, minLevel slog.Level, query string, limit int) []logbuf.Record {
out := make([]logbuf.Record, 0, limit)
for _, snap := range snaps {
for _, rec := range snap.Records {
if !matchLog(rec, minLevel, query) {
continue
}
out = append(out, rec)
}
}
sort.SliceStable(out, func(i, j int) bool {
if !out[i].Time.Equal(out[j].Time) {
return out[i].Time.After(out[j].Time)
}
if out[i].Source != out[j].Source {
return out[i].Source < out[j].Source
}
return out[i].Seq > out[j].Seq
})
// Стеля накладається після злиття й сортування: інакше «останні 500»
// означало б 500 з кожного процесу, тобто тисячу, і найсвіжіші рядки
// одного могли б випасти на користь старих рядків другого.
if len(out) > limit {
out = out[:limit]
}
return out
}
// matchLog — чи проходить запис фільтр.
//
// Пошук іде і по повідомленню, і по атрибутах: імена хостів, адреси й
// тексти помилок живуть саме в атрибутах, а шукають зазвичай саме їх.
func matchLog(rec logbuf.Record, minLevel slog.Level, query string) bool {
var lv slog.Level
if err := lv.UnmarshalText([]byte(rec.Level)); err == nil && lv < minLevel {
return false
}
if query == "" {
return true
}
return strings.Contains(strings.ToLower(rec.Msg), query) ||
strings.Contains(strings.ToLower(rec.Attrs), query)
}