щас все сломаю

This commit is contained in:
dv 2026-04-29 17:29:10 +03:00
parent e4b993dbf3
commit 25a1e52a6b
5 changed files with 246 additions and 27 deletions

View file

@ -21,7 +21,7 @@ class PingAction(BaseAction):
proc = None proc = None
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "2", device.ip, "ping", "-c", "4", "-W", "2", device.ip,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )

View file

@ -19,6 +19,7 @@ async def autodetect_db_from_server(
ssh_port: int, ssh_port: int,
ssh_options: list, ssh_options: list,
server_config_path: str = "/etc/caps/config.ini", server_config_path: str = "/etc/caps/config.ini",
progress_cb=None,
) -> dict | None: ) -> dict | None:
"""SSH into server, read its caps config, extract DB connection params. """SSH into server, read its caps config, extract DB connection params.
@ -28,7 +29,7 @@ async def autodetect_db_from_server(
from configparser import RawConfigParser from configparser import RawConfigParser
config_text = await _read_server_config_with_retry( config_text = await _read_server_config_with_retry(
server_ip, ssh_port, ssh_options, server_config_path server_ip, ssh_port, ssh_options, server_config_path, progress_cb=progress_cb
) )
parser = RawConfigParser(strict=False) parser = RawConfigParser(strict=False)
@ -55,13 +56,15 @@ async def _read_server_config_with_retry(
ssh_options: list, ssh_options: list,
server_config_path: str, server_config_path: str,
attempts: int = 2, attempts: int = 2,
progress_cb=None,
) -> str: ) -> str:
"""Read server config, retrying once for transient command timeouts.""" """Read server config, retrying once for transient command timeouts."""
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
try: try:
return await ssh_run_multi( return await ssh_run_multi(
server_ip, ssh_port, ssh_options, f"cat {server_config_path}", timeout=30 server_ip, ssh_port, ssh_options, f"cat {server_config_path}", timeout=30,
progress_cb=progress_cb,
) )
except TimeoutError as exc: except TimeoutError as exc:
last_exc = exc last_exc = exc
@ -88,13 +91,16 @@ async def get_rack_hosts(
tunnel_host: str | None = None, tunnel_host: str | None = None,
tunnel_ssh_port: int = 22, tunnel_ssh_port: int = 22,
ssh_options: list | None = None, ssh_options: list | None = None,
progress_cb=None,
) -> list[tuple[str, str, str, str | None]]: ) -> list[tuple[str, str, str, str | None]]:
"""Return list of (external_id, host_ip, rack_name, rack_type) from the racks table. """Return list of (external_id, host_ip, rack_name, rack_type) from the racks table.
Supports an optional SSH tunnel when the DB is not directly reachable. Supports an optional SSH tunnel when the DB is not directly reachable.
""" """
if via_tunnel and tunnel_host and ssh_options: if via_tunnel and tunnel_host and ssh_options:
conn = await open_ssh_tunnel(tunnel_host, tunnel_ssh_port, ssh_options, host, port) conn = await open_ssh_tunnel(
tunnel_host, tunnel_ssh_port, ssh_options, host, port, progress_cb=progress_cb
)
async with conn: async with conn:
listener = await conn.forward_local_port("127.0.0.1", 0, host, port) listener = await conn.forward_local_port("127.0.0.1", 0, host, port)
local_port = listener.get_port() local_port = listener.get_port()

View file

