Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу замість карти мережі. Тому /tv/<токен> розгалужується до перевірки сесії, а доступ дає токен у посиланні. Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би простішим у коді й перетворив би забутий лінк на ключ до кабінету. Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом. Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками — виправлено міграцією 0026 і в редакторі. База стенда виявилась у SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а compose задає кодування явно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
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 }
|
||
}
|
||
}
|