Netpulse_SasS/web/src/components/DeviceForm.tsx
byrsapty fe4ed4e7d9
Some checks failed
CI / web (push) Has been cancelled
CI / server (push) Has been cancelled
CI / agent (push) Has been cancelled
Дедлайн сокета від входу через telnet обривав збір конфігу
Знайдено на живому ZTE C320. Вхід чекає «Username:» з дедлайном читання
у дві секунди, а дедлайн сокета липкий: він лишався на всі наступні
читання.

Наслідок подвійний. Довгий конфіг обривався на середині помилкою
«read tcp: i/o timeout» — мережевою там, де мережа ні до чого. А коли
ще й запрошення не збігалось зі зразком, той самий дедлайн спрацьовував
раніше за власний таймер, і замість «не дочекались запрошення» людина
бачила ту саму мережеву помилку.

Щоб це побачити, довелося спершу полагодити діагностику: стенограма
починалась після входу, тобто після того місця, де все зупинялось.
Тепер розмова входу пишеться теж — без пароля, лише те, що надіслав
пристрій.

Плюс вбудований профіль zte-zxan: наявні профілі ZTE чекають «>» або
«]», а OLT показує «ZXAN#». Окремий профіль, бо в Comware трапляються
рядки з самої решітки, і зразок із «#» обірвав би їхній конфіг.

Результат на живому C320: 26 555 рядків.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:04:36 +03:00

375 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 />
) : (
<div className="space-y-2 py-6 text-center text-xs leading-relaxed text-slate-500">
<p>
Розклад збору конфігів кріпиться до хоста, тож налаштовується після його
створення.
</p>
<p className="text-slate-600">
Натисніть «Зберегти» картка відкриється знову, і ця вкладка запрацює. Щоб профіль
підібрався сам, заповніть «Виробник» на вкладці «Хост».
</p>
</div>
))}
{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>
)
}