Знайдено на живому 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>
375 lines
14 KiB
TypeScript
375 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|