Netpulse_SasS/web/src/pages/TrapsPage.tsx
byrsapty c7fecfba0b
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 55s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m5s
Згортання меню, лого з фавіконом, вирівнювання полів із підказкою
МЕНЮ. Шість груп, разом 1246 px при вікні 720 — довше за екран. Тепер
згортаються, стан живе в localStorage як перелік ЗГОРНУТИХ (не
розгорнутих: інакше кожна нова група приїхала б до наявних користувачів
схованою). Ключ групи окремо від заголовка — підписи тут уже
переписували, і якби ключем був заголовок, кожне переписування слова
тихо скидало б налаштування всім.

Група з активною сторінкою розгортається сама, але рівно одна — вибір
для решти не чіпається. Правка стану на ЗМІНІ шляху, а не на кожному
рендері, тож згорнути активну групу теж можна.

Дві наявні поломки мобільного вигляду, знайдені дорогою: рейка
вмикалась класами md:, а розгалуження заголовок/риска було на JS і про
md: не знало — людина згортала панель на моніторі, і в шухляді на
телефоні ЗНИКАЛИ ВСІ назви розділів.

ЛОГО. Пульс на лінії зв'язку: вузол → сплеск → провал → норма → вузол.
Два вузли по краях — ключова деталь: без них це іконка «activity» з
будь-якого безкоштовного набору, з ними кардіограма стає лінією між
двома точками мережі. SVG кодом, не растр: слідує темі через
currentColor, важить сотні байтів.

Три РІЗНІ малюнки, а не один масштабований: повне лого, знак, і
спрощений для 16 px усередині favicon.ico (зібраний власним
кодувальником на zlib, без сторонніх інструментів). Пульсація вузла —
опційна й типово вимкнена: у шапці вона конкурувала б зі справжнім
показником зв'язку, який стоїть поруч.

ПОЛЯ З ПІДКАЗКОЮ. Field малює підказку під контролем, тож у ряду з
вирівнюванням по низу колонка з підказкою вища — її підпис і поле
спливають угору, а гола кнопка з'їжджає вниз. Видно було в словнику
трапів; та сама вада знайшлась у редакторі фільтра портів.

Ряди вирівняно по верху, а кнопки без підпису загорнуто у FieldGap —
порожній підпис такої самої висоти. Відступ у пікселях полагодив би
один ряд і роз'їхався б від зміни розміру шрифту.

Перевірено очима в живому Chrome: іконки віддаються (4/4, правильні
типи й розміри), лого 64×64 на сторінці входу, праве коло справді
sky-400 і пульсує лише там.
2026-08-27 22:18:17 +03:00

