From e6b585dd4c616284eb26e49699469bbf30c62c76 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Thu, 27 Aug 2026 23:50:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=BE=D1=80=D1=96=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=96=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=C2=BB:=20=D0=BA=D1=96?= =?UTF-8?q?=D0=BB=D1=8C=D1=86=D0=B5=D0=B2=D0=B8=D0=B9=20=D0=B1=D1=83=D1=84?= =?UTF-8?q?=D0=B5=D1=80=20=D1=83=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D1=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд — усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам NetPulse, треба було заходити по ssh. Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ (8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки буває довшим за сотню звичайних, тож «5000 рядків» означало б непередбачувані десятки мегабайтів. Два процеси — один перелік із позначкою джерела, а не дві вкладки: людина знає симптом («о третій ночі перестали йти сповіщення»), а не те, який із двох процесів за це відповідає. Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені в місці народження, але кільце робить журнал видимим у браузері й вивантажуваним у файл, що піде в тікет. Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не переживає перезапуску й не покаже причини падіння бази. Поруч — скільки записів витіснено: без цього числа не відрізнити «нічого не сталося» від «сталося стільки, що початок уже не влазить». --- server/cmd/netpulse-api/main.go | 6 + server/internal/httpapi/serverlog_test.go | 164 +++++++++ server/internal/logbuf/http.go | 11 + web/src/App.tsx | 13 + web/src/api/client.ts | 23 ++ web/src/components/AppShell.tsx | 7 + web/src/pages/ServerLogPage.tsx | 405 ++++++++++++++++++++++ web/src/test/serverlog.test.tsx | 242 +++++++++++++ web/src/types.ts | 47 +++ 9 files changed, 918 insertions(+) create mode 100644 server/internal/httpapi/serverlog_test.go create mode 100644 web/src/pages/ServerLogPage.tsx create mode 100644 web/src/test/serverlog.test.tsx diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index e06e053..52ccdca 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -79,6 +79,12 @@ func run() error { // побачити другу половину змогли б лише ті, хто прочитав про // цей прапорець. Там, де топологія інша, ім'я не розв'язується // за мілісекунди, і сторінка чесно каже, що саме налаштувати. + // + // Ціна цієї зручності: у мережі, де DNS відповідає на БУДЬ-ЯКЕ + // ім'я (пошуковий домен, wildcard), запит із похідним від -dek + // токеном піде чужому хосту. Сам ключ із токена не дістати + // (HMAC), але тим, кого це стосується, лікується порожнім + // NETPULSE_COLLECTOR_LOG_URL. collectorLog = flag.String("collector-log", envOr("NETPULSE_COLLECTOR_LOG_URL", "http://collector:9444"), "адреса внутрішньої ручки журналу колектора; порожньо — не питати") diff --git a/server/internal/httpapi/serverlog_test.go b/server/internal/httpapi/serverlog_test.go new file mode 100644 index 0000000..ff56b99 --- /dev/null +++ b/server/internal/httpapi/serverlog_test.go @@ -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 "з'єднання відхилено" } diff --git a/server/internal/logbuf/http.go b/server/internal/logbuf/http.go index 9056193..fae2eb2 100644 --- a/server/internal/logbuf/http.go +++ b/server/internal/logbuf/http.go @@ -63,6 +63,17 @@ import ( // й секретного, — зі спеки ключів шифрування (-dek). Мітка робить // виведене значення непридатним ні для чого іншого, а HMAC не дає з // нього дістати сам ключ. +// +// ЧОГО ЦЕЙ ЗАХИСТ НЕ ДАЄ, І ЦЕ ТРЕБА ЗНАТИ +// +// Канал відкритий: токен і сам журнал ідуть звичайним HTTP. У типовому +// розгортанні обидва процеси стоять у одній мережі docker на одній +// машині, і це та сама межа довіри, у якій уже ходить h2c до колектора +// (див. docker-compose.yml). Але інсталяція, що рознесе процеси на різні +// машини через мережу, якій не довіряє, отримає токен відкритим текстом +// на дроті. Для такої топології правильний крок — не пускати 9444 повз +// периметр і лишити -collector-log порожнім; сторінка тоді чесно +// покаже лише половину, а не тихо винесе журнал у мережу. // tokenLabel — розділювач призначень. Без нього виведене значення // формально було б «ще однією функцією від ключа шифрування», а таких diff --git a/web/src/App.tsx b/web/src/App.tsx index 14f6183..baee5b8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ import { AuditPage } from './pages/AuditPage' import { TrapsPage } from './pages/TrapsPage' import { SLAPage } from './pages/SLAPage' import { StoragePage } from './pages/StoragePage' +import { ServerLogPage } from './pages/ServerLogPage' import { ProfilePage } from './pages/ProfilePage' import { BillingPage } from './pages/BillingPage' import { TvPage } from './pages/TvPage' @@ -155,6 +156,18 @@ function Workspace() { сховища й дзеркала. */} } />} /> } />} /> + {/* Журнал сервера під settings:write, а НЕ під audit:read — і це + протилежне рішення до сховища й дзеркала навмисно. Там ішлося + про стан мережі, який видно кожному, хто бачить моніторинг; + тут — про системний журнал з адресами, іменами хостів і + текстами помилок SQL. Аудит («хто що зробив») дають + внутрішньому контролеру, і додати йому це мовчки означало б + розширити його право. settings:write є лише у власника й + адміна — тобто в тих, хто й так бачить усе. */} + } />} + /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ffd6181..c094d3d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -95,6 +95,7 @@ import type { SLAReport, BillingOverview, LicenseState, + ServerLogPage, } from '../types' /** Те, що приймає POST/PATCH /api/v1/devices. */ @@ -1673,4 +1674,26 @@ export const api = { clearLicense: () => request('/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('/api/v1/server-log' + (qs ? '?' + qs : '')) + }, } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index b75015e..f2e37bf 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -176,6 +176,13 @@ const navGroups: NavGroup[] = [ // пункт не з'явиться — а разом із ним і група, якщо в ній більше // нічого не лишилось. { to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' }, + // Одразу під журналом аудиту, бо їх плутають, а різниця між ними + // вирішальна: аудит відповідає на «хто що зробив», журнал сервера — + // на «на що лається сама система». Поруч цю різницю видно з двох + // підписів; порізно людина шукала б причину відмови в аудиті й не + // знайшла б її ніколи. Право інше й ширше (settings:write), тому + // пункт бачать не всі, хто бачить аудит. + { to: '/server-log', label: 'Журнал сервера', icon: '📜', perm: 'settings:write' }, // Тариф останнім в «Адмініструванні», а не окремою групою: питання // «скільки в мене лишилось слотів» ставлять поруч із питанням // «кого ще завести», і обидва — тут. Окрема група з одного пункту diff --git a/web/src/pages/ServerLogPage.tsx b/web/src/pages/ServerLogPage.tsx new file mode 100644 index 0000000..66b079c --- /dev/null +++ b/web/src/pages/ServerLogPage.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [sourceFilter, setSourceFilter] = useState('') + + // Пошук їде на сервер із затримкою: інакше кожне натиснуте літерою + // перезапитувало б увесь журнал, і робило б це саме тоді, коли системі + // й так важко. + 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 ( + <> + + + + + + } + /> + + + {error} + + + + + + {loading && !data ? ( + + ) : records.length === 0 ? ( + + ) : ( + + {records.map((r) => ( + + ))} + + )} + + {data && records.length >= data.limit && ( +

+ показано {data.limit} рядків — стеля однієї відповіді. Звузьте рівень або пошук, щоб + побачити старіше. +

+ )} +
+ + ) +} + +// --------------------------------------------------------------------- +// Межі журналу +// --------------------------------------------------------------------- + +/** + * Найважливіший блок на сторінці — і саме тому він СТОЇТЬ ТУТ, над + * переліком, а не лежить у документації. + * + * Порожній журнал і журнал, у якому шуканого не буває ніколи, на екрані + * виглядають однаково. Без цього рядка людина, яка після нічного падіння + * бази не знайшла тут нічого, зробить висновок «нічого не сталося» — і + * піде шукати причину деінде, маючи хибну відправну точку. + * + * Разом із межами показано стан кожного джерела. «Витіснено N рядків» — + * не діагностика заради діагностики: без цього числа неможливо + * відрізнити «за годину нічого не сталося» від «сталося стільки, що + * початок уже не влазить», а це протилежні висновки. + */ +function Limits({ sources }: { sources: ServerLogSource[] }) { + return ( + +

