Сторінка «Журнал сервера»: кільцевий буфер у памяті
Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд — усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам NetPulse, треба було заходити по ssh. Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ (8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки буває довшим за сотню звичайних, тож «5000 рядків» означало б непередбачувані десятки мегабайтів. Два процеси — один перелік із позначкою джерела, а не дві вкладки: людина знає симптом («о третій ночі перестали йти сповіщення»), а не те, який із двох процесів за це відповідає. Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені в місці народження, але кільце робить журнал видимим у браузері й вивантажуваним у файл, що піде в тікет. Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не переживає перезапуску й не покаже причини падіння бази. Поруч — скільки записів витіснено: без цього числа не відрізнити «нічого не сталося» від «сталося стільки, що початок уже не влазить».
This commit is contained in:
parent
e0385d4960
commit
e6b585dd4c
9 changed files with 918 additions and 0 deletions
|
|
@ -79,6 +79,12 @@ func run() error {
|
||||||
// побачити другу половину змогли б лише ті, хто прочитав про
|
// побачити другу половину змогли б лише ті, хто прочитав про
|
||||||
// цей прапорець. Там, де топологія інша, ім'я не розв'язується
|
// цей прапорець. Там, де топологія інша, ім'я не розв'язується
|
||||||
// за мілісекунди, і сторінка чесно каже, що саме налаштувати.
|
// за мілісекунди, і сторінка чесно каже, що саме налаштувати.
|
||||||
|
//
|
||||||
|
// Ціна цієї зручності: у мережі, де DNS відповідає на БУДЬ-ЯКЕ
|
||||||
|
// ім'я (пошуковий домен, wildcard), запит із похідним від -dek
|
||||||
|
// токеном піде чужому хосту. Сам ключ із токена не дістати
|
||||||
|
// (HMAC), але тим, кого це стосується, лікується порожнім
|
||||||
|
// NETPULSE_COLLECTOR_LOG_URL.
|
||||||
collectorLog = flag.String("collector-log",
|
collectorLog = flag.String("collector-log",
|
||||||
envOr("NETPULSE_COLLECTOR_LOG_URL", "http://collector:9444"),
|
envOr("NETPULSE_COLLECTOR_LOG_URL", "http://collector:9444"),
|
||||||
"адреса внутрішньої ручки журналу колектора; порожньо — не питати")
|
"адреса внутрішньої ручки журналу колектора; порожньо — не питати")
|
||||||
|
|
|
||||||
164
server/internal/httpapi/serverlog_test.go
Normal file
164
server/internal/httpapi/serverlog_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тести злиття й фільтрів — БЕЗ бази навмисно.
|
||||||
|
//
|
||||||
|
// Решта тестів цього пакета йде проти справжнього Postgres і сама себе
|
||||||
|
// пропускає без NETPULSE_TEST_DSN. Для журналу сервера це було б погано:
|
||||||
|
// саме злиття двох джерел і фільтр за рівнем — та частина, яка
|
||||||
|
// ламається тихо, а перевірка, яку локально ніхто не запускає, ловить
|
||||||
|
// рівно нічого. Store тут не потрібен узагалі, тож і фікстури немає.
|
||||||
|
|
||||||
|
func rec(src string, seq uint64, at time.Time, level, msg, attrs string) logbuf.Record {
|
||||||
|
return logbuf.Record{Seq: seq, Time: at, Level: level, Msg: msg, Attrs: attrs, Source: src}
|
||||||
|
}
|
||||||
|
|
||||||
|
// snaps будує двох сусідів із рядками, що перемішані в часі.
|
||||||
|
func snaps() []logbuf.Snapshot {
|
||||||
|
t0 := time.Date(2026, 8, 27, 3, 0, 0, 0, time.UTC)
|
||||||
|
api := logbuf.Snapshot{Source: "api", Records: []logbuf.Record{
|
||||||
|
rec("api", 3, t0.Add(30*time.Second), "ERROR", "аудит строків зберігання", "err=timeout"),
|
||||||
|
rec("api", 2, t0.Add(10*time.Second), "INFO", "http", "path=/api/v1/devices status=200"),
|
||||||
|
rec("api", 1, t0, "WARN", "ліцензія: пільговий період", "діб=5"),
|
||||||
|
}}
|
||||||
|
collector := logbuf.Snapshot{Source: "collector", Records: []logbuf.Record{
|
||||||
|
rec("collector", 2, t0.Add(20*time.Second), "ERROR", "дзеркало", "url=https://forgejo.example/np.git"),
|
||||||
|
rec("collector", 1, t0.Add(5*time.Second), "INFO", "бекап", "хостів=12"),
|
||||||
|
}}
|
||||||
|
return []logbuf.Snapshot{api, collector}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Головне про злиття: обидва джерела в одному переліку й за часом, а не
|
||||||
|
// за процесами. Половина цієї перевірки — що рядки колектора взагалі
|
||||||
|
// доїхали: перелік лише з API виглядає як робочий і мовчки ховає ту
|
||||||
|
// половину системи, заради якої сторінку й відкривали.
|
||||||
|
func TestMergeLogInterleavesSources(t *testing.T) {
|
||||||
|
got := mergeLog(snaps(), slog.LevelDebug, "", 100)
|
||||||
|
|
||||||
|
want := []string{"api", "collector", "api", "collector", "api"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("рядків %d замість %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
seen := map[string]int{}
|
||||||
|
for i, r := range got {
|
||||||
|
if r.Source != want[i] {
|
||||||
|
t.Fatalf("позиція %d: джерело %q замість %q (%s)", i, r.Source, want[i], r.Msg)
|
||||||
|
}
|
||||||
|
seen[r.Source]++
|
||||||
|
if i > 0 && got[i-1].Time.Before(r.Time) {
|
||||||
|
t.Fatalf("порядок не за спаданням часу на позиції %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen["collector"] == 0 {
|
||||||
|
t.Fatal("рядки колектора не доїхали — сторінка показувала б half-системи як цілу")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фільтр рівня — НИЖНЯ межа, а не точний збіг.
|
||||||
|
func TestMergeLogLevelIsMinimum(t *testing.T) {
|
||||||
|
warn := mergeLog(snaps(), slog.LevelWarn, "", 100)
|
||||||
|
if len(warn) != 3 {
|
||||||
|
t.Fatalf("на рівні warn %d рядків замість 3", len(warn))
|
||||||
|
}
|
||||||
|
var errs int
|
||||||
|
for _, r := range warn {
|
||||||
|
if r.Level == "INFO" {
|
||||||
|
t.Fatalf("info просочився крізь фільтр warn: %s", r.Msg)
|
||||||
|
}
|
||||||
|
if r.Level == "ERROR" {
|
||||||
|
errs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Друга половина: error НЕ відсіяно. Без неї зеленим був би й
|
||||||
|
// фільтр, що розуміє рівень як точний збіг, — і людина, яка обрала
|
||||||
|
// «warn», не побачила б жодної помилки.
|
||||||
|
if errs != 2 {
|
||||||
|
t.Fatalf("помилки відсіяно фільтром warn: %d із 2", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(mergeLog(snaps(), slog.LevelError, "", 100)); got != 2 {
|
||||||
|
t.Fatalf("на рівні error %d рядків замість 2", got)
|
||||||
|
}
|
||||||
|
if got := len(mergeLog(snaps(), slog.LevelDebug, "", 100)); got != 5 {
|
||||||
|
t.Fatalf("без фільтра %d рядків замість 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пошук іде і по повідомленню, і по атрибутах: адреси, імена хостів і
|
||||||
|
// тексти помилок живуть саме в атрибутах, і пошук лише по msg знаходив
|
||||||
|
// би найменш цікаве.
|
||||||
|
func TestMergeLogSearchesAttrs(t *testing.T) {
|
||||||
|
byMsg := mergeLog(snaps(), slog.LevelDebug, "дзеркало", 100)
|
||||||
|
if len(byMsg) != 1 || byMsg[0].Source != "collector" {
|
||||||
|
t.Fatalf("пошук по повідомленню: %+v", byMsg)
|
||||||
|
}
|
||||||
|
byAttr := mergeLog(snaps(), slog.LevelDebug, "forgejo.example", 100)
|
||||||
|
if len(byAttr) != 1 || byAttr[0].Msg != "дзеркало" {
|
||||||
|
t.Fatalf("пошук по атрибутах нічого не дав: %+v", byAttr)
|
||||||
|
}
|
||||||
|
if got := mergeLog(snaps(), slog.LevelDebug, "ДЕВАЙС", 100); len(got) != 0 {
|
||||||
|
t.Fatalf("знайдено те, чого немає: %+v", got)
|
||||||
|
}
|
||||||
|
// Регістр не має значення: імена хостів пишуть як завгодно.
|
||||||
|
if got := mergeLog(snaps(), slog.LevelDebug, "http", 100); len(got) != 2 {
|
||||||
|
t.Fatalf("пошук чутливий до регістру або до джерела: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стеля накладається ПІСЛЯ злиття: інакше «останні N» означало б N із
|
||||||
|
// кожного процесу, і найсвіжіші рядки одного випадали б на користь
|
||||||
|
// старих рядків другого.
|
||||||
|
func TestMergeLogLimitAfterMerge(t *testing.T) {
|
||||||
|
got := mergeLog(snaps(), slog.LevelDebug, "", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("стелю не накладено: %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Source != "api" || got[1].Source != "collector" {
|
||||||
|
t.Fatalf("обрізано не найсвіжіші: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLogLevel(t *testing.T) {
|
||||||
|
for in, want := range map[string]slog.Level{
|
||||||
|
"": slog.LevelDebug,
|
||||||
|
"info": slog.LevelInfo,
|
||||||
|
"WARN": slog.LevelWarn,
|
||||||
|
"warning": slog.LevelWarn,
|
||||||
|
" error ": slog.LevelError,
|
||||||
|
} {
|
||||||
|
got, ok := parseLogLevel(in)
|
||||||
|
if !ok || got != want {
|
||||||
|
t.Fatalf("%q: %v/%v замість %v", in, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := parseLogLevel("критично"); ok {
|
||||||
|
t.Fatal("невідомий рівень прийнято — сторінка мовчки показала б не те, що просили")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стан джерела має розрізняти «тихо» й «недоступне». Це те саме
|
||||||
|
// рішення, що й поле Blind у попередженні про місце: на екрані вони
|
||||||
|
// виглядають однаково, а означають протилежне.
|
||||||
|
func TestDescribeSourceSeparatesSilentFromBroken(t *testing.T) {
|
||||||
|
quiet := describeSource("collector", "Колектор",
|
||||||
|
logbuf.Snapshot{Source: "collector", MaxBytes: logbuf.DefaultMaxBytes}, nil)
|
||||||
|
if !quiet.OK || quiet.Error != "" || quiet.Records != 0 {
|
||||||
|
t.Fatalf("тихе джерело описано як зламане: %+v", quiet)
|
||||||
|
}
|
||||||
|
|
||||||
|
broken := describeSource("collector", "Колектор", logbuf.Snapshot{}, errTest{})
|
||||||
|
if broken.OK || broken.Error == "" {
|
||||||
|
t.Fatalf("недоступне джерело описано як тихе: %+v", broken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errTest struct{}
|
||||||
|
|
||||||
|
func (errTest) Error() string { return "з'єднання відхилено" }
|
||||||
|
|
@ -63,6 +63,17 @@ import (
|
||||||
// й секретного, — зі спеки ключів шифрування (-dek). Мітка робить
|
// й секретного, — зі спеки ключів шифрування (-dek). Мітка робить
|
||||||
// виведене значення непридатним ні для чого іншого, а HMAC не дає з
|
// виведене значення непридатним ні для чого іншого, а HMAC не дає з
|
||||||
// нього дістати сам ключ.
|
// нього дістати сам ключ.
|
||||||
|
//
|
||||||
|
// ЧОГО ЦЕЙ ЗАХИСТ НЕ ДАЄ, І ЦЕ ТРЕБА ЗНАТИ
|
||||||
|
//
|
||||||
|
// Канал відкритий: токен і сам журнал ідуть звичайним HTTP. У типовому
|
||||||
|
// розгортанні обидва процеси стоять у одній мережі docker на одній
|
||||||
|
// машині, і це та сама межа довіри, у якій уже ходить h2c до колектора
|
||||||
|
// (див. docker-compose.yml). Але інсталяція, що рознесе процеси на різні
|
||||||
|
// машини через мережу, якій не довіряє, отримає токен відкритим текстом
|
||||||
|
// на дроті. Для такої топології правильний крок — не пускати 9444 повз
|
||||||
|
// периметр і лишити -collector-log порожнім; сторінка тоді чесно
|
||||||
|
// покаже лише половину, а не тихо винесе журнал у мережу.
|
||||||
|
|
||||||
// tokenLabel — розділювач призначень. Без нього виведене значення
|
// tokenLabel — розділювач призначень. Без нього виведене значення
|
||||||
// формально було б «ще однією функцією від ключа шифрування», а таких
|
// формально було б «ще однією функцією від ключа шифрування», а таких
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { AuditPage } from './pages/AuditPage'
|
||||||
import { TrapsPage } from './pages/TrapsPage'
|
import { TrapsPage } from './pages/TrapsPage'
|
||||||
import { SLAPage } from './pages/SLAPage'
|
import { SLAPage } from './pages/SLAPage'
|
||||||
import { StoragePage } from './pages/StoragePage'
|
import { StoragePage } from './pages/StoragePage'
|
||||||
|
import { ServerLogPage } from './pages/ServerLogPage'
|
||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
import { BillingPage } from './pages/BillingPage'
|
import { BillingPage } from './pages/BillingPage'
|
||||||
import { TvPage } from './pages/TvPage'
|
import { TvPage } from './pages/TvPage'
|
||||||
|
|
@ -155,6 +156,18 @@ function Workspace() {
|
||||||
сховища й дзеркала. */}
|
сховища й дзеркала. */}
|
||||||
<Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} />
|
<Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} />
|
||||||
<Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} />
|
<Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} />
|
||||||
|
{/* Журнал сервера під settings:write, а НЕ під audit:read — і це
|
||||||
|
протилежне рішення до сховища й дзеркала навмисно. Там ішлося
|
||||||
|
про стан мережі, який видно кожному, хто бачить моніторинг;
|
||||||
|
тут — про системний журнал з адресами, іменами хостів і
|
||||||
|
текстами помилок SQL. Аудит («хто що зробив») дають
|
||||||
|
внутрішньому контролеру, і додати йому це мовчки означало б
|
||||||
|
розширити його право. settings:write є лише у власника й
|
||||||
|
адміна — тобто в тих, хто й так бачить усе. */}
|
||||||
|
<Route
|
||||||
|
path="/server-log"
|
||||||
|
element={<Guard perm="settings:write" element={<ServerLogPage />} />}
|
||||||
|
/>
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="*" element={<Home />} />
|
<Route path="*" element={<Home />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ import type {
|
||||||
SLAReport,
|
SLAReport,
|
||||||
BillingOverview,
|
BillingOverview,
|
||||||
LicenseState,
|
LicenseState,
|
||||||
|
ServerLogPage,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
||||||
|
|
@ -1673,4 +1674,26 @@ export const api = {
|
||||||
|
|
||||||
clearLicense: () =>
|
clearLicense: () =>
|
||||||
request<LicenseState>('/api/v1/billing/license', { method: 'DELETE' }),
|
request<LicenseState>('/api/v1/billing/license', { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Журнал сервера
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Останні рядки журналу обох процесів, найсвіжіші зверху.
|
||||||
|
*
|
||||||
|
* Фільтр і пошук їдуть на СЕРВЕР, а не накладаються тут. Причина в
|
||||||
|
* тому, що стеля відповіді накладається після фільтра: «останні 500
|
||||||
|
* рядків, серед яких три помилки» і «останні 500 помилок» — різні
|
||||||
|
* відповіді, а людина, яка обрала рівень «error», питає друге.
|
||||||
|
* Клієнтський фільтр давав би перше й мовчки.
|
||||||
|
*/
|
||||||
|
serverLog: (p: { level?: string; q?: string; limit?: number } = {}) => {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
if (p.level) sp.set('level', p.level)
|
||||||
|
if (p.q) sp.set('q', p.q)
|
||||||
|
if (p.limit) sp.set('limit', String(p.limit))
|
||||||
|
const qs = sp.toString()
|
||||||
|
return request<ServerLogPage>('/api/v1/server-log' + (qs ? '?' + qs : ''))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,13 @@ const navGroups: NavGroup[] = [
|
||||||
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
|
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
|
||||||
// нічого не лишилось.
|
// нічого не лишилось.
|
||||||
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
|
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
|
||||||
|
// Одразу під журналом аудиту, бо їх плутають, а різниця між ними
|
||||||
|
// вирішальна: аудит відповідає на «хто що зробив», журнал сервера —
|
||||||
|
// на «на що лається сама система». Поруч цю різницю видно з двох
|
||||||
|
// підписів; порізно людина шукала б причину відмови в аудиті й не
|
||||||
|
// знайшла б її ніколи. Право інше й ширше (settings:write), тому
|
||||||
|
// пункт бачать не всі, хто бачить аудит.
|
||||||
|
{ to: '/server-log', label: 'Журнал сервера', icon: '📜', perm: 'settings:write' },
|
||||||
// Тариф останнім в «Адмініструванні», а не окремою групою: питання
|
// Тариф останнім в «Адмініструванні», а не окремою групою: питання
|
||||||
// «скільки в мене лишилось слотів» ставлять поруч із питанням
|
// «скільки в мене лишилось слотів» ставлять поруч із питанням
|
||||||
// «кого ще завести», і обидва — тут. Окрема група з одного пункту
|
// «кого ще завести», і обидва — тут. Окрема група з одного пункту
|
||||||
|
|
|
||||||
405
web/src/pages/ServerLogPage.tsx
Normal file
405
web/src/pages/ServerLogPage.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { fmtBytes } from '../components/BackupDefaultsForm'
|
||||||
|
import { Button, Card, ErrorNote, PageBody, PageHeader, Spinner, ago, inputClass } from '../components/ui'
|
||||||
|
import type { ServerLogPage as ServerLogPageData, ServerLogRecord, ServerLogSource } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Журнал сервера»: на що лається сам NetPulse.
|
||||||
|
*
|
||||||
|
* Досі продукт показував syslog із пристроїв, журнал аудиту, черги й
|
||||||
|
* стенограми команд — тобто все про мережу й нічого про себе. Побачити,
|
||||||
|
* що не поїхало дзеркало конфігів або відмовила розсилка, можна було
|
||||||
|
* лише через `docker logs`, тобто маючи ssh. Права дивитись на
|
||||||
|
* моніторинг для цього не вистачало, і не мало вистачати — доступ до
|
||||||
|
* машини не роздають заради одного питання.
|
||||||
|
*
|
||||||
|
* ЩО НА СТОРІНЦІ ГОЛОВНЕ Й ЧОМУ
|
||||||
|
*
|
||||||
|
* Головне тут — не перелік, а рядок про його межі, і стоїть він
|
||||||
|
* ПОРУЧ ІЗ ПЕРЕЛІКОМ, а не в документації. Журнал живе в пам'яті
|
||||||
|
* процесів і не переживає їхнього перезапуску, а причини падіння бази в
|
||||||
|
* ньому не буде в принципі — той самий процес, що мав би її записати,
|
||||||
|
* лежить разом із нею. Людина, яка про це не знає, відкриє сторінку
|
||||||
|
* після нічної аварії, побачить тишу й зробить висновок «нічого не
|
||||||
|
* сталося». Це найгірший із можливих наслідків цієї сторінки, і одного
|
||||||
|
* рядка тексту вистачає, щоб його не було.
|
||||||
|
*
|
||||||
|
* Друге — стан кожного з двох джерел окремо. Процесів два (REST і
|
||||||
|
* колектор), пам'ять у них своя, і «колектор мовчить» та «колектор
|
||||||
|
* недоступний» на екрані виглядають однаково. Тому недоступне джерело
|
||||||
|
* пишеться словами, а не ховається за порожнім переліком.
|
||||||
|
*
|
||||||
|
* ЧОМУ ОДИН ПЕРЕЛІК, А НЕ ДВІ ВКЛАДКИ
|
||||||
|
*
|
||||||
|
* Людина, яка сюди прийшла, знає симптом («о третій ночі перестали йти
|
||||||
|
* сповіщення») і не знає, який із двох процесів за це відповідає. Дві
|
||||||
|
* вкладки змусили б її вгадати відповідь на питання, заради якого вона
|
||||||
|
* й прийшла. Тому один перелік за часом і позначка джерела в кожному
|
||||||
|
* рядку — вона ж і фільтр.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEVELS = [
|
||||||
|
{ key: '', label: 'усе' },
|
||||||
|
{ key: 'info', label: 'info+' },
|
||||||
|
{ key: 'warn', label: 'warn+' },
|
||||||
|
{ key: 'error', label: 'лише error' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Такт автооновлення. П'ять секунд — як у знімку черг. */
|
||||||
|
const REFRESH_MS = 5000
|
||||||
|
|
||||||
|
export function ServerLogPage() {
|
||||||
|
const [level, setLevel] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [live, setLive] = useState(true)
|
||||||
|
const [data, setData] = useState<ServerLogPageData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||||
|
|
||||||
|
// Пошук їде на сервер із затримкою: інакше кожне натиснуте літерою
|
||||||
|
// перезапитувало б увесь журнал, і робило б це саме тоді, коли системі
|
||||||
|
// й так важко.
|
||||||
|
const [debounced, setDebounced] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setDebounced(query.trim()), 300)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Через ref, щоб такт автооновлення не перезапускався від кожної зміни
|
||||||
|
// фільтра: інакше поле пошуку скидало б відлік і сторінка оновлювалась
|
||||||
|
// би невпопад.
|
||||||
|
const params = useRef({ level, q: debounced })
|
||||||
|
params.current = { level, q: debounced }
|
||||||
|
|
||||||
|
const load = useCallback(async (spin: boolean) => {
|
||||||
|
if (spin) setLoading(true)
|
||||||
|
try {
|
||||||
|
setData(await api.serverLog(params.current))
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
if (spin) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(true)
|
||||||
|
}, [load, level, debounced])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!live) return
|
||||||
|
const t = window.setInterval(() => void load(false), REFRESH_MS)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [live, load])
|
||||||
|
|
||||||
|
const records = useMemo(
|
||||||
|
() => (data?.records ?? []).filter((r) => !sourceFilter || r.source === sourceFilter),
|
||||||
|
[data, sourceFilter],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Журнал сервера"
|
||||||
|
subtitle="що NetPulse пише про себе · час місцевий · найсвіжіші зверху"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
kind={live ? 'primary' : 'default'}
|
||||||
|
onClick={() => setLive((v) => !v)}
|
||||||
|
title="оновлювати перелік кожні кілька секунд"
|
||||||
|
>
|
||||||
|
{live ? '● Автооновлення' : '○ Автооновлення'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void load(true)}>Оновити</Button>
|
||||||
|
<Button
|
||||||
|
disabled={records.length === 0}
|
||||||
|
onClick={() => downloadText(records)}
|
||||||
|
title="зберегти те, що зараз показано, звичайним текстом"
|
||||||
|
>
|
||||||
|
Завантажити текстом
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody className="space-y-3">
|
||||||
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
|
||||||
|
<Filters
|
||||||
|
level={level}
|
||||||
|
onLevel={setLevel}
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
sources={data?.sources ?? []}
|
||||||
|
source={sourceFilter}
|
||||||
|
onSource={setSourceFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Limits sources={data?.sources ?? []} />
|
||||||
|
|
||||||
|
{loading && !data ? (
|
||||||
|
<Spinner />
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<EmptyNote hasFilter={Boolean(level || debounced || sourceFilter)} />
|
||||||
|
) : (
|
||||||
|
<Card className="divide-y divide-slate-800/70 font-mono text-xs">
|
||||||
|
{records.map((r) => (
|
||||||
|
<LogLine key={r.source + ':' + r.seq} row={r} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && records.length >= data.limit && (
|
||||||
|
<p className="text-center text-xs text-slate-600">
|
||||||
|
показано {data.limit} рядків — стеля однієї відповіді. Звузьте рівень або пошук, щоб
|
||||||
|
побачити старіше.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Межі журналу
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найважливіший блок на сторінці — і саме тому він СТОЇТЬ ТУТ, над
|
||||||
|
* переліком, а не лежить у документації.
|
||||||
|
*
|
||||||
|
* Порожній журнал і журнал, у якому шуканого не буває ніколи, на екрані
|
||||||
|
* виглядають однаково. Без цього рядка людина, яка після нічного падіння
|
||||||
|
* бази не знайшла тут нічого, зробить висновок «нічого не сталося» — і
|
||||||
|
* піде шукати причину деінде, маючи хибну відправну точку.
|
||||||
|
*
|
||||||
|
* Разом із межами показано стан кожного джерела. «Витіснено N рядків» —
|
||||||
|
* не діагностика заради діагностики: без цього числа неможливо
|
||||||
|
* відрізнити «за годину нічого не сталося» від «сталося стільки, що
|
||||||
|
* початок уже не влазить», а це протилежні висновки.
|
||||||
|
*/
|
||||||
|
function Limits({ sources }: { sources: ServerLogSource[] }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-3">
|
||||||
|
<p className="text-xs leading-relaxed text-amber-300/90">
|
||||||
|
Журнал живе в пам'яті процесів: він <b>не переживає їхнього перезапуску</b> й{' '}
|
||||||
|
<b>не покаже причини падіння самої бази чи процесу</b> — останні слова процесу, що впав,
|
||||||
|
лишаються лише в <code className="text-amber-200">docker logs</code>. Порожньо тут означає
|
||||||
|
«після останнього запуску такого не було», а не «такого не сталося».
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
|
{sources.map((s) => (
|
||||||
|
<SourceState key={s.key} s={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceState({ s }: { s: ServerLogSource }) {
|
||||||
|
if (!s.ok) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-rose-900/60 bg-rose-950/30 px-2.5 py-2">
|
||||||
|
<div className="text-xs font-medium text-rose-300">{s.label} — недоступний</div>
|
||||||
|
{/* Причина текстом, а не значком: «недоступний» без неї
|
||||||
|
перетворює сторінку на здогадки, а виправляють тут щоразу
|
||||||
|
різне — від невиставленої адреси до впалого процесу. */}
|
||||||
|
<div className="mt-0.5 break-words text-[11px] leading-snug text-rose-400/80">{s.error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-slate-800 bg-slate-900/40 px-2.5 py-2">
|
||||||
|
<div className="text-xs font-medium text-slate-300">{s.label}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] leading-snug text-slate-500">
|
||||||
|
{s.records} рядків · {fmtBytes(s.bytes)} з {fmtBytes(s.max_bytes)}
|
||||||
|
{s.since && <> · від {ago(s.since)}</>}
|
||||||
|
{s.dropped > 0 && (
|
||||||
|
<span className="text-amber-500/90"> · витіснено {s.dropped} старіших</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Фільтри
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Filters({
|
||||||
|
level,
|
||||||
|
onLevel,
|
||||||
|
query,
|
||||||
|
onQuery,
|
||||||
|
sources,
|
||||||
|
source,
|
||||||
|
onSource,
|
||||||
|
}: {
|
||||||
|
level: string
|
||||||
|
onLevel: (v: string) => void
|
||||||
|
query: string
|
||||||
|
onQuery: (v: string) => void
|
||||||
|
sources: ServerLogSource[]
|
||||||
|
source: string
|
||||||
|
onSource: (v: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-wrap items-center gap-2 p-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLevel(l.key)}
|
||||||
|
className={`rounded border px-2.5 py-1.5 text-xs transition-colors ${
|
||||||
|
level === l.key
|
||||||
|
? 'border-sky-600 bg-sky-950/60 text-sky-200'
|
||||||
|
: 'border-slate-700 text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Джерело — фільтр, а не вкладка: за замовчуванням видно обидва,
|
||||||
|
і звузити до одного можна тоді, коли вже знаєш, кого шукаєш. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<SourceChip label="обидва" active={source === ''} onClick={() => onSource('')} />
|
||||||
|
{sources
|
||||||
|
.filter((s) => s.ok)
|
||||||
|
.map((s) => (
|
||||||
|
<SourceChip
|
||||||
|
key={s.key}
|
||||||
|
label={s.label}
|
||||||
|
active={source === s.key}
|
||||||
|
onClick={() => onSource(s.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
|
placeholder="пошук по тексту й атрибутах…"
|
||||||
|
className={inputClass + ' ml-auto max-w-xs'}
|
||||||
|
aria-label="пошук у журналі"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceChip({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded border px-2.5 py-1.5 text-xs transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-slate-500 bg-slate-800 text-slate-100'
|
||||||
|
: 'border-slate-700 text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Рядок журналу
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LEVEL_CLASS: Record<string, string> = {
|
||||||
|
ERROR: 'text-rose-400',
|
||||||
|
WARN: 'text-amber-400',
|
||||||
|
INFO: 'text-sky-400',
|
||||||
|
DEBUG: 'text-slate-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogLine({ row }: { row: ServerLogRecord }) {
|
||||||
|
const cls = LEVEL_CLASS[row.level] ?? 'text-slate-400'
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 px-3 py-1.5">
|
||||||
|
<span className="text-slate-600">{fmtTime(row.time)}</span>
|
||||||
|
<span className={`w-12 shrink-0 ${cls}`}>{row.level.toLowerCase()}</span>
|
||||||
|
{/* Джерело в кожному рядку: перелік спільний, і без позначки
|
||||||
|
неможливо сказати, який із двох процесів це написав. */}
|
||||||
|
<span className="shrink-0 rounded bg-slate-800 px-1.5 text-[10px] text-slate-400">
|
||||||
|
{row.source}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-200">{row.msg}</span>
|
||||||
|
{row.attrs && <span className="break-all text-slate-500">{row.attrs}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyNote({ hasFilter }: { hasFilter: boolean }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-6 text-center text-xs leading-relaxed text-slate-500">
|
||||||
|
{hasFilter ? (
|
||||||
|
<>За цим фільтром рядків немає. Спробуйте знизити рівень або очистити пошук.</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Журнал порожній. Це нормальний стан щойно запущеної системи: у кільце потрапляє те, що
|
||||||
|
процеси написали ПІСЛЯ старту.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Вивантаження
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Зберігає рівно те, що зараз на екрані.
|
||||||
|
*
|
||||||
|
* Саме показане, а не «весь журнал»: файл із цієї кнопки йде в тікет
|
||||||
|
* поруч зі словами «ось що було», і розходження між побаченим і
|
||||||
|
* надісланим — найдорожча з можливих тут помилок.
|
||||||
|
*
|
||||||
|
* Складається в браузері, а не запитується окремою ручкою: сервер уже
|
||||||
|
* віддав ці рядки, і другий запит по них додав би лише спосіб отримати
|
||||||
|
* інший набір.
|
||||||
|
*/
|
||||||
|
function downloadText(records: ServerLogRecord[]) {
|
||||||
|
const text = records
|
||||||
|
.map((r) => [fmtTime(r.time), r.level, r.source ?? '', r.msg, r.attrs ?? ''].join(' ').trimEnd())
|
||||||
|
.join('\n')
|
||||||
|
const url = URL.createObjectURL(new Blob([text + '\n'], { type: 'text/plain;charset=utf-8' }))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `netpulse-serverlog-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}.txt`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Час із мілісекундами й без дати.
|
||||||
|
*
|
||||||
|
* Дата тут майже завжди сьогоднішня (кільце тримає години, а не доби), а
|
||||||
|
* мілісекунди — навпаки, потрібні: сусідні рядки одного такту різняться
|
||||||
|
* саме ними, і без них порядок подій виглядає випадковим.
|
||||||
|
*/
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||||
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
|
||||||
|
}
|
||||||
242
web/src/test/serverlog.test.tsx
Normal file
242
web/src/test/serverlog.test.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import { ServerLogPage } from '../pages/ServerLogPage'
|
||||||
|
import { AppShell } from '../components/AppShell'
|
||||||
|
import { session } from '../api/session'
|
||||||
|
import { FakeWebSocket, fetchRouter } from './support'
|
||||||
|
import type { Permission } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сторінка «Журнал сервера»: `pages/ServerLogPage.tsx`.
|
||||||
|
*
|
||||||
|
* Тут перевіряється не «сторінка малюється», а три речі, кожна з яких
|
||||||
|
* ламається тихо й веде до ХИБНОГО ВИСНОВКУ, а не до видимої помилки.
|
||||||
|
*
|
||||||
|
* 1. **Рядок про межі журналу.** Кільце живе в пам'яті й не переживає
|
||||||
|
* перезапуску процесу, а причини падіння бази в ньому не буде
|
||||||
|
* ніколи. Якщо цей текст зникне, людина після нічної аварії побачить
|
||||||
|
* порожній перелік і зробить висновок «нічого не сталося». Тому текст
|
||||||
|
* перевіряється як поведінка, а не як оформлення.
|
||||||
|
*
|
||||||
|
* 2. **Недоступне джерело проти тихого.** Процесів два, і «колектор
|
||||||
|
* мовчить» та «колектор недоступний» на екрані виглядають однаково.
|
||||||
|
* Якщо помилка джерела перестане показуватись, сторінка буде
|
||||||
|
* показувати половину системи як цілу — і жодного знаку про це.
|
||||||
|
*
|
||||||
|
* 3. **Фільтр їде на сервер.** Стеля відповіді накладається ПІСЛЯ
|
||||||
|
* фільтра, тож фільтрувати на клієнті означає показувати «останні 500
|
||||||
|
* рядків, серед яких три помилки» там, де просили «останні 500
|
||||||
|
* помилок». Обидві сторінки виглядають робочими.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t0 = '2026-08-27T03:00:00.000Z'
|
||||||
|
|
||||||
|
function page(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
records: [
|
||||||
|
{ seq: 3, time: t0, level: 'ERROR', msg: 'дзеркало', attrs: 'url=https://forgejo.example/np.git', source: 'collector' },
|
||||||
|
{ seq: 2, time: t0, level: 'INFO', msg: 'бекап', attrs: 'хостів=12', source: 'collector' },
|
||||||
|
{ seq: 1, time: t0, level: 'WARN', msg: 'ліцензія', attrs: 'діб=5', source: 'api' },
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{ key: 'api', label: 'API', ok: true, records: 1, bytes: 1024, max_bytes: 8388608, dropped: 0, since: t0 },
|
||||||
|
{ key: 'collector', label: 'Колектор', ok: true, records: 2, bytes: 2048, max_bytes: 8388608, dropped: 0, since: t0 },
|
||||||
|
],
|
||||||
|
limit: 500,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(body: unknown = page()) {
|
||||||
|
const server = fetchRouter({ 'GET /api/v1/server-log': body })
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ServerLogPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
session.set('tok', {
|
||||||
|
userID: 'u-me',
|
||||||
|
username: 'me',
|
||||||
|
tenantID: 't-1',
|
||||||
|
permissions: ['settings:write'],
|
||||||
|
})
|
||||||
|
vi.stubGlobal('WebSocket', class {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('межі журналу написані на самій сторінці', () => {
|
||||||
|
it('поруч із переліком стоїть, що журнал не переживає перезапуску', async () => {
|
||||||
|
show()
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
|
||||||
|
// Саме ці два твердження й породжують хибний висновок, якщо їх
|
||||||
|
// не написати: людина шукає в журналі те, чого там не буває.
|
||||||
|
expect(screen.getByText(/не переживає їхнього перезапуску/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/не покаже причини падіння самої бази/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('текст стоїть на сторінці, а не з’являється лише в порожньому журналі', async () => {
|
||||||
|
// Друга половина. Без неї зеленим був би й варіант, де пояснення
|
||||||
|
// показують тільки тоді, коли рядків немає, — тобто рівно не тоді,
|
||||||
|
// коли воно потрібне: людина, яка бачить сотню рядків і НЕ бачить
|
||||||
|
// серед них падіння, теж має знати чому.
|
||||||
|
show(page({ records: [] }))
|
||||||
|
await screen.findByText(/Журнал порожній/)
|
||||||
|
expect(screen.getByText(/не переживає їхнього перезапуску/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('стан двох джерел', () => {
|
||||||
|
it('недоступний колектор названо словами, а не сховано в порожнечу', async () => {
|
||||||
|
show(
|
||||||
|
page({
|
||||||
|
records: [{ seq: 1, time: t0, level: 'WARN', msg: 'ліцензія', attrs: '', source: 'api' }],
|
||||||
|
sources: [
|
||||||
|
{ key: 'api', label: 'API', ok: true, records: 1, bytes: 1024, max_bytes: 8388608, dropped: 0, since: t0 },
|
||||||
|
{ key: 'collector', label: 'Колектор', ok: false, error: 'dial tcp: connection refused', records: 0, bytes: 0, max_bytes: 0, dropped: 0 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await screen.findByText('ліцензія')
|
||||||
|
expect(screen.getByText('Колектор — недоступний')).toBeInTheDocument()
|
||||||
|
// Причина текстом: без неї «недоступний» лишає людину з здогадками,
|
||||||
|
// а лікується це щоразу різним.
|
||||||
|
expect(screen.getByText(/connection refused/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('живий колектор недоступним не називається', async () => {
|
||||||
|
// Друга половина: перша була б зелена й на сторінці, яка вважає
|
||||||
|
// недоступними обидва джерела завжди.
|
||||||
|
show()
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
expect(screen.queryByText(/недоступний/)).not.toBeInTheDocument()
|
||||||
|
// І про витіснення теж не пишеться, поки нічого не витіснено:
|
||||||
|
// попередження, яке горить завжди, перестають читати.
|
||||||
|
expect(screen.queryByText(/витіснено/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('витіснені рядки названо: тиша й переповнення — різні речі', async () => {
|
||||||
|
show(
|
||||||
|
page({
|
||||||
|
sources: [
|
||||||
|
{ key: 'api', label: 'API', ok: true, records: 1, bytes: 8388608, max_bytes: 8388608, dropped: 4321, since: t0 },
|
||||||
|
{ key: 'collector', label: 'Колектор', ok: true, records: 2, bytes: 2048, max_bytes: 8388608, dropped: 0, since: t0 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
expect(screen.getByText(/витіснено 4321 старіших/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('фільтри йдуть на сервер', () => {
|
||||||
|
it('вибір рівня перезапитує журнал із параметром level', async () => {
|
||||||
|
const server = show()
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'лише error' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(server.calls.some((c) => c.url.includes('level=error'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пошук їде на сервер, а не фільтрує вже завантажене', async () => {
|
||||||
|
const server = show()
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('пошук у журналі'), {
|
||||||
|
target: { value: 'forgejo' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(server.calls.some((c) => c.url.includes('q=forgejo'))).toBe(true)
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('джерело звужує перелік, але обидва лишаються доступними', async () => {
|
||||||
|
show()
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
// До звуження видно рядки обох процесів.
|
||||||
|
expect(screen.getByText('ліцензія')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Колектор' }))
|
||||||
|
await waitFor(() => expect(screen.queryByText('ліцензія')).not.toBeInTheDocument())
|
||||||
|
expect(screen.getByText('дзеркало')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// І назад: фільтр, а не вкладка — «обидва» має лишатись досяжним.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'обидва' }))
|
||||||
|
await waitFor(() => expect(screen.getByText('ліцензія')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('пункт меню й право на нього', () => {
|
||||||
|
/** Оболонка з довільним набором прав. */
|
||||||
|
function shell(permissions: Permission[]) {
|
||||||
|
session.set('tok', { userID: 'u-1', username: 'me', tenantID: 't-1', permissions })
|
||||||
|
FakeWebSocket.reset()
|
||||||
|
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||||
|
fetchRouter({
|
||||||
|
'GET /api/v1/alerts': {
|
||||||
|
alerts: [],
|
||||||
|
counts: { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={['/devices']}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="*" element={<div>сторінка</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('із settings:write пункт є', async () => {
|
||||||
|
shell(['settings:write'])
|
||||||
|
expect(await screen.findByRole('link', { name: /Журнал сервера/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('самого audit:read замало — і це навмисно', async () => {
|
||||||
|
// Обидві половини потрібні. Перша половина сама по собі була б
|
||||||
|
// зелена й тоді, коли пункт зник для всіх; ця — сама по собі
|
||||||
|
// зелена й тоді, коли право взагалі не перевіряється правильно.
|
||||||
|
//
|
||||||
|
// Зміст перевірки: аудит («хто що зробив») дають внутрішньому
|
||||||
|
// контролеру, і системний журнал з адресами, іменами хостів і
|
||||||
|
// текстами помилок SQL до нього не додається мовчки.
|
||||||
|
shell(['audit:read'])
|
||||||
|
expect(await screen.findByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('link', { name: /Журнал сервера/ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('автооновлення', () => {
|
||||||
|
it('вимкнене автооновлення справді спиняє опитування', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
try {
|
||||||
|
const server = show()
|
||||||
|
await screen.findByText('дзеркало')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Автооновлення/ }))
|
||||||
|
const before = server.calls.length
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000)
|
||||||
|
expect(server.calls.length).toBe(before)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -2376,3 +2376,50 @@ export interface BillingOverview {
|
||||||
license: LicenseState
|
license: LicenseState
|
||||||
can_manage: boolean
|
can_manage: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Журнал сервера
|
||||||
|
//
|
||||||
|
// Не журнал мережі й не журнал аудиту, а те, що NetPulse пише про себе.
|
||||||
|
// Форма повторює server/internal/logbuf і httpapi/serverlog.go.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Один рядок журналу процесу. Атрибути вже зведені в текст на сервері. */
|
||||||
|
export interface ServerLogRecord {
|
||||||
|
/** Номер у межах процесу: два рядки в одну мілісекунду впорядковує саме він. */
|
||||||
|
seq: number
|
||||||
|
time: string
|
||||||
|
/** DEBUG | INFO | WARN | ERROR — так, як їх пише slog. */
|
||||||
|
level: string
|
||||||
|
msg: string
|
||||||
|
attrs?: string
|
||||||
|
/** api | collector — який із двох процесів це написав. */
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стан одного джерела.
|
||||||
|
*
|
||||||
|
* `ok: false` разом із `error` — це «джерело недоступне», і воно НЕ те
|
||||||
|
* саме, що джерело без рядків. На екрані вони виглядають однаково, а
|
||||||
|
* означають протилежне, тому сторінка мусить розрізняти їх сама.
|
||||||
|
*/
|
||||||
|
export interface ServerLogSource {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
ok: boolean
|
||||||
|
error?: string
|
||||||
|
records: number
|
||||||
|
bytes: number
|
||||||
|
max_bytes: number
|
||||||
|
/** Скільки рядків уже витіснено зі стелі — тобто початок історії втрачено. */
|
||||||
|
dropped: number
|
||||||
|
/** З якої миті кільце тримає записи: старт процесу або час найстарішого рядка. */
|
||||||
|
since?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerLogPage {
|
||||||
|
records: ServerLogRecord[]
|
||||||
|
sources: ServerLogSource[]
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue