Netpulse_SasS/web/src/components/DeviceForm.tsx
byrsapty 2fe562936d
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Тригери описуються в шаблоні; форма шаблону — на вкладках
Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не
окремий хост. Тепер воно живе поруч із перевірками, які дають йому
дані, а не окремою сторінкою, де його доводилось повторювати руками
для кожного комутатора.

Тригер розгортається в ОДНЕ правило alr.rules із селектором за
шаблоном, а не в правило на кожен хост: призначили шаблон новому
пристрою — він одразу під правилом, без перегенерації.

Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери
з лічильниками; смуга вкладок винесена у спільний компонент. Умова
тригера редагується полями, JSON лишився запасним виходом.

Правила з шаблону в списку правил помічені й не редагуються там.
Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:45:49 +03:00

369 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 { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Button, Confirm, ErrorNote, Field, Modal, Tabs, Toggle, inputClass } from './ui'
import type { ConfirmRequest } from './ui'
import { Picker } from './Picker'
import { TemplatePicker } from './TemplatePicker'
import { ChecksEditor } from './ChecksEditor'
import { BackupPolicyForm } from './BackupPolicyForm'
import type { CheckInput, DeviceInput } from '../api/client'
import type {
AgentSummary,
CheckType,
Credential,
DeviceGroup,
DeviceSummary,
Template,
} from '../types'
export const kindLabel: Record<string, string> = {
router: 'маршрутизатор',
switch: 'комутатор',
firewall: 'фаєрвол',
server: 'сервер',
ap: 'точка доступу',
olt: 'OLT',
onu: 'ONU',
ups: 'ДБЖ',
printer: 'принтер',
camera: 'камера',
sensor: 'датчик',
vm: 'віртуальна машина',
container: 'контейнер',
other: 'інше',
}
type Tab = 'main' | 'templates' | 'access' | 'ncm' | 'checks'
/**
* Картка хоста.
*
* Вкладками, а не однією довгою простинею: у формі шість різних тем, і
* половину з них відкривають раз на життя хоста. Порядок такий самий,
* як частота звернень — назва й групи щодня, ручні перевірки майже
* ніколи.
*
* Інтервалів опитування тут навмисно немає: вони живуть у шаблонах.
* Два джерела правди про одне число рано чи пізно розійдуться, і
* розійдуться саме тоді, коли треба буде швидко зрозуміти, чому хост
* опитується не так, як написано.
*/
export function DeviceForm({
device,
groups,
agents,
checkTypes,
credentials,
onClose,
onSave,
onDelete,
}: {
device?: DeviceSummary
groups: DeviceGroup[]
agents: AgentSummary[]
checkTypes: CheckType[]
credentials: Credential[]
onClose: () => void
onSave: (in_: DeviceInput) => Promise<void>
onDelete?: () => Promise<void>
}) {
const [tab, setTab] = useState<Tab>('main')
const [name, setName] = useState(device?.name ?? '')
const [address, setAddress] = useState(device?.address ?? '')
const [kind, setKind] = useState(device?.kind ?? 'other')
const [vendor, setVendor] = useState(device?.vendor ?? '')
// Поточний зонд, а не порожньо: сервер трактує порожнє значення
// буквально, і форма з порожнім полем відвʼязувала хост від зонда
// при кожному збереженні.
const [agentID, setAgentID] = useState(device?.agent_id ?? '')
const [enabled, setEnabled] = useState(device?.enabled ?? true)
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
const [credIDs, setCredIDs] = useState<string[]>([])
const [credsLoaded, setCredsLoaded] = useState(!device)
const [templates, setTemplates] = useState<Template[]>([])
const [templateIDs, setTemplateIDs] = useState<string[]>([])
const [templatesLoaded, setTemplatesLoaded] = useState(false)
const [checks, setChecks] = useState<CheckInput[]>([])
const [checksLoaded, setChecksLoaded] = useState(!device)
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
if (!device) return
let alive = true
api
.listDeviceChecks(device.id)
.then((cs) => {
if (!alive) return
setChecks(
cs.map((c) => ({
id: c.id,
check_type: c.check_type,
params: c.params,
interval_sec: c.interval_sec,
timeout_ms: c.timeout_ms,
retries: c.retries,
enabled: c.enabled,
})),
)
setChecksLoaded(true)
})
.catch(() => setChecksLoaded(true))
return () => {
alive = false
}
}, [device])
useEffect(() => {
if (!device) return
let alive = true
api
.getDeviceCredentials(device.id)
.then((ids) => {
if (!alive) return
setCredIDs(ids)
setCredsLoaded(true)
})
.catch(() => setCredsLoaded(true))
return () => {
alive = false
}
}, [device])
useEffect(() => {
let alive = true
Promise.all([
api.listTemplates().catch(() => [] as Template[]),
device ? api.getDeviceTemplates(device.id).catch(() => [] as string[]) : Promise.resolve([]),
]).then(([list, ids]) => {
if (!alive) return
setTemplates(list)
// Новий хост починає з доступності: пінг — єдина перевірка, яка
// працює будь-де без налаштування, а хост без жодної не
// опитується взагалі.
const icmp = list.find((t) => t.key === 'icmp-basic')
setTemplateIDs(device ? ids : icmp ? [icmp.id] : [])
setTemplatesLoaded(true)
})
return () => {
alive = false
}
}, [device])
const tabs: { key: Tab; label: string; badge?: number }[] = [
{ key: 'main', label: 'Хост' },
{ key: 'templates', label: 'Шаблони', badge: templateIDs.length },
{ key: 'access', label: 'Доступи', badge: credIDs.length },
{ key: 'ncm', label: 'Збір конфігів' },
{ key: 'checks', label: 'Ручні перевірки', badge: checks.length },
]
return (
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose} wide>
<div className="space-y-3">
<Tabs tabs={tabs} active={tab} onChange={setTab} />
{tab === 'main' && (
<>
<Field label="Назва">
<input
className={inputClass}
autoFocus={!device}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="IP-адреса" hint="За нею опитує зонд">
<input
className={inputClass}
placeholder="10.0.0.1"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</Field>
<Field label="Тип">
<select
className={inputClass}
value={kind}
onChange={(e) => setKind(e.target.value)}
>
{Object.entries(kindLabel).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Виробник" hint="За ним підбирається профіль збору конфігу">
<input
className={inputClass}
value={vendor}
onChange={(e) => setVendor(e.target.value)}
/>
</Field>
<Field label="Зонд" hint="Хто опитує цей хост">
<select
className={inputClass}
value={agentID}
onChange={(e) => setAgentID(e.target.value)}
>
<option value=""> без зонда </option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
</div>
<Field label="Групи хостів" hint="Задають межі доступу й фільтри">
<Picker
items={groups.map((g) => ({ id: g.id, label: g.name }))}
value={groupIDs}
onChange={setGroupIDs}
empty="Груп ще немає — заведіть їх на сторінці «Групи»"
/>
</Field>
<label className="flex items-center gap-2 text-sm text-slate-300">
<Toggle checked={enabled} onChange={setEnabled} />
Опитувати цей хост
</label>
</>
)}
{tab === 'templates' && (
<>
<p className="text-[11px] leading-relaxed text-slate-600">
Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як
опитувати хост окремо міняти інтервали тут не треба.
</p>
<TemplatePicker
templates={templates}
value={templateIDs}
onChange={setTemplateIDs}
/>
{templateIDs.length === 0 && checks.length === 0 && (
<p className="rounded border border-amber-900/60 bg-amber-950/20 p-2.5 text-[11px] leading-relaxed text-amber-200">
Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший
варіант «Доступність (ICMP)».
</p>
)}
</>
)}
{tab === 'access' && (
<>
<p className="text-[11px] leading-relaxed text-slate-600">
SNMP-community й паролі SSH заводяться на сторінці «Доступи». Тут лише вибір, які з
них застосовувати до цього хоста.
</p>
<Field label="Доступи до обладнання">
<Picker
items={credentials.map((c) => ({ id: c.id, label: c.name, hint: c.proto }))}
value={credIDs}
onChange={setCredIDs}
empty="Доступів ще немає"
/>
</Field>
</>
)}
{tab === 'ncm' &&
(device ? (
<BackupPolicyForm device={device} credentials={credentials} inline />
) : (
<p className="py-6 text-center text-xs leading-relaxed text-slate-600">
Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам
хост, до якого вона кріпиться.
</p>
))}
{tab === 'checks' && (
<>
<p className="text-[11px] leading-relaxed text-slate-600">
Одиничні перевірки, яких немає в жодному шаблоні. Якщо те саме потрібно кільком
хостам краще шаблон: виправлений там OID доїжджає до всіх сам.
</p>
{checksLoaded ? (
<ChecksEditor types={checkTypes} checks={checks} onChange={setChecks} />
) : (
<p className="text-xs text-slate-600">Завантаження перевірок</p>
)}
</>
)}
<ErrorNote>{err}</ErrorNote>
<Confirm request={confirm} onClose={() => setConfirm(null)} />
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-800 pt-3">
{onDelete && (
<Button
kind="danger"
disabled={busy}
className="mr-auto"
onClick={() =>
setConfirm({
title: 'Видалити хост',
message: (
<>
Хост <b>{device?.name ?? name}</b> зникне зі списків і мап.
</>
),
detail:
'Опитування припиниться, активні алерти по ньому закриються. Зібрані метрики та конфіги збережуться — хост позначається видаленим, а не витирається.',
onConfirm: onDelete,
})
}
>
Видалити
</Button>
)}
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !name.trim()}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await onSave({
name: name.trim(),
address: address.trim(),
kind,
vendor: vendor.trim(),
agent_id: agentID,
enabled,
group_ids: groupIDs,
// Шлемо лише те, що встигли побачити: інакше швидке
// збереження стерло б налаштування, які форма ще не
// завантажила.
...(checksLoaded ? { checks } : {}),
...(credsLoaded ? { credential_ids: credIDs } : {}),
...(templatesLoaded ? { template_ids: templateIDs } : {}),
})
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
</Modal>
)
}