787 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
Card,
ErrorNote,
Field,
FieldGap,
PageBody,
PageHeader,
Spinner,
ago,
inputClass,
plural,
} from '../components/ui'
import type { TrapName, TrapPage, TrapRow, TrapsMeta, TrapVarbind } from '../types'
/**
* «SNMP-трапи»: що прилітає в мережу саме по собі.
*
* Окремий розділ, а не вкладка в картці хоста, і причина в самій
* природі трапа. Трап приходить від АДРЕСИ, а не від хоста. Половина
* найцікавішого тут — саме ті адреси, за якими хоста немає: у картці
* такий трап неможливо показати ніде, бо картки не існує.
*
* Три рішення, які видно на екрані.
*
* 1. Назва трапа або є, або її немає — і другий випадок написано
* словами. Вбудований словник — це шість трапів, які визначає сам
* протокол (linkDown, linkUp, coldStart, warmStart,
* authenticationFailure, egpNeighborLoss). Усе інше показується сирим
* OID із написом «невідомий трап», доки хтось не додасть назву в
* словник кабінету. Вгадувати назву за схожістю OID ми не беремося:
* саме назву читають, коли вирішують, чи вставати о третій ночі.
*
* 2. Невідомі відправники — окремий блок угорі, а не рядки, загублені в
* журналі. Журнал відсортований за часом, і три трапи на добу від
* чужої адреси тонуть між тисячею своїх. А це найцінніші три: майже
* завжди вони означають, що в мережі з'явилось кероване залізо, про
* яке моніторинг не знає.
*
* 3. Внизу — те, чого приймач НЕ бачить. Порожній журнал без цього
* блоку читається як «трапів не було», хоча найчастіше означає «їх
* нікому приймати»: модуль на зонді вимкнений або йому не дали
* 162/udp.
*
* Стан фільтра живе в адресі: відфільтрований журнал пересилають колезі,
* і посилання має нести питання цілком.
*/
const PERIODS = [
{ key: '1h', label: 'година', hours: 1 },
{ key: '24h', label: 'доба', hours: 24 },
{ key: '7d', label: '7 днів', hours: 24 * 7 },
{ key: '30d', label: '30 днів', hours: 24 * 30 },
{ key: '90d', label: '90 днів', hours: 24 * 90 },
]
const DEFAULT_PERIOD = '24h'
const PAGE_SIZE = 50
interface Filter {
period: string
oids: string[]
sourceIP: string
q: string
unknown: boolean
}
function parseFilter(sp: URLSearchParams): Filter {
return {
period: sp.get('period') || DEFAULT_PERIOD,
oids: sp.getAll('oid'),
sourceIP: sp.get('source_ip') || '',
q: sp.get('q') || '',
unknown: sp.get('unknown') === '1',
}
}
function writeFilter(f: Filter): URLSearchParams {
const sp = new URLSearchParams()
if (f.period !== DEFAULT_PERIOD) sp.set('period', f.period)
for (const o of f.oids) sp.append('oid', o)
if (f.sourceIP) sp.set('source_ip', f.sourceIP)
if (f.q) sp.set('q', f.q)
if (f.unknown) sp.set('unknown', '1')
return sp
}
function periodRange(f: Filter): { from: string; to: string } {
const p = PERIODS.find((x) => x.key === f.period) ?? PERIODS[1]
const to = new Date()
return {
from: new Date(to.getTime() - p.hours * 3600_000).toISOString(),
to: to.toISOString(),
}
}
export function TrapsPage() {
const [sp, setSp] = useSearchParams()
// Розбираємо саме РЯДОК запиту: так `filter` міняє тотожність лише
// тоді, коли міняється питання, і ефект нижче не перечитує журнал на
// кожному перемальовуванні.
const search = sp.toString()
const filter = useMemo(() => parseFilter(new URLSearchParams(search)), [search])
const [meta, setMeta] = useState<TrapsMeta | null>(null)
const [page, setPage] = useState<TrapPage | null>(null)
const [rows, setRows] = useState<TrapRow[]>([])
const [range, setRange] = useState<{ from: string; to: string } | null>(null)
const [loading, setLoading] = useState(true)
const [more, setMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const canEdit = session.can('settings:write')
const patch = useCallback(
(p: Partial<Filter>) => setSp(writeFilter({ ...filter, ...p }), { replace: true }),
[filter, setSp],
)
const loadMeta = useCallback(() => {
api
.trapsMeta()
.then(setMeta)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
useEffect(loadMeta, [loadMeta])
// Перша сторінка перечитується на будь-яку зміну питання. Накопичені
// рядки при цьому скидаються: домішувати відповіді на два різні
// питання в один перелік — найшвидший спосіб зробити висновок про те,
// чого не було.
useEffect(() => {
let alive = true
setLoading(true)
setError(null)
const win = periodRange(filter)
api
.traps({
...win,
oids: filter.oids,
source_ip: filter.sourceIP || undefined,
q: filter.q || undefined,
unknown: filter.unknown || undefined,
limit: PAGE_SIZE,
})
.then((p) => {
if (!alive) return
setPage(p)
setRows(p.traps)
setRange({ from: p.from, to: p.to })
})
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)))
.finally(() => alive && setLoading(false))
return () => {
alive = false
}
}, [filter])
const loadMore = useCallback(() => {
if (!page?.next_cursor || !range) return
setMore(true)
api
.traps({
from: range.from,
to: range.to,
oids: filter.oids,
source_ip: filter.sourceIP || undefined,
q: filter.q || undefined,
unknown: filter.unknown || undefined,
limit: PAGE_SIZE,
cursor: page.next_cursor,
})
.then((p) => {
setRows((prev) => [...prev, ...p.traps])
setPage(p)
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setMore(false))
}, [page, range, filter])
const active =
filter.oids.length + (filter.sourceIP ? 1 : 0) + (filter.q ? 1 : 0) + (filter.unknown ? 1 : 0)
return (
<>
<PageHeader
title="SNMP-трапи"
subtitle="події, які обладнання надсилає саме · час місцевий"
actions={
active > 0 ? (
<Button
onClick={() =>
setSp(writeFilter({ period: filter.period, ...EMPTY }), { replace: true })
}
>
Скинути фільтри ({active})
</Button>
) : undefined
}
/>
<PageBody className="space-y-3">
<ErrorNote>{error}</ErrorNote>
<UnknownSources meta={meta} canEdit={canEdit} onChange={loadMeta} onFilter={patch} />
<Filters meta={meta} filter={filter} onChange={patch} />
{loading ? (
<Spinner />
) : rows.length === 0 ? (
<EmptyNote filter={filter} />
) : (
<Card className="divide-y divide-slate-800/70">
{rows.map((r, i) => (
<TrapItem
key={r.id + r.ts}
row={r}
showDate={i === 0 || dayKey(rows[i - 1].ts) !== dayKey(r.ts)}
/>
))}
</Card>
)}
{page?.next_cursor && !loading && (
<div className="flex justify-center">
<Button onClick={loadMore} disabled={more}>
{more ? 'Завантаження…' : 'Показати ще'}
</Button>
</div>
)}
{!page?.next_cursor && rows.length > 0 && !loading && (
<p className="text-center text-xs text-slate-600">
це всі трапи за обраний період {rows.length} {plural(rows.length, ['подія', 'події', 'подій'])}
</p>
)}
<Dictionary meta={meta} canEdit={canEdit} onChange={loadMeta} />
<BlindSpots meta={meta} />
</PageBody>
</>
)
}
const EMPTY: Omit<Filter, 'period'> = { oids: [], sourceIP: '', q: '', unknown: false }
// ---------------------------------------------------------------------
// Невідомі відправники
// ---------------------------------------------------------------------
/**
* Найдешевший блок на сторінці й, можливо, найкорисніший.
*
* Він відповідає на питання, якого ніхто не ставить, доки не побачить:
* «а хто ще шле нам трапи?». Адреса, з якої йдуть трапи й за якою немає
* хоста, майже завжди означає одне з двох — у мережі з'явилось кероване
* залізо, яке забули завести, або в ній стоїть щось чуже.
*
* Алертом це зробити не можна: алерт без хоста нікуди не
* маршрутизується й нічого не каже черговому. Тому перелік, а не
* сповіщення.
*/
function UnknownSources({
meta,
canEdit,
onChange,
onFilter,
}: {
meta: TrapsMeta | null
canEdit: boolean
onChange: () => void
onFilter: (p: Partial<Filter>) => void
}) {
const [busy, setBusy] = useState('')
const list = meta?.unknown ?? []
if (list.length === 0) return null
const forget = (ip: string) => {
setBusy(ip)
api
.forgetUnknownTrapSource(ip)
.then(onChange)
.finally(() => setBusy(''))
}
return (
<Card className="border-amber-900/60 bg-amber-950/20 p-3">
<div className="flex flex-wrap items-baseline gap-2">
<h2 className="text-sm font-semibold text-amber-200">
Трапи від {list.length} {plural(list.length, ['адреси', 'адрес', 'адрес'])}, яких немає серед хостів
</h2>
<button
type="button"
className="text-xs text-sky-400 underline-offset-2 hover:underline"
onClick={() => onFilter({ unknown: true, sourceIP: '' })}
>
показати ці події в журналі
</button>
</div>
<p className="mt-1 text-xs leading-snug text-amber-200/70">
Пристрій шле трапи, але його адреса не зіставилась із жодним хостом. Найчастіше це нове
залізо, яке забули завести в інвентар або чуже в спільному сегменті.
</p>
<div className="mt-2 space-y-1">
{list.map((u) => (
<div
key={u.source_ip}
className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded border border-amber-900/40 px-2 py-1.5 text-xs"
>
<code className="font-mono text-sm text-amber-100">{u.source_ip}</code>
<span className="text-slate-400">
{u.trap_count} {plural(u.trap_count, ['трап', 'трапи', 'трапів'])} · останній {ago(u.last_seen_at)}
</span>
<span className="text-slate-500">
{u.last_trap_name || (u.last_trap_oid ? `невідомий трап ${u.last_trap_oid}` : '')}
</span>
{u.agent_name && <span className="text-slate-600">через зонд {u.agent_name}</span>}
<div className="ml-auto flex gap-2">
<button
type="button"
className="text-sky-400 underline-offset-2 hover:underline"
onClick={() => onFilter({ sourceIP: u.source_ip, unknown: false })}
>
що саме шле
</button>
{canEdit && (
<button
type="button"
disabled={busy === u.source_ip}
className="text-slate-500 underline-offset-2 hover:underline disabled:opacity-40"
// Не видалення даних: трапи лишаються в журналі, а
// прибирається саме питання «що це таке». Прийде
// наступний трап — адреса повернеться в перелік.
title="Прибрати з переліку. Трапи лишаться в журналі; якщо прийде новий — адреса з'явиться знову"
onClick={() => forget(u.source_ip)}
>
розібрався
</button>
)}
</div>
</div>
))}
</div>
</Card>
)
}
// ---------------------------------------------------------------------
// Фільтри
// ---------------------------------------------------------------------
function Filters({
meta,
filter,
onChange,
}: {
meta: TrapsMeta | null
filter: Filter
onChange: (p: Partial<Filter>) => void
}) {
// Пошук набирають по літері, а кожна літера — це скан jsonb по
// гіпертаблиці. Локальний стан плюс пауза: на сервер слово потрапляє,
// коли людина закінчила його друкувати.
const [q, setQ] = useState(filter.q)
const [ip, setIP] = useState(filter.sourceIP)
useEffect(() => setQ(filter.q), [filter.q])
useEffect(() => setIP(filter.sourceIP), [filter.sourceIP])
useEffect(() => {
if (q === filter.q && ip === filter.sourceIP) return
const t = setTimeout(() => onChange({ q, sourceIP: ip }), 400)
return () => clearTimeout(t)
}, [q, ip, filter.q, filter.sourceIP, onChange])
const names = meta?.names ?? []
return (
<Card className="grid gap-3 p-3 md:grid-cols-4">
<Field label="Період">
<select
className={inputClass}
value={filter.period}
onChange={(e) => onChange({ period: e.target.value })}
>
{PERIODS.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</Field>
<Field label="Трап" hint="перелічені — ті, назви яких ми знаємо">
<select
className={inputClass}
value={filter.oids[0] ?? ''}
onChange={(e) => onChange({ oids: e.target.value ? [e.target.value] : [] })}
>
<option value="">будь-який</option>
{names.map((n) => (
<option key={n.oid} value={n.oid}>
{n.name} ({n.oid})
</option>
))}
{/* OID, обраний ззовні (посилання від колеги), може бути
відсутнім у словнику. Без цього пункту select мовчки
скинувся б на «будь-який», і фільтр в адресі розійшовся б
із тим, що показано. */}
{filter.oids[0] && !names.some((n) => n.oid === filter.oids[0]) && (
<option value={filter.oids[0]}>невідомий трап {filter.oids[0]}</option>
)}
</select>
</Field>
<Field label="Адреса джерела" hint="можна підмережею: 10.20.0.0/24">
<input
className={inputClass}
value={ip}
placeholder="10.20.0.7"
onChange={(e) => setIP(e.target.value)}
/>
</Field>
<Field label="Пошук у varbind-ах" hint="ім'я порту, текст події">
<input
className={inputClass}
value={q}
placeholder="GigabitEthernet0/7"
onChange={(e) => setQ(e.target.value)}
/>
</Field>
<label className="flex items-center gap-2 text-xs text-slate-400 md:col-span-4">
<input
type="checkbox"
checked={filter.unknown}
onChange={(e) => onChange({ unknown: e.target.checked })}
/>
лише трапи від адрес, яких немає серед хостів
</label>
</Card>
)
}
// ---------------------------------------------------------------------
// Рядок журналу
// ---------------------------------------------------------------------
function TrapItem({ row, showDate }: { row: TrapRow; showDate: boolean }) {
const [open, setOpen] = useState(false)
const unknownTrap = !row.name
return (
<>
{showDate && (
<div className="bg-slate-900/80 px-3 py-1 text-[11px] font-medium text-slate-500">
{fmtDay(row.ts)}
</div>
)}
<div className="px-3 py-2">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono text-xs text-slate-500">{fmtTime(row.ts)}</span>
{unknownTrap ? (
// Порожня клітинка тут була б найгіршим варіантом: вона
// читається як «нічого особливого». Кажемо прямо.
<span className="text-sm text-slate-300">
<span className="text-slate-500">невідомий трап</span>{' '}
<code className="font-mono text-xs">{row.trap_oid || 'без OID'}</code>
</span>
) : (
<span className="text-sm font-medium text-slate-100">
{row.name}
{row.name_origin === 'custom' && (
<span className="ml-1 text-[10px] text-slate-600" title="назва зі словника кабінету">
ваша назва
</span>
)}
</span>
)}
{row.device_name ? (
<span className="text-xs text-slate-400">{row.device_name}</span>
) : (
<span
className="rounded border border-amber-800/60 px-1.5 py-0.5 text-[10px] text-amber-300"
title="Адреса відправника не зіставилась із жодним хостом"
>
не наш хост
</span>
)}
{row.source_ip && (
<code className="font-mono text-[11px] text-slate-600">{row.source_ip}</code>
)}
{row.varbinds.length > 0 && (
<button
type="button"
className="ml-auto text-xs text-sky-400 underline-offset-2 hover:underline"
onClick={() => setOpen((v) => !v)}
>
{open
? 'згорнути'
: `${row.varbinds.length} ${plural(row.varbinds.length, ['поле', 'поля', 'полів'])}`}
</button>
)}
</div>
{!open && <p className="mt-0.5 text-xs text-slate-500">{summary(row)}</p>}
{open && (
<div className="mt-2 space-y-1 rounded border border-slate-800 bg-slate-950/60 p-2">
{row.description && <p className="text-xs text-slate-500">{row.description}</p>}
<p className="font-mono text-[11px] text-slate-600">OID: {row.trap_oid || '—'}</p>
{row.varbinds.map((v, i) => (
<VarbindRow key={v.oid + i} vb={v} />
))}
</div>
)}
</div>
</>
)
}
function VarbindRow({ vb }: { vb: TrapVarbind }) {
// Позначка про обрізання приходить із порожнім OID: жоден справжній
// varbind такого мати не може. Показуємо її як попередження, а не як
// поле — інакше «ми показали не все» виглядало б даними пристрою.
if (!vb.oid) {
return <p className="text-[11px] text-amber-500/80">{vb.value}</p>
}
return (
<div className="flex flex-wrap items-baseline gap-x-2 text-xs">
<span className="min-w-[9rem] text-slate-400">
{vb.name || <code className="font-mono text-[11px] text-slate-600">{vb.oid}</code>}
</span>
<span className="text-slate-200">
{vb.value_label ? (
<>
{vb.value_label} <span className="text-slate-600">({vb.value})</span>
</>
) : (
vb.value || <span className="text-slate-600"></span>
)}
</span>
{vb.type && <span className="text-[10px] text-slate-700">{vb.type}</span>}
{vb.name && <code className="font-mono text-[10px] text-slate-700">{vb.oid}</code>}
</div>
)
}
/** Короткий переказ рядка: названі поля, бо решта — цифри без сенсу. */
function summary(row: TrapRow): string {
const parts: string[] = []
for (const v of row.varbinds) {
if (!v.name || v.name === 'sysUpTime' || v.name === 'snmpTrapOID') continue
parts.push(`${v.name}=${v.value_label || v.value}`)
if (parts.length === 4) break
}
if (parts.length > 0) return parts.join(' · ')
return row.description || `${row.varbinds.length} полів без відомих назв`
}
// ---------------------------------------------------------------------
// Словник
// ---------------------------------------------------------------------
/**
* Свої відповідності «OID → назва».
*
* Це і є чесна відповідь на питання про MIB. Повного словника не буде:
* компілятор ASN.1 і сховище вендорських файлів — окремий продукт.
* Вбудовані шість трапів визначає сам протокол, а решту вписують сюди —
* ті кілька, які кабінету справді потрібні.
*/
function Dictionary({
meta,
canEdit,
onChange,
}: {
meta: TrapsMeta | null
canEdit: boolean
onChange: () => void
}) {
const [oid, setOID] = useState('')
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const names = [...(meta?.names ?? [])].sort((a, b) => a.name.localeCompare(b.name))
const custom = names.filter((n) => n.origin === 'custom')
const builtin = names.filter((n) => n.origin === 'builtin')
const save = () => {
setBusy(true)
setErr(null)
api
.saveTrapName(oid, name, desc)
.then(() => {
setOID('')
setName('')
setDesc('')
onChange()
})
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
.finally(() => setBusy(false))
}
const remove = (o: string) => {
api.deleteTrapName(o).then(onChange)
}
return (
<Card className="p-3">
<h2 className="text-sm font-semibold text-slate-200">Словник трапів</h2>
<p className="mt-1 text-xs leading-snug text-slate-500">
Вбудовані назви є лише для шести трапів, які визначає сам протокол SNMP. Усе інше
показується сирим OID, доки ви не назвете його самі назви за схожістю OID ми навмисно не
вигадуємо. OID трапа видно в самому рядку журналу: розгорніть подію.
</p>
{canEdit && (
<div className="mt-3 grid gap-2 md:grid-cols-[1fr_1fr_1fr_auto] md:items-start">
<Field label="OID трапа">
<input
className={inputClass}
value={oid}
placeholder="1.3.6.1.4.1.9.9.41.2.0.1"
onChange={(e) => setOID(e.target.value)}
/>
</Field>
<Field label="Назва">
<input
className={inputClass}
value={name}
placeholder="clogMessageGenerated"
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="Пояснення" hint="необов'язково">
<input
className={inputClass}
value={desc}
placeholder="повідомлення в syslog пристрою"
onChange={(e) => setDesc(e.target.value)}
/>
</Field>
<FieldGap>
<Button kind="primary" disabled={busy || !oid || !name} onClick={save}>
Додати
</Button>
</FieldGap>
</div>
)}
<ErrorNote>{err}</ErrorNote>
{custom.length > 0 && (
<ul className="mt-3 space-y-1">
{custom.map((n) => (
<DictRow key={n.oid} n={n} canEdit={canEdit} onRemove={() => remove(n.oid)} />
))}
</ul>
)}
<details className="mt-3">
<summary className="cursor-pointer text-xs text-slate-500">
вбудовані назви ({builtin.length})
</summary>
<ul className="mt-1 space-y-1">
{builtin.map((n) => (
<DictRow key={n.oid} n={n} canEdit={false} onRemove={() => undefined} />
))}
</ul>
</details>
</Card>
)
}
function DictRow({
n,
canEdit,
onRemove,
}: {
n: TrapName
canEdit: boolean
onRemove: () => void
}) {
return (
<li className="flex flex-wrap items-baseline gap-x-3 text-xs">
<code className="font-mono text-[11px] text-slate-500">{n.oid}</code>
<span className="text-slate-200">{n.name}</span>
{n.description && <span className="text-slate-600">{n.description}</span>}
{canEdit && (
<button
type="button"
className="ml-auto text-slate-500 underline-offset-2 hover:underline"
onClick={onRemove}
>
прибрати
</button>
)}
</li>
)
}
// ---------------------------------------------------------------------
// Порожньо й сліпі зони
// ---------------------------------------------------------------------
function EmptyNote({ filter }: { filter: Filter }) {
const narrowed =
filter.oids.length > 0 || filter.sourceIP !== '' || filter.q !== '' || filter.unknown
return (
<Card className="p-4 text-sm text-slate-400">
{narrowed ? (
<p>За цим питанням трапів немає. Спробуйте ширший період або скиньте фільтри.</p>
) : (
<>
<p>За обраний період не прийшло жодного трапа.</p>
<p className="mt-2 text-xs leading-snug text-slate-500">
Це може означати і «все спокійно», і «приймати нікому». Перевірте, що на зонді
увімкнено модуль <code className="font-mono">traps</code> і що йому дозволено слухати
162/udp, а на пристрої налаштовано надсилання трапів на адресу зонда. Подробиці
нижче.
</p>
</>
)}
</Card>
)
}
/**
* Чого приймач НЕ бачить.
*
* Перелік приходить із сервера, а не написаний тут, і це навмисно: він
* має жити поруч із кодом, який його спростовує. Рядок, який перестав
* бути правдою, прибирає той, хто це полагодив.
*/
function BlindSpots({ meta }: { meta: TrapsMeta | null }) {
const list = meta?.blind_spots ?? []
if (list.length === 0) return null
return (
<Card className="p-3">
<h2 className="text-sm font-semibold text-slate-300">Чого тут не видно</h2>
<ul className="mt-2 space-y-2">
{list.map((b) => (
<li key={b.title}>
<p className="text-xs font-medium text-slate-300">{b.title}</p>
<p className="text-xs leading-snug text-slate-500">{b.note}</p>
</li>
))}
</ul>
</Card>
)
}
// ---------------------------------------------------------------------
function dayKey(iso: string): string {
return new Date(iso).toDateString()
}
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString('uk-UA', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString('uk-UA', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}