From b917ad64320a4532bfb8dd01903236db4e1dc4d4 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 16:18:34 +0300 Subject: [PATCH] sse ssh discovery drawer upgrade --- backend/alembic/versions/0010_rack_type.py | 22 +++ backend/app/models/rack.py | 1 + backend/app/schemas/device.py | 2 + backend/app/schemas/rack.py | 3 + backend/app/services/device_discovery.py | 18 ++ frontend/src/api/objects.ts | 1 + frontend/src/api/racks.ts | 3 +- frontend/src/components/DiscoveryDrawer.tsx | 169 +++++++++++++++---- frontend/src/pages/InventoryObjectDetail.tsx | 25 ++- 9 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 backend/alembic/versions/0010_rack_type.py diff --git a/backend/alembic/versions/0010_rack_type.py b/backend/alembic/versions/0010_rack_type.py new file mode 100644 index 0000000..641caf2 --- /dev/null +++ b/backend/alembic/versions/0010_rack_type.py @@ -0,0 +1,22 @@ +"""add rack_type to racks + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-04-08 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("racks", sa.Column("rack_type", sa.String(50), nullable=True)) + + +def downgrade() -> None: + op.drop_column("racks", "rack_type") diff --git a/backend/app/models/rack.py b/backend/app/models/rack.py index d5f3971..59978e6 100644 --- a/backend/app/models/rack.py +++ b/backend/app/models/rack.py @@ -17,6 +17,7 @@ class Rack(Base, TimestampMixin): index=True, ) name: Mapped[str] = mapped_column(String(100), nullable=False) + rack_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index cda854d..b7ef0f7 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -71,6 +71,7 @@ class DeviceRead(BaseModel): external_id: Optional[str] = None rack_id: Optional[int] = None rack_name: Optional[str] = None + rack_type: Optional[str] = None ssh_user_override: Optional[str] = None ssh_port_override: Optional[int] = None device_meta: dict[str, Any] = {} @@ -91,6 +92,7 @@ class DeviceRead(BaseModel): obj = cls.model_validate(device) if device.rack is not None: obj.rack_name = device.rack.name + obj.rack_type = device.rack.rack_type return obj diff --git a/backend/app/schemas/rack.py b/backend/app/schemas/rack.py index 81c9548..0ab5927 100644 --- a/backend/app/schemas/rack.py +++ b/backend/app/schemas/rack.py @@ -6,6 +6,7 @@ from pydantic import BaseModel class RackCreate(BaseModel): name: str + rack_type: Optional[str] = None description: Optional[str] = None location: Optional[str] = None zone_id: Optional[int] = None @@ -13,6 +14,7 @@ class RackCreate(BaseModel): class RackUpdate(BaseModel): name: Optional[str] = None + rack_type: Optional[str] = None description: Optional[str] = None location: Optional[str] = None zone_id: Optional[int] = None @@ -22,6 +24,7 @@ class RackRead(BaseModel): id: int object_id: int name: str + rack_type: Optional[str] = None description: Optional[str] = None location: Optional[str] = None zone_id: Optional[int] = None diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index e2a3a37..a4fc6e7 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -525,6 +525,7 @@ async def discover_via_ssh( return result elif obj.server_ip: # Autodetect: SSH into server, read its caps.conf to get DB URI + await _emit("disc_action", {"message": f"Чтение конфигурации сервера {obj.server_ip}..."}) detected = await _autodetect_db_from_server( server_ip=obj.server_ip, ssh_port=ssh_port, @@ -572,6 +573,7 @@ async def discover_via_ssh( return result # Step 1: get rack hosts from object's DB + await _emit("disc_action", {"message": "Получение списка стоек из БД..."}) try: rack_hosts = await _get_rack_hosts_direct( host=db_host, @@ -720,6 +722,7 @@ async def discover_via_ssh( rack_hostname = external_id rack_ssh_ok = False config_text = None + await _emit("disc_action", {"message": f"SSH → {rack_host} ({rack_name})"}) try: config_text = await _read_remote_config( host=rack_host, @@ -820,6 +823,7 @@ async def discover_via_ssh( # Get all rack IPs to exclude them from device discovery all_rack_ips = [host for _, host, _ in rack_hosts] + await _emit("disc_action", {"message": f"Парсинг конфигурации {rack_name}..."}) devices_found = _parse_config(config_text, rack_host, all_rack_ips) logger.info(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}") @@ -962,10 +966,15 @@ async def discover_via_ssh( - infra_ips - common_gateways ) + await _emit("disc_phase", {"phase": "slaves", "candidate_count": len(slave_candidates)}) if slave_candidates: logger.info(f"[ssh_discovery] Step 2.5: Slave discovery — {len(slave_candidates)} ARP candidate(s): {slave_candidates}") + await _emit("disc_action", {"message": f"Поиск слейв-стоек: проверяем {len(slave_candidates)} ARP-кандидатов..."}) + else: + await _emit("disc_action", {"message": "Слейв-стойки: ARP-кандидатов не найдено"}) for candidate_ip in slave_candidates: logger.info(f"[ssh_discovery] Checking slave candidate {candidate_ip}...") + await _emit("disc_action", {"message": f"Проверяем слейв-кандидата {candidate_ip}..."}) # Check each rack host that could be the master — slave's linking.uri must match is_slave = False slave_identifier = "" @@ -997,6 +1006,7 @@ async def discover_via_ssh( continue logger.info(f"[ssh_discovery] {candidate_ip} — SLAVE rack confirmed (name={slave_identifier}, master={master_ip_for_slave})") + await _emit("disc_action", {"message": f"Слейв подтверждён: {slave_identifier} ({candidate_ip})"}) # Create Rack entry for the slave in our DB slave_rack_id: int | None = None @@ -1063,6 +1073,7 @@ async def discover_via_ssh( all_rack_ips_with_slave = list(all_rack_ips_set) + [candidate_ip] slave_devices = _parse_config(slave_config_text, candidate_ip, all_rack_ips_with_slave) logger.info(f"[ssh_discovery] Slave {candidate_ip}: found {len(slave_devices)} plugin devices") + await _emit("disc_slave_found", {"rack_name": slave_identifier, "ip": candidate_ip, "found": len(slave_devices)}) for found in slave_devices: is_shared = found.category == "vm" if is_shared: @@ -1105,6 +1116,8 @@ async def discover_via_ssh( result.errors.append(f"Flush error after slave {candidate_ip}: {exc}") # Step 3: Discover MikroTik via SSH_CLIENT + await _emit("disc_phase", {"phase": "mikrotik"}) + await _emit("disc_action", {"message": "Поиск MikroTik (SSH_CLIENT)..."}) logger.info(f"[ssh_discovery] Step 3: Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}") mikrotik_ip = await _discover_mikrotik_via_ssh_client( host=obj.server_ip or rack_hosts[0][1], @@ -1114,6 +1127,7 @@ async def discover_via_ssh( ) if mikrotik_ip: logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}") + await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip}) try: existing = (await db.execute( select(Device).where( @@ -1169,6 +1183,8 @@ async def discover_via_ssh( logger.info(f"[ssh_discovery] MikroTik: No IP found via SSH_CLIENT (reason: SSH_CLIENT env var not available or parsing failed)") # Step 4: Discover Proxmox via port 8006 scan + await _emit("disc_phase", {"phase": "proxmox"}) + await _emit("disc_action", {"message": "Сканирование порта 8006 (Proxmox)..."}) logger.info(f"[ssh_discovery] Step 4: Attempting to discover Proxmox servers via port 8006 scan on {obj.server_ip or 'main_server'}") proxmox_ips = await _discover_proxmox_via_port_scan( host=obj.server_ip or rack_hosts[0][1], @@ -1178,6 +1194,8 @@ async def discover_via_ssh( ) if proxmox_ips: logger.info(f"[ssh_discovery] Proxmox discovery found {len(proxmox_ips)} server(s) on port 8006: {proxmox_ips}") + for proxmox_ip in proxmox_ips: + await _emit("disc_infra_found", {"type": "proxmox", "ip": proxmox_ip}) for idx, proxmox_ip in enumerate(proxmox_ips, 1): hostname = f"proxmox_{idx}" try: diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index bc96f67..8d36fb5 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -32,6 +32,7 @@ export interface DeviceItem { source: string rack_id: number | null rack_name: string | null + rack_type: string | null ssh_user_override: string | null ssh_port_override: number | null status: string diff --git a/frontend/src/api/racks.ts b/frontend/src/api/racks.ts index 9538ba7..8c058ba 100644 --- a/frontend/src/api/racks.ts +++ b/frontend/src/api/racks.ts @@ -5,6 +5,7 @@ export interface RackItem { id: number object_id: number name: string + rack_type: string | null description: string | null location: string | null zone_id: number | null @@ -22,7 +23,7 @@ export const useRacks = (objId: number) => export const useCreateRack = (objId: number) => { const qc = useQueryClient() return useMutation({ - mutationFn: (body: { name: string; description?: string; location?: string; zone_id?: number | null }) => + mutationFn: (body: { name: string; rack_type?: string; description?: string; location?: string; zone_id?: number | null }) => api.post(`/api/v1/objects/${objId}/racks`, body).then((r) => r.data), onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }), }) diff --git a/frontend/src/components/DiscoveryDrawer.tsx b/frontend/src/components/DiscoveryDrawer.tsx index 5e2d722..847c5e6 100644 --- a/frontend/src/components/DiscoveryDrawer.tsx +++ b/frontend/src/components/DiscoveryDrawer.tsx @@ -17,13 +17,19 @@ interface RackStatus { ssh_ok: boolean } -interface DiscoveryResult { - created: number - updated: number - skipped: number - errors: string[] +interface SlaveItem { + rack_name: string + ip: string + found: number } +interface InfraItem { + type: 'mikrotik' | 'proxmox' + ip: string +} + +type PhaseState = 'idle' | 'running' | 'done' + interface Props { open: boolean onClose: () => void @@ -34,20 +40,32 @@ interface Props { export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) { const accessToken = useAuthStore((s) => s.accessToken) - const [phase, setPhase] = useState<'connecting' | 'running' | 'done'>('connecting') + const [phase, setPhase] = useState<'connecting' | 'racks' | 'slaves' | 'infra' | 'done'>('connecting') + const [currentAction, setCurrentAction] = useState(null) const [racks, setRacks] = useState([]) const [current, setCurrent] = useState(0) const [total, setTotal] = useState(0) - const [result, setResult] = useState(null) + const [slaves, setSlaves] = useState([]) + const [slavesPhase, setSlavesPhase] = useState('idle') + const [mikrotikPhase, setMikrotikPhase] = useState('idle') + const [proxmoxPhase, setProxmoxPhase] = useState('idle') + const [infraFound, setInfraFound] = useState([]) + const [result, setResult] = useState<{ created: number; updated: number; skipped: number; errors: string[] } | null>(null) const ctrlRef = useRef(null) useEffect(() => { if (!open || !taskId || !accessToken) return setPhase('connecting') + setCurrentAction(null) setRacks([]) setCurrent(0) setTotal(0) + setSlaves([]) + setSlavesPhase('idle') + setMikrotikPhase('idle') + setProxmoxPhase('idle') + setInfraFound([]) setResult(null) const ctrl = new AbortController() @@ -60,9 +78,12 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete try { const data = JSON.parse(ev.data) - if (ev.event === 'disc_server') { + if (ev.event === 'disc_action') { + setCurrentAction(data.message) + + } else if (ev.event === 'disc_server') { setTotal(data.rack_count) - setPhase('running') + setPhase('racks') } else if (ev.event === 'disc_rack_start') { setTotal(data.total) @@ -89,6 +110,25 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete ) ) + } 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_done') { setResult({ created: data.created ?? 0, @@ -96,8 +136,11 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete 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') - setCurrent(data.rack_count ?? total) + setCurrentAction(null) ctrl.abort() onComplete?.() } @@ -122,6 +165,12 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete return } + const phaseIcon = (state: PhaseState) => { + if (state === 'running') return + if (state === 'done') return + return + } + return ( - {/* Connecting phase */} - {phase === 'connecting' && ( - - - Подключение к серверу... - + {/* Current action */} + {(phase === 'connecting' || currentAction) && ( +
+ {phase === 'connecting' && !currentAction ? ( + + + Запуск discovery... + + ) : currentAction ? ( + + + {currentAction} + + ) : null} +
)} - {/* Progress bar */} - {phase !== 'connecting' && ( + {/* Rack progress bar */} + {(phase === 'racks' || phase === 'slaves' || phase === 'infra' || phase === 'done') && (
- {phase === 'done' ? 'Завершено' : `Обход стоек: ${current} / ${total}`} + Стойки: {Math.min(current, total)} / {total} = total ? 100 : percent} status={phase === 'done' ? 'success' : 'active'} style={{ marginBottom: 0 }} /> @@ -158,33 +216,72 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete {racks.map((r) => (
{rackIcon(r.state)} - {r.rack_name} - - {r.rack_host} - + {r.rack_name} + {r.rack_host} {r.state === 'done' && ( - - {r.found} уст. - + {r.found} уст. )} - {r.state === 'error' && SSH ошибка} + {r.state === 'error' && SSH ошибка}
))}
)} + {/* Slave phase */} + {slavesPhase !== 'idle' && ( +
+
0 ? 6 : 0 }}> + {phaseIcon(slavesPhase)} + + Слейв-стойки + {slaves.length > 0 && {slaves.length} найдено} + +
+ {slaves.map((s) => ( +
+ + {s.rack_name} + {s.ip} + {s.found} уст. +
+ ))} +
+ )} + + {/* MikroTik phase */} + {mikrotikPhase !== 'idle' && ( +
+ {phaseIcon(mikrotikPhase)} + MikroTik (SSH_CLIENT) + {infraFound.filter((i) => i.type === 'mikrotik').map((i) => ( + {i.ip} + ))} + {mikrotikPhase === 'done' && infraFound.filter((i) => i.type === 'mikrotik').length === 0 && ( + не найден + )} +
+ )} + + {/* Proxmox phase */} + {proxmoxPhase !== 'idle' && ( +
+ {phaseIcon(proxmoxPhase)} + Proxmox (порт 8006) + {infraFound.filter((i) => i.type === 'proxmox').map((i) => ( + {i.ip} + ))} + {proxmoxPhase === 'done' && infraFound.filter((i) => i.type === 'proxmox').length === 0 && ( + не найдено + )} +
+ )} + {/* Final result */} {phase === 'done' && result && ( -
+
+{result.created} добавлено {result.updated} обновлено diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx index acacdd8..0fb5a26 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -61,6 +61,9 @@ import { stripEmpty } from '../utils/form' import dayjs from 'dayjs' const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router']) + +const rackLabel = (name: string, type: string | null | undefined): string => + type ? `${name} ${type}` : name const DEVICE_COL_SPAN = 8 type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number } @@ -350,7 +353,10 @@ export function InventoryObjectDetailPage() { if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers) const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b)) - for (const [rackName, items] of sortedRacks) addGroup(rackName, `rack:${rackName}`, items) + for (const [rackName, items] of sortedRacks) { + const label = rackLabel(rackName, items[0]?.rack_type) + addGroup(label, `rack:${rackName}`, items) + } if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack) @@ -396,7 +402,7 @@ export function InventoryObjectDetailPage() { render: (_: unknown, row: TableRow) => { if (row._type === 'group') return null const d = row as DeviceRow - if (d.rack_name) return {d.rack_name} + if (d.rack_name) return {rackLabel(d.rack_name, d.rack_type)} const loc = (d as any).location if (loc === 'server_room') return Серверная if (loc === 'street') return Улица @@ -495,7 +501,13 @@ export function InventoryObjectDetailPage() { ] const rackColumns = [ - { title: 'Название', dataIndex: 'name', key: 'name' }, + { + title: 'Название', + key: 'name', + render: (_: unknown, row: RackItem) => ( + {row.name}{row.rack_type ? {row.rack_type} : null} + ), + }, { title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? {v} : '—' }, { title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' }, { @@ -653,10 +665,13 @@ export function InventoryObjectDetailPage() { Стойки
- + + + + - ({ value: z.id, label: z.name }))} />