sse ssh discovery

This commit is contained in:
dv 2026-04-08 15:58:47 +03:00
parent 56b0eaa495
commit c8429814bf
6 changed files with 300 additions and 26 deletions

View file

@ -1,3 +1,5 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, distinct from sqlalchemy import select, distinct
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -9,11 +11,13 @@ from app.models.object import Object
from app.models.tag import Tag from app.models.tag import Tag
from app.models.user import User from app.models.user import User
from app.schemas.admin import SheetsSyncRequest from app.schemas.admin import SheetsSyncRequest
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult, DiscoveryStarted
from app.services import crypto from app.services import crypto
from app.services.database import AsyncSessionLocal
from app.services.device_discovery import discover_via_ssh from app.services.device_discovery import discover_via_ssh
from app.services.object_db import sync_from_object_db from app.services.object_db import sync_from_object_db
from app.services.sheets import sync_from_sheets from app.services.sheets import sync_from_sheets
from app.services.sse import sse_manager, SSEEvent
router = APIRouter(prefix="/api/v1/objects", tags=["objects"]) router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
@ -171,22 +175,44 @@ async def sync_devices(
) )
@router.post("/{obj_id}/discover", response_model=SyncResult) async def _run_discovery_background(obj_id: int, task_id: str) -> None:
"""Run SSH discovery in background, emitting SSE progress events."""
async def emit(event_type: str, data: dict) -> None:
await sse_manager.publish(SSEEvent(type=event_type, task_id=task_id, data=data))
try:
async with AsyncSessionLocal() as session:
async with session.begin():
result_row = await session.execute(
select(Object).options(selectinload(Object.tags)).where(Object.id == obj_id)
)
obj = result_row.scalar_one_or_none()
if obj is None:
await emit("disc_done", {"created": 0, "updated": 0, "skipped": 0, "errors": ["Object not found"]})
return
discovery_result = await discover_via_ssh(db=session, obj=obj, progress_cb=emit)
await emit("disc_done", {
"created": discovery_result.created,
"updated": discovery_result.updated,
"skipped": discovery_result.skipped,
"errors": discovery_result.errors,
})
except Exception as exc:
await emit("disc_done", {"created": 0, "updated": 0, "skipped": 0, "errors": [str(exc)]})
@router.post("/{obj_id}/discover", response_model=DiscoveryStarted)
async def discover_devices( async def discover_devices(
obj_id: int, obj_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
): ):
"""SSH into each rack PC, parse caps config, discover network devices.""" """SSH into each rack PC, parse caps config, discover network devices."""
obj = await _load_object(db, obj_id) await _load_object(db, obj_id)
result = await discover_via_ssh(db, obj) task_id = f"disc-{obj_id}"
await db.flush() asyncio.create_task(_run_discovery_background(obj_id, task_id))
return SyncResult( return DiscoveryStarted(task_id=task_id)
created=result.created,
updated=result.updated,
skipped=result.skipped,
errors=result.errors,
)
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult) @router.post("/{obj_id}/sync/sheets", response_model=SyncResult)

View file

@ -90,3 +90,7 @@ class SyncResult(BaseModel):
updated: int updated: int
skipped: int skipped: int
errors: list[str] = [] errors: list[str] = []
class DiscoveryStarted(BaseModel):
task_id: str

View file

