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) }