362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|