Сторінка «Журнал сервера»: кільцевий буфер у памяті
All checks were successful
CI / hygiene (push) Successful in 11s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m55s
CI / agent (push) Successful in 1m0s

Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд —
усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам
NetPulse, треба було заходити по ssh.

Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно
тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ
(8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки
буває довшим за сотню звичайних, тож «5000 рядків» означало б
непередбачувані десятки мегабайтів.

Два процеси — один перелік із позначкою джерела, а не дві вкладки:
людина знає симптом («о третій ночі перестали йти сповіщення»), а не
те, який із двох процесів за це відповідає.

Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал
DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені
в місці народження, але кільце робить журнал видимим у браузері й
вивантажуваним у файл, що піде в тікет.

Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не
переживає перезапуску й не покаже причини падіння бази. Поруч —
скільки записів витіснено: без цього числа не відрізнити «нічого не
сталося» від «сталося стільки, що початок уже не влазить».
This commit is contained in:
byrsapty 2026-08-27 23:50:03 +03:00
parent e0385d4960
commit e6b585dd4c
9 changed files with 918 additions and 0 deletions

View file

@ -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"),
"адреса внутрішньої ручки журналу колектора; порожньо — не питати") "адреса внутрішньої ручки журналу колектора; порожньо — не питати")

View 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 "з'єднання відхилено" }

View file

@ -63,6 +63,17 @@ import (
// й секретного, — зі спеки ключів шифрування (-dek). Мітка робить // й секретного, — зі спеки ключів шифрування (-dek). Мітка робить
// виведене значення непридатним ні для чого іншого, а HMAC не дає з // виведене значення непридатним ні для чого іншого, а HMAC не дає з
// нього дістати сам ключ. // нього дістати сам ключ.
//
// ЧОГО ЦЕЙ ЗАХИСТ НЕ ДАЄ, І ЦЕ ТРЕБА ЗНАТИ
//
// Канал відкритий: токен і сам журнал ідуть звичайним HTTP. У типовому
// розгортанні обидва процеси стоять у одній мережі docker на одній
// машині, і це та сама межа довіри, у якій уже ходить h2c до колектора
// (див. docker-compose.yml). Але інсталяція, що рознесе процеси на різні
// машини через мережу, якій не довіряє, отримає токен відкритим текстом
// на дроті. Для такої топології правильний крок — не пускати 9444 повз
// периметр і лишити -collector-log порожнім; сторінка тоді чесно
// покаже лише половину, а не тихо винесе журнал у мережу.
// tokenLabel — розділювач призначень. Без нього виведене значення // tokenLabel — розділювач призначень. Без нього виведене значення
// формально було б «ще однією функцією від ключа шифрування», а таких // формально було б «ще однією функцією від ключа шифрування», а таких

View file

@ -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>

View file

@ -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 : ''))
},
} }

View file

@ -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' },
// Тариф останнім в «Адмініструванні», а не окремою групою: питання // Тариф останнім в «Адмініструванні», а не окремою групою: питання
// «скільки в мене лишилось слотів» ставлять поруч із питанням // «скільки в мене лишилось слотів» ставлять поруч із питанням
// «кого ще завести», і обидва — тут. Окрема група з одного пункту // «кого ще завести», і обидва — тут. Окрема група з одного пункту

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

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

View file

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