Логін: - core.users.username замість пошти як ідентифікатор: у мережевій інсталяції половина акаунтів технічні (noc, monitoring, oncall) і скриньки не мають узагалі. Пошта лишилась необов'язковим полем - наявним користувачам логін виведено з пошти, збіги розведено суфіксом: мовчки злити admin@a.com і admin@b.com в один логін — це втрата акаунта, а не міграція - сервер шукає за логіном і за поштою, тому звичка вводити email нікого не відхиляє Групи (модель Zabbix): - групи хостів і групи доступу; права read/write/deny на групу хостів - роль каже, що вільно робити; група — над якими хостами. Інженер над філією та інженер над усією мережею мають однакову роль і різний доступ - хто не входить у жодну групу, групами не обмежений — свідомо не по-заббіксівськи: там кожна нова інсталяція починається з питання «чому порожньо» - заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу - фільтр накладається в самому запиті, а не після вибірки Хости: додавання, редагування, м'яке видалення, прив'язка до груп, фільтр за групою в таблиці. Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис, значок, розмір, ширина, колір рамки, закріплення. Виправлено за скаргами: - перемикач вилазив на 14px за трек: у ручки не заданий left, а статичну позицію зсуває типове text-align: center у <button> - сторінка правил не оновлювала «активних»: після повернення правила алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого перечитування — бракувало підписки на живі події - канал із секретом показувався як «секрету немає» Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3, обидва без запису, алертів 1 замість 3, редагування чужого хоста 403. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { api } from '../api/client'
|
||
import { session } from '../api/session'
|
||
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
|
||
import {
|
||
Button,
|
||
DataTable,
|
||
ErrorNote,
|
||
Field,
|
||
Modal,
|
||
PageHeader,
|
||
Spinner,
|
||
Toggle,
|
||
inputClass,
|
||
} from '../components/ui'
|
||
import type { AlertRule, Severity } from '../types'
|
||
|
||
// Списки метрик повторюють whitelist сервера. Дублювання свідоме: без
|
||
// нього людина дізнавалась би про друкарську помилку не з форми, а з
|
||
// правила, яке мовчки нічого не знаходить.
|
||
const metricsBySource: Record<string, { key: string; label: string; unit?: string }[]> = {
|
||
icmp: [
|
||
{ key: 'rtt_avg_ms', label: 'Затримка, середня', unit: 'мс' },
|
||
{ key: 'rtt_max_ms', label: 'Затримка, максимальна', unit: 'мс' },
|
||
{ key: 'jitter_ms', label: 'Джитер', unit: 'мс' },
|
||
{ key: 'loss_pct', label: 'Втрати пакетів', unit: '%' },
|
||
{ key: 'reachable', label: 'Доступність (1/0)' },
|
||
{ key: 'no_data', label: 'Даних немає взагалі' },
|
||
],
|
||
interface: [
|
||
{ key: 'util_in_pct', label: 'Завантаження вхідне', unit: '%' },
|
||
{ key: 'util_out_pct', label: 'Завантаження вихідне', unit: '%' },
|
||
{ key: 'in_errors', label: 'Помилки на вході' },
|
||
{ key: 'out_errors', label: 'Помилки на виході' },
|
||
{ key: 'in_discards', label: 'Відкинуто на вході' },
|
||
{ key: 'out_discards', label: 'Відкинуто на виході' },
|
||
{ key: 'in_bps', label: 'Трафік вхідний', unit: 'біт/с' },
|
||
{ key: 'out_bps', label: 'Трафік вихідний', unit: 'біт/с' },
|
||
{ key: 'oper_up', label: 'Порт піднято (1/0)' },
|
||
],
|
||
metric: [{ key: '', label: '(довільний ключ серії)' }],
|
||
}
|
||
|
||
const severityLabel: Record<Severity, string> = {
|
||
info: 'Інформація',
|
||
warning: 'Увага',
|
||
average: 'Середня',
|
||
high: 'Висока',
|
||
disaster: 'Аварія',
|
||
}
|
||
|
||
const sourceLabel: Record<string, string> = {
|
||
icmp: 'Пінг (ICMP)',
|
||
interface: 'Інтерфейси (SNMP)',
|
||
metric: 'Довільна метрика',
|
||
}
|
||
|
||
export function RulesPage() {
|
||
const [rules, setRules] = useState<AlertRule[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
const canWrite = session.can('alerts:write')
|
||
|
||
const reload = useCallback(async () => {
|
||
try {
|
||
setRules(await api.listAlertRules())
|
||
setError(null)
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void reload()
|
||
}, [reload])
|
||
|
||
// Лічильник «активних» рахує сам сервер, і міняє його движок, а не ця
|
||
// сторінка. Після вимкнення правила його алерти закриються одразу, а
|
||
// от після повернення вони піднімуться лише наступним тіком — тобто
|
||
// за секунди ПІСЛЯ нашого перечитування. Без підписки таблиця так і
|
||
// лишалася б із нулями до перезавантаження сторінки.
|
||
useLiveRefresh(isAlertEvent, reload)
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="Правила алертів"
|
||
subtitle={
|
||
loading
|
||
? undefined
|
||
: `${rules.length} усього · ${rules.filter((r) => r.enabled).length} увімкнено`
|
||
}
|
||
actions={
|
||
canWrite && (
|
||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||
+ Правило
|
||
</Button>
|
||
)
|
||
}
|
||
/>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||
{error && (
|
||
<div className="p-4">
|
||
<ErrorNote>{error}</ErrorNote>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<Spinner />
|
||
) : (
|
||
<DataTable
|
||
rows={rules}
|
||
rowKey={(r) => r.id}
|
||
empty="Правил ще немає — без них система мовчатиме про будь-яку аварію"
|
||
columns={[
|
||
{
|
||
key: 'name',
|
||
header: 'Назва',
|
||
cell: (r) => <span className="font-medium text-slate-100">{r.name}</span>,
|
||
},
|
||
{
|
||
key: 'source',
|
||
header: 'Джерело',
|
||
cell: (r) => <span className="text-slate-400">{sourceLabel[r.source] ?? r.source}</span>,
|
||
},
|
||
{
|
||
key: 'cond',
|
||
header: 'Умова',
|
||
hideOnMobile: true,
|
||
cell: (r) => (
|
||
<code className="text-[11px] text-slate-400">{describeCondition(r)}</code>
|
||
),
|
||
},
|
||
{
|
||
key: 'sev',
|
||
header: 'Серйозність',
|
||
cell: (r) => severityLabel[r.severity] ?? r.severity,
|
||
},
|
||
{
|
||
key: 'active',
|
||
header: 'Активних',
|
||
cell: (r) =>
|
||
r.active_alerts > 0 ? (
|
||
<span className="rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200">
|
||
{r.active_alerts}
|
||
</span>
|
||
) : (
|
||
<span className="text-slate-600">0</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'enabled',
|
||
header: 'Увімк.',
|
||
cell: (r) => (
|
||
<div className="flex items-center gap-2">
|
||
<Toggle
|
||
checked={r.enabled}
|
||
disabled={!canWrite}
|
||
onChange={async (v) => {
|
||
await api.setRuleEnabled(r.id, v)
|
||
await reload()
|
||
}}
|
||
/>
|
||
{canWrite && (
|
||
<button
|
||
className="text-xs text-slate-600 hover:text-red-400"
|
||
title="Видалити правило разом з його алертами"
|
||
onClick={async () => {
|
||
await api.deleteAlertRule(r.id)
|
||
await reload()
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{creating && (
|
||
<RuleForm
|
||
onClose={() => setCreating(false)}
|
||
onSaved={async () => {
|
||
setCreating(false)
|
||
await reload()
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function describeCondition(r: AlertRule): string {
|
||
const c = r.condition as {
|
||
metric?: string
|
||
metric_key?: string
|
||
op?: string
|
||
value?: number
|
||
agg?: string
|
||
}
|
||
if (!c) return '—'
|
||
const name = c.metric_key || c.metric || '?'
|
||
if (name === 'no_data') return `даних немає ${r.for_seconds} с`
|
||
const scope = c.agg ? `${c.agg} за ${r.for_seconds} с` : `всі виміри ${r.for_seconds} с`
|
||
return `${name} ${c.op ?? ''} ${c.value ?? ''} (${scope})`
|
||
}
|
||
|
||
function RuleForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
|
||
const [name, setName] = useState('')
|
||
const [source, setSource] = useState('icmp')
|
||
const [metric, setMetric] = useState('loss_pct')
|
||
const [metricKey, setMetricKey] = useState('')
|
||
const [op, setOp] = useState('>')
|
||
const [value, setValue] = useState('20')
|
||
const [agg, setAgg] = useState('')
|
||
const [severity, setSeverity] = useState<Severity>('warning')
|
||
const [forSec, setForSec] = useState('180')
|
||
const [topo, setTopo] = useState(true)
|
||
const [busy, setBusy] = useState(false)
|
||
const [err, setErr] = useState<string | null>(null)
|
||
|
||
const isNoData = metric === 'no_data'
|
||
const metrics = metricsBySource[source] ?? []
|
||
|
||
async function submit() {
|
||
setBusy(true)
|
||
setErr(null)
|
||
try {
|
||
const condition: Record<string, unknown> = isNoData
|
||
? { metric: 'no_data' }
|
||
: source === 'metric'
|
||
? { metric_key: metricKey, op, value: Number(value) }
|
||
: { metric, op, value: Number(value) }
|
||
if (!isNoData && agg) condition.agg = agg
|
||
|
||
await api.createAlertRule({
|
||
name: name.trim(),
|
||
source,
|
||
severity,
|
||
condition,
|
||
for_seconds: Number(forSec) || 60,
|
||
depends_on_topology: topo,
|
||
})
|
||
await onSaved()
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal title="Нове правило" onClose={onClose}>
|
||
<div className="space-y-3">
|
||
<Field label="Назва">
|
||
<input
|
||
className={inputClass}
|
||
autoFocus
|
||
placeholder="Наприклад: Втрати пакетів на магістралі"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
/>
|
||
</Field>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Джерело">
|
||
<select
|
||
className={inputClass}
|
||
value={source}
|
||
onChange={(e) => {
|
||
setSource(e.target.value)
|
||
const first = metricsBySource[e.target.value]?.[0]
|
||
setMetric(first?.key ?? '')
|
||
}}
|
||
>
|
||
{Object.keys(metricsBySource).map((s) => (
|
||
<option key={s} value={s}>
|
||
{sourceLabel[s]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
|
||
<Field label="Серйозність">
|
||
<select
|
||
className={inputClass}
|
||
value={severity}
|
||
onChange={(e) => setSeverity(e.target.value as Severity)}
|
||
>
|
||
{(Object.keys(severityLabel) as Severity[]).map((s) => (
|
||
<option key={s} value={s}>
|
||
{severityLabel[s]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
|
||
{source === 'metric' ? (
|
||
<Field label="Ключ метрики" hint="Як у ts.series.metric_key — наприклад cpu.util">
|
||
<input
|
||
className={inputClass}
|
||
value={metricKey}
|
||
onChange={(e) => setMetricKey(e.target.value)}
|
||
/>
|
||
</Field>
|
||
) : (
|
||
<Field label="Метрика">
|
||
<select className={inputClass} value={metric} onChange={(e) => setMetric(e.target.value)}>
|
||
{metrics.map((m) => (
|
||
<option key={m.key} value={m.key}>
|
||
{m.label}
|
||
{m.unit ? `, ${m.unit}` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
)}
|
||
|
||
{!isNoData && (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Field label="Оператор">
|
||
<select className={inputClass} value={op} onChange={(e) => setOp(e.target.value)}>
|
||
{['>', '>=', '<', '<=', '==', '!='].map((o) => (
|
||
<option key={o} value={o}>
|
||
{o}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<Field label="Поріг">
|
||
<input
|
||
className={inputClass}
|
||
type="number"
|
||
step="any"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
/>
|
||
</Field>
|
||
<Field label="Агрегація">
|
||
<select className={inputClass} value={agg} onChange={(e) => setAgg(e.target.value)}>
|
||
<option value="">усі виміри</option>
|
||
{['avg', 'min', 'max', 'sum', 'last'].map((a) => (
|
||
<option key={a} value={a}>
|
||
{a}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
)}
|
||
|
||
<Field
|
||
label="Триматися, секунд"
|
||
hint={
|
||
agg
|
||
? 'Вікно, за яке рахується агрегат.'
|
||
: 'Умова має виконуватись усі виміри цього вікна — це й є захист від мигтіння.'
|
||
}
|
||
>
|
||
<input
|
||
className={inputClass}
|
||
type="number"
|
||
min="10"
|
||
value={forSec}
|
||
onChange={(e) => setForSec(e.target.value)}
|
||
/>
|
||
</Field>
|
||
|
||
<label className="flex items-start gap-2.5 rounded border border-slate-800 bg-slate-900 p-2.5">
|
||
<input
|
||
type="checkbox"
|
||
className="mt-0.5"
|
||
checked={topo}
|
||
onChange={(e) => setTopo(e.target.checked)}
|
||
/>
|
||
<span className="text-xs leading-snug text-slate-400">
|
||
Враховувати топологію
|
||
<span className="mt-0.5 block text-slate-600">
|
||
Пристрої за впалим маршрутизатором не піднімуть власних алертів — інакше одна
|
||
аварія дає десятки однакових сповіщень.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
<ErrorNote>{err}</ErrorNote>
|
||
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<Button onClick={onClose}>Скасувати</Button>
|
||
<Button
|
||
kind="primary"
|
||
disabled={busy || !name.trim() || (source === 'metric' && !metricKey.trim())}
|
||
onClick={() => void submit()}
|
||
>
|
||
{busy ? 'Збереження…' : 'Створити'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|