Netpulse_SasS/web/src/components/TriggerEditor.tsx
byrsapty 4d1fd40e91
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Режим NOC TV: дашборд на екрані в диспетчерській без входу
Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу
замість карти мережі. Тому /tv/<токен> розгалужується до перевірки
сесії, а доступ дає токен у посиланні.

Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики
ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би
простішим у коді й перетворив би забутий лінк на ключ до кабінету.

Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом.

Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у
формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками —
виправлено міграцією 0026 і в редакторі. База стенда виявилась у
SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а
compose задає кодування явно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:41:22 +03:00

376 lines
15 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 { Button, Field, Toggle, inputClass } from './ui'
import type { TemplateTrigger } from '../types'
/**
* Редактор тригерів шаблону.
*
* Тригер — те саме правило сповіщення, тільки описане один раз на клас
* пристроїв замість повторення на кожному хості. Тому й живе він у
* шаблоні поруч із перевірками, які дають йому дані.
*
* Умова редагується полями, а не JSON-ом: «cpu.util_pct більше 85
* протягом 5 хв» — те, що людина тримає в голові, і змушувати її
* перекладати це у фігурні дужки означає перекладати на неї роботу
* форми. JSON лишається запасним виходом для джерел, яким полів мало.
*/
const SOURCES: { key: string; label: string; hint: string }[] = [
{ key: 'metric', label: 'Метрика', hint: 'Значення, зібране перевіркою шаблону' },
{ key: 'icmp', label: 'Пінг', hint: 'Втрати або затримка' },
{ key: 'interface', label: 'Інтерфейс', hint: 'Завантаження, помилки, стан порту' },
{ key: 'syslog', label: 'Syslog', hint: 'Збіг у тексті повідомлення' },
{ key: 'ncm', label: 'Конфіг', hint: 'Зміна або невдалий збір' },
{ key: 'agent', label: 'Зонд', hint: 'Зонд не на звʼязку' },
]
const SEVERITIES: { key: string; label: string; tone: string }[] = [
{ key: 'info', label: 'Інформація', tone: 'text-sky-300' },
{ key: 'warning', label: 'Попередження', tone: 'text-amber-300' },
{ key: 'average', label: 'Середня', tone: 'text-orange-300' },
{ key: 'high', label: 'Висока', tone: 'text-red-300' },
{ key: 'disaster', label: 'Аварія', tone: 'text-red-400' },
]
const OPS = ['>', '>=', '<', '<=', '==', '!='] as const
// Параметри пінгу, які має сенс порівнювати з числом. Ключі мусять
// збігатися з icmpMetrics у движку сповіщень: розійдуться — правило
// мовчатиме, а в журналі буде «невідома метрика».
const ICMP_FIELDS: { key: string; label: string; unit: string }[] = [
{ key: 'loss_pct', label: 'втрати', unit: '%' },
{ key: 'rtt_avg_ms', label: 'затримка', unit: 'мс' },
{ key: 'jitter_ms', label: 'джитер', unit: 'мс' },
]
const AGGS: { key: string; label: string }[] = [
{ key: 'avg', label: 'середнє' },
{ key: 'max', label: 'максимум' },
{ key: 'min', label: 'мінімум' },
{ key: 'last', label: 'останнє' },
]
type Cond = Record<string, unknown>
export function TriggerEditor({
triggers,
readOnly,
metricKeys,
onChange,
}: {
triggers: TemplateTrigger[]
readOnly: boolean
/** Ключі метрик цього ж шаблону — щоб не згадувати їх напамʼять. */
metricKeys: string[]
onChange: (next: TemplateTrigger[]) => void
}) {
function patch(i: number, next: Partial<TemplateTrigger>) {
onChange(triggers.map((t, n) => (n === i ? { ...t, ...next } : t)))
}
function patchCond(i: number, next: Cond) {
const cur = (triggers[i].condition ?? {}) as Cond
patch(i, { condition: { ...cur, ...next } })
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">Тригери</span>
{!readOnly && (
<Button
onClick={() =>
onChange([
...triggers,
{
key: '',
name: '',
description: '',
source: 'metric',
severity: 'warning',
condition: { metric_key: metricKeys[0] ?? '', agg: 'avg', window: '5m', op: '>', value: 0 },
for_seconds: 60,
depends_on_topology: true,
enabled: true,
position: triggers.length,
},
])
}
>
Додати тригер
</Button>
)}
</div>
<p className="text-[11px] leading-relaxed text-slate-600">
«Процесор вище 85% пʼять хвилин це проблема» описує клас пристроїв, а не окремий хост.
Тригер, заданий тут, стане правилом для кожного, кому чіпляють шаблон, і зникне разом
із відвʼязаним шаблоном. Разові правила на конкретний хост лишаються на сторінці
«Правила».
</p>
{triggers.length === 0 && (
<p className="py-6 text-center text-xs text-slate-600">
Шаблон без тригерів збирає дані мовчки про проблему не скаже ніхто.
</p>
)}
{triggers.map((t, i) => {
const cond = (t.condition ?? {}) as Cond
return (
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-3">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="Назва">
<input
className={inputClass}
value={t.name}
disabled={readOnly}
placeholder="Високе навантаження процесора"
onChange={(e) => patch(i, { name: e.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Джерело">
<select
className={inputClass}
value={t.source}
disabled={readOnly}
onChange={(e) => patch(i, { source: e.target.value, condition: defaultCond(e.target.value, metricKeys) })}
>
{SOURCES.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</Field>
<Field label="Важливість">
<select
className={inputClass}
value={t.severity}
disabled={readOnly}
onChange={(e) => patch(i, { severity: e.target.value })}
>
{SEVERITIES.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</Field>
</div>
</div>
{t.source === 'metric' && (
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr]">
<Field label="Метрика">
<input
className={`${inputClass} font-mono`}
list={`metrics-${i}`}
value={String(cond.metric_key ?? '')}
disabled={readOnly}
placeholder="cpu.util_pct"
onChange={(e) => patchCond(i, { metric_key: e.target.value })}
/>
<datalist id={`metrics-${i}`}>
{metricKeys.map((k) => (
<option key={k} value={k} />
))}
</datalist>
</Field>
<Field label="Як зводити">
<select
className={inputClass}
value={String(cond.agg ?? 'avg')}
disabled={readOnly}
onChange={(e) => patchCond(i, { agg: e.target.value })}
>
{AGGS.map((a) => (
<option key={a.key} value={a.key}>
{a.label}
</option>
))}
</select>
</Field>
<Field label="Вікно" hint="5m, 1h">
<input
className={inputClass}
value={String(cond.window ?? '5m')}
disabled={readOnly}
onChange={(e) => patchCond(i, { window: e.target.value })}
/>
</Field>
</div>
)}
{t.source === 'icmp' && (
<Field label="Умова">
<div className="flex gap-2">
<select
className={inputClass}
value={String(cond.metric ?? 'loss_pct')}
disabled={readOnly}
onChange={(e) => patchCond(i, { metric: e.target.value })}
>
{ICMP_FIELDS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
<select
className={`${inputClass} w-20`}
value={String(cond.op ?? '>')}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: e.target.value })}
>
{OPS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<input
className={`${inputClass} w-28`}
type="number"
value={Number(cond.value ?? 0)}
disabled={readOnly}
onChange={(e) => patchCond(i, { value: Number(e.target.value) })}
/>
<span className="self-center text-xs text-slate-500">
{ICMP_FIELDS.find((f) => f.key === (cond.metric ?? 'loss_pct'))?.unit}
</span>
</div>
</Field>
)}
{t.source === 'syslog' && (
<Field label="Збіг у тексті" hint="Регулярний вираз">
<input
className={`${inputClass} font-mono`}
value={String(cond.regex ?? '')}
disabled={readOnly}
placeholder="%LINK-3-UPDOWN.*down"
onChange={(e) => patchCond(i, { regex: e.target.value })}
/>
</Field>
)}
{(t.source === 'metric' || t.source === 'interface') && (
<div className="grid grid-cols-2 gap-2">
<Field label="Порівняння">
<select
className={inputClass}
value={String(cond.op ?? '>')}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: e.target.value })}
>
{OPS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</Field>
<Field label="Значення">
<input
className={inputClass}
type="number"
value={Number(cond.value ?? 0)}
disabled={readOnly}
onChange={(e) => patchCond(i, { value: Number(e.target.value) })}
/>
</Field>
</div>
)}
{(t.source === 'ncm' || t.source === 'agent') && (
<Field label="Умова" hint="JSON — для цього джерела полів ще немає">
<input
className={`${inputClass} font-mono text-[11px]`}
value={JSON.stringify(cond)}
disabled={readOnly}
onChange={(e) => {
try {
patch(i, { condition: JSON.parse(e.target.value) as Cond })
} catch {
// Недописаний JSON — нормальний проміжний стан при
// наборі. Помилку показує збереження, а не кожне
// натискання клавіші.
}
}}
/>
</Field>
)}
<div className="grid gap-2 sm:grid-cols-2">
<Field label="Витримка, с" hint="Скільки триматись умові до алерту">
<input
className={inputClass}
type="number"
min="0"
max="86400"
value={t.for_seconds}
disabled={readOnly}
onChange={(e) => patch(i, { for_seconds: Number(e.target.value) || 0 })}
/>
</Field>
<Field label="Опис" hint="Що робити черговому">
<input
className={inputClass}
value={t.description ?? ''}
disabled={readOnly}
onChange={(e) => patch(i, { description: e.target.value })}
/>
</Field>
</div>
<div className="mt-2 flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-slate-400">
<Toggle
checked={t.enabled}
disabled={readOnly}
onChange={(v) => patch(i, { enabled: v })}
/>
Увімкнено
</label>
<label className="flex items-center gap-2 text-xs text-slate-400">
<Toggle
checked={t.depends_on_topology}
disabled={readOnly}
onChange={(v) => patch(i, { depends_on_topology: v })}
/>
Мовчати, якщо лежить вузол вище
</label>
{!readOnly && (
<Button
kind="danger"
className="ml-auto"
onClick={() => onChange(triggers.filter((_, n) => n !== i))}
>
Прибрати
</Button>
)}
</div>
</div>
)
})}
</div>
)
}
function defaultCond(source: string, metricKeys: string[]): Cond {
switch (source) {
case 'icmp':
return { metric: 'loss_pct', op: '>', value: 20 }
case 'syslog':
return { regex: '' }
case 'interface':
return { metric: 'util_out_pct', op: '>', value: 90 }
case 'ncm':
return { event: 'changed' }
case 'agent':
return { event: 'offline' }
default:
return { metric_key: metricKeys[0] ?? '', agg: 'avg', window: '5m', op: '>', value: 0 }
}
}