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. Порожньо тут означає
+ «після останнього запуску такого не було», а не «такого не сталося».
+
+ {/* Причина текстом, а не значком: «недоступний» без неї
+ перетворює сторінку на здогадки, а виправляють тут щоразу
+ різне — від невиставленої адреси до впалого процесу. */}
+
+ {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
+}