diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6259af3..26b8dfc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,14 @@ "Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\")", "Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")", "Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")", - "Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")" + "Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")", + "Bash(npx tsc:*)", + "Bash(python -c \"import openpyxl; print\\(''openpyxl available:'', openpyxl.__version__\\)\")", + "Bash(pip install:*)", + "Bash(python create_objects_template.py)", + "Bash(python -c \":*)", + "Bash(python -c \"import sys; data = sys.stdin.buffer.read\\(\\); print\\(data.decode\\(''''utf-8'''', errors=''''replace''''\\)\\)\")", + "Bash(docker compose:*)" ] } } 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/main.py b/backend/app/main.py index b24462f..1eb4838 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -10,6 +11,12 @@ from app.routers import admin, alerts, auth, devices, events, global_devices, ob from app.workers.monitor import start_monitor, stop_monitor from app.workers.startup import recover_stale_tasks +# Configure logging to output to console +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +) + @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/backend/app/models/device.py b/backend/app/models/device.py index e5bcfbd..66fbbf0 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -26,7 +26,7 @@ DEVICE_ROLES = ("plate", "face", "overview", "other") DEVICE_LOCATIONS = ("server_room", "street") DEVICE_STATUSES = ("online", "offline", "unknown") -DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync") +DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync", "auto_ssh") class Device(Base, TimestampMixin): 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/routers/devices.py b/backend/app/routers/devices.py index 867e100..8cc7916 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -244,6 +244,18 @@ async def update_device( return device +@router.delete("", status_code=status.HTTP_204_NO_CONTENT) +async def delete_all_devices( + obj_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + await _get_object(db, obj_id) + result = await db.execute(select(Device).where(Device.object_id == obj_id)) + for device in result.scalars().all(): + await db.delete(device) + + @router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_device( obj_id: int, diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 019a06d..7e19be8 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -1,4 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import asyncio +import re +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from sqlalchemy import select, distinct from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -9,11 +13,25 @@ from app.models.object import Object from app.models.tag import Tag from app.models.user import User from app.schemas.admin import SheetsSyncRequest -from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult +from app.schemas.object import ( + ObjectCreate, + ObjectImportError, + ObjectImportPreview, + ObjectImportResult, + ObjectImportRow, + ObjectImportSkipped, + ObjectMetaResponse, + ObjectRead, + ObjectUpdate, + SyncResult, + DiscoveryStarted, +) from app.services import crypto +from app.services.database import AsyncSessionLocal from app.services.device_discovery import discover_via_ssh from app.services.object_db import sync_from_object_db from app.services.sheets import sync_from_sheets +from app.services.sse import sse_manager, SSEEvent router = APIRouter(prefix="/api/v1/objects", tags=["objects"]) @@ -132,20 +150,26 @@ async def update_object( @router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_object( obj_id: int, + force: bool = False, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): obj = await _load_object(db, obj_id) - # Check for devices (RESTRICT constraint would catch this, but give a clear message) - count_result = await db.execute( - select(Device.id).where(Device.object_id == obj_id).limit(1) - ) - if count_result.scalar_one_or_none() is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Cannot delete object with existing devices. Remove all devices first.", + if force: + devices_result = await db.execute(select(Device).where(Device.object_id == obj_id)) + for device in devices_result.scalars().all(): + await db.delete(device) + await db.flush() + else: + count_result = await db.execute( + select(Device.id).where(Device.object_id == obj_id).limit(1) ) + if count_result.scalar_one_or_none() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Cannot delete object with existing devices. Remove all devices first.", + ) await db.delete(obj) @@ -165,21 +189,219 @@ 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( obj_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): """SSH into each rack PC, parse caps config, discover network devices.""" - obj = await _load_object(db, obj_id) - result = await discover_via_ssh(db, obj) - await db.flush() - return SyncResult( - created=result.created, - updated=result.updated, - skipped=result.skipped, - errors=result.errors, + await _load_object(db, obj_id) + task_id = f"disc-{obj_id}" + asyncio.create_task(_run_discovery_background(obj_id, task_id)) + return DiscoveryStarted(task_id=task_id) + + +@router.post("/import/preview", response_model=ObjectImportPreview) +async def preview_objects_import( + file: Annotated[UploadFile, File()], + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Dry-run: parse xlsx and return what would be created/skipped.""" + return await _parse_objects_xlsx(db, file, dry_run=True) + + +@router.post("/import", response_model=ObjectImportResult, status_code=status.HTTP_201_CREATED) +async def import_objects( + file: Annotated[UploadFile, File()], + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Import objects from xlsx. Deduplication by server_ip (skip + warn on duplicate).""" + preview = await _parse_objects_xlsx(db, file, dry_run=False) + return ObjectImportResult( + created=len(preview.would_create), + skipped=len(preview.would_skip), + errors=preview.errors, + ) + + +def _norm_col(name: str) -> str: + """Normalize xlsx header: 'server_ip *' → 'server_ip', 'ssh_user (root)' → 'ssh_user'.""" + name = re.sub(r"\s*\*", "", name) + name = re.sub(r"\s*\(.*?\)", "", name) + return name.strip().lower() + + +async def _parse_objects_xlsx( + db: AsyncSession, file: UploadFile, dry_run: bool +) -> ObjectImportPreview: + import io + try: + from openpyxl import load_workbook + except ImportError: + raise HTTPException(status_code=500, detail="openpyxl not installed") + + content = await file.read() + try: + wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + except Exception: + raise HTTPException(status_code=400, detail="Не удалось открыть файл. Убедитесь, что файл в формате .xlsx") + + # Use first sheet (the data sheet) + ws = wb.worksheets[0] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + raise HTTPException(status_code=400, detail="Файл пустой") + + # Build column index from header row + header = [_norm_col(str(c)) if c is not None else "" for c in rows[0]] + col = {name: idx for idx, name in enumerate(header) if name} + + required = {"name", "city", "server_ip"} + missing = required - col.keys() + if missing: + raise HTTPException( + status_code=400, + detail=f"Отсутствуют обязательные колонки: {', '.join(sorted(missing))}", + ) + + def _cell(row: tuple, key: str) -> str | None: + idx = col.get(key) + if idx is None or idx >= len(row): + return None + val = row[idx] + if val is None: + return None + s = str(val).strip() + return s if s else None + + would_create: list[ObjectImportRow] = [] + would_skip: list[ObjectImportSkipped] = [] + errors: list[ObjectImportError] = [] + + # Track IPs seen within this file to catch intra-file duplicates + seen_ips: dict[str, int] = {} + + # Pre-load existing server_ips from DB for fast lookup + existing_ips_result = await db.execute( + select(Object.server_ip, Object.name).where(Object.server_ip.isnot(None)) + ) + existing_ips: dict[str, str] = {ip: name for ip, name in existing_ips_result.all()} + + for row_num, row in enumerate(rows[1:], start=2): + # Skip entirely blank rows + if all(c is None or str(c).strip() == "" for c in row): + continue + + name = _cell(row, "name") + city = _cell(row, "city") + server_ip = _cell(row, "server_ip") + + # Validate required fields + missing_fields = [] + if not name: + missing_fields.append("name") + if not city: + missing_fields.append("city") + if not server_ip: + missing_fields.append("server_ip") + if missing_fields: + errors.append(ObjectImportError( + row=row_num, + reason=f"Обязательные поля не заполнены: {', '.join(missing_fields)}", + )) + continue + + # Deduplicate within file + if server_ip in seen_ips: + errors.append(ObjectImportError( + row=row_num, + reason=f"Дублирующийся IP {server_ip} в файле (строка {seen_ips[server_ip]})", + )) + continue + seen_ips[server_ip] = row_num + + # Deduplicate against DB + if server_ip in existing_ips: + would_skip.append(ObjectImportSkipped( + row=row_num, + name=name, + server_ip=server_ip, + reason=f"Объект с IP {server_ip} уже существует: «{existing_ips[server_ip]}»", + )) + continue + + ssh_user = _cell(row, "ssh_user") or "root" + ssh_password = _cell(row, "ssh_password") or "123123" + db_via_tunnel_raw = _cell(row, "db_via_tunnel") + db_via_tunnel = bool(int(db_via_tunnel_raw)) if db_via_tunnel_raw and db_via_tunnel_raw.isdigit() else False + + import_row = ObjectImportRow( + row=row_num, + name=name, + city=city, + server_ip=server_ip, + client=_cell(row, "client"), + description=_cell(row, "description"), + notes=_cell(row, "notes"), + ssh_user=ssh_user, + db_via_tunnel=db_via_tunnel, + ) + would_create.append(import_row) + + if not dry_run: + obj = Object( + name=name, + city=city, + server_ip=server_ip, + client=import_row.client, + description=import_row.description, + notes=import_row.notes, + ssh_user=ssh_user, + db_via_tunnel=db_via_tunnel, + is_active=True, + ) + obj.ssh_pass_enc = crypto.encrypt(ssh_password) + db.add(obj) + # update local cache so next rows in this file see the newly added IP + existing_ips[server_ip] = name + + if not dry_run: + await db.flush() + + return ObjectImportPreview( + would_create=would_create, + would_skip=would_skip, + errors=errors, ) 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/object.py b/backend/app/schemas/object.py index fd1042c..a07ef5e 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -85,8 +85,50 @@ class ObjectMetaResponse(BaseModel): clients: list[str] +# --- Import from xlsx --- + +class ObjectImportRow(BaseModel): + row: int + name: str + city: str + server_ip: str + client: Optional[str] = None + description: Optional[str] = None + notes: Optional[str] = None + ssh_user: str + db_via_tunnel: bool + + +class ObjectImportSkipped(BaseModel): + row: int + name: str + server_ip: str + reason: str + + +class ObjectImportError(BaseModel): + row: int + reason: str + + +class ObjectImportPreview(BaseModel): + would_create: list[ObjectImportRow] + would_skip: list[ObjectImportSkipped] + errors: list[ObjectImportError] + + +class ObjectImportResult(BaseModel): + created: int + skipped: int + errors: list[ObjectImportError] + + class SyncResult(BaseModel): created: int updated: int skipped: int errors: list[str] = [] + + +class DiscoveryStarted(BaseModel): + task_id: str 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 e784c91..236a2df 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -11,7 +11,10 @@ Limitations (devices not discoverable automatically): - Physical MikroTik router (not in rack configs) """ +import logging import re +from collections.abc import Callable, Awaitable +from datetime import datetime, timezone from configparser import RawConfigParser from dataclasses import dataclass, field from io import StringIO @@ -26,6 +29,8 @@ from app.models.device import Device from app.models.object import Object from app.services.crypto import decrypt +logger = logging.getLogger(__name__) + # ── URI extraction ──────────────────────────────────────────────────────────── _SERIAL_SCHEMES = {"serial", "com"} @@ -93,36 +98,65 @@ class DiscoveredDevice: cls: str -def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: - """Extract discovered network devices from a caps config file.""" +def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] | None = None) -> list[DiscoveredDevice]: + """Extract discovered network devices from a caps config file. + + Args: + config_text: Config file content + rack_host: IP of current rack (will be excluded from results) + all_rack_hosts: All rack IPs (will be excluded to avoid confusing rack servers with devices) + """ + if all_rack_hosts is None: + all_rack_hosts = [rack_host] + parser = RawConfigParser(strict=False) parser.read_string(config_text) discovered: list[DiscoveredDevice] = [] seen_ips: set[str] = set() + all_sections = parser.sections() + plugin_sections = [s for s in all_sections if s.startswith("plugin:")] - for section in parser.sections(): + logger.info(f"[ssh_discovery] Config parsing: found {len(all_sections)} sections total, {len(plugin_sections)} are plugins") + + for section in all_sections: if not section.startswith("plugin:"): continue plugin_name = section[len("plugin:"):] if any(plugin_name.startswith(prefix) for prefix in _SKIP_PLUGIN_PREFIXES): + logger.info(f"[ssh_discovery] Plugin '{plugin_name}' - IGNORED (in skip list)") continue cls = parser.get(section, "cls", fallback="") if not cls: + logger.info(f"[ssh_discovery] Plugin '{plugin_name}' - IGNORED (no 'cls' field)") continue - # Collect all URI-like values in this section - uri_fields = ("uri", "frame_uri", "host") - for field_name in uri_fields: + logger.info(f"[ssh_discovery] Plugin '{plugin_name}' (cls={cls})") + + # uri/frame_uri/host → actual device (camera, io_board, etc.) + device_uri_fields = ("uri", "frame_uri", "host") + found_ip_in_plugin = False + for field_name in device_uri_fields: raw = parser.get(section, field_name, fallback=None) if not raw: + logger.info(f"[ssh_discovery] {field_name}: (not set)") continue + logger.info(f"[ssh_discovery] {field_name}='{raw}'") ip = _extract_ip(raw) - if not ip or ip == rack_host or ip in seen_ips: + if not ip: + logger.info(f"[ssh_discovery] → IGNORED (not a valid network IP)") continue + if ip in all_rack_hosts: + logger.info(f"[ssh_discovery] → IGNORED (is a rack server IP: {ip})") + continue + if ip in seen_ips: + logger.info(f"[ssh_discovery] → IGNORED (duplicate, already seen)") + continue + seen_ips.add(ip) + logger.info(f"[ssh_discovery] → EXTRACTED {ip} (category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)})") discovered.append(DiscoveredDevice( ip=ip, category=_cls_to_category(cls), @@ -130,6 +164,43 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: plugin_name=plugin_name, cls=cls, )) + found_ip_in_plugin = True + + # stream_uri → RTSP server (separate VM, not the camera itself) + stream_raw = parser.get(section, "stream_uri", fallback=None) + if stream_raw: + logger.info(f"[ssh_discovery] stream_uri='{stream_raw}'") + stream_ip = _extract_ip(stream_raw) + if stream_ip and stream_ip not in all_rack_hosts and stream_ip not in seen_ips: + seen_ips.add(stream_ip) + logger.info(f"[ssh_discovery] → EXTRACTED {stream_ip} as RTSP server (category=vm)") + discovered.append(DiscoveredDevice( + ip=stream_ip, + category="vm", + role="other", + plugin_name=f"{plugin_name}_rtsp", + cls="rtsp_server", + )) + found_ip_in_plugin = True + + if not found_ip_in_plugin: + logger.info(f"[ssh_discovery] → No network devices found in this plugin") + + # Parse [caps.voice] section for Asterisk server + voice_addr = parser.get("caps.voice", "server_addr", fallback=None) + if voice_addr: + logger.info(f"[ssh_discovery] [caps.voice] server_addr='{voice_addr}'") + voice_ip = _extract_ip(f"sip://{voice_addr}") or (voice_addr.strip() if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", voice_addr.strip()) else None) + if voice_ip and voice_ip not in all_rack_hosts and voice_ip not in seen_ips: + seen_ips.add(voice_ip) + logger.info(f"[ssh_discovery] → EXTRACTED {voice_ip} as Asterisk (category=vm)") + discovered.append(DiscoveredDevice( + ip=voice_ip, + category="vm", + role="other", + plugin_name="asterisk", + cls="asterisk", + )) return discovered @@ -155,6 +226,135 @@ async def _read_remote_config( return result.stdout +async def _get_ip_neighbors( + host: str, + port: int, + username: str, + password: str, +) -> list[str]: + """Run `ip neigh show` and return REACHABLE neighbour IPs.""" + try: + async with asyncssh.connect( + host, + port=port, + username=username, + password=password, + known_hosts=None, + connect_timeout=10, + ) as conn: + result = await conn.run("ip neigh show", check=False) + ips = [] + for line in result.stdout.strip().splitlines(): + parts = line.split() + if not parts: + continue + ip = parts[0] + state = parts[-1] if parts else "" + # Accept REACHABLE and DELAY; skip STALE/FAILED/INCOMPLETE + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip) and state in ("REACHABLE", "DELAY"): + ips.append(ip) + return ips + except Exception as e: + logger.warning(f"[ssh_discovery] ip neigh failed on {host}: {e}") + return [] + + +async def _check_if_slave( + host: str, + port: int, + username: str, + password: str, + master_ip: str, + config_path: str, +) -> tuple[bool, str, str]: + """ + SSH into candidate host and check if it is a slave rack pointing at master_ip. + Returns (is_slave, identifier, config_text). + - is_slave: True if [plugin:linking] has secondary cls and uri contains master_ip + - identifier: value of [caps.rack] identifier field + - config_text: raw config (for device parsing if slave confirmed) + """ + try: + config_text = await _read_remote_config(host, port, username, password, config_path) + parser = RawConfigParser(strict=False) + parser.read_string(config_text) + linking_cls = parser.get("plugin:linking", "cls", fallback="") + linking_uri = parser.get("plugin:linking", "uri", fallback="") + identifier = parser.get("caps.rack", "identifier", fallback="").strip() + if "secondary" in linking_cls.lower() and master_ip in linking_uri: + return True, identifier or host, config_text + except Exception: + pass + return False, "", "" + + +async def _discover_mikrotik_via_ssh_client( + host: str, + port: int, + username: str, + password: str, +) -> str | None: + """ + Discover MikroTik router IP via SSH_CLIENT environment variable. + When connected via L2TP → MikroTik → main_server, the SSH_CLIENT env var + shows the client IP connecting to the server (MikroTik's L2TP interface IP). + """ + try: + async with asyncssh.connect( + host, + port=port, + username=username, + password=password, + known_hosts=None, + connect_timeout=10, + ) as conn: + result = await conn.run("echo $SSH_CLIENT", check=False) + ssh_client_output = result.stdout.strip() + if ssh_client_output: + # SSH_CLIENT format: "client_ip client_port server_port" + parts = ssh_client_output.split() + if parts: + return parts[0] + except Exception as e: + logger.warning(f"[ssh_discovery] Failed to discover MikroTik via SSH_CLIENT: {e}") + return None + + +async def _discover_proxmox_via_port_scan( + host: str, + port: int, + username: str, + password: str, +) -> list[str]: + """ + Discover Proxmox servers on the local subnet by scanning port 8006. + Runs a subnet scanning script that checks port 8006 on all IPs in the subnet. + """ + try: + async with asyncssh.connect( + host, + port=port, + username=username, + password=password, + known_hosts=None, + connect_timeout=10, + ) as conn: + # Scan script: determine subnet and scan all IPs for port 8006 + scan_script = ( + "subnet=$(ip -o -f inet addr show | awk '!/ lo |docker|br-|wg/ {print $4; exit}' | " + "cut -d/ -f1 | awk -F. '{print $1\".\"$2\".\"$3}') && " + "(set +m; for ip in $subnet.{1..254}; do " + "timeout 0.3 bash -c \"echo > /dev/tcp/$ip/8006\" 2>/dev/null && echo \"$ip\" & " + "done; wait)" + ) + result = await conn.run(scan_script, check=False) + ips = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()] + return ips + except Exception as e: + logger.warning(f"[ssh_discovery] Failed to discover Proxmox servers via port scan: {e}") + return [] + + def _parse_db_uri(uri: str) -> dict | None: """Parse postgresql+psycopg2://user:pass@host:port/dbname → dict.""" try: @@ -224,8 +424,8 @@ async def _get_rack_hosts_direct( tunnel_ssh_port: int = 22, tunnel_ssh_user: str | None = None, tunnel_ssh_password: str | None = None, -) -> list[tuple[str, str, str]]: - """Return list of (external_id, host_ip, rack_name) from the racks table.""" +) -> list[tuple[str, str, str, str | None]]: + """Return list of (external_id, host_ip, rack_name, rack_type) from the racks table.""" if via_tunnel and tunnel_host and tunnel_ssh_user and tunnel_ssh_password: async with asyncssh.connect( tunnel_host, @@ -246,33 +446,54 @@ async def _get_rack_hosts_direct( return await _fetch_rack_hosts(dsn, table) -async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str, str]]: - """Return list of (external_id, host_ip, rack_name) from the racks table.""" +async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str, str, str | None]]: + """Return list of (external_id, host_ip, rack_name, rack_type) from the racks table.""" async with await psycopg.AsyncConnection.connect(dsn) as conn: cursor = await conn.execute( - f"SELECT id, data->>'host' AS host, name FROM \"{table}\" WHERE data->>'host' IS NOT NULL" + f"SELECT id, data->>'host' AS host, name, data->>'rack_type' AS rack_type " + f"FROM \"{table}\" WHERE data->>'host' IS NOT NULL" ) rows = await cursor.fetchall() - return [(str(r[0]), str(r[1]), str(r[2] or r[0])) for r in rows if r[1]] + return [(str(r[0]), str(r[1]), str(r[2] or r[0]), r[3] or None) for r in rows if r[1]] # ── Main entry point ────────────────────────────────────────────────────────── +@dataclass +class DeviceAction: + """Track each device discovery action for summary reporting.""" + ip: str + hostname: str + action: str # "ADDED", "UPDATED", "SKIPPED", "IGNORED", "ERROR" + reason: str + category: str = "" + role: str = "" + + @dataclass class DiscoveryResult: created: int = 0 updated: int = 0 skipped: int = 0 errors: list[str] = field(default_factory=list) + actions: list[DeviceAction] = field(default_factory=list) + + +ProgressCallback = Callable[[str, dict], Awaitable[None]] async def discover_via_ssh( db: AsyncSession, obj: Object, config_path: str = "/etc/caps/config.ini", + progress_cb: ProgressCallback | None = None, ) -> 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: result.errors.append("SSH credentials not configured on this object (ssh_user / ssh_password)") return result @@ -295,6 +516,7 @@ async def discover_via_ssh( db_user: str | None = obj.db_user db_password: str | None = None db_table: str | None = obj.db_table + server_ip_ssh_verified = False if db_host and db_name and db_user and obj.db_pass_enc and db_table: try: @@ -304,6 +526,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, @@ -342,6 +565,7 @@ async def discover_via_ssh( db_password = detected["password"] db_name = detected["dbname"] db_table = db_table or obj.db_table or "racks" + server_ip_ssh_verified = True else: result.errors.append( "DB connection not configured. Either fill in the DB fields on the object " @@ -350,6 +574,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, @@ -372,8 +597,118 @@ async def discover_via_ssh( result.errors.append("No racks with host IPs found in object DB") return result + await _emit("disc_server", {"rack_count": len(rack_hosts)}) + # Step 2: SSH into each rack, parse config, upsert devices - for external_id, rack_host, rack_name in rack_hosts: + # 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 + neighbor_candidates: set[str] = set() + discovered_device_ips: set[str] = set() + + # Build mapping of external_id -> Rack.id for device assignment. + # If a rack with that name doesn't exist yet in our racks table, create it now. + from app.models.rack import Rack + rack_id_mapping: dict[str, int] = {} + for ext_id, _, _, rack_type_db in rack_hosts: + existing_rack_row = (await db.execute( + select(Rack).where(Rack.object_id == obj.id, Rack.name == ext_id) + )).scalar_one_or_none() + if existing_rack_row: + rack_id_mapping[ext_id] = existing_rack_row.id + if rack_type_db and not existing_rack_row.rack_type: + existing_rack_row.rack_type = rack_type_db + logger.info(f"[ssh_discovery] Updated rack '{ext_id}' rack_type={rack_type_db}") + else: + new_rack = Rack(object_id=obj.id, name=ext_id, rack_type=rack_type_db) + db.add(new_rack) + await db.flush() # get the new PK + rack_id_mapping[ext_id] = new_rack.id + logger.info(f"[ssh_discovery] Created rack entry '{ext_id}' rack_type={rack_type_db} (id={new_rack.id})") + + # Step 1b: Add main CAPS server and DB server as devices + # verified=True means we successfully connected to this host during this discovery run + now = datetime.now(timezone.utc) + infra_devices = [] + if obj.server_ip: + infra_devices.append((obj.server_ip, "main_server", "caps_server", server_ip_ssh_verified)) + if db_host and db_host != obj.server_ip: + infra_devices.append((db_host, "vm", "db_server", True)) # DB query succeeded = online + + for infra_ip, infra_category, infra_hostname, infra_verified in infra_devices: + try: + existing_infra = (await db.execute( + select(Device).where( + Device.object_id == obj.id, + Device.ip == infra_ip, + ) + )).scalar_one_or_none() + if existing_infra is None: + db.add(Device( + object_id=obj.id, + ip=infra_ip, + hostname=infra_hostname, + category=infra_category, + role="other", + source="auto_ssh", + location="server_room", + status="online" if infra_verified else "unknown", + last_seen=now if infra_verified else None, + )) + logger.info(f"[ssh_discovery] {infra_ip} ({infra_hostname}) - ADDED: category={infra_category}, location=server_room, status={'online' if infra_verified else 'unknown'}, source=auto_ssh") + result.created += 1 + result.actions.append(DeviceAction( + ip=infra_ip, + hostname=infra_hostname, + action="ADDED", + reason=f"object infrastructure device ({infra_category})", + category=infra_category, + role="other", + )) + else: + infra_changes = [] + if infra_verified: + if existing_infra.status != "online": + existing_infra.status = "online" + infra_changes.append("status: →online") + existing_infra.last_seen = now + infra_changes.append(f"last_seen: →{now.isoformat()}") + if infra_changes: + logger.info(f"[ssh_discovery] {infra_ip} ({infra_hostname}) - UPDATED: {', '.join(infra_changes)}") + result.updated += 1 + result.actions.append(DeviceAction( + ip=infra_ip, + hostname=existing_infra.hostname or infra_hostname, + action="UPDATED", + reason="; ".join(infra_changes), + category=existing_infra.category, + role=existing_infra.role, + )) + else: + logger.info(f"[ssh_discovery] {infra_ip} ({infra_hostname}) - SKIPPED: already exists (category={existing_infra.category}, hostname={existing_infra.hostname})") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=infra_ip, + hostname=existing_infra.hostname or infra_hostname, + action="SKIPPED", + reason=f"already exists (category={existing_infra.category})", + category=existing_infra.category, + role=existing_infra.role, + )) + except Exception as exc: + logger.error(f"[ssh_discovery] {infra_ip} ({infra_hostname}) - ERROR: {exc}") + result.errors.append(f"Error upserting infra device {infra_ip}: {exc}") + + if infra_devices: + await db.flush() + + for rack_idx, (external_id, rack_host, rack_name, _rack_type_db) 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 existing_rack_pc = (await db.execute( select(Device).where( @@ -388,6 +723,10 @@ async def discover_via_ssh( "config_path", config_path ) + 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, @@ -396,15 +735,114 @@ async def discover_via_ssh( password=ssh_password, config_path=effective_config_path, ) + rack_ssh_ok = True except Exception as exc: result.errors.append(f"SSH to {rack_host} failed: {exc}") - continue - devices_found = _parse_config(config_text, rack_host) + logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_hostname}), ssh_ok={rack_ssh_ok}, in_db={existing_rack_pc is not None}") + + try: + if existing_rack_pc is None: + # New device — insert with status based on SSH result + db.add(Device( + object_id=obj.id, + ip=rack_host, + hostname=rack_hostname, + category="embedded", + role="other", + source="auto_ssh", + rack_id=rack_id_mapping.get(external_id), + status="online" if rack_ssh_ok else "unknown", + last_seen=now if rack_ssh_ok else None, + )) + status_str = "online" if rack_ssh_ok else "unknown" + logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: status={status_str}") + result.created += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=rack_hostname, + action="ADDED", + reason="rack PC from racks table" + (" (SSH ok)" if rack_ssh_ok else " (SSH failed, added as unknown)"), + category="embedded", + role="other", + )) + else: + rack_changes = [] + if rack_ssh_ok: + if existing_rack_pc.status != "online": + existing_rack_pc.status = "online" + rack_changes.append("status: →online") + existing_rack_pc.last_seen = now + rack_changes.append("last_seen: updated") + if existing_rack_pc.rack_id is None and rack_id_mapping.get(external_id) is not None: + existing_rack_pc.rack_id = rack_id_mapping[external_id] + rack_changes.append(f"rack_id: None→{existing_rack_pc.rack_id}") + if rack_changes: + logger.info(f"[ssh_discovery] {rack_host} ({existing_rack_pc.hostname}) - UPDATED: {', '.join(rack_changes)}") + result.updated += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=existing_rack_pc.hostname, + action="UPDATED", + reason="; ".join(rack_changes), + category=existing_rack_pc.category, + role=existing_rack_pc.role, + )) + else: + reason = "SSH failed, rack already in DB" if not rack_ssh_ok else "rack already up to date" + logger.info(f"[ssh_discovery] {rack_host} ({existing_rack_pc.hostname}) - SKIPPED: {reason}") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=existing_rack_pc.hostname, + action="SKIPPED", + reason=reason, + category=existing_rack_pc.category, + role=existing_rack_pc.role, + )) + except Exception as exc: + logger.error(f"[ssh_discovery] {rack_host} - ERROR: {exc}") + result.errors.append(f"Error processing rack {rack_host}: {exc}") + result.actions.append(DeviceAction( + ip=rack_host, + hostname=rack_hostname, + action="ERROR", + reason=str(exc), + category="embedded", + role="other", + )) + + if not rack_ssh_ok: + try: + await db.flush() + except Exception as 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 + + # Collect ARP neighbours for slave rack discovery (best-effort) + neigh_ips = await _get_ip_neighbors(rack_host, ssh_port, obj.ssh_user, ssh_password) + neighbor_candidates.update(neigh_ips) + discovered_device_ips.add(rack_host) # rack PC itself is a known device + + # 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}") for found in devices_found: - # Hostname: "rack{rack_name}-{plugin_name}", e.g. "rack01-camera_grz" - hostname = f"rack{rack_name}-{found.plugin_name}" + # vm devices (RTSP, Asterisk, etc.) are shared infrastructure — place in server_room, + # not tied to any specific rack. Other devices belong to the current rack. + is_shared = found.category == "vm" + if is_shared: + hostname = found.plugin_name # e.g. "asterisk", "camera_1_rtsp" + rack_id = None + device_location = "server_room" + else: + hostname = f"{external_id}-{found.plugin_name}" # e.g. "lane-1-1-camera_1" + rack_id = rack_id_mapping.get(external_id) + device_location = None try: existing = (await db.execute( @@ -415,20 +853,63 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing is not None: - changed = False + changes = [] + + # PROTECT: Don't overwrite embedded (rack) devices with plugin devices! + # If device is currently embedded (a rack), never change it + if existing.category == "embedded": + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - SKIPPED: device is rack server (embedded), cannot overwrite with plugin device") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=found.ip, + hostname=existing.hostname, + action="SKIPPED", + reason=f"device is rack server (embedded), cannot overwrite with plugin device", + category=existing.category, + role=existing.role, + )) + continue + if existing.category != found.category and existing.source == "auto_ssh": + changes.append(f"category: {existing.category}→{found.category}") existing.category = found.category - changed = True if existing.role == "other" and found.role != "other": + changes.append(f"role: other→{found.role}") existing.role = found.role - changed = True - # Update hostname if it was never set or still auto-generated - if not existing.hostname or existing.hostname.startswith("rack"): - if existing.hostname != hostname: - existing.hostname = hostname - changed = True - result.updated += 1 if changed else 0 - result.skipped += 0 if changed else 1 + if not is_shared: + # Rack-specific device: update hostname if stale + if not existing.hostname or existing.hostname.startswith("rack"): + if existing.hostname != hostname: + changes.append(f"hostname: '{existing.hostname}'→'{hostname}'") + existing.hostname = hostname + # Update rack_id if not set + if existing.rack_id is None and rack_id is not None: + existing.rack_id = rack_id + changes.append(f"rack_id: None→{rack_id}") + # Shared vm devices: never update hostname/rack_id — they're already in server_room + + if changes: + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - UPDATED: {', '.join(changes)}") + result.updated += 1 + result.actions.append(DeviceAction( + ip=found.ip, + hostname=existing.hostname, + action="UPDATED", + reason="; ".join(changes), + category=found.category, + role=found.role, + )) + else: + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - SKIPPED: already exists with same values (source={existing.source}, category={existing.category}, role={existing.role}, hostname={existing.hostname})") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=found.ip, + hostname=existing.hostname, + action="SKIPPED", + reason=f"already exists with same values (source={existing.source}, category={existing.category}, role={existing.role}, hostname={existing.hostname})", + category=existing.category, + role=existing.role, + )) else: db.add(Device( object_id=obj.id, @@ -437,11 +918,35 @@ async def discover_via_ssh( category=found.category, role=found.role, source="auto_ssh", + rack_id=rack_id, + location=device_location, device_meta={"plugin": found.plugin_name, "cls": found.cls}, )) + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ADDED: category={found.category}, role={found.role}, hostname={hostname}, rack_id={rack_id}, location={device_location}, source=auto_ssh") result.created += 1 + result.actions.append(DeviceAction( + ip=found.ip, + hostname=hostname, + action="ADDED", + reason=f"new device from plugin {found.plugin_name}", + category=found.category, + role=found.role, + )) except Exception as exc: + logger.error(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ERROR: {exc}") result.errors.append(f"Error upserting {found.ip}: {exc}") + result.actions.append(DeviceAction( + ip=found.ip, + hostname=hostname, + action="ERROR", + reason=str(exc), + category=found.category, + role=found.role, + )) + + # Track all plugin device IPs as known (for slave candidate filtering) + for found in devices_found: + discovered_device_ips.add(found.ip) # Flush after each rack so that subsequent SELECTs (for other racks # that may share the same device IPs) see the just-added rows. @@ -451,5 +956,340 @@ async def discover_via_ssh( await db.flush() except Exception as 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 + # Filter out already-known IPs: rack hosts, discovered devices, infra IPs, common gateways + all_rack_ips_set = {host for _, host, _, _ in rack_hosts} + infra_ips = {ip for ip in [obj.server_ip, db_host] if ip} + common_gateways = {ip for ip in neighbor_candidates if ip.endswith(".1") or ip.endswith(".254")} + slave_candidates = ( + neighbor_candidates + - all_rack_ips_set + - discovered_device_ips + - 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 = "" + slave_config_text = "" + master_ip_for_slave = "" + for _, master_host, _, _ in rack_hosts: + ok, identifier, slave_cfg = await _check_if_slave( + host=candidate_ip, + port=ssh_port, + username=obj.ssh_user, + password=ssh_password, + master_ip=master_host, + config_path=config_path, + ) + if ok: + is_slave = True + slave_config_text = slave_cfg + master_ip_for_slave = master_host + # Derive slave name from master's external_id: "lane-1-1" → "lane-1-1-slave" + master_ext_id = next( + (ext_id for ext_id, host, _, _ in rack_hosts if host == master_host), + master_host, + ) + slave_identifier = f"{master_ext_id}-slave" + break + + if not is_slave: + logger.info(f"[ssh_discovery] {candidate_ip} — not a slave rack (no secondary linking pointing to known master)") + 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 + try: + existing_slave_rack = (await db.execute( + select(Rack).where(Rack.object_id == obj.id, Rack.name == slave_identifier) + )).scalar_one_or_none() + if existing_slave_rack: + slave_rack_id = existing_slave_rack.id + else: + new_slave_rack = Rack(object_id=obj.id, name=slave_identifier, description="slave (auto-discovered via ARP)") + db.add(new_slave_rack) + await db.flush() + slave_rack_id = new_slave_rack.id + logger.info(f"[ssh_discovery] Created rack entry '{slave_identifier}' (id={slave_rack_id}) for slave") + except Exception as exc: + result.errors.append(f"Error creating rack entry for slave {candidate_ip}: {exc}") + + # Upsert the slave rack PC itself as embedded device + try: + existing_slave_dev = (await db.execute( + select(Device).where(Device.object_id == obj.id, Device.ip == candidate_ip) + )).scalar_one_or_none() + if existing_slave_dev is None: + db.add(Device( + object_id=obj.id, + ip=candidate_ip, + hostname=slave_identifier, + category="embedded", + role="other", + source="auto_ssh", + rack_id=slave_rack_id, + status="online", + last_seen=now, + )) + logger.info(f"[ssh_discovery] {candidate_ip} ({slave_identifier}) - ADDED: slave rack PC") + result.created += 1 + result.actions.append(DeviceAction( + ip=candidate_ip, hostname=slave_identifier, + action="ADDED", reason=f"slave rack PC (master={master_ip_for_slave})", + category="embedded", role="other", + )) + else: + slave_changes = [] + if existing_slave_dev.status != "online": + existing_slave_dev.status = "online" + slave_changes.append("status: →online") + existing_slave_dev.last_seen = now + slave_changes.append("last_seen: updated") + if existing_slave_dev.rack_id is None and slave_rack_id: + existing_slave_dev.rack_id = slave_rack_id + slave_changes.append(f"rack_id: →{slave_rack_id}") + result.updated += 1 + result.actions.append(DeviceAction( + ip=candidate_ip, hostname=existing_slave_dev.hostname or slave_identifier, + action="UPDATED", reason="; ".join(slave_changes), + category="embedded", role="other", + )) + await db.flush() + except Exception as exc: + result.errors.append(f"Error upserting slave device {candidate_ip}: {exc}") + + # Parse slave config and add its devices + 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: + hostname = found.plugin_name + s_rack_id = None + device_location = "server_room" + else: + hostname = f"{slave_identifier}-{found.plugin_name}" + s_rack_id = slave_rack_id + device_location = None + try: + existing = (await db.execute( + select(Device).where(Device.object_id == obj.id, Device.ip == found.ip) + )).scalar_one_or_none() + if existing is None: + db.add(Device( + object_id=obj.id, ip=found.ip, hostname=hostname, + category=found.category, role=found.role, + source="auto_ssh", rack_id=s_rack_id, location=device_location, + device_meta={"plugin": found.plugin_name, "cls": found.cls}, + )) + logger.info(f"[ssh_discovery] {found.ip} ({hostname}) - ADDED from slave {slave_identifier}") + result.created += 1 + result.actions.append(DeviceAction( + ip=found.ip, hostname=hostname, action="ADDED", + reason=f"slave {slave_identifier} plugin {found.plugin_name}", + category=found.category, role=found.role, + )) + else: + result.skipped += 1 + result.actions.append(DeviceAction( + ip=found.ip, hostname=existing.hostname or hostname, action="SKIPPED", + reason="already exists", category=existing.category, role=existing.role, + )) + except Exception as exc: + result.errors.append(f"Error upserting slave device {found.ip}: {exc}") + try: + await db.flush() + except Exception as exc: + 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], + port=obj.ssh_port or 22, + username=obj.ssh_user, + password=ssh_password, + ) + 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( + Device.object_id == obj.id, + Device.ip == mikrotik_ip, + ) + )).scalar_one_or_none() + + if existing is not None: + logger.info(f"[ssh_discovery] {mikrotik_ip} (mikrotik) - SKIPPED: already exists (source={existing.source}, category={existing.category}, hostname={existing.hostname})") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=mikrotik_ip, + hostname="mikrotik", + action="SKIPPED", + reason=f"already exists (source={existing.source}, category={existing.category}, hostname={existing.hostname})", + category=existing.category, + role=existing.role, + )) + else: + db.add(Device( + object_id=obj.id, + ip=mikrotik_ip, + hostname="mikrotik", + category="router", + role="other", + source="auto_ssh", + )) + logger.info(f"[ssh_discovery] {mikrotik_ip} (mikrotik) - ADDED: category=router, role=other, hostname=mikrotik, source=auto_ssh") + result.created += 1 + result.actions.append(DeviceAction( + ip=mikrotik_ip, + hostname="mikrotik", + action="ADDED", + reason="discovered via SSH_CLIENT", + category="router", + role="other", + )) + except Exception as exc: + logger.error(f"[ssh_discovery] {mikrotik_ip} (mikrotik) - ERROR: {exc}") + result.errors.append(f"Error upserting {mikrotik_ip}: {exc}") + result.actions.append(DeviceAction( + ip=mikrotik_ip, + hostname="mikrotik", + action="ERROR", + reason=str(exc), + category="router", + role="other", + )) + + await db.flush() + else: + 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], + port=obj.ssh_port or 22, + username=obj.ssh_user, + password=ssh_password, + ) + 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: + existing = (await db.execute( + select(Device).where( + Device.object_id == obj.id, + Device.ip == proxmox_ip, + ) + )).scalar_one_or_none() + + if existing is not None: + logger.info(f"[ssh_discovery] {proxmox_ip} ({hostname}) - SKIPPED: already exists (source={existing.source}, category={existing.category}, hostname={existing.hostname})") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=proxmox_ip, + hostname=hostname, + action="SKIPPED", + reason=f"already exists (source={existing.source}, category={existing.category}, hostname={existing.hostname})", + category=existing.category, + role=existing.role, + )) + else: + db.add(Device( + object_id=obj.id, + ip=proxmox_ip, + hostname=hostname, + category="vm", + role="other", + source="auto_ssh", + )) + logger.info(f"[ssh_discovery] {proxmox_ip} ({hostname}) - ADDED: category=vm, role=other, hostname={hostname}, source=auto_ssh") + result.created += 1 + result.actions.append(DeviceAction( + ip=proxmox_ip, + hostname=hostname, + action="ADDED", + reason="discovered via port 8006 scan", + category="vm", + role="other", + )) + except Exception as exc: + logger.error(f"[ssh_discovery] {proxmox_ip} ({hostname}) - ERROR: {exc}") + result.errors.append(f"Error upserting {proxmox_ip}: {exc}") + result.actions.append(DeviceAction( + ip=proxmox_ip, + hostname=hostname, + action="ERROR", + reason=str(exc), + category="vm", + role="other", + )) + + await db.flush() + else: + logger.info(f"[ssh_discovery] Proxmox: No servers found via port 8006 scan (reason: no hosts responding on port 8006)") + + # ── Summary Report ───────────────────────────────────────────────────────────── + logger.info("=" * 100) + logger.info(f"[ssh_discovery] SUMMARY: ADDED={result.created}, UPDATED={result.updated}, SKIPPED={result.skipped}, ERRORS={len(result.errors)}") + logger.info(f"[ssh_discovery] Total actions collected: {len(result.actions)}") + logger.info("=" * 100) + + # Group actions by status + added = [a for a in result.actions if a.action == "ADDED"] + updated = [a for a in result.actions if a.action == "UPDATED"] + skipped = [a for a in result.actions if a.action == "SKIPPED"] + errors = [a for a in result.actions if a.action == "ERROR"] + + logger.info(f"[ssh_discovery] Actions by type: ADDED={len(added)}, UPDATED={len(updated)}, SKIPPED={len(skipped)}, ERROR={len(errors)}") + + if added: + logger.info("[ssh_discovery] ─── ADDED devices ───") + for action in added: + logger.info(f" {action.ip:20} ({action.hostname:30}) | {action.category:15} | {action.role:10} | {action.reason}") + + if updated: + logger.info("[ssh_discovery] ─── UPDATED devices ───") + for action in updated: + logger.info(f" {action.ip:20} ({action.hostname:30}) | {action.category:15} | {action.role:10} | {action.reason}") + + if skipped: + logger.info("[ssh_discovery] ─── SKIPPED devices ───") + for action in skipped: + logger.info(f" {action.ip:20} ({action.hostname:30}) | {action.category:15} | {action.role:10} | {action.reason}") + + if errors: + logger.error("[ssh_discovery] ─── ERROR devices ───") + for action in errors: + logger.error(f" {action.ip:20} ({action.hostname:30}) | {action.category:15} | {action.role:10} | {action.reason}") + + logger.info("=" * 100) return result diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6207f71..e668bc0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ # Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var) "gspread>=6.0", "google-auth>=2.0", + "openpyxl>=3.1", ] [project.optional-dependencies] diff --git a/create_objects_template.py b/create_objects_template.py new file mode 100644 index 0000000..dd95dc9 --- /dev/null +++ b/create_objects_template.py @@ -0,0 +1,129 @@ +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +wb = Workbook() +ws = wb.active +ws.title = "Импорт объектов" + +DARK_BLUE = "1F3864" +LIGHT_GRAY = "F2F2F2" +LIGHT_BLUE = "EBF3FB" +WHITE = "FFFFFF" + +columns = [ + ("name *", 30), + ("city *", 20), + ("server_ip *", 18), + ("client", 25), + ("description", 35), + ("notes", 35), + ("ssh_user (root)", 15), + ("ssh_password (123123)", 18), + ("db_via_tunnel (0)", 20), +] + +sample_row = [ + "Парковка ТЦ Мега", + "Москва", + "192.168.1.10", + "ООО Рога и Копыта", + "Въезд с ул. Ленина", + "Контакт: +7 999 000-00-00", + "admin", + "qwerty123", + 0, +] + +header_font = Font(name="Arial", bold=True, color=WHITE, size=11) +header_fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE) +header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + +sample_fill = PatternFill("solid", start_color=LIGHT_GRAY, fgColor=LIGHT_GRAY) +blue_fill = PatternFill("solid", start_color=LIGHT_BLUE, fgColor=LIGHT_BLUE) +white_fill = PatternFill("solid", start_color=WHITE, fgColor=WHITE) +data_font = Font(name="Arial", size=10) +data_align = Alignment(vertical="center") + +# Header row +for col_idx, (header, width) in enumerate(columns, start=1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_align + ws.column_dimensions[get_column_letter(col_idx)].width = width + +ws.row_dimensions[1].height = 30 + +# Sample data row (row 2) +for col_idx, value in enumerate(sample_row, start=1): + cell = ws.cell(row=2, column=col_idx, value=value) + cell.font = data_font + cell.fill = sample_fill + cell.alignment = data_align + +# Empty data rows 3-52, alternating white / light blue +for row in range(3, 53): + fill = white_fill if (row % 2 == 1) else blue_fill + for col_idx in range(1, len(columns) + 1): + cell = ws.cell(row=row, column=col_idx) + cell.font = data_font + cell.fill = fill + cell.alignment = data_align + +ws.freeze_panes = "A2" + +# --- Справка sheet --- +ws2 = wb.create_sheet("Справка") + +ws2.column_dimensions["A"].width = 22 +ws2.column_dimensions["B"].width = 40 +ws2.column_dimensions["C"].width = 20 +ws2.column_dimensions["D"].width = 20 + +title_cell = ws2.cell(row=1, column=1, value="Инструкция по заполнению") +title_cell.font = Font(name="Arial", bold=True, size=13) +ws2.row_dimensions[1].height = 24 + +# Sub-header +headers_ref = ["Колонка", "Описание", "Обязательность", "Значение по умолчанию"] +for col_idx, h in enumerate(headers_ref, start=1): + cell = ws2.cell(row=3, column=col_idx, value=h) + cell.font = Font(name="Arial", bold=True, color=WHITE, size=11) + cell.fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE) + cell.alignment = Alignment(horizontal="center", vertical="center") +ws2.row_dimensions[3].height = 22 + +ref_rows = [ + ("name", "Название объекта (парковки, площадки)", "Обязательно", "—"), + ("city", "Город расположения объекта", "Обязательно", "—"), + ("server_ip", "IP-адрес основного сервера CAPS", "Обязательно", "—"), + ("client", "Клиент / заказчик", "Необязательно","—"), + ("description", "Описание объекта", "Необязательно","—"), + ("notes", "Заметки (контакты, особенности и т.п.)", "Необязательно","—"), + ("ssh_user", "Логин для SSH-подключения к серверу объекта", "Необязательно","root"), + ("ssh_password", "Пароль для SSH-подключения к серверу объекта", "Необязательно","123123"), + ("db_via_tunnel", "Подключаться к БД CAPS через SSH-туннель (0 — нет, 1 — да)","Необязательно","0"), +] + +for i, (col_name, desc, required, default) in enumerate(ref_rows, start=4): + fill = white_fill if (i % 2 == 0) else blue_fill + values = [col_name, desc, required, default] + for col_idx, val in enumerate(values, start=1): + cell = ws2.cell(row=i, column=col_idx, value=val) + cell.font = Font(name="Arial", size=10) + cell.fill = fill + cell.alignment = Alignment(vertical="center", wrap_text=True) + ws2.row_dimensions[i].height = 18 + +note_row = len(ref_rows) + 5 +note_cell = ws2.cell(row=note_row, column=1, + value="⚠ Дедупликация по полю server_ip: если объект с таким IP уже существует в системе, строка будет пропущена с предупреждением.") +note_cell.font = Font(name="Arial", size=10, bold=True, color="C00000") +note_cell.alignment = Alignment(wrap_text=True) +ws2.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=4) +ws2.row_dimensions[note_row].height = 32 + +out_path = r"C:\Users\Professional\Documents\VSCode\utp_service\objects_import_template.xlsx" +wb.save(out_path) +print(f"Saved: {out_path}") diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index dfd2666..d755acb 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 @@ -130,6 +131,22 @@ export const useDeleteObject = () => { }) } +export const useForceDeleteObject = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/objects/${id}`, { params: { force: true } }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }), + }) +} + +export const useDeleteAllDevices = (objId: number) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.delete(`/api/v1/objects/${objId}/devices`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + }) +} + export interface DeviceCreatePayload { ip: string hostname?: string @@ -204,15 +221,16 @@ export const useSyncObject = (objId: number) => { }) } -export const useDiscoverObject = (objId: number) => { - const qc = useQueryClient() - return useMutation({ - mutationFn: () => - api.post(`/api/v1/objects/${objId}/discover`).then((r) => r.data), - onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), - }) +export interface DiscoveryStarted { + task_id: string } +export const useDiscoverObject = (objId: number) => + useMutation({ + mutationFn: () => + api.post(`/api/v1/objects/${objId}/discover`).then((r) => r.data), + }) + export interface SheetsSyncPayload { spreadsheet_id: string sheet_name?: string @@ -243,6 +261,69 @@ export const useObjectsMeta = () => staleTime: 30_000, }) +// --- Object xlsx import --- + +export interface ObjectImportRow { + row: number + name: string + city: string + server_ip: string + client: string | null + description: string | null + notes: string | null + ssh_user: string + db_via_tunnel: boolean +} + +export interface ObjectImportSkipped { + row: number + name: string + server_ip: string + reason: string +} + +export interface ObjectImportError { + row: number + reason: string +} + +export interface ObjectImportPreview { + would_create: ObjectImportRow[] + would_skip: ObjectImportSkipped[] + errors: ObjectImportError[] +} + +export interface ObjectImportResult { + created: number + skipped: number + errors: ObjectImportError[] +} + +export const usePreviewObjectsXLSX = () => + useMutation({ + mutationFn: (file: File) => { + const form = new FormData() + form.append('file', file) + return api + .post('/api/v1/objects/import/preview', form) + .then((r) => r.data) + }, + }) + +export const useImportObjectsXLSX = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (file: File) => { + const form = new FormData() + form.append('file', file) + return api + .post('/api/v1/objects/import', form) + .then((r) => r.data) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }), + }) +} + export const useSyncSheets = (objId: number) => { const qc = useQueryClient() return useMutation({ 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 new file mode 100644 index 0000000..847c5e6 --- /dev/null +++ b/frontend/src/components/DiscoveryDrawer.tsx @@ -0,0 +1,307 @@ +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 +} + +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(null) + const [racks, setRacks] = useState([]) + const [current, setCurrent] = useState(0) + const [total, setTotal] = useState(0) + 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() + 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_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() { + 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 + if (state === 'done') return + if (state === 'error') return + return + } + + const phaseIcon = (state: PhaseState) => { + if (state === 'running') return + if (state === 'done') return + return + } + + return ( + + {/* Current action */} + {(phase === 'connecting' || currentAction) && ( +
+ {phase === 'connecting' && !currentAction ? ( + + + Запуск discovery... + + ) : currentAction ? ( + + + {currentAction} + + ) : null} +
+ )} + + {/* Rack progress bar */} + {(phase === 'racks' || phase === 'slaves' || phase === 'infra' || phase === 'done') && ( +
+ + Стойки: {Math.min(current, total)} / {total} + + = total ? 100 : percent} + status={phase === 'done' ? 'success' : 'active'} + style={{ marginBottom: 0 }} + /> +
+ )} + + {/* Rack list */} + {racks.length > 0 && ( +
+ {racks.map((r) => ( +
+ {rackIcon(r.state)} + {r.rack_name} + {r.rack_host} + {r.state === 'done' && ( + {r.found} уст. + )} + {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} обновлено + {result.skipped} без изменений + + {result.errors.length > 0 && ( +
+ {result.errors.map((e, i) => ( + + ⚠ {e} + + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx index 2691f35..7b2ef9a 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -35,10 +35,13 @@ import { } from 'antd' import type { UploadFile } from 'antd' import { useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useNavigate, useParams } from 'react-router-dom' import { useCreateDevice, useDeleteDevice, + useDeleteAllDevices, + useForceDeleteObject, useDiscoverObject, useUpdateDevice, useDevices, @@ -50,12 +53,23 @@ import { type DeviceImportPreview, type DeviceItem, } from '../api/objects' -import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks' +import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks' import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones' import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' +import { DiscoveryDrawer } from '../components/DiscoveryDrawer' 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 } +type DeviceRow = DeviceItem & { _type: 'device' } +type TableRow = GroupRow | DeviceRow + const DEVICE_CATEGORIES = [ { value: 'main_server', label: 'Главный сервер' }, { value: 'vm', label: 'Виртуалка / сервис' }, @@ -84,6 +98,7 @@ export function InventoryObjectDetailPage() { const { id } = useParams<{ id: string }>() const objId = Number(id) const navigate = useNavigate() + const queryClient = useQueryClient() const { data: obj, isLoading: objLoading } = useObject(objId) const { data: devices, isLoading: devLoading } = useDevices(objId) @@ -98,15 +113,21 @@ export function InventoryObjectDetailPage() { const previewCSV = usePreviewCSV(objId) const importCSV = useImportCSV(objId) const createRack = useCreateRack(objId) + const updateRack = useUpdateRack(objId) const deleteRack = useDeleteRack(objId) const createZone = useCreateZone(objId) const deleteZone = useDeleteZone(objId) + const deleteAllDevices = useDeleteAllDevices(objId) + const forceDeleteObject = useForceDeleteObject() const [deviceModal, setDeviceModal] = useState(false) const [editDevice, setEditDevice] = useState(null) const [csvModal, setCsvModal] = useState(false) const [rackModal, setRackModal] = useState(false) const [sheetsModal, setSheetsModal] = useState(false) + const [discoveryTaskId, setDiscoveryTaskId] = useState(null) + const [discoveryOpen, setDiscoveryOpen] = useState(false) + const [editRack, setEditRack] = useState(null) const [csvFile, setCsvFile] = useState(null) const [csvPreview, setCsvPreview] = useState(null) const [searchText, setSearchText] = useState('') @@ -115,6 +136,7 @@ export function InventoryObjectDetailPage() { const [rackForm] = Form.useForm() const [zoneForm] = Form.useForm() const [sheetsForm] = Form.useForm() + const [editRackForm] = Form.useForm() const rackOptions = useMemo( () => (racks ?? []).map((r) => ({ value: r.id, label: r.name })), @@ -132,18 +154,18 @@ export function InventoryObjectDetailPage() { const handleDiscover = async () => { try { - const result = await discoverMutation.mutateAsync() - const msg = `SSH Discovery: +${result.created} найдено, ${result.updated} обновлено` - if (result.errors.length > 0) { - message.warning(`${msg}. Ошибки: ${result.errors.join('; ')}`) - } else { - message.success(msg) - } + const { task_id } = await discoverMutation.mutateAsync() + setDiscoveryTaskId(task_id) + setDiscoveryOpen(true) } catch { message.error('Ошибка SSH discovery') } } + const handleDiscoveryComplete = () => { + queryClient.invalidateQueries({ queryKey: ['devices', objId] }) + } + const handleSyncSheets = async () => { try { const values = await sheetsForm.validateFields() @@ -217,6 +239,34 @@ export function InventoryObjectDetailPage() { } } + const handleDeleteAllDevices = async () => { + try { + await deleteAllDevices.mutateAsync() + message.success('Все устройства удалены') + } catch { + message.error('Ошибка при удалении устройств') + } + } + + const handleDeleteObject = () => { + Modal.confirm({ + title: `Удалить объект «${obj?.name}»?`, + content: `Будут удалены все устройства (${devices?.length ?? 0} шт.) и сам объект. Это действие необратимо.`, + okText: 'Удалить', + cancelText: 'Отмена', + okButtonProps: { danger: true }, + onOk: async () => { + try { + await forceDeleteObject.mutateAsync(objId) + message.success('Объект удалён') + navigate('/inventory/objects') + } catch { + message.error('Ошибка при удалении объекта') + } + }, + }) + } + const handleCsvPreview = async () => { if (!csvFile) return try { @@ -268,6 +318,26 @@ export function InventoryObjectDetailPage() { } } + const openEditRack = (row: RackItem) => { + editRackForm.setFieldsValue({ + rack_type: row.rack_type ?? '', + location: row.location ?? '', + }) + setEditRack(row) + } + + const handleSaveRack = async () => { + if (!editRack) return + const values = editRackForm.getFieldsValue() + try { + await updateRack.mutateAsync({ id: editRack.id, body: stripEmpty(values) as any }) + message.success('Стойка обновлена') + setEditRack(null) + } catch { + message.error('Ошибка при обновлении стойки') + } + } + const filteredDevices = useMemo(() => { if (!searchText) return devices ?? [] const q = searchText.toLowerCase() @@ -276,6 +346,46 @@ export function InventoryObjectDetailPage() { ) }, [devices, searchText]) + const groupedTableData = useMemo((): TableRow[] => { + const servers = filteredDevices.filter((d) => SERVER_CATEGORIES.has(d.category)) + const rest = filteredDevices.filter((d) => !SERVER_CATEGORIES.has(d.category)) + + const rackMap = new Map() + const noRack: DeviceItem[] = [] + for (const d of rest) { + if (d.rack_name) { + if (!rackMap.has(d.rack_name)) rackMap.set(d.rack_name, []) + rackMap.get(d.rack_name)!.push(d) + } else { + noRack.push(d) + } + } + + const rows: TableRow[] = [] + const addGroup = (label: string, key: string, items: DeviceItem[]) => { + rows.push({ + _type: 'group', + _key: key, + label, + count: items.length, + onlineCount: items.filter((d) => d.status === 'online').length, + }) + items.forEach((d) => rows.push({ ...d, _type: 'device' })) + } + + if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers) + + const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b)) + 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) + + return rows + }, [filteredDevices]) + if (objLoading) return const deviceColumns = [ @@ -283,88 +393,111 @@ export function InventoryObjectDetailPage() { title: 'IP', dataIndex: 'ip', key: 'ip', - render: (ip: string) => {ip}, + onCell: (row: TableRow) => + row._type === 'group' + ? { colSpan: DEVICE_COL_SPAN, style: { background: '#f5f5f5', padding: '6px 16px', borderBottom: '1px solid #e8e8e8' } } + : {}, + render: (_: unknown, row: TableRow) => { + if (row._type === 'group') { + return ( + + {row.label} + {row.count} устройств + {row.onlineCount > 0 && Online: {row.onlineCount}} + + ) + } + return {(row as DeviceRow).ip} + }, }, { title: 'Hostname', dataIndex: 'hostname', key: 'hostname', - render: (h: string | null) => h ?? '—', + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : ((row as DeviceRow).hostname ?? '—'), }, { title: 'Стойка / локация', key: 'location', - render: (_: unknown, row: DeviceItem) => { - if (row.rack_name) return {row.rack_name} - const loc = (row as any).location + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => { + if (row._type === 'group') return null + const d = row as DeviceRow + 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 Улица return }, - filters: [ - { text: 'Без привязки', value: '__none__' }, - { text: 'Серверная', value: 'server_room' }, - { text: 'Улица', value: 'street' }, - ...(racks ?? []).map((r) => ({ text: r.name, value: `rack:${r.name}` })), - ], - onFilter: (value: unknown, record: DeviceItem) => { - if (value === '__none__') return !record.rack_name && !(record as any).location - if (value === 'server_room') return (record as any).location === 'server_room' - if (value === 'street') return (record as any).location === 'street' - if (typeof value === 'string' && value.startsWith('rack:')) - return record.rack_name === value.slice(5) - return false - }, }, { title: 'Категория', dataIndex: 'category', key: 'category', - render: (c: string) => , - filters: DEVICE_CATEGORIES.map((c) => ({ text: c.label, value: c.value })), - onFilter: (value: unknown, record: DeviceItem) => record.category === value, + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : , }, { title: 'Статус', dataIndex: 'status', key: 'status', - render: (s: string) => , + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : , }, { title: 'Последний ping', dataIndex: 'last_seen', key: 'last_seen', - render: (ts: string | null, row: DeviceItem) => - ts ? ( - - {row.last_ping_ms}ms + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => { + if (row._type === 'group') return null + const d = row as DeviceRow + return d.last_seen ? ( + + {d.last_ping_ms}ms - ) : '—', + ) : '—' + }, }, { title: 'Источник', dataIndex: 'source', key: 'source', - render: (s: string) => {s}, + onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {}, + render: (_: unknown, row: TableRow) => + row._type === 'group' ? null : ( + + {(row as DeviceRow).source} + + ), }, { title: '', key: 'actions', width: 100, - render: (_: unknown, row: DeviceItem) => ( - - + + + ) + } + return ( + + {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 ?? '—' }, { title: '', key: 'actions', - width: 60, + width: 90, render: (_: unknown, row: RackItem) => ( - deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))} - > - + - setSearchText(e.target.value)} - /> + + + setSearchText(e.target.value)} + /> + + + + + + + row._type === 'group' ? row._key : String((row as DeviceRow).id)} loading={devLoading} size="middle" pagination={{ pageSize: 50 }} @@ -497,7 +688,7 @@ export function InventoryObjectDetailPage() { { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }} + onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields(); setEditRack(null) }} footer={null} width={820} > @@ -522,10 +713,13 @@ export function InventoryObjectDetailPage() { Стойки
- + + + + - ({ value: z.id, label: z.name }))} /> @@ -687,6 +881,14 @@ export function InventoryObjectDetailPage() {
+ setDiscoveryOpen(false)} + objectName={obj?.name ?? ''} + taskId={discoveryTaskId} + onComplete={handleDiscoveryComplete} + /> + {/* Google Sheets Sync Modal */} (null) + const [importPreview, setImportPreview] = useState(null) + const [importStep, setImportStep] = useState<'upload' | 'preview'>('upload') + const previewMutation = usePreviewObjectsXLSX() + const importMutation = useImportObjectsXLSX() + const objectUrl = (id: number) => mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}` @@ -133,6 +154,39 @@ export function ObjectsPage({ mode }: Props) { const isSubmitting = createObject.isPending || updateObject.isPending + const openImport = () => { + setImportFile(null) + setImportPreview(null) + setImportStep('upload') + setImportOpen(true) + } + + const handleImportFileChange = async (file: File) => { + setImportFile(file) + setImportPreview(null) + try { + const preview = await previewMutation.mutateAsync(file) + setImportPreview(preview) + setImportStep('preview') + } catch { + message.error('Не удалось прочитать файл') + } + } + + const handleImportConfirm = async () => { + if (!importFile) return + try { + const result = await importMutation.mutateAsync(importFile) + setImportOpen(false) + message.success( + `Импорт завершён: создано ${result.created}, пропущено ${result.skipped}` + + (result.errors.length ? `, ошибок ${result.errors.length}` : '') + ) + } catch { + message.error('Ошибка при импорте') + } + } + const renderObjectRow = (obj: ObjectItem) => (
+ + + + )}
@@ -230,6 +289,163 @@ export function ObjectsPage({ mode }: Props) { /> )} + {/* Import xlsx modal */} + setImportOpen(false)} + width={900} + footer={ + importStep === 'preview' && importPreview + ? [ + , + , + ] + : null + } + > + {importStep === 'upload' && ( +
+ { + handleImportFileChange(file) + return false + }} + style={{ padding: '16px 32px' }} + > +

+ +

+

Перетащите .xlsx файл или нажмите для выбора

+

+ Поддерживается шаблон objects_import_template.xlsx +

+
+ {previewMutation.isPending && ( + + Анализируем файл... + + )} +
+ )} + + {importStep === 'preview' && importPreview && ( +
+ {/* Summary */} + + + Будет создано: {importPreview.would_create.length} + + {importPreview.would_skip.length > 0 && ( + + Пропущено (дубли): {importPreview.would_skip.length} + + )} + {importPreview.errors.length > 0 && ( + + Ошибок: {importPreview.errors.length} + + )} + + + {/* Would create table */} + {importPreview.would_create.length > 0 && ( + <> + Будет создано +
{v}, + }, + { title: 'Клиент', dataIndex: 'client', width: 130, render: (v) => v ?? '—' }, + { + title: 'SSH', + dataIndex: 'ssh_user', + width: 80, + render: (v: string) => {v}, + }, + ]} + /> + + )} + + {/* Skipped */} + {importPreview.would_skip.length > 0 && ( + <> + + + Пропущены (дубликаты по IP) + +
{v}, + }, + { title: 'Причина', dataIndex: 'reason' }, + ]} + /> + + )} + + {/* Errors */} + {importPreview.errors.length > 0 && ( + <> + +
+ + )} + + )} + + {/* Create / Edit modal — only relevant in inventory mode */}