@ -33,6 +33,7 @@ from .ssh_client import (
get_ip_neighbors_multi, get_ip_neighbors_multi,
ssh_run_multi, ssh_run_multi,
verify_is_mikrotik, verify_is_mikrotik,
emit_ping_status,
) )
from .types import DeviceAction, DiscoveredDevice, DiscoveryResult, ProgressCallback from .types import DeviceAction, DiscoveredDevice, DiscoveryResult, ProgressCallback
@ -92,6 +93,7 @@ async def discover_via_ssh(
via_tunnel=use_tunnel, via_tunnel=use_tunnel,
tunnel_host=obj.server_ip, tunnel_ssh_port=ssh_port, tunnel_host=obj.server_ip, tunnel_ssh_port=ssh_port,
ssh_options=ssh_options, ssh_options=ssh_options,
progress_cb=_emit,
) )
if db_device_obj is not None: if db_device_obj is not None:
db_device_obj.status = "online" db_device_obj.status = "online"
@ -153,7 +155,9 @@ async def discover_via_ssh(
) )
if rack_ssh_ok: if rack_ssh_ok:
neigh_ips = await get_ip_neighbors_multi(rack_host, rack_ssh_port or embedded_ssh_ports[0], ssh_options) neigh_ips = await get_ip_neighbors_multi(
rack_host, rack_ssh_port or embedded_ssh_ports[0], ssh_options, progress_cb=_emit
)
neighbor_candidates.update(neigh_ips) neighbor_candidates.update(neigh_ips)
discovered_device_ips.add(rack_host) discovered_device_ips.add(rack_host)
@ -163,8 +167,14 @@ async def discover_via_ssh(
_rack_found = len(devices_found) _rack_found = len(devices_found)
for found in devices_found: for found in devices_found:
ping_online, ping_ms = await emit_ping_status(
found.ip, _emit, phase="discovered_device"
)
await _upsert_plugin_device( await _upsert_plugin_device(
db, obj, found, external_id, rack_id_mapping, result db, obj, found, external_id, rack_id_mapping, result,
ping_online=ping_online,
ping_ms=ping_ms if ping_online else None,
checked_at=now,
) )
discovered_device_ips.add(found.ip) discovered_device_ips.add(found.ip)
@ -270,6 +280,7 @@ async def _resolve_db_connection(
try: try:
detected = await autodetect_db_from_server( detected = await autodetect_db_from_server(
server_ip=obj.server_ip, ssh_port=ssh_port, ssh_options=ssh_options, server_ip=obj.server_ip, ssh_port=ssh_port, ssh_options=ssh_options,
progress_cb=_emit,
) )
except Exception as exc: except Exception as exc:
result.errors.append( result.errors.append(
@ -301,7 +312,8 @@ async def _resolve_db_connection(
async def _emit_db_autodetect_error(obj, ssh_port, ssh_options, result: DiscoveryResult) -> None: async def _emit_db_autodetect_error(obj, ssh_port, ssh_options, result: DiscoveryResult) -> None:
try: try:
raw = await ssh_run_multi( raw = await ssh_run_multi(
obj.server_ip, ssh_port, ssh_options, "cat /etc/caps/config.ini", timeout=30 obj.server_ip, ssh_port, ssh_options, "cat /etc/caps/config.ini", timeout=30,
progress_cb=None,
) )
diag_parser = RawConfigParser(strict=False) diag_parser = RawConfigParser(strict=False)
diag_parser.read_string(raw) diag_parser.read_string(raw)
@ -502,7 +514,8 @@ async def _ssh_fetch_rack_config(
for attempt in range(1, 3): for attempt in range(1, 3):
try: try:
config_text = await ssh_run_multi( config_text = await ssh_run_multi(
rack_host, ssh_port, ssh_options, f"cat {config_path}", timeout=30 rack_host, ssh_port, ssh_options, f"cat {config_path}", timeout=30,
progress_cb=_emit,
) )
return True, config_text, ssh_port return True, config_text, ssh_port
except TimeoutError as exc: except TimeoutError as exc:
@ -616,6 +629,9 @@ async def _upsert_plugin_device(
result: DiscoveryResult, result: DiscoveryResult,
slave_rack_id: int | None = None, slave_rack_id: int | None = None,
slave_identifier: str | None = None, slave_identifier: str | None = None,
ping_online: bool | None = None,
ping_ms: int | None = None,
checked_at: datetime | None = None,
) -> None: ) -> None:
"""Upsert a single device discovered from a plugin section. """Upsert a single device discovered from a plugin section.
@ -684,6 +700,17 @@ async def _upsert_plugin_device(
existing.connection_params = cp existing.connection_params = cp
changes.append(f"connection_params.credentials updated") changes.append(f"connection_params.credentials updated")
if ping_online is not None:
new_status = "online" if ping_online else "offline"
if existing.status != new_status:
existing.status = new_status
changes.append(f"status: ->{new_status}")
if checked_at is not None:
existing.last_ping_at = checked_at
if ping_online:
existing.last_seen = checked_at
existing.last_ping_ms = ping_ms if ping_online else None
if changes: if changes:
logger.info("[discovery] %s (%s) - UPDATED: %s", found.ip, found.plugin_name, ", ".join(changes)) logger.info("[discovery] %s (%s) - UPDATED: %s", found.ip, found.plugin_name, ", ".join(changes))
result.updated += 1 result.updated += 1
@ -713,6 +740,10 @@ async def _upsert_plugin_device(
rack_id=rack_id, location=device_location, rack_id=rack_id, location=device_location,
device_meta={"plugin": found.plugin_name, "cls": found.cls}, device_meta={"plugin": found.plugin_name, "cls": found.cls},
connection_params=conn_params, connection_params=conn_params,
status="online" if ping_online else "offline" if ping_online is not None else "unknown",
last_seen=checked_at if ping_online else None,
last_ping_at=checked_at if ping_online is not None else None,
last_ping_ms=ping_ms if ping_online else None,
)) ))
cred_note = f", http_user={found.http_username}" if found.http_username else "" cred_note = f", http_user={found.http_username}" if found.http_username else ""
logger.info( logger.info(
@ -752,7 +783,9 @@ async def _discover_slave_racks(
await _emit("disc_action", {"message": f"Проверяем слейв-кандидата {candidate_ip}..."}) await _emit("disc_action", {"message": f"Проверяем слейв-кандидата {candidate_ip}..."})
try: try:
slave_config, slave_ssh_port = await fetch_slave_config(candidate_ip, ssh_ports, ssh_options, config_path) slave_config, slave_ssh_port = await fetch_slave_config(
candidate_ip, ssh_ports, ssh_options, config_path, progress_cb=_emit
)
is_slave = False is_slave = False
slave_identifier = "" slave_identifier = ""
master_ip_for_slave = "" master_ip_for_slave = ""
@ -794,6 +827,9 @@ async def _discover_slave_racks(
}) })
for found in slave_devices: for found in slave_devices:
ping_online, ping_ms = await emit_ping_status(
found.ip, _emit, phase="discovered_device"
)
await _upsert_plugin_device( await _upsert_plugin_device(
db, obj, found, db, obj, found,
external_id="", external_id="",
@ -801,6 +837,9 @@ async def _discover_slave_racks(
result=result, result=result,
slave_rack_id=slave_rack_id, slave_rack_id=slave_rack_id,
slave_identifier=slave_identifier, slave_identifier=slave_identifier,
ping_online=ping_online,
ping_ms=ping_ms if ping_online else None,
checked_at=now,
) )
try: try:
@ -906,7 +945,7 @@ async def _discover_mikrotik(
) -> None: ) -> None:
target_host = obj.server_ip or rack_hosts[0][1] target_host = obj.server_ip or rack_hosts[0][1]
logger.info("[discovery] Phase 3: MikroTik via SSH_CLIENT on %s", target_host) logger.info("[discovery] Phase 3: MikroTik via SSH_CLIENT on %s", target_host)
mikrotik_ip = await discover_mikrotik_multi(target_host, ssh_port, ssh_options) mikrotik_ip = await discover_mikrotik_multi(target_host, ssh_port, ssh_options, progress_cb=_emit)
if not mikrotik_ip: if not mikrotik_ip:
logger.info("[discovery] MikroTik: no IP found via SSH_CLIENT") logger.info("[discovery] MikroTik: no IP found via SSH_CLIENT")
@ -914,7 +953,7 @@ async def _discover_mikrotik(
logger.info("[discovery] MikroTik candidate: %s — verifying...", mikrotik_ip) logger.info("[discovery] MikroTik candidate: %s — verifying...", mikrotik_ip)
await _emit("disc_action", {"message": f"Проверка MikroTik {mikrotik_ip} (WinBox/SSH)..."}) await _emit("disc_action", {"message": f"Проверка MikroTik {mikrotik_ip} (WinBox/SSH)..."})
is_mikrotik = await verify_is_mikrotik(mikrotik_ip, ssh_port, ssh_options) is_mikrotik = await verify_is_mikrotik(mikrotik_ip, ssh_port, ssh_options, progress_cb=_emit)
if not is_mikrotik: if not is_mikrotik:
logger.info("[discovery] %s — NOT a MikroTik (WinBox and SSH banner check failed), skipping", mikrotik_ip) logger.info("[discovery] %s — NOT a MikroTik (WinBox and SSH banner check failed), skipping", mikrotik_ip)
await _emit("disc_action", {"message": f"{mikrotik_ip} — не MikroTik, пропускаем"}) await _emit("disc_action", {"message": f"{mikrotik_ip} — не MikroTik, пропускаем"})

View file

@ -6,11 +6,59 @@ No SQLAlchemy, no business logic — pure network I/O.
import asyncio import asyncio
import logging import logging
import re import re
import socket
import time
from collections.abc import Awaitable, Callable
import asyncssh import asyncssh
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ProgressCallback = Callable[[str, dict], Awaitable[None]]
PING_TIMEOUT = 3
SSH_CONNECT_TIMEOUT = 15
DISCOVERY_SSH_KWARGS: dict = {
"kex_algs": ["curve25519-sha256"],
"encryption_algs": ["aes128-ctr"],
"mac_algs": ["hmac-sha2-256"],
}
async def _connect_with_sock_compat(**kwargs) -> asyncssh.SSHClientConnection:
"""Connect with IPQoS disabled and conservative SSH algorithms.
AsyncSSH doesn't expose OpenSSH's `IPQoS=none` option directly, so discovery
opens the TCP socket itself, clears IP_TOS, and passes the connected socket
to AsyncSSH.
"""
host = kwargs.pop("host")
port = kwargs.pop("port", 22)
connect_timeout = kwargs.pop("connect_timeout", 10)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
try:
try:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_TOS, 0)
except OSError:
logger.debug("[discovery] Could not clear IP_TOS for %s:%s", host, port)
loop = asyncio.get_running_loop()
await asyncio.wait_for(loop.sock_connect(sock, (host, port)), timeout=connect_timeout)
return await asyncssh.connect(
host=host,
port=port,
sock=sock,
connect_timeout=connect_timeout,
**DISCOVERY_SSH_KWARGS,
**kwargs,
)
except BaseException:
sock.close()
raise
# ── Single connection helpers (used internally and for legacy paths) ────────── # ── Single connection helpers (used internally and for legacy paths) ──────────
@ -24,8 +72,8 @@ async def read_remote_file(
"""SSH into host and return the contents of remote_path.""" """SSH into host and return the contents of remote_path."""
if not await is_port_open(host, port, timeout=3.0): if not await is_port_open(host, port, timeout=3.0):
raise ConnectionRefusedError(f"{host}:{port} — порт недоступен") raise ConnectionRefusedError(f"{host}:{port} — порт недоступен")
async with asyncssh.connect( async with await _connect_with_sock_compat(
host, port=port, username=username, password=password, host=host, port=port, username=username, password=password,
known_hosts=None, connect_timeout=10, known_hosts=None, connect_timeout=10,
) as conn: ) as conn:
result = await conn.run(f"cat {remote_path}", check=True) result = await conn.run(f"cat {remote_path}", check=True)
@ -40,8 +88,8 @@ async def get_ip_neighbors(
if not await is_port_open(host, port, timeout=3.0): if not await is_port_open(host, port, timeout=3.0):
logger.info("[discovery] %s:%d — port closed, skipping ip neigh", host, port) logger.info("[discovery] %s:%d — port closed, skipping ip neigh", host, port)
return [] return []
async with asyncssh.connect( async with await _connect_with_sock_compat(
host, port=port, username=username, password=password, host=host, port=port, username=username, password=password,
known_hosts=None, connect_timeout=10, known_hosts=None, connect_timeout=10,
) as conn: ) as conn:
result = await conn.run("ip neigh show", check=False) result = await conn.run("ip neigh show", check=False)
@ -69,12 +117,59 @@ async def is_port_open(host: str, port: int, timeout: float = 3.0) -> bool:
return False return False
async def ping_host(host: str, timeout: int = PING_TIMEOUT) -> tuple[bool, int]:
"""Ping a host once. Returns (success, latency_ms)."""
start = time.monotonic()
try:
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", str(timeout), host,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=timeout + 2)
ms = int((time.monotonic() - start) * 1000)
return proc.returncode == 0, ms
except Exception:
return False, 0
async def emit_ping_status(
host: str,
progress_cb: ProgressCallback | None = None,
*,
phase: str = "ssh",
) -> tuple[bool, int]:
online, ping_ms = await ping_host(host)
if progress_cb is not None:
await progress_cb("disc_device_ping", {
"ip": host,
"status": "online" if online else "offline",
"ping_ms": ping_ms if online else None,
"phase": phase,
})
logger.info(
"[discovery] ping %s before %s: %s%s",
host,
phase,
"online" if online else "offline",
f" ({ping_ms} ms)" if online else "",
)
return online, ping_ms
# ── Multi-auth helpers ──────────────────────────────────────────────────────── # ── Multi-auth helpers ────────────────────────────────────────────────────────
async def ssh_run_multi( async def ssh_run_multi(
host: str, port: int, options: list, command: str, timeout: int = 15, host: str,
port: int,
options: list,
command: str,
timeout: int = 15,
progress_cb: ProgressCallback | None = None,
) -> str: ) -> str:
"""Try each SSH auth option in order; return stdout on first success or raise.""" """Try each SSH auth option in order; return stdout on first success or raise."""
await emit_ping_status(host, progress_cb)
# Fast pre-check: skip SSH entirely if port is not open # Fast pre-check: skip SSH entirely if port is not open
if not await is_port_open(host, port, timeout=3.0): if not await is_port_open(host, port, timeout=3.0):
raise ConnectionRefusedError(f"{host}:{port} — порт недоступен (TCP check failed)") raise ConnectionRefusedError(f"{host}:{port} — порт недоступен (TCP check failed)")
@ -84,7 +179,10 @@ async def ssh_run_multi(
_log_attempt(i, len(options), host, opt) _log_attempt(i, len(options), host, opt)
try: try:
connect_kwargs = _build_connect_kwargs(host, port, opt) connect_kwargs = _build_connect_kwargs(host, port, opt)
conn = await asyncssh.connect(**connect_kwargs) conn = await asyncio.wait_for(
_connect_with_sock_compat(**connect_kwargs, connect_timeout=SSH_CONNECT_TIMEOUT),
timeout=SSH_CONNECT_TIMEOUT,
)
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
logger.info( logger.info(
@ -116,20 +214,34 @@ async def ssh_run_multi(
raise last_exc raise last_exc
async def get_ip_neighbors_multi(host: str, port: int, options: list) -> list[str]: async def get_ip_neighbors_multi(
host: str,
port: int,
options: list,
progress_cb: ProgressCallback | None = None,
) -> list[str]:
"""Multi-auth version of get_ip_neighbors.""" """Multi-auth version of get_ip_neighbors."""
try: try:
stdout = await ssh_run_multi(host, port, options, "ip neigh show", timeout=15) stdout = await ssh_run_multi(
host, port, options, "ip neigh show", timeout=15, progress_cb=progress_cb
)
return _parse_neigh_output(stdout) return _parse_neigh_output(stdout)
except Exception as exc: except Exception as exc:
logger.warning("[discovery] ip neigh failed on %s: %s", host, exc) logger.warning("[discovery] ip neigh failed on %s: %s", host, exc)
return [] return []
async def discover_mikrotik_multi(host: str, port: int, options: list) -> str | None: async def discover_mikrotik_multi(
host: str,
port: int,
options: list,
progress_cb: ProgressCallback | None = None,
) -> str | None:
"""Discover MikroTik IP via SSH_CLIENT environment variable (multi-auth).""" """Discover MikroTik IP via SSH_CLIENT environment variable (multi-auth)."""
try: try:
stdout = await ssh_run_multi(host, port, options, "echo $SSH_CLIENT", timeout=15) stdout = await ssh_run_multi(
host, port, options, "echo $SSH_CLIENT", timeout=15, progress_cb=progress_cb
)
parts = stdout.strip().split() parts = stdout.strip().split()
return parts[0] if parts else None return parts[0] if parts else None
except Exception as exc: except Exception as exc:
@ -138,13 +250,19 @@ async def discover_mikrotik_multi(host: str, port: int, options: list) -> str |
async def fetch_slave_config( async def fetch_slave_config(
host: str, ports: list[int], options: list, config_path: str, host: str,
ports: list[int],
options: list,
config_path: str,
progress_cb: ProgressCallback | None = None,
) -> tuple[str | None, int | None]: ) -> tuple[str | None, int | None]:
"""Read a remote config file from a slave candidate. Returns (config, port).""" """Read a remote config file from a slave candidate. Returns (config, port)."""
for port in ports: for port in ports:
for attempt in range(1, 3): for attempt in range(1, 3):
try: try:
config_text = await ssh_run_multi(host, port, options, f"cat {config_path}", timeout=30) config_text = await ssh_run_multi(
host, port, options, f"cat {config_path}", timeout=30, progress_cb=progress_cb
)
return config_text, port return config_text, port
except TimeoutError as exc: except TimeoutError as exc:
if attempt < 2: if attempt < 2:
@ -170,8 +288,8 @@ async def discover_proxmox_via_port_scan(
Migrate to multi-auth when needed. Migrate to multi-auth when needed.
""" """
try: try:
async with asyncssh.connect( async with await _connect_with_sock_compat(
host, port=port, username=username, password=password, host=host, port=port, username=username, password=password,
known_hosts=None, connect_timeout=10, known_hosts=None, connect_timeout=10,
) as conn: ) as conn:
scan_script = ( scan_script = (
@ -196,6 +314,7 @@ async def open_ssh_tunnel(
ssh_options: list, ssh_options: list,
target_host: str, target_host: str,
target_port: int, target_port: int,
progress_cb: ProgressCallback | None = None,
): ):
"""Open an asyncssh connection, trying each option in order. """Open an asyncssh connection, trying each option in order.
@ -203,6 +322,7 @@ async def open_ssh_tunnel(
The caller is responsible for closing the connection. The caller is responsible for closing the connection.
""" """
last_exc: Exception = Exception("No SSH options provided") last_exc: Exception = Exception("No SSH options provided")
await emit_ping_status(tunnel_host, progress_cb, phase="ssh_tunnel")
for opt in ssh_options: for opt in ssh_options:
try: try:
connect_kwargs = _build_connect_kwargs(tunnel_host, tunnel_port, opt) connect_kwargs = _build_connect_kwargs(tunnel_host, tunnel_port, opt)
@ -217,7 +337,10 @@ async def open_ssh_tunnel(
"[discovery] Tunnel attempt for %s — password: user=%s password=%s", "[discovery] Tunnel attempt for %s — password: user=%s password=%s",
tunnel_host, opt.username, masked, tunnel_host, opt.username, masked,
) )
conn = await asyncssh.connect(**connect_kwargs) conn = await asyncio.wait_for(
_connect_with_sock_compat(**connect_kwargs),
timeout=SSH_CONNECT_TIMEOUT,
)
logger.info( logger.info(
"[discovery] Tunnel auth succeeded for %s with %s(%s)", "[discovery] Tunnel auth succeeded for %s with %s(%s)",
tunnel_host, opt.type, opt.username, tunnel_host, opt.type, opt.username,
@ -239,6 +362,7 @@ async def verify_is_mikrotik(
ssh_port: int = 22, ssh_port: int = 22,
ssh_options: list | None = None, ssh_options: list | None = None,
timeout: float = 3.0, timeout: float = 3.0,
progress_cb: ProgressCallback | None = None,
) -> bool: ) -> bool:
"""Check whether host is actually a MikroTik RouterOS device. """Check whether host is actually a MikroTik RouterOS device.
@ -265,11 +389,12 @@ async def verify_is_mikrotik(
# Probe 2: SSH banner # Probe 2: SSH banner
if ssh_options: if ssh_options:
await emit_ping_status(host, progress_cb, phase="mikrotik_verify")
for opt in ssh_options: for opt in ssh_options:
try: try:
kwargs = _build_connect_kwargs(host, ssh_port, opt) kwargs = _build_connect_kwargs(host, ssh_port, opt)
kwargs["connect_timeout"] = int(timeout) kwargs["connect_timeout"] = int(timeout)
async with asyncssh.connect(**kwargs) as conn: async with await _connect_with_sock_compat(**kwargs) as conn:
banner = (conn.get_extra_info("server_version") or "").lower() banner = (conn.get_extra_info("server_version") or "").lower()
if "rosssh" in banner or "mikrotik" in banner: if "rosssh" in banner or "mikrotik" in banner:
logger.info( logger.info(

View file

@ -28,6 +28,13 @@ interface InfraItem {
ip: string ip: string
} }
interface DevicePingItem {
ip: string
status: 'online' | 'offline'
ping_ms?: number | null
phase?: string
}
type PhaseState = 'idle' | 'running' | 'done' type PhaseState = 'idle' | 'running' | 'done'
interface Props { interface Props {
@ -50,6 +57,7 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
const [mikrotikPhase, setMikrotikPhase] = useState<PhaseState>('idle') const [mikrotikPhase, setMikrotikPhase] = useState<PhaseState>('idle')
const [proxmoxPhase, setProxmoxPhase] = useState<PhaseState>('idle') const [proxmoxPhase, setProxmoxPhase] = useState<PhaseState>('idle')
const [infraFound, setInfraFound] = useState<InfraItem[]>([]) 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 [result, setResult] = useState<{ created: number; updated: number; skipped: number; errors: string[] } | null>(null)
const ctrlRef = useRef<AbortController | null>(null) const ctrlRef = useRef<AbortController | null>(null)
@ -66,6 +74,7 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
setMikrotikPhase('idle') setMikrotikPhase('idle')
setProxmoxPhase('idle') setProxmoxPhase('idle')
setInfraFound([]) setInfraFound([])
setDevicePings([])
setResult(null) setResult(null)
const ctrl = new AbortController() const ctrl = new AbortController()
@ -129,6 +138,18 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
} else if (ev.event === 'disc_infra_found') { } else if (ev.event === 'disc_infra_found') {
setInfraFound((prev) => [...prev, { type: data.type, ip: data.ip }]) setInfraFound((prev) => [...prev, { type: data.type, ip: data.ip }])
} else if (ev.event === 'disc_device_ping') {
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') { } else if (ev.event === 'disc_done') {
setResult({ setResult({
created: data.created ?? 0, created: data.created ?? 0,
@ -231,6 +252,34 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete
</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 */} {/* Slave phase */}
{slavesPhase !== 'idle' && ( {slavesPhase !== 'idle' && (
<div style={{ marginBottom: 12, padding: '8px 0', borderTop: '1px solid #f0f0f0' }}> <div style={{ marginBottom: 12, padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>