+ Журнал живе в пам'яті процесів: він не переживає їхнього перезапуску й{' '} + не покаже причини падіння самої бази чи процесу — останні слова процесу, що впав, + лишаються лише в docker logs. Порожньо тут означає + «після останнього запуску такого не було», а не «такого не сталося». +

+ +
+ {sources.map((s) => ( + + ))} +
+
+ ) +} + +function SourceState({ s }: { s: ServerLogSource }) { + if (!s.ok) { + return ( +
+
{s.label} — недоступний
+ {/* Причина текстом, а не значком: «недоступний» без неї + перетворює сторінку на здогадки, а виправляють тут щоразу + різне — від невиставленої адреси до впалого процесу. */} +
{s.error}
+
+ ) + } + return ( +
+
{s.label}
+
+ {s.records} рядків · {fmtBytes(s.bytes)} з {fmtBytes(s.max_bytes)} + {s.since && <> · від {ago(s.since)}} + {s.dropped > 0 && ( + · витіснено {s.dropped} старіших + )} +
+
+ ) +} + +// --------------------------------------------------------------------- +// Фільтри +// --------------------------------------------------------------------- + +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 ( + +
+ {LEVELS.map((l) => ( + + ))} +
+ + {/* Джерело — фільтр, а не вкладка: за замовчуванням видно обидва, + і звузити до одного можна тоді, коли вже знаєш, кого шукаєш. */} +
+ onSource('')} /> + {sources + .filter((s) => s.ok) + .map((s) => ( + onSource(s.key)} + /> + ))} +
+ + onQuery(e.target.value)} + placeholder="пошук по тексту й атрибутах…" + className={inputClass + ' ml-auto max-w-xs'} + aria-label="пошук у журналі" + /> +
+ ) +} + +function SourceChip({ + label, + active, + onClick, +}: { + label: string + active: boolean + onClick: () => void +}) { + return ( + + ) +} + +// --------------------------------------------------------------------- +// Рядок журналу +// --------------------------------------------------------------------- + +const LEVEL_CLASS: Record = { + 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 ( +
+ {fmtTime(row.time)} + {row.level.toLowerCase()} + {/* Джерело в кожному рядку: перелік спільний, і без позначки + неможливо сказати, який із двох процесів це написав. */} + + {row.source} + + {row.msg} + {row.attrs && {row.attrs}} +
+ ) +} + +function EmptyNote({ hasFilter }: { hasFilter: boolean }) { + return ( + + {hasFilter ? ( + <>За цим фільтром рядків немає. Спробуйте знизити рівень або очистити пошук. + ) : ( + <> + Журнал порожній. Це нормальний стан щойно запущеної системи: у кільце потрапляє те, що + процеси написали ПІСЛЯ старту. + + )} + + ) +} + +// --------------------------------------------------------------------- +// Вивантаження +// --------------------------------------------------------------------- + +/** + * Зберігає рівно те, що зараз на екрані. + * + * Саме показане, а не «весь журнал»: файл із цієї кнопки йде в тікет + * поруч зі словами «ось що було», і розходження між побаченим і + * надісланим — найдорожча з можливих тут помилок. + * + * Складається в браузері, а не запитується окремою ручкою: сервер уже + * віддав ці рядки, і другий запит по них додав би лише спосіб отримати + * інший набір. + */ +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)}` +} diff --git a/web/src/test/serverlog.test.tsx b/web/src/test/serverlog.test.tsx new file mode 100644 index 0000000..af43d70 --- /dev/null +++ b/web/src/test/serverlog.test.tsx @@ -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 = {}) { + 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( + + + , + ) + 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( + + + }> + сторінка} /> + + + , + ) + } + + 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() + } + }) +}) diff --git a/web/src/types.ts b/web/src/types.ts index be93d52..4e33ca8 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -2376,3 +2376,50 @@ export interface BillingOverview { license: LicenseState 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 +}