Netpulse_SasS/web/src/pages/RulesPage.tsx
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- 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>
2026-08-24 02:00:04 +03:00

410 lines
14 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, 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>
)
}