@ -13,6 +13,7 @@ Limitations (devices not discoverable automatically):
import logging import logging
import re import re
from collections.abc import Callable, Awaitable
from datetime import datetime, timezone from datetime import datetime, timezone
from configparser import RawConfigParser from configparser import RawConfigParser
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -477,13 +478,21 @@ class DiscoveryResult:
actions: list[DeviceAction] = field(default_factory=list) actions: list[DeviceAction] = field(default_factory=list)
ProgressCallback = Callable[[str, dict], Awaitable[None]]
async def discover_via_ssh( async def discover_via_ssh(
db: AsyncSession, db: AsyncSession,
obj: Object, obj: Object,
config_path: str = "/etc/caps/config.ini", config_path: str = "/etc/caps/config.ini",
progress_cb: ProgressCallback | None = None,
) -> DiscoveryResult: ) -> DiscoveryResult:
result = DiscoveryResult() result = DiscoveryResult()
async def _emit(event_type: str, data: dict) -> None:
if progress_cb is not None:
await progress_cb(event_type, data)
if not obj.ssh_user or not obj.ssh_pass_enc: if not obj.ssh_user or not obj.ssh_pass_enc:
result.errors.append("SSH credentials not configured on this object (ssh_user / ssh_password)") result.errors.append("SSH credentials not configured on this object (ssh_user / ssh_password)")
return result return result
@ -585,6 +594,8 @@ async def discover_via_ssh(
result.errors.append("No racks with host IPs found in object DB") result.errors.append("No racks with host IPs found in object DB")
return result return result
await _emit("disc_server", {"rack_count": len(rack_hosts)})
# Step 2: SSH into each rack, parse config, upsert devices # Step 2: SSH into each rack, parse config, upsert devices
# neighbor_candidates: IPs seen in ARP tables of rack PCs; used for slave discovery # neighbor_candidates: IPs seen in ARP tables of rack PCs; used for slave discovery
# discovered_device_ips: all IPs added as devices during this run # discovered_device_ips: all IPs added as devices during this run
@ -684,7 +695,14 @@ async def discover_via_ssh(
if infra_devices: if infra_devices:
await db.flush() await db.flush()
for external_id, rack_host, rack_name in rack_hosts: for rack_idx, (external_id, rack_host, rack_name) in enumerate(rack_hosts):
await _emit("disc_rack_start", {
"rack_name": rack_name,
"rack_host": rack_host,
"index": rack_idx,
"total": len(rack_hosts),
})
# Resolve config_path override from the embedded device's connection_params # Resolve config_path override from the embedded device's connection_params
existing_rack_pc = (await db.execute( existing_rack_pc = (await db.execute(
select(Device).where( select(Device).where(
@ -792,6 +810,7 @@ async def discover_via_ssh(
await db.flush() await db.flush()
except Exception as exc: except Exception as exc:
result.errors.append(f"Flush error for offline rack {rack_host}: {exc}") result.errors.append(f"Flush error for offline rack {rack_host}: {exc}")
await _emit("disc_rack_done", {"rack_name": rack_name, "rack_host": rack_host, "found": 0, "ssh_ok": False})
continue # no config to parse continue # no config to parse
# Collect ARP neighbours for slave rack discovery (best-effort) # Collect ARP neighbours for slave rack discovery (best-effort)
@ -929,6 +948,7 @@ async def discover_via_ssh(
await db.flush() await db.flush()
except Exception as exc: except Exception as exc:
result.errors.append(f"Flush error after rack {rack_host}: {exc}") result.errors.append(f"Flush error after rack {rack_host}: {exc}")
await _emit("disc_rack_done", {"rack_name": rack_name, "rack_host": rack_host, "found": len(devices_found), "ssh_ok": True})
# Step 2.5: Discover slave racks via ARP neighbor tables # Step 2.5: Discover slave racks via ARP neighbor tables
# Filter out already-known IPs: rack hosts, discovered devices, infra IPs, common gateways # Filter out already-known IPs: rack hosts, discovered devices, infra IPs, common gateways

View file

@ -220,15 +220,16 @@ export const useSyncObject = (objId: number) => {
}) })
} }
export const useDiscoverObject = (objId: number) => { export interface DiscoveryStarted {
const qc = useQueryClient() task_id: string
return useMutation({
mutationFn: () =>
api.post<SyncResult>(`/api/v1/objects/${objId}/discover`).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
} }
export const useDiscoverObject = (objId: number) =>
useMutation({
mutationFn: () =>
api.post<DiscoveryStarted>(`/api/v1/objects/${objId}/discover`).then((r) => r.data),
})
export interface SheetsSyncPayload { export interface SheetsSyncPayload {
spreadsheet_id: string spreadsheet_id: string
sheet_name?: string sheet_name?: string

View file

@ -0,0 +1,210 @@
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 DiscoveryResult {
created: number
updated: number
skipped: number
errors: string[]
}
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' | 'running' | 'done'>('connecting')
const [racks, setRacks] = useState<RackStatus[]>([])
const [current, setCurrent] = useState(0)
const [total, setTotal] = useState(0)
const [result, setResult] = useState<DiscoveryResult | null>(null)
const ctrlRef = useRef<AbortController | null>(null)
useEffect(() => {
if (!open || !taskId || !accessToken) return
setPhase('connecting')
setRacks([])
setCurrent(0)
setTotal(0)
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_server') {
setTotal(data.rack_count)
setPhase('running')
} 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_done') {
setResult({
created: data.created ?? 0,
updated: data.updated ?? 0,
skipped: data.skipped ?? 0,
errors: data.errors ?? [],
})
setPhase('done')
setCurrent(data.rack_count ?? total)
ctrl.abort()
onComplete?.()
}
} catch {
// ignore parse errors
}
},
onerror() {
ctrl.abort()
},
})
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' }} />
}
return (
<Drawer
title={`SSH Discovery — ${objectName}`}
open={open}
onClose={onClose}
width={440}
footer={null}
>
{/* Connecting phase */}
{phase === 'connecting' && (
<Space style={{ marginBottom: 16 }}>
<LoadingOutlined />
<Typography.Text>Подключение к серверу...</Typography.Text>
</Space>
)}
{/* Progress bar */}
{phase !== 'connecting' && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{phase === 'done' ? 'Завершено' : `Обход стоек: ${current} / ${total}`}
</Typography.Text>
<Progress
percent={phase === 'done' ? 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: '5px 0',
gap: 8,
borderBottom: '1px solid #f0f0f0',
}}
>
{rackIcon(r.state)}
<Typography.Text style={{ flex: 1 }}>{r.rack_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.rack_host}
</Typography.Text>
{r.state === 'done' && (
<Tag color="blue" style={{ marginLeft: 4 }}>
{r.found} уст.
</Tag>
)}
{r.state === 'error' && <Tag color="red">SSH ошибка</Tag>}
</div>
))}
</div>
)}
{/* Final result */}
{phase === 'done' && result && (
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12 }}>
<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>
)
}

