utp_service/frontend/src/components/DiscoveryDrawer.tsx
2026-05-04 16:00:00 +03:00

362 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 {
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
MinusCircleOutlined,
} from '@ant-design/icons'
import { Drawer, Progress, Space, Tag, Typography } from 'antd'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { useEffect, useRef, useState } from 'react'
import { useAuthStore } from '../store/auth'
interface RackStatus {
rack_name: string
rack_host: string
state: 'pending' | 'running' | 'done' | 'error'
found: number
ssh_ok: boolean
}
interface SlaveItem {
rack_name: string
ip: string
found: number
}
interface InfraItem {
type: 'mikrotik' | 'proxmox'
ip: string
}
interface DevicePingItem {
ip: string
status: 'online' | 'offline'
ping_ms?: number | null
phase?: string
}
type PhaseState = 'idle' | 'running' | 'done'
interface Props {
open: boolean
onClose: () => void
objectName: string
taskId: string | null
onComplete?: () => void
}
export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) {
const accessToken = useAuthStore((s) => s.accessToken)
const [phase, setPhase] = useState<'connecting' | 'racks' | 'slaves' | 'infra' | 'done'>('connecting')
const [currentAction, setCurrentAction] = useState<string | null>(null)
const [racks, setRacks] = useState<RackStatus[]>([])
const [current, setCurrent] = useState(0)
const [total, setTotal] = useState(0)
const [slaves, setSlaves] = useState<SlaveItem[]>([])
const [slavesPhase, setSlavesPhase] = useState<PhaseState>('idle')
const [mikrotikPhase, setMikrotikPhase] = useState<PhaseState>('idle')
const [proxmoxPhase, setProxmoxPhase] = useState<PhaseState>('idle')
const [infraFound, setInfraFound] = useState<InfraItem[]>([])
const [devicePings, setDevicePings] = useState<DevicePingItem[]>([])
const [result, setResult] = useState<{ created: number; updated: number; skipped: number; errors: string[] } | null>(null)
const ctrlRef = useRef<AbortController | null>(null)
useEffect(() => {
if (!open || !taskId || !accessToken) return
setPhase('connecting')
setCurrentAction(null)
setRacks([])
setCurrent(0)
setTotal(0)
setSlaves([])
setSlavesPhase('idle')
setMikrotikPhase('idle')
setProxmoxPhase('idle')
setInfraFound([])
setDevicePings([])
setResult(null)
const ctrl = new AbortController()
ctrlRef.current = ctrl
fetchEventSource(`/api/v1/events?task_id=${taskId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: ctrl.signal,
onmessage(ev) {
try {
const data = JSON.parse(ev.data)
if (ev.event === 'disc_action') {
setCurrentAction(data.message)
} else if (ev.event === 'disc_server') {
setTotal(data.rack_count)
setPhase('racks')
} else if (ev.event === 'disc_rack_start') {
setTotal(data.total)
setCurrent(data.index)
setRacks((prev) => {
if (prev.find((r) => r.rack_name === data.rack_name)) {
return prev.map((r) =>
r.rack_name === data.rack_name ? { ...r, state: 'running' } : r
)
}
return [
...prev,
{ rack_name: data.rack_name, rack_host: data.rack_host, state: 'running', found: 0, ssh_ok: false },
]
})
} else if (ev.event === 'disc_rack_done') {
setCurrent((c) => c + 1)
setRacks((prev) =>
prev.map((r) =>
r.rack_name === data.rack_name
? { ...r, state: data.ssh_ok ? 'done' : 'error', found: data.found ?? 0, ssh_ok: data.ssh_ok }
: r
)
)
} else if (ev.event === 'disc_phase') {
if (data.phase === 'slaves') {
setPhase('slaves')
setSlavesPhase('running')
} else if (data.phase === 'mikrotik') {
setPhase('infra')
setSlavesPhase((s) => s === 'running' ? 'done' : s)
setMikrotikPhase('running')
} else if (data.phase === 'proxmox') {
setMikrotikPhase((s) => s === 'running' ? 'done' : s)
setProxmoxPhase('running')
}
} else if (ev.event === 'disc_slave_found') {
setSlaves((prev) => [...prev, { rack_name: data.rack_name, ip: data.ip, found: data.found }])
} else if (ev.event === 'disc_infra_found') {
setInfraFound((prev) => [...prev, { type: data.type, ip: data.ip }])
} else if (ev.event === 'disc_device_ping') {
setCurrentAction((prev) => {
if (prev) return prev
const status = data.status === 'online' ? 'online' : 'offline'
return `Проверка доступности ${data.ip}: ${status}`
})
setDevicePings((prev) => {
const item = {
ip: data.ip,
status: data.status === 'online' ? 'online' : 'offline',
ping_ms: data.ping_ms,
phase: data.phase,
} satisfies DevicePingItem
const withoutCurrent = prev.filter((p) => p.ip !== item.ip)
return [item, ...withoutCurrent].slice(0, 12)
})
} else if (ev.event === 'disc_done') {
setResult({
created: data.created ?? 0,
updated: data.updated ?? 0,
skipped: data.skipped ?? 0,
errors: data.errors ?? [],
})
setSlavesPhase((s) => s === 'running' ? 'done' : s)
setMikrotikPhase((s) => s === 'running' ? 'done' : s)
setProxmoxPhase((s) => s === 'running' ? 'done' : s)
setPhase('done')
setCurrentAction(null)
ctrl.abort()
onComplete?.()
}
} catch {
// ignore parse errors
}
},
onerror(err) {
ctrl.abort()
throw err
},
})
return () => ctrl.abort()
}, [open, taskId, accessToken])
const percent = total > 0 ? Math.round((current / total) * 100) : 0
const rackIcon = (state: RackStatus['state']) => {
if (state === 'running') return <LoadingOutlined style={{ color: '#1677ff' }} />
if (state === 'done') return <CheckCircleOutlined style={{ color: '#52c41a' }} />
if (state === 'error') return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
return <MinusCircleOutlined style={{ color: '#d9d9d9' }} />
}
const phaseIcon = (state: PhaseState) => {
if (state === 'running') return <LoadingOutlined style={{ color: '#1677ff', marginRight: 6 }} />
if (state === 'done') return <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 6 }} />
return <MinusCircleOutlined style={{ color: '#d9d9d9', marginRight: 6 }} />
}
return (
<Drawer
title={`SSH Discovery — ${objectName}`}
open={open}
onClose={onClose}
width={440}
footer={null}
>
{/* Current action */}
{(phase === 'connecting' || currentAction) && (
<div style={{ marginBottom: 12, minHeight: 20 }}>
{phase === 'connecting' && !currentAction ? (
<Space>
<LoadingOutlined />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Запуск discovery...</Typography.Text>
</Space>
) : currentAction ? (
<Space>
<LoadingOutlined style={{ fontSize: 11, color: '#8c8c8c' }} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{currentAction}</Typography.Text>
</Space>
) : null}
</div>
)}
{/* Rack progress bar */}
{(phase === 'racks' || phase === 'slaves' || phase === 'infra' || phase === 'done') && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Стойки: {Math.min(current, total)} / {total}
</Typography.Text>
<Progress
percent={phase === 'done' || current >= total ? 100 : percent}
status={phase === 'done' ? 'success' : 'active'}
style={{ marginBottom: 0 }}
/>
</div>
)}
{/* Rack list */}
{racks.length > 0 && (
<div style={{ marginBottom: 16 }}>
{racks.map((r) => (
<div
key={r.rack_name}
style={{ display: 'flex', alignItems: 'center', padding: '4px 0', gap: 8, borderBottom: '1px solid #f5f5f5' }}
>
{rackIcon(r.state)}
<Typography.Text style={{ flex: 1, fontSize: 13 }}>{r.rack_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{r.rack_host}</Typography.Text>
{r.state === 'done' && (
<Tag color="blue" style={{ marginLeft: 4, fontSize: 11 }}>{r.found} уст.</Tag>
)}
{r.state === 'error' && <Tag color="red" style={{ fontSize: 11 }}>SSH ошибка</Tag>}
</div>
))}
</div>
)}
{/* Ping status */}
{devicePings.length > 0 && (
<div style={{ marginBottom: 16, padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>
Ping
</Typography.Text>
{devicePings.map((p) => (
<div
key={p.ip}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0' }}
>
{p.status === 'online' ? (
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 11 }} />
) : (
<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 11 }} />
)}
<Typography.Text style={{ flex: 1, fontSize: 12 }}>{p.ip}</Typography.Text>
<Tag color={p.status === 'online' ? 'green' : 'red'} style={{ fontSize: 11 }}>
{p.status}
</Tag>
{p.status === 'online' && p.ping_ms != null && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{p.ping_ms} ms</Typography.Text>
)}
</div>
))}
</div>
)}
{/* Slave phase */}
{slavesPhase !== 'idle' && (
<div style={{ marginBottom: 12, padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: slaves.length > 0 ? 6 : 0 }}>
{phaseIcon(slavesPhase)}
<Typography.Text style={{ fontSize: 13 }}>
Слейв-стойки
{slaves.length > 0 && <Tag color="purple" style={{ marginLeft: 8 }}>{slaves.length} найдено</Tag>}
</Typography.Text>
</div>
{slaves.map((s) => (
<div key={s.rack_name} style={{ display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 22, paddingBottom: 4 }}>
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 11 }} />
<Typography.Text style={{ fontSize: 12 }}>{s.rack_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{s.ip}</Typography.Text>
<Tag color="blue" style={{ fontSize: 11 }}>{s.found} уст.</Tag>
</div>
))}
</div>
)}
{/* MikroTik phase */}
{mikrotikPhase !== 'idle' && (
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderTop: '1px solid #f0f0f0' }}>
{phaseIcon(mikrotikPhase)}
<Typography.Text style={{ fontSize: 13, flex: 1 }}>MikroTik (SSH_CLIENT)</Typography.Text>
{infraFound.filter((i) => i.type === 'mikrotik').map((i) => (
<Tag key={i.ip} color="orange" style={{ fontSize: 11 }}>{i.ip}</Tag>
))}
{mikrotikPhase === 'done' && infraFound.filter((i) => i.type === 'mikrotik').length === 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>не найден</Typography.Text>
)}
</div>
)}
{/* Proxmox phase */}
{proxmoxPhase !== 'idle' && (
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderTop: '1px solid #f0f0f0' }}>
{phaseIcon(proxmoxPhase)}
<Typography.Text style={{ fontSize: 13, flex: 1 }}>Proxmox (порт 8006)</Typography.Text>
{infraFound.filter((i) => i.type === 'proxmox').map((i) => (
<Tag key={i.ip} color="cyan" style={{ fontSize: 11 }}>{i.ip}</Tag>
))}
{proxmoxPhase === 'done' && infraFound.filter((i) => i.type === 'proxmox').length === 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>не найдено</Typography.Text>
)}
</div>
)}
{/* Final result */}
{phase === 'done' && result && (
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 8 }}>
<Space wrap>
<Tag color="green">+{result.created} добавлено</Tag>
<Tag color="blue">{result.updated} обновлено</Tag>
<Tag>{result.skipped} без изменений</Tag>
</Space>
{result.errors.length > 0 && (
<div style={{ marginTop: 10 }}>
{result.errors.map((e, i) => (
<Typography.Text
key={i}
type="danger"
style={{ display: 'block', fontSize: 12, marginBottom: 4 }}
>
{e}
</Typography.Text>
))}
</div>
)}
</div>
)}
</Drawer>
)
}