МЕНЮ. Шість груп, разом 1246 px при вікні 720 — довше за екран. Тепер згортаються, стан живе в localStorage як перелік ЗГОРНУТИХ (не розгорнутих: інакше кожна нова група приїхала б до наявних користувачів схованою). Ключ групи окремо від заголовка — підписи тут уже переписували, і якби ключем був заголовок, кожне переписування слова тихо скидало б налаштування всім. Група з активною сторінкою розгортається сама, але рівно одна — вибір для решти не чіпається. Правка стану на ЗМІНІ шляху, а не на кожному рендері, тож згорнути активну групу теж можна. Дві наявні поломки мобільного вигляду, знайдені дорогою: рейка вмикалась класами md:, а розгалуження заголовок/риска було на JS і про md: не знало — людина згортала панель на моніторі, і в шухляді на телефоні ЗНИКАЛИ ВСІ назви розділів. ЛОГО. Пульс на лінії зв'язку: вузол → сплеск → провал → норма → вузол. Два вузли по краях — ключова деталь: без них це іконка «activity» з будь-якого безкоштовного набору, з ними кардіограма стає лінією між двома точками мережі. SVG кодом, не растр: слідує темі через currentColor, важить сотні байтів. Три РІЗНІ малюнки, а не один масштабований: повне лого, знак, і спрощений для 16 px усередині favicon.ico (зібраний власним кодувальником на zlib, без сторонніх інструментів). Пульсація вузла — опційна й типово вимкнена: у шапці вона конкурувала б зі справжнім показником зв'язку, який стоїть поруч. ПОЛЯ З ПІДКАЗКОЮ. Field малює підказку під контролем, тож у ряду з вирівнюванням по низу колонка з підказкою вища — її підпис і поле спливають угору, а гола кнопка з'їжджає вниз. Видно було в словнику трапів; та сама вада знайшлась у редакторі фільтра портів. Ряди вирівняно по верху, а кнопки без підпису загорнуто у FieldGap — порожній підпис такої самої висоти. Відступ у пікселях полагодив би один ряд і роз'їхався б від зміни розміру шрифту. Перевірено очима в живому Chrome: іконки віддаються (4/4, правильні типи й розміри), лого 64×64 на сторінці входу, праве коло справді sky-400 і пульсує лише там.
787 lines
29 KiB
TypeScript
787 lines
29 KiB
TypeScript
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',
|
||
})
|
||
}
|