View file

@ -35,6 +35,7 @@ import {
} from 'antd' } from 'antd'
import type { UploadFile } from 'antd' import type { UploadFile } from 'antd'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
useCreateDevice, useCreateDevice,
@ -55,6 +56,7 @@ import {
import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks' import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks'
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones' import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -93,6 +95,7 @@ export function InventoryObjectDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const objId = Number(id) const objId = Number(id)
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: obj, isLoading: objLoading } = useObject(objId) const { data: obj, isLoading: objLoading } = useObject(objId)
const { data: devices, isLoading: devLoading } = useDevices(objId) const { data: devices, isLoading: devLoading } = useDevices(objId)
@ -118,6 +121,8 @@ export function InventoryObjectDetailPage() {
const [csvModal, setCsvModal] = useState(false) const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false) const [rackModal, setRackModal] = useState(false)
const [sheetsModal, setSheetsModal] = useState(false) const [sheetsModal, setSheetsModal] = useState(false)
const [discoveryTaskId, setDiscoveryTaskId] = useState<string | null>(null)
const [discoveryOpen, setDiscoveryOpen] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null) const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null) const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
@ -143,18 +148,18 @@ export function InventoryObjectDetailPage() {
const handleDiscover = async () => { const handleDiscover = async () => {
try { try {
const result = await discoverMutation.mutateAsync() const { task_id } = await discoverMutation.mutateAsync()
const msg = `SSH Discovery: +${result.created} найдено, ${result.updated} обновлено` setDiscoveryTaskId(task_id)
if (result.errors.length > 0) { setDiscoveryOpen(true)
message.warning(`${msg}. Ошибки: ${result.errors.join('; ')}`)
} else {
message.success(msg)
}
} catch { } catch {
message.error('Ошибка SSH discovery') message.error('Ошибка SSH discovery')
} }
} }
const handleDiscoveryComplete = () => {
queryClient.invalidateQueries({ queryKey: ['devices', objId] })
}
const handleSyncSheets = async () => { const handleSyncSheets = async () => {
try { try {
const values = await sheetsForm.validateFields() const values = await sheetsForm.validateFields()
@ -813,6 +818,14 @@ export function InventoryObjectDetailPage() {
</Form> </Form>
</Modal> </Modal>
<DiscoveryDrawer
open={discoveryOpen}
onClose={() => setDiscoveryOpen(false)}
objectName={obj?.name ?? ''}
taskId={discoveryTaskId}
onComplete={handleDiscoveryComplete}
/>
{/* Google Sheets Sync Modal */} {/* Google Sheets Sync Modal */}
<Modal <Modal
title="Синхронизация из Google Sheets" title="Синхронизация из Google Sheets"